@hed-hog/catalog 0.0.294 → 0.0.296
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/catalog.controller.d.ts +4 -4
- package/dist/catalog.service.d.ts +4 -4
- package/dist/catalog.service.d.ts.map +1 -1
- package/dist/catalog.service.js +14 -6
- package/dist/catalog.service.js.map +1 -1
- package/hedhog/frontend/app/dashboard/page.tsx.ejs +14 -83
- package/package.json +10 -10
- package/src/catalog.service.ts +1585 -1573
package/src/catalog.service.ts
CHANGED
|
@@ -1,96 +1,96 @@
|
|
|
1
|
-
import { getLocaleText } from '@hed-hog/api-locale';
|
|
2
|
-
import { PaginationDTO, PaginationService } from '@hed-hog/api-pagination';
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
} from '@hed-hog/api-prisma';
|
|
8
|
-
import { AiService } from '@hed-hog/core';
|
|
9
|
-
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
|
10
|
-
import { CatalogResourceConfig, catalogResourceMap } from './catalog-resource.config';
|
|
11
|
-
|
|
12
|
-
type CatalogAttributeValueInput = {
|
|
13
|
-
attribute_id?: number;
|
|
14
|
-
attribute_option_id?: number | null;
|
|
15
|
-
value_text?: string | null;
|
|
16
|
-
value_number?: number | null;
|
|
17
|
-
value_boolean?: boolean | null;
|
|
18
|
-
raw_value?: string | null;
|
|
19
|
-
value_unit?: string | null;
|
|
20
|
-
normalized_value?: string | null;
|
|
21
|
-
normalized_text?: string | null;
|
|
22
|
-
normalized_number?: number | null;
|
|
23
|
-
source_type?: string | null;
|
|
24
|
-
confidence_score?: number | null;
|
|
25
|
-
is_verified?: boolean | null;
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
type CatalogAttributeGroupPayload = {
|
|
29
|
-
id: number | null;
|
|
30
|
-
slug: string;
|
|
31
|
-
name: string;
|
|
32
|
-
attributes: Array<Record<string, any>>;
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
type CatalogAttributeSnapshotPayload = {
|
|
36
|
-
slug: string;
|
|
37
|
-
name: string;
|
|
38
|
-
data_type: string;
|
|
39
|
-
unit: string | null;
|
|
40
|
-
group_name: string;
|
|
41
|
-
is_required: boolean;
|
|
42
|
-
is_highlight: boolean;
|
|
43
|
-
is_filter_visible: boolean;
|
|
44
|
-
is_comparison_visible: boolean;
|
|
45
|
-
value: string | number | boolean | null;
|
|
46
|
-
value_text: string | null;
|
|
47
|
-
value_number: number | null;
|
|
48
|
-
value_boolean: boolean | null;
|
|
49
|
-
normalized_value: string | null;
|
|
50
|
-
raw_value: string | null;
|
|
51
|
-
option: {
|
|
52
|
-
id: number;
|
|
53
|
-
slug: string;
|
|
54
|
-
label: string;
|
|
55
|
-
option_value: string;
|
|
56
|
-
} | null;
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
type ProductRecordWithRelations = Record<string, any> & {
|
|
60
|
-
brand_id?: number | null;
|
|
61
|
-
catalog_category_id?: number | null;
|
|
62
|
-
};
|
|
63
|
-
|
|
64
|
-
type CatalogAiFieldContext = {
|
|
65
|
-
key: string;
|
|
66
|
-
label: string;
|
|
67
|
-
type: string;
|
|
68
|
-
required: boolean;
|
|
69
|
-
options: Array<{
|
|
70
|
-
value: string;
|
|
71
|
-
label: string;
|
|
72
|
-
}>;
|
|
73
|
-
relation?: {
|
|
74
|
-
endpoint?: string;
|
|
75
|
-
resource?: string;
|
|
76
|
-
labelKeys?: string[];
|
|
77
|
-
} | null;
|
|
78
|
-
};
|
|
79
|
-
|
|
80
|
-
type CatalogAiAssistInput = {
|
|
81
|
-
prompt: string;
|
|
82
|
-
current_values?: Record<string, unknown>;
|
|
83
|
-
fields?: Record<string, unknown>[];
|
|
84
|
-
current_attribute_values?: Record<string, unknown>[];
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
@Injectable()
|
|
88
|
-
export class CatalogService {
|
|
89
|
-
constructor(
|
|
90
|
-
private readonly prisma: PrismaService,
|
|
91
|
-
private readonly pagination: PaginationService,
|
|
92
|
-
private readonly aiService: AiService,
|
|
93
|
-
) {}
|
|
1
|
+
import { getLocaleText } from '@hed-hog/api-locale';
|
|
2
|
+
import { PaginationDTO, PaginationService } from '@hed-hog/api-pagination';
|
|
3
|
+
import {
|
|
4
|
+
Prisma,
|
|
5
|
+
PrismaService,
|
|
6
|
+
type catalog_product_attribute_value_source_type_8522ae66b0_enum,
|
|
7
|
+
} from '@hed-hog/api-prisma';
|
|
8
|
+
import { AiService } from '@hed-hog/core';
|
|
9
|
+
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
|
10
|
+
import { CatalogResourceConfig, catalogResourceMap } from './catalog-resource.config';
|
|
11
|
+
|
|
12
|
+
type CatalogAttributeValueInput = {
|
|
13
|
+
attribute_id?: number;
|
|
14
|
+
attribute_option_id?: number | null;
|
|
15
|
+
value_text?: string | null;
|
|
16
|
+
value_number?: number | null;
|
|
17
|
+
value_boolean?: boolean | null;
|
|
18
|
+
raw_value?: string | null;
|
|
19
|
+
value_unit?: string | null;
|
|
20
|
+
normalized_value?: string | null;
|
|
21
|
+
normalized_text?: string | null;
|
|
22
|
+
normalized_number?: number | null;
|
|
23
|
+
source_type?: string | null;
|
|
24
|
+
confidence_score?: number | null;
|
|
25
|
+
is_verified?: boolean | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type CatalogAttributeGroupPayload = {
|
|
29
|
+
id: number | null;
|
|
30
|
+
slug: string;
|
|
31
|
+
name: string;
|
|
32
|
+
attributes: Array<Record<string, any>>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type CatalogAttributeSnapshotPayload = {
|
|
36
|
+
slug: string;
|
|
37
|
+
name: string;
|
|
38
|
+
data_type: string;
|
|
39
|
+
unit: string | null;
|
|
40
|
+
group_name: string;
|
|
41
|
+
is_required: boolean;
|
|
42
|
+
is_highlight: boolean;
|
|
43
|
+
is_filter_visible: boolean;
|
|
44
|
+
is_comparison_visible: boolean;
|
|
45
|
+
value: string | number | boolean | null;
|
|
46
|
+
value_text: string | null;
|
|
47
|
+
value_number: number | null;
|
|
48
|
+
value_boolean: boolean | null;
|
|
49
|
+
normalized_value: string | null;
|
|
50
|
+
raw_value: string | null;
|
|
51
|
+
option: {
|
|
52
|
+
id: number;
|
|
53
|
+
slug: string;
|
|
54
|
+
label: string;
|
|
55
|
+
option_value: string;
|
|
56
|
+
} | null;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
type ProductRecordWithRelations = Record<string, any> & {
|
|
60
|
+
brand_id?: number | null;
|
|
61
|
+
catalog_category_id?: number | null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
type CatalogAiFieldContext = {
|
|
65
|
+
key: string;
|
|
66
|
+
label: string;
|
|
67
|
+
type: string;
|
|
68
|
+
required: boolean;
|
|
69
|
+
options: Array<{
|
|
70
|
+
value: string;
|
|
71
|
+
label: string;
|
|
72
|
+
}>;
|
|
73
|
+
relation?: {
|
|
74
|
+
endpoint?: string;
|
|
75
|
+
resource?: string;
|
|
76
|
+
labelKeys?: string[];
|
|
77
|
+
} | null;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
type CatalogAiAssistInput = {
|
|
81
|
+
prompt: string;
|
|
82
|
+
current_values?: Record<string, unknown>;
|
|
83
|
+
fields?: Record<string, unknown>[];
|
|
84
|
+
current_attribute_values?: Record<string, unknown>[];
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
@Injectable()
|
|
88
|
+
export class CatalogService {
|
|
89
|
+
constructor(
|
|
90
|
+
private readonly prisma: PrismaService,
|
|
91
|
+
private readonly pagination: PaginationService,
|
|
92
|
+
private readonly aiService: AiService,
|
|
93
|
+
) {}
|
|
94
94
|
|
|
95
95
|
private getConfig(resource: string, locale: string): CatalogResourceConfig {
|
|
96
96
|
const config = catalogResourceMap.get(resource);
|
|
@@ -106,10 +106,18 @@ export class CatalogService {
|
|
|
106
106
|
|
|
107
107
|
private getModel(resource: string, locale: string) {
|
|
108
108
|
const config = this.getConfig(resource, locale);
|
|
109
|
-
|
|
109
|
+
const model = this.prisma[config.model];
|
|
110
|
+
|
|
111
|
+
if (!model) {
|
|
112
|
+
throw new BadRequestException(
|
|
113
|
+
getLocaleText('resourceNotSupported', locale, `Model "${config.model}" is not available`),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return model;
|
|
110
118
|
}
|
|
111
119
|
|
|
112
|
-
private normalizeValue(value: unknown) {
|
|
120
|
+
private normalizeValue(value: unknown) {
|
|
113
121
|
if (value === 'true') {
|
|
114
122
|
return true;
|
|
115
123
|
}
|
|
@@ -122,666 +130,666 @@ export class CatalogService {
|
|
|
122
130
|
return value.includes('.') ? Number(value) : Number.parseInt(value, 10);
|
|
123
131
|
}
|
|
124
132
|
|
|
125
|
-
return value;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
private extractJsonObject(content: string) {
|
|
129
|
-
const trimmed = String(content ?? '').trim();
|
|
130
|
-
|
|
131
|
-
if (!trimmed) {
|
|
132
|
-
throw new BadRequestException('AI returned an empty response');
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
|
|
136
|
-
const candidate = fencedMatch?.[1]?.trim() || trimmed;
|
|
137
|
-
|
|
138
|
-
try {
|
|
139
|
-
return JSON.parse(candidate) as Record<string, unknown>;
|
|
140
|
-
} catch {
|
|
141
|
-
const firstBraceIndex = candidate.indexOf('{');
|
|
142
|
-
const lastBraceIndex = candidate.lastIndexOf('}');
|
|
143
|
-
|
|
144
|
-
if (firstBraceIndex >= 0 && lastBraceIndex > firstBraceIndex) {
|
|
145
|
-
try {
|
|
146
|
-
return JSON.parse(candidate.slice(firstBraceIndex, lastBraceIndex + 1)) as Record<string, unknown>;
|
|
147
|
-
} catch {
|
|
148
|
-
throw new BadRequestException('AI returned an invalid JSON payload');
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
throw new BadRequestException('AI returned an invalid JSON payload');
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
private normalizeComparableText(value: unknown) {
|
|
157
|
-
return String(value ?? '')
|
|
158
|
-
.normalize('NFD')
|
|
159
|
-
.replace(/[\u0300-\u036f]/g, '')
|
|
160
|
-
.trim()
|
|
161
|
-
.toLowerCase();
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
private normalizeAiFieldContext(
|
|
165
|
-
resource: string,
|
|
166
|
-
fields: Record<string, unknown>[],
|
|
167
|
-
) {
|
|
168
|
-
const config = catalogResourceMap.get(resource);
|
|
169
|
-
|
|
170
|
-
if (!config) {
|
|
171
|
-
return [];
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const allowedKeys = new Set(config.fields);
|
|
175
|
-
|
|
176
|
-
return fields
|
|
177
|
-
.map((field) => {
|
|
178
|
-
const key = String(field.key ?? '').trim();
|
|
179
|
-
|
|
180
|
-
if (!key || !allowedKeys.has(key)) {
|
|
181
|
-
return null;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const options = Array.isArray(field.options)
|
|
185
|
-
? field.options
|
|
186
|
-
.map((option) => ({
|
|
187
|
-
value: String((option as Record<string, unknown>).value ?? '').trim(),
|
|
188
|
-
label: String((option as Record<string, unknown>).label ?? '').trim(),
|
|
189
|
-
}))
|
|
190
|
-
.filter((option) => option.value && option.label)
|
|
191
|
-
: [];
|
|
192
|
-
const relationData =
|
|
193
|
-
field.relation && typeof field.relation === 'object'
|
|
194
|
-
? (field.relation as Record<string, unknown>)
|
|
195
|
-
: null;
|
|
196
|
-
|
|
197
|
-
return {
|
|
198
|
-
key,
|
|
199
|
-
label: String(field.label ?? key),
|
|
200
|
-
type: String(field.type ?? 'text'),
|
|
201
|
-
required: Boolean(field.required ?? false),
|
|
202
|
-
options,
|
|
203
|
-
relation: relationData
|
|
204
|
-
? {
|
|
205
|
-
endpoint: relationData.endpoint
|
|
206
|
-
? String(relationData.endpoint)
|
|
207
|
-
: undefined,
|
|
208
|
-
resource: relationData.resource
|
|
209
|
-
? String(relationData.resource)
|
|
210
|
-
: undefined,
|
|
211
|
-
labelKeys: Array.isArray(relationData.labelKeys)
|
|
212
|
-
? relationData.labelKeys.map((item) => String(item))
|
|
213
|
-
: [],
|
|
214
|
-
}
|
|
215
|
-
: null,
|
|
216
|
-
} satisfies CatalogAiFieldContext;
|
|
217
|
-
})
|
|
218
|
-
.filter(Boolean) as CatalogAiFieldContext[];
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
private normalizeBooleanSuggestion(value: unknown) {
|
|
222
|
-
if (typeof value === 'boolean') {
|
|
223
|
-
return value;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
if (typeof value === 'number') {
|
|
227
|
-
return value !== 0;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
const normalized = this.normalizeComparableText(value);
|
|
231
|
-
|
|
232
|
-
if (!normalized) {
|
|
233
|
-
return null;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
if (['true', '1', 'yes', 'y', 'sim', 'ativo', 'active', 'enabled'].includes(normalized)) {
|
|
237
|
-
return true;
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
if (['false', '0', 'no', 'n', 'nao', 'não', 'inativo', 'inactive', 'disabled'].includes(normalized)) {
|
|
241
|
-
return false;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
return null;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
private normalizeNumberSuggestion(value: unknown) {
|
|
248
|
-
if (typeof value === 'number') {
|
|
249
|
-
return Number.isFinite(value) ? value : null;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
const raw = String(value ?? '').trim();
|
|
253
|
-
|
|
254
|
-
if (!raw) {
|
|
255
|
-
return null;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
const normalized = raw.replace(/[^\d,.\-]/g, '').replace(',', '.');
|
|
259
|
-
const parsed = Number(normalized);
|
|
260
|
-
return Number.isFinite(parsed) ? parsed : null;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
private normalizeSelectSuggestion(
|
|
264
|
-
rawValue: unknown,
|
|
265
|
-
options: Array<{ value: string; label: string }>,
|
|
266
|
-
) {
|
|
267
|
-
const normalized = this.normalizeComparableText(rawValue);
|
|
268
|
-
|
|
269
|
-
if (!normalized) {
|
|
270
|
-
return null;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
const exact = options.find((option) => {
|
|
274
|
-
return (
|
|
275
|
-
this.normalizeComparableText(option.value) === normalized ||
|
|
276
|
-
this.normalizeComparableText(option.label) === normalized
|
|
277
|
-
);
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
if (exact) {
|
|
281
|
-
return exact.value;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
const partial = options.find((option) => {
|
|
285
|
-
return (
|
|
286
|
-
this.normalizeComparableText(option.value).includes(normalized) ||
|
|
287
|
-
normalized.includes(this.normalizeComparableText(option.value)) ||
|
|
288
|
-
this.normalizeComparableText(option.label).includes(normalized) ||
|
|
289
|
-
normalized.includes(this.normalizeComparableText(option.label))
|
|
290
|
-
);
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
return partial?.value ?? null;
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
private async resolveRelationSuggestion(
|
|
297
|
-
field: CatalogAiFieldContext,
|
|
298
|
-
rawValue: unknown,
|
|
299
|
-
) {
|
|
300
|
-
if (rawValue === null || rawValue === undefined || rawValue === '') {
|
|
301
|
-
return null;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
const relation = field.relation;
|
|
305
|
-
|
|
306
|
-
if (!relation) {
|
|
307
|
-
return null;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
const modelInfo = (() => {
|
|
311
|
-
if (relation.resource) {
|
|
312
|
-
const config = catalogResourceMap.get(relation.resource);
|
|
313
|
-
|
|
314
|
-
if (config) {
|
|
315
|
-
return {
|
|
316
|
-
model: config.model,
|
|
317
|
-
searchFields:
|
|
318
|
-
relation.labelKeys?.length ? relation.labelKeys : config.searchFields,
|
|
319
|
-
idField: 'id',
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
if (relation.endpoint === '/locale') {
|
|
325
|
-
return {
|
|
326
|
-
model: 'locale',
|
|
327
|
-
searchFields: relation.labelKeys?.length ? relation.labelKeys : ['name', 'code'],
|
|
328
|
-
idField: 'id',
|
|
329
|
-
};
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
if (relation.endpoint === '/content') {
|
|
333
|
-
return {
|
|
334
|
-
model: 'content',
|
|
335
|
-
searchFields: relation.labelKeys?.length ? relation.labelKeys : ['title', 'slug'],
|
|
336
|
-
idField: 'id',
|
|
337
|
-
};
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
return null;
|
|
341
|
-
})();
|
|
342
|
-
|
|
343
|
-
if (!modelInfo) {
|
|
344
|
-
return null;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
const model = (this.prisma as Record<string, any>)[modelInfo.model];
|
|
348
|
-
|
|
349
|
-
if (!model?.findMany) {
|
|
350
|
-
return null;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
const numericId = this.normalizeNumberSuggestion(rawValue);
|
|
354
|
-
if (numericId && Number.isInteger(numericId)) {
|
|
355
|
-
const byId = await model.findUnique({
|
|
356
|
-
where: { [modelInfo.idField]: numericId },
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
if (byId) {
|
|
360
|
-
return Number(byId[modelInfo.idField]);
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
const needle = String(rawValue ?? '').trim();
|
|
365
|
-
|
|
366
|
-
if (!needle) {
|
|
367
|
-
return null;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
const results = await model.findMany({
|
|
371
|
-
where: {
|
|
372
|
-
OR: modelInfo.searchFields.map((fieldKey) => ({
|
|
373
|
-
[fieldKey]: {
|
|
374
|
-
contains: needle,
|
|
375
|
-
mode: 'insensitive',
|
|
376
|
-
},
|
|
377
|
-
})),
|
|
378
|
-
},
|
|
379
|
-
take: 5,
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
if (!results.length) {
|
|
383
|
-
return null;
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
const normalizedNeedle = this.normalizeComparableText(needle);
|
|
387
|
-
const exact = results.find((result: Record<string, unknown>) =>
|
|
388
|
-
modelInfo.searchFields.some((fieldKey) => {
|
|
389
|
-
return this.normalizeComparableText(result[fieldKey]) === normalizedNeedle;
|
|
390
|
-
}),
|
|
391
|
-
);
|
|
392
|
-
|
|
393
|
-
if (exact) {
|
|
394
|
-
return Number(exact[modelInfo.idField]);
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
return results.length === 1 ? Number(results[0][modelInfo.idField]) : null;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
private async normalizeAiFieldSuggestion(
|
|
401
|
-
field: CatalogAiFieldContext,
|
|
402
|
-
rawValue: unknown,
|
|
403
|
-
warnings: string[],
|
|
404
|
-
) {
|
|
405
|
-
if (rawValue === undefined) {
|
|
406
|
-
return undefined;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
if (rawValue === null) {
|
|
410
|
-
return null;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
switch (field.type) {
|
|
414
|
-
case 'switch': {
|
|
415
|
-
const value = this.normalizeBooleanSuggestion(rawValue);
|
|
416
|
-
|
|
417
|
-
if (value === null) {
|
|
418
|
-
warnings.push(`Boolean field "${field.label}" could not be resolved safely.`);
|
|
419
|
-
return undefined;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
return value;
|
|
423
|
-
}
|
|
424
|
-
case 'number':
|
|
425
|
-
case 'currency': {
|
|
426
|
-
const value = this.normalizeNumberSuggestion(rawValue);
|
|
427
|
-
|
|
428
|
-
if (value === null) {
|
|
429
|
-
warnings.push(`Numeric field "${field.label}" could not be resolved safely.`);
|
|
430
|
-
return undefined;
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
return value;
|
|
434
|
-
}
|
|
435
|
-
case 'select': {
|
|
436
|
-
const value = this.normalizeSelectSuggestion(rawValue, field.options);
|
|
437
|
-
|
|
438
|
-
if (!value) {
|
|
439
|
-
warnings.push(`Select field "${field.label}" returned an invalid option.`);
|
|
440
|
-
return undefined;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
return value;
|
|
444
|
-
}
|
|
445
|
-
case 'relation': {
|
|
446
|
-
const value = await this.resolveRelationSuggestion(field, rawValue);
|
|
447
|
-
|
|
448
|
-
if (!value) {
|
|
449
|
-
warnings.push(`Relation field "${field.label}" could not be resolved safely.`);
|
|
450
|
-
return undefined;
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
return value;
|
|
454
|
-
}
|
|
455
|
-
case 'upload':
|
|
456
|
-
warnings.push(`File field "${field.label}" must still be filled manually.`);
|
|
457
|
-
return undefined;
|
|
458
|
-
case 'json':
|
|
459
|
-
if (typeof rawValue === 'object') {
|
|
460
|
-
return rawValue;
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
try {
|
|
464
|
-
return JSON.parse(String(rawValue));
|
|
465
|
-
} catch {
|
|
466
|
-
warnings.push(`JSON field "${field.label}" returned an invalid value.`);
|
|
467
|
-
return undefined;
|
|
468
|
-
}
|
|
469
|
-
case 'date':
|
|
470
|
-
case 'datetime':
|
|
471
|
-
case 'text':
|
|
472
|
-
case 'url':
|
|
473
|
-
case 'textarea':
|
|
474
|
-
case 'richtext':
|
|
475
|
-
default:
|
|
476
|
-
return String(rawValue).trim() || null;
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
private buildCurrentAttributeValueMap(values: Record<string, unknown>[]) {
|
|
481
|
-
const valueMap = new Map<number, Record<string, unknown>>();
|
|
482
|
-
|
|
483
|
-
for (const value of values) {
|
|
484
|
-
const attributeId = Number(value.attribute_id ?? 0);
|
|
485
|
-
|
|
486
|
-
if (!attributeId) {
|
|
487
|
-
continue;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
valueMap.set(attributeId, value);
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
return valueMap;
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
private buildAttributeContext(
|
|
497
|
-
attributesPayload: Awaited<ReturnType<CatalogService['buildCategoryAttributePayload']>>,
|
|
498
|
-
currentAttributeValues: Record<string, unknown>[],
|
|
499
|
-
) {
|
|
500
|
-
const currentMap = this.buildCurrentAttributeValueMap(currentAttributeValues);
|
|
501
|
-
|
|
502
|
-
return (attributesPayload.groups ?? []).flatMap((group) =>
|
|
503
|
-
group.attributes.map((attribute) => {
|
|
504
|
-
const current = currentMap.get(Number(attribute.id)) ?? attribute.value ?? null;
|
|
505
|
-
const relation = (attribute.category_attribute ?? {}) as Record<string, unknown>;
|
|
506
|
-
|
|
507
|
-
return {
|
|
508
|
-
id: Number(attribute.id),
|
|
509
|
-
slug: String(attribute.slug ?? ''),
|
|
510
|
-
name: String(attribute.name ?? attribute.slug ?? ''),
|
|
511
|
-
data_type: String(attribute.data_type ?? 'text'),
|
|
512
|
-
unit: attribute.unit ? String(attribute.unit) : null,
|
|
513
|
-
group_name: String(group.name ??
|
|
514
|
-
is_required: Boolean(relation.is_required ?? false),
|
|
515
|
-
is_highlight: Boolean(relation.is_highlight ?? false),
|
|
516
|
-
is_filter_visible: Boolean(relation.is_filter_visible ??
|
|
517
|
-
is_comparison_visible: Boolean(relation.is_comparison_visible ?? false),
|
|
518
|
-
options: Array.isArray(attribute.options)
|
|
519
|
-
? attribute.options.map((option) => ({
|
|
520
|
-
id: Number(option.id),
|
|
521
|
-
slug: String(option.slug ?? ''),
|
|
522
|
-
label: String(option.label ?? option.option_value ?? ''),
|
|
523
|
-
option_value: String(option.option_value ?? ''),
|
|
524
|
-
normalized_value: option.normalized_value
|
|
525
|
-
? String(option.normalized_value)
|
|
526
|
-
: null,
|
|
527
|
-
}))
|
|
528
|
-
: [],
|
|
529
|
-
current_value: current,
|
|
530
|
-
};
|
|
531
|
-
}),
|
|
532
|
-
);
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
private async normalizeAiProductAttributeSuggestions(
|
|
536
|
-
categoryId: number | null,
|
|
537
|
-
rawSuggestions: unknown,
|
|
538
|
-
currentAttributeValues: Record<string, unknown>[],
|
|
539
|
-
warnings: string[],
|
|
540
|
-
) {
|
|
541
|
-
if (!categoryId || rawSuggestions === undefined || rawSuggestions === null) {
|
|
542
|
-
return [];
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
const attributesPayload = await this.buildCategoryAttributePayload(categoryId);
|
|
546
|
-
const attributeContext = this.buildAttributeContext(
|
|
547
|
-
attributesPayload,
|
|
548
|
-
currentAttributeValues,
|
|
549
|
-
);
|
|
550
|
-
|
|
551
|
-
const normalizedEntries = Array.isArray(rawSuggestions)
|
|
552
|
-
? rawSuggestions
|
|
553
|
-
: rawSuggestions && typeof rawSuggestions === 'object'
|
|
554
|
-
? Object.entries(rawSuggestions as Record<string, unknown>).map(
|
|
555
|
-
([slug, value]) => ({
|
|
556
|
-
slug,
|
|
557
|
-
...(value && typeof value === 'object'
|
|
558
|
-
? (value as Record<string, unknown>)
|
|
559
|
-
: { value }),
|
|
560
|
-
}),
|
|
561
|
-
)
|
|
562
|
-
: [];
|
|
563
|
-
|
|
564
|
-
const normalizedSuggestions: Record<string, unknown>[] = [];
|
|
565
|
-
|
|
566
|
-
for (const entry of normalizedEntries) {
|
|
567
|
-
const entryRecord = (
|
|
568
|
-
entry && typeof entry === 'object'
|
|
569
|
-
? entry
|
|
570
|
-
: { value: entry }
|
|
571
|
-
) as Record<string, unknown>;
|
|
572
|
-
const rawIdentifier =
|
|
573
|
-
entryRecord.slug ??
|
|
574
|
-
entryRecord.attribute_slug ??
|
|
575
|
-
entryRecord.code ??
|
|
576
|
-
entryRecord.name ??
|
|
577
|
-
entryRecord.attribute ??
|
|
578
|
-
null;
|
|
579
|
-
const normalizedIdentifier = this.normalizeComparableText(rawIdentifier);
|
|
580
|
-
|
|
581
|
-
const attribute = attributeContext.find((item) => {
|
|
582
|
-
return (
|
|
583
|
-
item.id === Number(entryRecord.attribute_id ?? 0) ||
|
|
584
|
-
this.normalizeComparableText(item.slug) === normalizedIdentifier ||
|
|
585
|
-
this.normalizeComparableText(item.name) === normalizedIdentifier
|
|
586
|
-
);
|
|
587
|
-
});
|
|
588
|
-
|
|
589
|
-
if (!attribute) {
|
|
590
|
-
if (rawIdentifier) {
|
|
591
|
-
warnings.push(`Structured attribute "${String(rawIdentifier)}" is not available for the selected category.`);
|
|
592
|
-
}
|
|
593
|
-
continue;
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
const rawValue =
|
|
597
|
-
entryRecord.value ??
|
|
598
|
-
entryRecord.value_text ??
|
|
599
|
-
entryRecord.value_number ??
|
|
600
|
-
entryRecord.value_boolean ??
|
|
601
|
-
entryRecord.option ??
|
|
602
|
-
entryRecord.option_value ??
|
|
603
|
-
entryRecord.label;
|
|
604
|
-
const draft: Record<string, unknown> = {
|
|
605
|
-
attribute_id: attribute.id,
|
|
606
|
-
attribute_slug: attribute.slug,
|
|
607
|
-
attribute_name: attribute.name,
|
|
608
|
-
data_type: attribute.data_type,
|
|
609
|
-
group_name: attribute.group_name,
|
|
610
|
-
value_unit: attribute.unit ?? null,
|
|
611
|
-
};
|
|
612
|
-
|
|
613
|
-
if (attribute.data_type === 'option') {
|
|
614
|
-
const normalizedOption = this.normalizeComparableText(
|
|
615
|
-
entryRecord.option ??
|
|
616
|
-
entryRecord.option_value ??
|
|
617
|
-
entryRecord.label ??
|
|
618
|
-
rawValue,
|
|
619
|
-
);
|
|
620
|
-
const option = attribute.options.find((item) => {
|
|
621
|
-
return (
|
|
622
|
-
this.normalizeComparableText(item.option_value) === normalizedOption ||
|
|
623
|
-
this.normalizeComparableText(item.label) === normalizedOption ||
|
|
624
|
-
this.normalizeComparableText(item.slug) === normalizedOption
|
|
625
|
-
);
|
|
626
|
-
});
|
|
627
|
-
|
|
628
|
-
if (!option) {
|
|
629
|
-
warnings.push(`Option value for "${attribute.name}" could not be resolved safely.`);
|
|
630
|
-
continue;
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
draft.attribute_option_id = option.id;
|
|
634
|
-
draft.value_text = option.option_value;
|
|
635
|
-
} else if (attribute.data_type === 'number') {
|
|
636
|
-
const numberValue = this.normalizeNumberSuggestion(rawValue);
|
|
637
|
-
|
|
638
|
-
if (numberValue === null) {
|
|
639
|
-
warnings.push(`Numeric attribute "${attribute.name}" could not be resolved safely.`);
|
|
640
|
-
continue;
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
draft.value_number = numberValue;
|
|
644
|
-
draft.raw_value = String(numberValue);
|
|
645
|
-
draft.normalized_value = String(numberValue);
|
|
646
|
-
if (entryRecord.unit) {
|
|
647
|
-
draft.value_unit = String(entryRecord.unit).trim() || attribute.unit || null;
|
|
648
|
-
}
|
|
649
|
-
} else if (attribute.data_type === 'boolean') {
|
|
650
|
-
const booleanValue = this.normalizeBooleanSuggestion(rawValue);
|
|
651
|
-
|
|
652
|
-
if (booleanValue === null) {
|
|
653
|
-
warnings.push(`Boolean attribute "${attribute.name}" could not be resolved safely.`);
|
|
654
|
-
continue;
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
draft.value_boolean = booleanValue;
|
|
658
|
-
draft.raw_value = String(booleanValue);
|
|
659
|
-
draft.normalized_value = String(booleanValue);
|
|
660
|
-
} else {
|
|
661
|
-
const textValue = String(rawValue ?? '').trim();
|
|
662
|
-
|
|
663
|
-
if (!textValue) {
|
|
664
|
-
continue;
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
draft.value_text = textValue;
|
|
668
|
-
draft.raw_value = textValue;
|
|
669
|
-
draft.normalized_value = textValue;
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
normalizedSuggestions.push(draft);
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
return normalizedSuggestions;
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
private buildAiSystemPrompt(
|
|
679
|
-
resource: string,
|
|
680
|
-
locale: string,
|
|
681
|
-
fieldContext: CatalogAiFieldContext[],
|
|
682
|
-
currentValues: Record<string, unknown>,
|
|
683
|
-
productAttributes: Record<string, unknown>[],
|
|
684
|
-
) {
|
|
685
|
-
const isPt = locale?.startsWith('pt');
|
|
686
|
-
const fieldLines = fieldContext.map((field) => {
|
|
687
|
-
const optionText = field.options.length
|
|
688
|
-
? ` options=${field.options.map((option) => `${option.label} (${option.value})`).join(', ')}`
|
|
689
|
-
: '';
|
|
690
|
-
const relationText = field.relation
|
|
691
|
-
? ` relation=${field.relation.resource || field.relation.endpoint || 'external'}`
|
|
692
|
-
: '';
|
|
693
|
-
|
|
694
|
-
return `- ${field.key}: type=${field.type}; required=${field.required ? 'yes' : 'no'}; label="${field.label}"${optionText}${relationText}`;
|
|
695
|
-
});
|
|
696
|
-
const productAttributeLines = productAttributes.map((attribute) => {
|
|
697
|
-
const options = Array.isArray(attribute.options) ? attribute.options : [];
|
|
698
|
-
const optionText = options.length
|
|
699
|
-
? ` options=${options
|
|
700
|
-
.map((option) => `${String((option as Record<string, unknown>).label ?? '')} (${String((option as Record<string, unknown>).option_value ?? '')})`)
|
|
701
|
-
.join(', ')}`
|
|
702
|
-
: '';
|
|
703
|
-
|
|
704
|
-
return `- ${String(attribute.slug)}: type=${String(attribute.data_type)}; name="${String(attribute.name)}"; required=${Boolean(attribute.is_required) ? 'yes' : 'no'}; group="${String(attribute.group_name)}"${optionText}`;
|
|
705
|
-
});
|
|
706
|
-
|
|
707
|
-
return [
|
|
708
|
-
isPt
|
|
709
|
-
? 'Voce preenche formularios administrativos do catalogo. Responda apenas com JSON valido.'
|
|
710
|
-
: 'You fill catalog admin forms. Respond with valid JSON only.',
|
|
711
|
-
isPt
|
|
712
|
-
? `Recurso atual: ${resource}.`
|
|
713
|
-
: `Current resource: ${resource}.`,
|
|
714
|
-
isPt
|
|
715
|
-
? 'Use apenas os campos permitidos listados abaixo. Nao invente ids, arquivos ou campos extras.'
|
|
716
|
-
: 'Use only the allowed fields listed below. Do not invent ids, files, or extra fields.',
|
|
717
|
-
isPt
|
|
718
|
-
? 'Para campos relacionais, prefira nomes/slug legiveis; o backend tentara resolver com seguranca.'
|
|
719
|
-
: 'For relation fields, prefer readable names/slugs; the backend will resolve them safely.',
|
|
720
|
-
isPt
|
|
721
|
-
? 'Para selects, prefira os valores validos informados.'
|
|
722
|
-
: 'For selects, prefer the valid values provided.',
|
|
723
|
-
isPt
|
|
724
|
-
? 'Estrutura da resposta: {"fields": { ... }, "product_attributes": { ... }, "notes": ["..."]}.'
|
|
725
|
-
: 'Response shape: {"fields": { ... }, "product_attributes": { ... }, "notes": ["..."]}.',
|
|
726
|
-
isPt
|
|
727
|
-
? 'Se nao souber um campo, simplesmente omita.'
|
|
728
|
-
: 'If you are unsure about a field, omit it.',
|
|
729
|
-
`Allowed fields:\n${fieldLines.join('\n')}`,
|
|
730
|
-
productAttributeLines.length
|
|
731
|
-
? `Structured product attributes:\n${productAttributeLines.join('\n')}`
|
|
732
|
-
: '',
|
|
733
|
-
`Current values: ${JSON.stringify(currentValues)}`,
|
|
734
|
-
]
|
|
735
|
-
.filter(Boolean)
|
|
736
|
-
.join('\n\n');
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
private sanitizePayload(config: CatalogResourceConfig, body: Record<string, unknown>) {
|
|
740
|
-
const normalizedBody = { ...body };
|
|
741
|
-
|
|
742
|
-
if (config.resource === 'attributes') {
|
|
743
|
-
if (
|
|
744
|
-
normalizedBody.name === undefined &&
|
|
745
|
-
normalizedBody.label !== undefined
|
|
746
|
-
) {
|
|
747
|
-
normalizedBody.name = normalizedBody.label;
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
if (normalizedBody.data_type === 'select') {
|
|
751
|
-
normalizedBody.data_type = 'option';
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
if (config.resource === 'category-attributes') {
|
|
756
|
-
if (
|
|
757
|
-
normalizedBody.is_highlight === undefined &&
|
|
758
|
-
normalizedBody.is_highlighted !== undefined
|
|
759
|
-
) {
|
|
760
|
-
normalizedBody.is_highlight = normalizedBody.is_highlighted;
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
if (
|
|
764
|
-
normalizedBody.sort_order === undefined &&
|
|
765
|
-
normalizedBody.display_order !== undefined
|
|
766
|
-
) {
|
|
767
|
-
normalizedBody.sort_order = normalizedBody.display_order;
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
const payload: Record<string, unknown> = {};
|
|
772
|
-
|
|
773
|
-
for (const field of config.fields) {
|
|
774
|
-
if (Object.prototype.hasOwnProperty.call(normalizedBody, field) && normalizedBody[field] !== undefined) {
|
|
775
|
-
payload[field] = normalizedBody[field];
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
return payload;
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
private buildWhere(config: CatalogResourceConfig, paginationParams: PaginationDTO, query: Record<string, unknown>) {
|
|
783
|
-
const OR = config.searchFields.length
|
|
784
|
-
? this.prisma.createInsensitiveSearch(config.searchFields, paginationParams)
|
|
133
|
+
return value;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private extractJsonObject(content: string) {
|
|
137
|
+
const trimmed = String(content ?? '').trim();
|
|
138
|
+
|
|
139
|
+
if (!trimmed) {
|
|
140
|
+
throw new BadRequestException('AI returned an empty response');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const fencedMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
|
|
144
|
+
const candidate = fencedMatch?.[1]?.trim() || trimmed;
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
return JSON.parse(candidate) as Record<string, unknown>;
|
|
148
|
+
} catch {
|
|
149
|
+
const firstBraceIndex = candidate.indexOf('{');
|
|
150
|
+
const lastBraceIndex = candidate.lastIndexOf('}');
|
|
151
|
+
|
|
152
|
+
if (firstBraceIndex >= 0 && lastBraceIndex > firstBraceIndex) {
|
|
153
|
+
try {
|
|
154
|
+
return JSON.parse(candidate.slice(firstBraceIndex, lastBraceIndex + 1)) as Record<string, unknown>;
|
|
155
|
+
} catch {
|
|
156
|
+
throw new BadRequestException('AI returned an invalid JSON payload');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
throw new BadRequestException('AI returned an invalid JSON payload');
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private normalizeComparableText(value: unknown) {
|
|
165
|
+
return String(value ?? '')
|
|
166
|
+
.normalize('NFD')
|
|
167
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
168
|
+
.trim()
|
|
169
|
+
.toLowerCase();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private normalizeAiFieldContext(
|
|
173
|
+
resource: string,
|
|
174
|
+
fields: Record<string, unknown>[],
|
|
175
|
+
) {
|
|
176
|
+
const config = catalogResourceMap.get(resource);
|
|
177
|
+
|
|
178
|
+
if (!config) {
|
|
179
|
+
return [];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const allowedKeys = new Set(config.fields);
|
|
183
|
+
|
|
184
|
+
return fields
|
|
185
|
+
.map((field) => {
|
|
186
|
+
const key = String(field.key ?? '').trim();
|
|
187
|
+
|
|
188
|
+
if (!key || !allowedKeys.has(key)) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const options = Array.isArray(field.options)
|
|
193
|
+
? field.options
|
|
194
|
+
.map((option) => ({
|
|
195
|
+
value: String((option as Record<string, unknown>).value ?? '').trim(),
|
|
196
|
+
label: String((option as Record<string, unknown>).label ?? '').trim(),
|
|
197
|
+
}))
|
|
198
|
+
.filter((option) => option.value && option.label)
|
|
199
|
+
: [];
|
|
200
|
+
const relationData =
|
|
201
|
+
field.relation && typeof field.relation === 'object'
|
|
202
|
+
? (field.relation as Record<string, unknown>)
|
|
203
|
+
: null;
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
key,
|
|
207
|
+
label: String(field.label ?? key),
|
|
208
|
+
type: String(field.type ?? 'text'),
|
|
209
|
+
required: Boolean(field.required ?? false),
|
|
210
|
+
options,
|
|
211
|
+
relation: relationData
|
|
212
|
+
? {
|
|
213
|
+
endpoint: relationData.endpoint
|
|
214
|
+
? String(relationData.endpoint)
|
|
215
|
+
: undefined,
|
|
216
|
+
resource: relationData.resource
|
|
217
|
+
? String(relationData.resource)
|
|
218
|
+
: undefined,
|
|
219
|
+
labelKeys: Array.isArray(relationData.labelKeys)
|
|
220
|
+
? relationData.labelKeys.map((item) => String(item))
|
|
221
|
+
: [],
|
|
222
|
+
}
|
|
223
|
+
: null,
|
|
224
|
+
} satisfies CatalogAiFieldContext;
|
|
225
|
+
})
|
|
226
|
+
.filter(Boolean) as CatalogAiFieldContext[];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private normalizeBooleanSuggestion(value: unknown) {
|
|
230
|
+
if (typeof value === 'boolean') {
|
|
231
|
+
return value;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (typeof value === 'number') {
|
|
235
|
+
return value !== 0;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const normalized = this.normalizeComparableText(value);
|
|
239
|
+
|
|
240
|
+
if (!normalized) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (['true', '1', 'yes', 'y', 'sim', 'ativo', 'active', 'enabled'].includes(normalized)) {
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (['false', '0', 'no', 'n', 'nao', 'não', 'inativo', 'inactive', 'disabled'].includes(normalized)) {
|
|
249
|
+
return false;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private normalizeNumberSuggestion(value: unknown) {
|
|
256
|
+
if (typeof value === 'number') {
|
|
257
|
+
return Number.isFinite(value) ? value : null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const raw = String(value ?? '').trim();
|
|
261
|
+
|
|
262
|
+
if (!raw) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const normalized = raw.replace(/[^\d,.\-]/g, '').replace(',', '.');
|
|
267
|
+
const parsed = Number(normalized);
|
|
268
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private normalizeSelectSuggestion(
|
|
272
|
+
rawValue: unknown,
|
|
273
|
+
options: Array<{ value: string; label: string }>,
|
|
274
|
+
) {
|
|
275
|
+
const normalized = this.normalizeComparableText(rawValue);
|
|
276
|
+
|
|
277
|
+
if (!normalized) {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const exact = options.find((option) => {
|
|
282
|
+
return (
|
|
283
|
+
this.normalizeComparableText(option.value) === normalized ||
|
|
284
|
+
this.normalizeComparableText(option.label) === normalized
|
|
285
|
+
);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
if (exact) {
|
|
289
|
+
return exact.value;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const partial = options.find((option) => {
|
|
293
|
+
return (
|
|
294
|
+
this.normalizeComparableText(option.value).includes(normalized) ||
|
|
295
|
+
normalized.includes(this.normalizeComparableText(option.value)) ||
|
|
296
|
+
this.normalizeComparableText(option.label).includes(normalized) ||
|
|
297
|
+
normalized.includes(this.normalizeComparableText(option.label))
|
|
298
|
+
);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
return partial?.value ?? null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private async resolveRelationSuggestion(
|
|
305
|
+
field: CatalogAiFieldContext,
|
|
306
|
+
rawValue: unknown,
|
|
307
|
+
) {
|
|
308
|
+
if (rawValue === null || rawValue === undefined || rawValue === '') {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const relation = field.relation;
|
|
313
|
+
|
|
314
|
+
if (!relation) {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const modelInfo = (() => {
|
|
319
|
+
if (relation.resource) {
|
|
320
|
+
const config = catalogResourceMap.get(relation.resource);
|
|
321
|
+
|
|
322
|
+
if (config) {
|
|
323
|
+
return {
|
|
324
|
+
model: config.model,
|
|
325
|
+
searchFields:
|
|
326
|
+
relation.labelKeys?.length ? relation.labelKeys : config.searchFields,
|
|
327
|
+
idField: 'id',
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (relation.endpoint === '/locale') {
|
|
333
|
+
return {
|
|
334
|
+
model: 'locale',
|
|
335
|
+
searchFields: relation.labelKeys?.length ? relation.labelKeys : ['name', 'code'],
|
|
336
|
+
idField: 'id',
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (relation.endpoint === '/content') {
|
|
341
|
+
return {
|
|
342
|
+
model: 'content',
|
|
343
|
+
searchFields: relation.labelKeys?.length ? relation.labelKeys : ['title', 'slug'],
|
|
344
|
+
idField: 'id',
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return null;
|
|
349
|
+
})();
|
|
350
|
+
|
|
351
|
+
if (!modelInfo) {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const model = (this.prisma as Record<string, any>)[modelInfo.model];
|
|
356
|
+
|
|
357
|
+
if (!model?.findMany) {
|
|
358
|
+
return null;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const numericId = this.normalizeNumberSuggestion(rawValue);
|
|
362
|
+
if (numericId && Number.isInteger(numericId)) {
|
|
363
|
+
const byId = await model.findUnique({
|
|
364
|
+
where: { [modelInfo.idField]: numericId },
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
if (byId) {
|
|
368
|
+
return Number(byId[modelInfo.idField]);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const needle = String(rawValue ?? '').trim();
|
|
373
|
+
|
|
374
|
+
if (!needle) {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const results = await model.findMany({
|
|
379
|
+
where: {
|
|
380
|
+
OR: modelInfo.searchFields.map((fieldKey) => ({
|
|
381
|
+
[fieldKey]: {
|
|
382
|
+
contains: needle,
|
|
383
|
+
mode: 'insensitive',
|
|
384
|
+
},
|
|
385
|
+
})),
|
|
386
|
+
},
|
|
387
|
+
take: 5,
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
if (!results.length) {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const normalizedNeedle = this.normalizeComparableText(needle);
|
|
395
|
+
const exact = results.find((result: Record<string, unknown>) =>
|
|
396
|
+
modelInfo.searchFields.some((fieldKey) => {
|
|
397
|
+
return this.normalizeComparableText(result[fieldKey]) === normalizedNeedle;
|
|
398
|
+
}),
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
if (exact) {
|
|
402
|
+
return Number(exact[modelInfo.idField]);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return results.length === 1 ? Number(results[0][modelInfo.idField]) : null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private async normalizeAiFieldSuggestion(
|
|
409
|
+
field: CatalogAiFieldContext,
|
|
410
|
+
rawValue: unknown,
|
|
411
|
+
warnings: string[],
|
|
412
|
+
) {
|
|
413
|
+
if (rawValue === undefined) {
|
|
414
|
+
return undefined;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (rawValue === null) {
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
switch (field.type) {
|
|
422
|
+
case 'switch': {
|
|
423
|
+
const value = this.normalizeBooleanSuggestion(rawValue);
|
|
424
|
+
|
|
425
|
+
if (value === null) {
|
|
426
|
+
warnings.push(`Boolean field "${field.label}" could not be resolved safely.`);
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return value;
|
|
431
|
+
}
|
|
432
|
+
case 'number':
|
|
433
|
+
case 'currency': {
|
|
434
|
+
const value = this.normalizeNumberSuggestion(rawValue);
|
|
435
|
+
|
|
436
|
+
if (value === null) {
|
|
437
|
+
warnings.push(`Numeric field "${field.label}" could not be resolved safely.`);
|
|
438
|
+
return undefined;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return value;
|
|
442
|
+
}
|
|
443
|
+
case 'select': {
|
|
444
|
+
const value = this.normalizeSelectSuggestion(rawValue, field.options);
|
|
445
|
+
|
|
446
|
+
if (!value) {
|
|
447
|
+
warnings.push(`Select field "${field.label}" returned an invalid option.`);
|
|
448
|
+
return undefined;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return value;
|
|
452
|
+
}
|
|
453
|
+
case 'relation': {
|
|
454
|
+
const value = await this.resolveRelationSuggestion(field, rawValue);
|
|
455
|
+
|
|
456
|
+
if (!value) {
|
|
457
|
+
warnings.push(`Relation field "${field.label}" could not be resolved safely.`);
|
|
458
|
+
return undefined;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return value;
|
|
462
|
+
}
|
|
463
|
+
case 'upload':
|
|
464
|
+
warnings.push(`File field "${field.label}" must still be filled manually.`);
|
|
465
|
+
return undefined;
|
|
466
|
+
case 'json':
|
|
467
|
+
if (typeof rawValue === 'object') {
|
|
468
|
+
return rawValue;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
try {
|
|
472
|
+
return JSON.parse(String(rawValue));
|
|
473
|
+
} catch {
|
|
474
|
+
warnings.push(`JSON field "${field.label}" returned an invalid value.`);
|
|
475
|
+
return undefined;
|
|
476
|
+
}
|
|
477
|
+
case 'date':
|
|
478
|
+
case 'datetime':
|
|
479
|
+
case 'text':
|
|
480
|
+
case 'url':
|
|
481
|
+
case 'textarea':
|
|
482
|
+
case 'richtext':
|
|
483
|
+
default:
|
|
484
|
+
return String(rawValue).trim() || null;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private buildCurrentAttributeValueMap(values: Record<string, unknown>[]) {
|
|
489
|
+
const valueMap = new Map<number, Record<string, unknown>>();
|
|
490
|
+
|
|
491
|
+
for (const value of values) {
|
|
492
|
+
const attributeId = Number(value.attribute_id ?? 0);
|
|
493
|
+
|
|
494
|
+
if (!attributeId) {
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
valueMap.set(attributeId, value);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return valueMap;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private buildAttributeContext(
|
|
505
|
+
attributesPayload: Awaited<ReturnType<CatalogService['buildCategoryAttributePayload']>>,
|
|
506
|
+
currentAttributeValues: Record<string, unknown>[],
|
|
507
|
+
) {
|
|
508
|
+
const currentMap = this.buildCurrentAttributeValueMap(currentAttributeValues);
|
|
509
|
+
|
|
510
|
+
return (attributesPayload.groups ?? []).flatMap((group) =>
|
|
511
|
+
group.attributes.map((attribute) => {
|
|
512
|
+
const current = currentMap.get(Number(attribute.id)) ?? attribute.value ?? null;
|
|
513
|
+
const relation = (attribute.category_attribute ?? {}) as Record<string, unknown>;
|
|
514
|
+
|
|
515
|
+
return {
|
|
516
|
+
id: Number(attribute.id),
|
|
517
|
+
slug: String(attribute.slug ?? ''),
|
|
518
|
+
name: String(attribute.label ?? attribute.name ?? attribute.slug ?? ''),
|
|
519
|
+
data_type: String(attribute.data_type ?? 'text'),
|
|
520
|
+
unit: attribute.unit ? String(attribute.unit) : null,
|
|
521
|
+
group_name: String(group.name ?? 'General'),
|
|
522
|
+
is_required: Boolean(relation.is_required ?? false),
|
|
523
|
+
is_highlight: Boolean(relation.is_highlight ?? false),
|
|
524
|
+
is_filter_visible: Boolean(relation.is_filter_visible ?? true),
|
|
525
|
+
is_comparison_visible: Boolean(relation.is_comparison_visible ?? false),
|
|
526
|
+
options: Array.isArray(attribute.options)
|
|
527
|
+
? attribute.options.map((option) => ({
|
|
528
|
+
id: Number(option.id),
|
|
529
|
+
slug: String(option.slug ?? ''),
|
|
530
|
+
label: String(option.label ?? option.option_value ?? ''),
|
|
531
|
+
option_value: String(option.option_value ?? ''),
|
|
532
|
+
normalized_value: option.normalized_value
|
|
533
|
+
? String(option.normalized_value)
|
|
534
|
+
: null,
|
|
535
|
+
}))
|
|
536
|
+
: [],
|
|
537
|
+
current_value: current,
|
|
538
|
+
};
|
|
539
|
+
}),
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
private async normalizeAiProductAttributeSuggestions(
|
|
544
|
+
categoryId: number | null,
|
|
545
|
+
rawSuggestions: unknown,
|
|
546
|
+
currentAttributeValues: Record<string, unknown>[],
|
|
547
|
+
warnings: string[],
|
|
548
|
+
) {
|
|
549
|
+
if (!categoryId || rawSuggestions === undefined || rawSuggestions === null) {
|
|
550
|
+
return [];
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const attributesPayload = await this.buildCategoryAttributePayload(categoryId);
|
|
554
|
+
const attributeContext = this.buildAttributeContext(
|
|
555
|
+
attributesPayload,
|
|
556
|
+
currentAttributeValues,
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
const normalizedEntries = Array.isArray(rawSuggestions)
|
|
560
|
+
? rawSuggestions
|
|
561
|
+
: rawSuggestions && typeof rawSuggestions === 'object'
|
|
562
|
+
? Object.entries(rawSuggestions as Record<string, unknown>).map(
|
|
563
|
+
([slug, value]) => ({
|
|
564
|
+
slug,
|
|
565
|
+
...(value && typeof value === 'object'
|
|
566
|
+
? (value as Record<string, unknown>)
|
|
567
|
+
: { value }),
|
|
568
|
+
}),
|
|
569
|
+
)
|
|
570
|
+
: [];
|
|
571
|
+
|
|
572
|
+
const normalizedSuggestions: Record<string, unknown>[] = [];
|
|
573
|
+
|
|
574
|
+
for (const entry of normalizedEntries) {
|
|
575
|
+
const entryRecord = (
|
|
576
|
+
entry && typeof entry === 'object'
|
|
577
|
+
? entry
|
|
578
|
+
: { value: entry }
|
|
579
|
+
) as Record<string, unknown>;
|
|
580
|
+
const rawIdentifier =
|
|
581
|
+
entryRecord.slug ??
|
|
582
|
+
entryRecord.attribute_slug ??
|
|
583
|
+
entryRecord.code ??
|
|
584
|
+
entryRecord.name ??
|
|
585
|
+
entryRecord.attribute ??
|
|
586
|
+
null;
|
|
587
|
+
const normalizedIdentifier = this.normalizeComparableText(rawIdentifier);
|
|
588
|
+
|
|
589
|
+
const attribute = attributeContext.find((item) => {
|
|
590
|
+
return (
|
|
591
|
+
item.id === Number(entryRecord.attribute_id ?? 0) ||
|
|
592
|
+
this.normalizeComparableText(item.slug) === normalizedIdentifier ||
|
|
593
|
+
this.normalizeComparableText(item.name) === normalizedIdentifier
|
|
594
|
+
);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
if (!attribute) {
|
|
598
|
+
if (rawIdentifier) {
|
|
599
|
+
warnings.push(`Structured attribute "${String(rawIdentifier)}" is not available for the selected category.`);
|
|
600
|
+
}
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const rawValue =
|
|
605
|
+
entryRecord.value ??
|
|
606
|
+
entryRecord.value_text ??
|
|
607
|
+
entryRecord.value_number ??
|
|
608
|
+
entryRecord.value_boolean ??
|
|
609
|
+
entryRecord.option ??
|
|
610
|
+
entryRecord.option_value ??
|
|
611
|
+
entryRecord.label;
|
|
612
|
+
const draft: Record<string, unknown> = {
|
|
613
|
+
attribute_id: attribute.id,
|
|
614
|
+
attribute_slug: attribute.slug,
|
|
615
|
+
attribute_name: attribute.name,
|
|
616
|
+
data_type: attribute.data_type,
|
|
617
|
+
group_name: attribute.group_name,
|
|
618
|
+
value_unit: attribute.unit ?? null,
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
if (attribute.data_type === 'option') {
|
|
622
|
+
const normalizedOption = this.normalizeComparableText(
|
|
623
|
+
entryRecord.option ??
|
|
624
|
+
entryRecord.option_value ??
|
|
625
|
+
entryRecord.label ??
|
|
626
|
+
rawValue,
|
|
627
|
+
);
|
|
628
|
+
const option = attribute.options.find((item) => {
|
|
629
|
+
return (
|
|
630
|
+
this.normalizeComparableText(item.option_value) === normalizedOption ||
|
|
631
|
+
this.normalizeComparableText(item.label) === normalizedOption ||
|
|
632
|
+
this.normalizeComparableText(item.slug) === normalizedOption
|
|
633
|
+
);
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
if (!option) {
|
|
637
|
+
warnings.push(`Option value for "${attribute.name}" could not be resolved safely.`);
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
draft.attribute_option_id = option.id;
|
|
642
|
+
draft.value_text = option.option_value;
|
|
643
|
+
} else if (attribute.data_type === 'number') {
|
|
644
|
+
const numberValue = this.normalizeNumberSuggestion(rawValue);
|
|
645
|
+
|
|
646
|
+
if (numberValue === null) {
|
|
647
|
+
warnings.push(`Numeric attribute "${attribute.name}" could not be resolved safely.`);
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
draft.value_number = numberValue;
|
|
652
|
+
draft.raw_value = String(numberValue);
|
|
653
|
+
draft.normalized_value = String(numberValue);
|
|
654
|
+
if (entryRecord.unit) {
|
|
655
|
+
draft.value_unit = String(entryRecord.unit).trim() || attribute.unit || null;
|
|
656
|
+
}
|
|
657
|
+
} else if (attribute.data_type === 'boolean') {
|
|
658
|
+
const booleanValue = this.normalizeBooleanSuggestion(rawValue);
|
|
659
|
+
|
|
660
|
+
if (booleanValue === null) {
|
|
661
|
+
warnings.push(`Boolean attribute "${attribute.name}" could not be resolved safely.`);
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
draft.value_boolean = booleanValue;
|
|
666
|
+
draft.raw_value = String(booleanValue);
|
|
667
|
+
draft.normalized_value = String(booleanValue);
|
|
668
|
+
} else {
|
|
669
|
+
const textValue = String(rawValue ?? '').trim();
|
|
670
|
+
|
|
671
|
+
if (!textValue) {
|
|
672
|
+
continue;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
draft.value_text = textValue;
|
|
676
|
+
draft.raw_value = textValue;
|
|
677
|
+
draft.normalized_value = textValue;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
normalizedSuggestions.push(draft);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
return normalizedSuggestions;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
private buildAiSystemPrompt(
|
|
687
|
+
resource: string,
|
|
688
|
+
locale: string,
|
|
689
|
+
fieldContext: CatalogAiFieldContext[],
|
|
690
|
+
currentValues: Record<string, unknown>,
|
|
691
|
+
productAttributes: Record<string, unknown>[],
|
|
692
|
+
) {
|
|
693
|
+
const isPt = locale?.startsWith('pt');
|
|
694
|
+
const fieldLines = fieldContext.map((field) => {
|
|
695
|
+
const optionText = field.options.length
|
|
696
|
+
? ` options=${field.options.map((option) => `${option.label} (${option.value})`).join(', ')}`
|
|
697
|
+
: '';
|
|
698
|
+
const relationText = field.relation
|
|
699
|
+
? ` relation=${field.relation.resource || field.relation.endpoint || 'external'}`
|
|
700
|
+
: '';
|
|
701
|
+
|
|
702
|
+
return `- ${field.key}: type=${field.type}; required=${field.required ? 'yes' : 'no'}; label="${field.label}"${optionText}${relationText}`;
|
|
703
|
+
});
|
|
704
|
+
const productAttributeLines = productAttributes.map((attribute) => {
|
|
705
|
+
const options = Array.isArray(attribute.options) ? attribute.options : [];
|
|
706
|
+
const optionText = options.length
|
|
707
|
+
? ` options=${options
|
|
708
|
+
.map((option) => `${String((option as Record<string, unknown>).label ?? '')} (${String((option as Record<string, unknown>).option_value ?? '')})`)
|
|
709
|
+
.join(', ')}`
|
|
710
|
+
: '';
|
|
711
|
+
|
|
712
|
+
return `- ${String(attribute.slug)}: type=${String(attribute.data_type)}; name="${String(attribute.name)}"; required=${Boolean(attribute.is_required) ? 'yes' : 'no'}; group="${String(attribute.group_name)}"${optionText}`;
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
return [
|
|
716
|
+
isPt
|
|
717
|
+
? 'Voce preenche formularios administrativos do catalogo. Responda apenas com JSON valido.'
|
|
718
|
+
: 'You fill catalog admin forms. Respond with valid JSON only.',
|
|
719
|
+
isPt
|
|
720
|
+
? `Recurso atual: ${resource}.`
|
|
721
|
+
: `Current resource: ${resource}.`,
|
|
722
|
+
isPt
|
|
723
|
+
? 'Use apenas os campos permitidos listados abaixo. Nao invente ids, arquivos ou campos extras.'
|
|
724
|
+
: 'Use only the allowed fields listed below. Do not invent ids, files, or extra fields.',
|
|
725
|
+
isPt
|
|
726
|
+
? 'Para campos relacionais, prefira nomes/slug legiveis; o backend tentara resolver com seguranca.'
|
|
727
|
+
: 'For relation fields, prefer readable names/slugs; the backend will resolve them safely.',
|
|
728
|
+
isPt
|
|
729
|
+
? 'Para selects, prefira os valores validos informados.'
|
|
730
|
+
: 'For selects, prefer the valid values provided.',
|
|
731
|
+
isPt
|
|
732
|
+
? 'Estrutura da resposta: {"fields": { ... }, "product_attributes": { ... }, "notes": ["..."]}.'
|
|
733
|
+
: 'Response shape: {"fields": { ... }, "product_attributes": { ... }, "notes": ["..."]}.',
|
|
734
|
+
isPt
|
|
735
|
+
? 'Se nao souber um campo, simplesmente omita.'
|
|
736
|
+
: 'If you are unsure about a field, omit it.',
|
|
737
|
+
`Allowed fields:\n${fieldLines.join('\n')}`,
|
|
738
|
+
productAttributeLines.length
|
|
739
|
+
? `Structured product attributes:\n${productAttributeLines.join('\n')}`
|
|
740
|
+
: '',
|
|
741
|
+
`Current values: ${JSON.stringify(currentValues)}`,
|
|
742
|
+
]
|
|
743
|
+
.filter(Boolean)
|
|
744
|
+
.join('\n\n');
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
private sanitizePayload(config: CatalogResourceConfig, body: Record<string, unknown>) {
|
|
748
|
+
const normalizedBody = { ...body };
|
|
749
|
+
|
|
750
|
+
if (config.resource === 'attributes') {
|
|
751
|
+
if (
|
|
752
|
+
normalizedBody.name === undefined &&
|
|
753
|
+
normalizedBody.label !== undefined
|
|
754
|
+
) {
|
|
755
|
+
normalizedBody.name = normalizedBody.label;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
if (normalizedBody.data_type === 'select') {
|
|
759
|
+
normalizedBody.data_type = 'option';
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
if (config.resource === 'category-attributes') {
|
|
764
|
+
if (
|
|
765
|
+
normalizedBody.is_highlight === undefined &&
|
|
766
|
+
normalizedBody.is_highlighted !== undefined
|
|
767
|
+
) {
|
|
768
|
+
normalizedBody.is_highlight = normalizedBody.is_highlighted;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (
|
|
772
|
+
normalizedBody.sort_order === undefined &&
|
|
773
|
+
normalizedBody.display_order !== undefined
|
|
774
|
+
) {
|
|
775
|
+
normalizedBody.sort_order = normalizedBody.display_order;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const payload: Record<string, unknown> = {};
|
|
780
|
+
|
|
781
|
+
for (const field of config.fields) {
|
|
782
|
+
if (Object.prototype.hasOwnProperty.call(normalizedBody, field) && normalizedBody[field] !== undefined) {
|
|
783
|
+
payload[field] = normalizedBody[field];
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
return payload;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
private buildWhere(config: CatalogResourceConfig, paginationParams: PaginationDTO, query: Record<string, unknown>) {
|
|
791
|
+
const OR = config.searchFields.length
|
|
792
|
+
? this.prisma.createInsensitiveSearch(config.searchFields, paginationParams)
|
|
785
793
|
: [];
|
|
786
794
|
const AND: Record<string, unknown>[] = [];
|
|
787
795
|
|
|
@@ -795,475 +803,480 @@ export class CatalogService {
|
|
|
795
803
|
}
|
|
796
804
|
}
|
|
797
805
|
|
|
798
|
-
return { OR, AND };
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
private async ensureCategoryExists(categoryId: number) {
|
|
802
|
-
const category = await this.prisma.catalog_category.findUnique({
|
|
803
|
-
where: { id: categoryId },
|
|
804
|
-
});
|
|
805
|
-
|
|
806
|
-
if (!category) {
|
|
807
|
-
throw new NotFoundException('Catalog category not found');
|
|
808
|
-
}
|
|
809
|
-
|
|
810
|
-
return category;
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
private normalizeCategoryTree(items: Record<string, any>[]) {
|
|
814
|
-
const byId = new Map<number, Record<string, any>>();
|
|
815
|
-
const roots: Record<string, any>[] = [];
|
|
816
|
-
|
|
817
|
-
for (const item of items) {
|
|
818
|
-
byId.set(item.id, { ...item, children: [] });
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
for (const item of byId.values()) {
|
|
822
|
-
if (item.parent_category_id && byId.has(item.parent_category_id)) {
|
|
823
|
-
byId.get(item.parent_category_id)?.children.push(item);
|
|
824
|
-
} else {
|
|
825
|
-
roots.push(item);
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
return roots;
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
private async buildCategoryAttributePayload(categoryId: number, productId?: number) {
|
|
833
|
-
const category = await this.ensureCategoryExists(categoryId);
|
|
834
|
-
const categoryAttributes = await this.prisma.catalog_category_attribute.findMany({
|
|
835
|
-
where: { catalog_category_id: categoryId },
|
|
836
|
-
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
|
|
837
|
-
});
|
|
838
|
-
|
|
839
|
-
const attributeIds = [...new Set(categoryAttributes.map((item) => item.attribute_id))];
|
|
840
|
-
const attributes = attributeIds.length
|
|
841
|
-
? await this.prisma.catalog_attribute.findMany({
|
|
842
|
-
where: { id: { in: attributeIds } },
|
|
843
|
-
orderBy: [{ display_order: 'asc' }, { id: 'asc' }],
|
|
844
|
-
})
|
|
845
|
-
: [];
|
|
846
|
-
const groupIds = [...new Set(attributes.map((item) => item.group_id).filter((value): value is number => typeof value === 'number'))];
|
|
847
|
-
const groups = groupIds.length
|
|
848
|
-
? await this.prisma.catalog_attribute_group.findMany({
|
|
849
|
-
where: { id: { in: groupIds } },
|
|
850
|
-
})
|
|
851
|
-
: [];
|
|
852
|
-
const options = attributeIds.length
|
|
853
|
-
? await this.prisma.catalog_attribute_option.findMany({
|
|
854
|
-
where: { attribute_id: { in: attributeIds } },
|
|
855
|
-
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
|
|
856
|
-
})
|
|
857
|
-
: [];
|
|
858
|
-
const values = productId && attributeIds.length
|
|
859
|
-
? await this.prisma.catalog_product_attribute_value.findMany({
|
|
860
|
-
where: { product_id: productId, attribute_id: { in: attributeIds } },
|
|
861
|
-
include: {
|
|
862
|
-
catalog_attribute_option: true,
|
|
863
|
-
},
|
|
864
|
-
})
|
|
865
|
-
: [];
|
|
866
|
-
|
|
867
|
-
const categoryAttributeMap = new Map(
|
|
868
|
-
categoryAttributes.map((item) => [item.attribute_id, item]),
|
|
869
|
-
);
|
|
870
|
-
const optionMap = new Map<number, Record<string, any>[]>();
|
|
871
|
-
const valueMap = new Map(
|
|
872
|
-
values.map((item) => [item.attribute_id, item]),
|
|
873
|
-
);
|
|
874
|
-
const groupMap = new Map(
|
|
875
|
-
groups.map((item) => [item.id, item]),
|
|
876
|
-
);
|
|
877
|
-
|
|
878
|
-
for (const option of options) {
|
|
879
|
-
const bucket = optionMap.get(option.attribute_id) ?? [];
|
|
880
|
-
bucket.push(option);
|
|
881
|
-
optionMap.set(option.attribute_id, bucket);
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
const grouped = new Map<string, CatalogAttributeGroupPayload>();
|
|
885
|
-
for (const attribute of attributes) {
|
|
886
|
-
const relation = categoryAttributeMap.get(attribute.id);
|
|
887
|
-
if (!relation) {
|
|
888
|
-
continue;
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
const groupId = attribute.group_id ?? 0;
|
|
892
|
-
const group = attribute.group_id ? groupMap.get(attribute.group_id) : null;
|
|
893
|
-
const fallbackGroupName = String(attribute.group_name ?? '').trim() || 'General';
|
|
894
|
-
const groupKey = attribute.group_id ? String(groupId) : `name:${fallbackGroupName}`;
|
|
895
|
-
const existingGroup: CatalogAttributeGroupPayload = grouped.get(groupKey) ?? {
|
|
896
|
-
id: group?.id ?? null,
|
|
897
|
-
slug: group?.slug ?? fallbackGroupName.toLowerCase().replace(/\s+/g, '-'),
|
|
898
|
-
name: group?.name ?? fallbackGroupName,
|
|
899
|
-
attributes: [],
|
|
900
|
-
};
|
|
901
|
-
|
|
902
|
-
existingGroup.attributes.push({
|
|
903
|
-
...attribute,
|
|
904
|
-
category_attribute: relation,
|
|
905
|
-
options: optionMap.get(attribute.id) ?? [],
|
|
906
|
-
value: valueMap.get(attribute.id) ?? null,
|
|
907
|
-
});
|
|
908
|
-
|
|
909
|
-
grouped.set(groupKey, existingGroup);
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
const resultGroups: CatalogAttributeGroupPayload[] = [...grouped.values()].map((group) => ({
|
|
913
|
-
...group,
|
|
914
|
-
attributes: group.attributes.sort((left, right) => {
|
|
915
|
-
const leftRelation = left.category_attribute as Record<string, number>;
|
|
916
|
-
const rightRelation = right.category_attribute as Record<string, number>;
|
|
917
|
-
return (
|
|
918
|
-
Number(leftRelation.sort_order ?? 0) - Number(rightRelation.sort_order ?? 0) ||
|
|
919
|
-
Number(leftRelation.display_order ?? 0) - Number(rightRelation.display_order ?? 0) ||
|
|
920
|
-
Number(left.display_order ?? 0) - Number(right.display_order ?? 0) ||
|
|
921
|
-
Number(left.id ?? 0) - Number(right.id ?? 0)
|
|
922
|
-
);
|
|
923
|
-
}),
|
|
924
|
-
}));
|
|
925
|
-
|
|
926
|
-
resultGroups.sort((left, right) => String(left.name).localeCompare(String(right.name)));
|
|
927
|
-
|
|
928
|
-
return {
|
|
929
|
-
category,
|
|
930
|
-
product_id: productId ?? null,
|
|
931
|
-
groups: resultGroups,
|
|
932
|
-
attributes: resultGroups.flatMap((group) => group.attributes),
|
|
933
|
-
};
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
private isMeaningfulAttributeValue(value: CatalogAttributeValueInput) {
|
|
937
|
-
if (value.attribute_option_id !== undefined && value.attribute_option_id !== null) {
|
|
938
|
-
return true;
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
if (value.value_number !== undefined && value.value_number !== null) {
|
|
942
|
-
return true;
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
if (value.value_boolean !== undefined && value.value_boolean !== null) {
|
|
946
|
-
return true;
|
|
947
|
-
}
|
|
948
|
-
|
|
949
|
-
return String(value.value_text ?? '').trim().length > 0;
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
private buildAttributeSnapshotValue(attribute: Record<string, any>) {
|
|
953
|
-
const value = (attribute.value ?? null) as Record<string, any> | null;
|
|
954
|
-
const dataType = String(attribute.data_type ?? 'text');
|
|
955
|
-
const option = value?.catalog_attribute_option ?? null;
|
|
956
|
-
|
|
957
|
-
if (!value) {
|
|
958
|
-
return {
|
|
959
|
-
value: null,
|
|
960
|
-
value_text: null,
|
|
961
|
-
value_number: null,
|
|
962
|
-
value_boolean: null,
|
|
963
|
-
normalized_value: null,
|
|
964
|
-
raw_value: null,
|
|
965
|
-
option: null,
|
|
966
|
-
};
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
if (dataType === 'option') {
|
|
970
|
-
const optionPayload = option
|
|
971
|
-
? {
|
|
972
|
-
id: Number(option.id),
|
|
973
|
-
slug: String(option.slug ?? ''),
|
|
974
|
-
label: String(option.label ?? option.option_value ?? ''),
|
|
975
|
-
option_value: String(option.option_value ?? ''),
|
|
976
|
-
}
|
|
977
|
-
: null;
|
|
978
|
-
|
|
979
|
-
return {
|
|
980
|
-
value: optionPayload?.option_value ?? value.value_text ?? null,
|
|
981
|
-
value_text: value.value_text ? String(value.value_text) : null,
|
|
982
|
-
value_number: null,
|
|
983
|
-
value_boolean: null,
|
|
984
|
-
normalized_value: value.normalized_value ? String(value.normalized_value) : null,
|
|
985
|
-
raw_value: value.raw_value ? String(value.raw_value) : null,
|
|
986
|
-
option: optionPayload,
|
|
987
|
-
};
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
if (dataType === 'number') {
|
|
991
|
-
return {
|
|
992
|
-
value: value.value_number === null || value.value_number === undefined
|
|
993
|
-
? null
|
|
994
|
-
: Number(value.value_number),
|
|
995
|
-
value_text: value.value_text ? String(value.value_text) : null,
|
|
996
|
-
value_number:
|
|
997
|
-
value.value_number === null || value.value_number === undefined
|
|
998
|
-
? null
|
|
999
|
-
: Number(value.value_number),
|
|
1000
|
-
value_boolean: null,
|
|
1001
|
-
normalized_value: value.normalized_value ? String(value.normalized_value) : null,
|
|
1002
|
-
raw_value: value.raw_value ? String(value.raw_value) : null,
|
|
1003
|
-
option: null,
|
|
1004
|
-
};
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
if (dataType === 'boolean') {
|
|
1008
|
-
return {
|
|
1009
|
-
value:
|
|
1010
|
-
value.value_boolean === null || value.value_boolean === undefined
|
|
1011
|
-
? null
|
|
1012
|
-
: Boolean(value.value_boolean),
|
|
1013
|
-
value_text: value.value_text ? String(value.value_text) : null,
|
|
1014
|
-
value_number: null,
|
|
1015
|
-
value_boolean:
|
|
1016
|
-
value.value_boolean === null || value.value_boolean === undefined
|
|
1017
|
-
? null
|
|
1018
|
-
: Boolean(value.value_boolean),
|
|
1019
|
-
normalized_value: value.normalized_value ? String(value.normalized_value) : null,
|
|
1020
|
-
raw_value: value.raw_value ? String(value.raw_value) : null,
|
|
1021
|
-
option: null,
|
|
1022
|
-
};
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
return {
|
|
1026
|
-
value: value.value_text ? String(value.value_text) : null,
|
|
1027
|
-
value_text: value.value_text ? String(value.value_text) : null,
|
|
1028
|
-
value_number: null,
|
|
1029
|
-
value_boolean: null,
|
|
1030
|
-
normalized_value: value.normalized_value ? String(value.normalized_value) : null,
|
|
1031
|
-
raw_value: value.raw_value ? String(value.raw_value) : null,
|
|
1032
|
-
option: null,
|
|
1033
|
-
};
|
|
1034
|
-
}
|
|
1035
|
-
|
|
1036
|
-
private toAttributeSnapshot(attribute: Record<string, any>): CatalogAttributeSnapshotPayload {
|
|
1037
|
-
const relation = (attribute.category_attribute ?? {}) as Record<string, any>;
|
|
1038
|
-
const snapshotValue = this.buildAttributeSnapshotValue(attribute);
|
|
1039
|
-
|
|
1040
|
-
return {
|
|
1041
|
-
slug: String(attribute.slug ?? ''),
|
|
1042
|
-
name: String(attribute.name ?? attribute.slug ?? ''),
|
|
1043
|
-
data_type: String(attribute.data_type ?? 'text'),
|
|
1044
|
-
unit: attribute.unit ? String(attribute.unit) : null,
|
|
1045
|
-
group_name: String(attribute.group_name ?? 'General'),
|
|
1046
|
-
is_required: Boolean(relation.is_required ?? false),
|
|
1047
|
-
is_highlight: Boolean(relation.is_highlight ?? false),
|
|
1048
|
-
is_filter_visible: Boolean(relation.is_filter_visible ?? false),
|
|
1049
|
-
is_comparison_visible: Boolean(relation.is_comparison_visible ?? false),
|
|
1050
|
-
...snapshotValue,
|
|
1051
|
-
};
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1054
|
-
private async decorateProductRecords<T extends ProductRecordWithRelations>(records: T[]) {
|
|
1055
|
-
if (!records.length) {
|
|
1056
|
-
return records;
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
const brandIds = [...new Set(
|
|
1060
|
-
records
|
|
1061
|
-
.map((record) => record.brand_id)
|
|
1062
|
-
.filter((value): value is number => typeof value === 'number'),
|
|
1063
|
-
)];
|
|
1064
|
-
const categoryIds = [...new Set(
|
|
1065
|
-
records
|
|
1066
|
-
.map((record) => record.catalog_category_id)
|
|
1067
|
-
.filter((value): value is number => typeof value === 'number'),
|
|
1068
|
-
)];
|
|
1069
|
-
|
|
1070
|
-
const [brands, categories] = await Promise.all([
|
|
1071
|
-
brandIds.length
|
|
1072
|
-
? this.prisma.catalog_brand.findMany({
|
|
1073
|
-
where: { id: { in: brandIds } },
|
|
1074
|
-
select: { id: true, name: true, slug: true, logo_file_id: true },
|
|
1075
|
-
})
|
|
1076
|
-
: Promise.resolve([]),
|
|
1077
|
-
categoryIds.length
|
|
1078
|
-
? this.prisma.catalog_category.findMany({
|
|
1079
|
-
where: { id: { in: categoryIds } },
|
|
1080
|
-
select: { id: true, name: true, slug: true },
|
|
1081
|
-
})
|
|
1082
|
-
: Promise.resolve([]),
|
|
1083
|
-
]);
|
|
1084
|
-
|
|
1085
|
-
const brandMap = new Map(brands.map((item) => [item.id, item]));
|
|
1086
|
-
const categoryMap = new Map(categories.map((item) => [item.id, item]));
|
|
1087
|
-
|
|
1088
|
-
return records.map((record) => {
|
|
1089
|
-
const brand = record.brand_id ? brandMap.get(record.brand_id) : null;
|
|
1090
|
-
const category = record.catalog_category_id
|
|
1091
|
-
? categoryMap.get(record.catalog_category_id)
|
|
1092
|
-
: null;
|
|
1093
|
-
|
|
1094
|
-
return {
|
|
1095
|
-
...record,
|
|
1096
|
-
brand_name: brand?.name ?? null,
|
|
1097
|
-
brand_slug: brand?.slug ?? null,
|
|
1098
|
-
brand_logo_file_id: brand?.logo_file_id ?? null,
|
|
1099
|
-
category_name: category?.name ?? null,
|
|
1100
|
-
category_slug: category?.slug ?? null,
|
|
1101
|
-
};
|
|
1102
|
-
});
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
private validateAttributeValueByType(
|
|
1106
|
-
attribute: {
|
|
1107
|
-
id: number;
|
|
1108
|
-
slug: string;
|
|
1109
|
-
name: string;
|
|
1110
|
-
data_type: string;
|
|
1111
|
-
},
|
|
1112
|
-
value: CatalogAttributeValueInput,
|
|
1113
|
-
) {
|
|
1114
|
-
const hasText = String(value.value_text ?? '').trim().length > 0;
|
|
1115
|
-
const hasNumber = value.value_number !== undefined && value.value_number !== null;
|
|
1116
|
-
const hasBoolean = value.value_boolean !== undefined && value.value_boolean !== null;
|
|
1117
|
-
const hasOption = value.attribute_option_id !== undefined && value.attribute_option_id !== null;
|
|
1118
|
-
const filledKinds = [hasText, hasNumber, hasBoolean, hasOption].filter(Boolean).length;
|
|
1119
|
-
const attributeLabel = attribute.name || attribute.slug || `#${attribute.id}`;
|
|
1120
|
-
|
|
1121
|
-
if (filledKinds > 1) {
|
|
1122
|
-
throw new BadRequestException(
|
|
1123
|
-
`Attribute "${attributeLabel}" must use a single value field compatible with its type`,
|
|
1124
|
-
);
|
|
1125
|
-
}
|
|
1126
|
-
|
|
1127
|
-
switch (attribute.data_type) {
|
|
1128
|
-
case 'text':
|
|
1129
|
-
case 'long_text':
|
|
1130
|
-
if (hasNumber || hasBoolean || hasOption) {
|
|
1131
|
-
throw new BadRequestException(`Attribute "${attributeLabel}" only accepts text values`);
|
|
1132
|
-
}
|
|
1133
|
-
break;
|
|
1134
|
-
case 'number':
|
|
1135
|
-
if (hasText || hasBoolean || hasOption) {
|
|
1136
|
-
throw new BadRequestException(`Attribute "${attributeLabel}" only accepts number values`);
|
|
1137
|
-
}
|
|
1138
|
-
break;
|
|
1139
|
-
case 'boolean':
|
|
1140
|
-
if (hasText || hasNumber || hasOption) {
|
|
1141
|
-
throw new BadRequestException(`Attribute "${attributeLabel}" only accepts boolean values`);
|
|
1142
|
-
}
|
|
1143
|
-
break;
|
|
1144
|
-
case 'option':
|
|
1145
|
-
if (hasText || hasNumber || hasBoolean) {
|
|
1146
|
-
throw new BadRequestException(`Attribute "${attributeLabel}" only accepts option values`);
|
|
1147
|
-
}
|
|
1148
|
-
break;
|
|
1149
|
-
default:
|
|
1150
|
-
break;
|
|
1151
|
-
}
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
private async buildProductStructuredPayload(productId: number) {
|
|
1155
|
-
const product = await this.getById('products', productId, 'en');
|
|
1156
|
-
const attributes = await this.listProductAttributes(productId);
|
|
1157
|
-
const groups = (attributes.groups ?? []).map((group) => ({
|
|
1158
|
-
id: group.id,
|
|
1159
|
-
slug: group.slug,
|
|
1160
|
-
name: group.name,
|
|
1161
|
-
attributes: group.attributes.map((attribute) =>
|
|
1162
|
-
this.toAttributeSnapshot(attribute as Record<string, any>),
|
|
1163
|
-
),
|
|
1164
|
-
}));
|
|
1165
|
-
const flatAttributes = groups.flatMap((group) => group.attributes);
|
|
1166
|
-
|
|
1167
|
-
return {
|
|
1168
|
-
product,
|
|
1169
|
-
category: attributes.category,
|
|
1170
|
-
groups,
|
|
1171
|
-
attributes: flatAttributes,
|
|
1172
|
-
comparison: {
|
|
1173
|
-
category: {
|
|
1174
|
-
id: attributes.category?.id ?? null,
|
|
1175
|
-
slug: attributes.category?.slug ?? null,
|
|
1176
|
-
name: attributes.category?.name ?? null,
|
|
1177
|
-
},
|
|
1178
|
-
highlights: flatAttributes.filter((attribute) => attribute.is_highlight),
|
|
1179
|
-
comparable_attributes: flatAttributes.filter(
|
|
1180
|
-
(attribute) => attribute.is_comparison_visible,
|
|
1181
|
-
),
|
|
1182
|
-
filter_attributes: flatAttributes.filter(
|
|
1183
|
-
(attribute) => attribute.is_filter_visible,
|
|
1184
|
-
),
|
|
1185
|
-
},
|
|
1186
|
-
};
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
async materializeProductSnapshots(productId: number) {
|
|
1190
|
-
const structured = await this.buildProductStructuredPayload(productId);
|
|
1191
|
-
const specSnapshot = {
|
|
1192
|
-
version: 1,
|
|
1193
|
-
source: 'catalog_product_attribute_value',
|
|
1194
|
-
category: structured.category
|
|
1195
|
-
? {
|
|
1196
|
-
id: structured.category.id,
|
|
1197
|
-
slug: structured.category.slug,
|
|
1198
|
-
name: structured.category.name,
|
|
1199
|
-
}
|
|
1200
|
-
: null,
|
|
1201
|
-
groups: structured.groups,
|
|
1202
|
-
attributes: structured.attributes.reduce<Record<string, CatalogAttributeSnapshotPayload>>(
|
|
1203
|
-
(acc, attribute) => {
|
|
1204
|
-
acc[attribute.slug] = attribute;
|
|
1205
|
-
return acc;
|
|
1206
|
-
},
|
|
1207
|
-
{},
|
|
1208
|
-
),
|
|
1209
|
-
generated_at: new Date().toISOString(),
|
|
1210
|
-
};
|
|
1211
|
-
const comparisonSnapshot = {
|
|
1212
|
-
version: 1,
|
|
1213
|
-
source: 'catalog_product_attribute_value',
|
|
1214
|
-
category: structured.comparison.category,
|
|
1215
|
-
highlights: structured.comparison.highlights,
|
|
1216
|
-
comparable_attributes: structured.comparison.comparable_attributes,
|
|
1217
|
-
generated_at: new Date().toISOString(),
|
|
1218
|
-
};
|
|
1219
|
-
|
|
1220
|
-
await this.prisma.catalog_product.update({
|
|
1221
|
-
where: { id: productId },
|
|
1222
|
-
data: {
|
|
1223
|
-
// Snapshots remain secondary/derived fields for legacy reads and fast payloads.
|
|
1224
|
-
spec_snapshot_json: specSnapshot,
|
|
1225
|
-
comparison_snapshot_json: comparisonSnapshot,
|
|
1226
|
-
},
|
|
1227
|
-
});
|
|
1228
|
-
|
|
1229
|
-
return {
|
|
1230
|
-
spec_snapshot_json: specSnapshot,
|
|
1231
|
-
comparison_snapshot_json: comparisonSnapshot,
|
|
1232
|
-
};
|
|
1233
|
-
}
|
|
1234
|
-
|
|
1235
|
-
async list(resource: string, locale: string, paginationParams: PaginationDTO, query: Record<string, unknown>) {
|
|
1236
|
-
const config = this.getConfig(resource, locale);
|
|
1237
|
-
const model = this.getModel(resource, locale);
|
|
1238
|
-
const where = this.buildWhere(config, paginationParams, query);
|
|
1239
|
-
|
|
1240
|
-
const result = await this.pagination.paginate(
|
|
1241
|
-
model,
|
|
1242
|
-
paginationParams,
|
|
1243
|
-
{
|
|
1244
|
-
where,
|
|
1245
|
-
orderBy: config.defaultOrderBy ?? { id: 'desc' },
|
|
1246
|
-
},
|
|
1247
|
-
locale,
|
|
1248
|
-
);
|
|
1249
|
-
|
|
1250
|
-
if (resource === 'products' && Array.isArray(result.data)) {
|
|
1251
|
-
return {
|
|
1252
|
-
...result,
|
|
1253
|
-
data: await this.decorateProductRecords(result.data as ProductRecordWithRelations[]),
|
|
1254
|
-
};
|
|
1255
|
-
}
|
|
1256
|
-
|
|
1257
|
-
return result;
|
|
1258
|
-
}
|
|
806
|
+
return { OR, AND };
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
private async ensureCategoryExists(categoryId: number) {
|
|
810
|
+
const category = await this.prisma.catalog_category.findUnique({
|
|
811
|
+
where: { id: categoryId },
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
if (!category) {
|
|
815
|
+
throw new NotFoundException('Catalog category not found');
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
return category;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
private normalizeCategoryTree(items: Record<string, any>[]) {
|
|
822
|
+
const byId = new Map<number, Record<string, any>>();
|
|
823
|
+
const roots: Record<string, any>[] = [];
|
|
824
|
+
|
|
825
|
+
for (const item of items) {
|
|
826
|
+
byId.set(item.id, { ...item, children: [] });
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
for (const item of byId.values()) {
|
|
830
|
+
if (item.parent_category_id && byId.has(item.parent_category_id)) {
|
|
831
|
+
byId.get(item.parent_category_id)?.children.push(item);
|
|
832
|
+
} else {
|
|
833
|
+
roots.push(item);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
return roots;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
private async buildCategoryAttributePayload(categoryId: number, productId?: number) {
|
|
841
|
+
const category = await this.ensureCategoryExists(categoryId);
|
|
842
|
+
const categoryAttributes = await this.prisma.catalog_category_attribute.findMany({
|
|
843
|
+
where: { catalog_category_id: categoryId },
|
|
844
|
+
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
const attributeIds = [...new Set(categoryAttributes.map((item) => item.attribute_id))];
|
|
848
|
+
const attributes = attributeIds.length
|
|
849
|
+
? await this.prisma.catalog_attribute.findMany({
|
|
850
|
+
where: { id: { in: attributeIds } },
|
|
851
|
+
orderBy: [{ display_order: 'asc' }, { id: 'asc' }],
|
|
852
|
+
})
|
|
853
|
+
: [];
|
|
854
|
+
const groupIds = [...new Set(attributes.map((item) => item.group_id).filter((value): value is number => typeof value === 'number'))];
|
|
855
|
+
const groups = groupIds.length
|
|
856
|
+
? await this.prisma.catalog_attribute_group.findMany({
|
|
857
|
+
where: { id: { in: groupIds } },
|
|
858
|
+
})
|
|
859
|
+
: [];
|
|
860
|
+
const options = attributeIds.length
|
|
861
|
+
? await this.prisma.catalog_attribute_option.findMany({
|
|
862
|
+
where: { attribute_id: { in: attributeIds } },
|
|
863
|
+
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
|
|
864
|
+
})
|
|
865
|
+
: [];
|
|
866
|
+
const values = productId && attributeIds.length
|
|
867
|
+
? await this.prisma.catalog_product_attribute_value.findMany({
|
|
868
|
+
where: { product_id: productId, attribute_id: { in: attributeIds } },
|
|
869
|
+
include: {
|
|
870
|
+
catalog_attribute_option: true,
|
|
871
|
+
},
|
|
872
|
+
})
|
|
873
|
+
: [];
|
|
874
|
+
|
|
875
|
+
const categoryAttributeMap = new Map(
|
|
876
|
+
categoryAttributes.map((item) => [item.attribute_id, item]),
|
|
877
|
+
);
|
|
878
|
+
const optionMap = new Map<number, Record<string, any>[]>();
|
|
879
|
+
const valueMap = new Map(
|
|
880
|
+
values.map((item) => [item.attribute_id, item]),
|
|
881
|
+
);
|
|
882
|
+
const groupMap = new Map(
|
|
883
|
+
groups.map((item) => [item.id, item]),
|
|
884
|
+
);
|
|
885
|
+
|
|
886
|
+
for (const option of options) {
|
|
887
|
+
const bucket = optionMap.get(option.attribute_id) ?? [];
|
|
888
|
+
bucket.push(option);
|
|
889
|
+
optionMap.set(option.attribute_id, bucket);
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
const grouped = new Map<string, CatalogAttributeGroupPayload>();
|
|
893
|
+
for (const attribute of attributes) {
|
|
894
|
+
const relation = categoryAttributeMap.get(attribute.id);
|
|
895
|
+
if (!relation) {
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const groupId = attribute.group_id ?? 0;
|
|
900
|
+
const group = attribute.group_id ? groupMap.get(attribute.group_id) : null;
|
|
901
|
+
const fallbackGroupName = String(attribute.group_name ?? '').trim() || 'General';
|
|
902
|
+
const groupKey = attribute.group_id ? String(groupId) : `name:${fallbackGroupName}`;
|
|
903
|
+
const existingGroup: CatalogAttributeGroupPayload = grouped.get(groupKey) ?? {
|
|
904
|
+
id: group?.id ?? null,
|
|
905
|
+
slug: group?.slug ?? fallbackGroupName.toLowerCase().replace(/\s+/g, '-'),
|
|
906
|
+
name: group?.name ?? fallbackGroupName,
|
|
907
|
+
attributes: [],
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
existingGroup.attributes.push({
|
|
911
|
+
...attribute,
|
|
912
|
+
category_attribute: relation,
|
|
913
|
+
options: optionMap.get(attribute.id) ?? [],
|
|
914
|
+
value: valueMap.get(attribute.id) ?? null,
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
grouped.set(groupKey, existingGroup);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
const resultGroups: CatalogAttributeGroupPayload[] = [...grouped.values()].map((group) => ({
|
|
921
|
+
...group,
|
|
922
|
+
attributes: group.attributes.sort((left, right) => {
|
|
923
|
+
const leftRelation = left.category_attribute as Record<string, number>;
|
|
924
|
+
const rightRelation = right.category_attribute as Record<string, number>;
|
|
925
|
+
return (
|
|
926
|
+
Number(leftRelation.sort_order ?? 0) - Number(rightRelation.sort_order ?? 0) ||
|
|
927
|
+
Number(leftRelation.display_order ?? 0) - Number(rightRelation.display_order ?? 0) ||
|
|
928
|
+
Number(left.display_order ?? 0) - Number(right.display_order ?? 0) ||
|
|
929
|
+
Number(left.id ?? 0) - Number(right.id ?? 0)
|
|
930
|
+
);
|
|
931
|
+
}),
|
|
932
|
+
}));
|
|
933
|
+
|
|
934
|
+
resultGroups.sort((left, right) => String(left.name).localeCompare(String(right.name)));
|
|
935
|
+
|
|
936
|
+
return {
|
|
937
|
+
category,
|
|
938
|
+
product_id: productId ?? null,
|
|
939
|
+
groups: resultGroups,
|
|
940
|
+
attributes: resultGroups.flatMap((group) => group.attributes),
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
private isMeaningfulAttributeValue(value: CatalogAttributeValueInput) {
|
|
945
|
+
if (value.attribute_option_id !== undefined && value.attribute_option_id !== null) {
|
|
946
|
+
return true;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
if (value.value_number !== undefined && value.value_number !== null) {
|
|
950
|
+
return true;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
if (value.value_boolean !== undefined && value.value_boolean !== null) {
|
|
954
|
+
return true;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
return String(value.value_text ?? '').trim().length > 0;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
private buildAttributeSnapshotValue(attribute: Record<string, any>) {
|
|
961
|
+
const value = (attribute.value ?? null) as Record<string, any> | null;
|
|
962
|
+
const dataType = String(attribute.data_type ?? 'text');
|
|
963
|
+
const option = value?.catalog_attribute_option ?? null;
|
|
964
|
+
|
|
965
|
+
if (!value) {
|
|
966
|
+
return {
|
|
967
|
+
value: null,
|
|
968
|
+
value_text: null,
|
|
969
|
+
value_number: null,
|
|
970
|
+
value_boolean: null,
|
|
971
|
+
normalized_value: null,
|
|
972
|
+
raw_value: null,
|
|
973
|
+
option: null,
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
if (dataType === 'option') {
|
|
978
|
+
const optionPayload = option
|
|
979
|
+
? {
|
|
980
|
+
id: Number(option.id),
|
|
981
|
+
slug: String(option.slug ?? ''),
|
|
982
|
+
label: String(option.label ?? option.option_value ?? ''),
|
|
983
|
+
option_value: String(option.option_value ?? ''),
|
|
984
|
+
}
|
|
985
|
+
: null;
|
|
986
|
+
|
|
987
|
+
return {
|
|
988
|
+
value: optionPayload?.option_value ?? value.value_text ?? null,
|
|
989
|
+
value_text: value.value_text ? String(value.value_text) : null,
|
|
990
|
+
value_number: null,
|
|
991
|
+
value_boolean: null,
|
|
992
|
+
normalized_value: value.normalized_value ? String(value.normalized_value) : null,
|
|
993
|
+
raw_value: value.raw_value ? String(value.raw_value) : null,
|
|
994
|
+
option: optionPayload,
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
if (dataType === 'number') {
|
|
999
|
+
return {
|
|
1000
|
+
value: value.value_number === null || value.value_number === undefined
|
|
1001
|
+
? null
|
|
1002
|
+
: Number(value.value_number),
|
|
1003
|
+
value_text: value.value_text ? String(value.value_text) : null,
|
|
1004
|
+
value_number:
|
|
1005
|
+
value.value_number === null || value.value_number === undefined
|
|
1006
|
+
? null
|
|
1007
|
+
: Number(value.value_number),
|
|
1008
|
+
value_boolean: null,
|
|
1009
|
+
normalized_value: value.normalized_value ? String(value.normalized_value) : null,
|
|
1010
|
+
raw_value: value.raw_value ? String(value.raw_value) : null,
|
|
1011
|
+
option: null,
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
if (dataType === 'boolean') {
|
|
1016
|
+
return {
|
|
1017
|
+
value:
|
|
1018
|
+
value.value_boolean === null || value.value_boolean === undefined
|
|
1019
|
+
? null
|
|
1020
|
+
: Boolean(value.value_boolean),
|
|
1021
|
+
value_text: value.value_text ? String(value.value_text) : null,
|
|
1022
|
+
value_number: null,
|
|
1023
|
+
value_boolean:
|
|
1024
|
+
value.value_boolean === null || value.value_boolean === undefined
|
|
1025
|
+
? null
|
|
1026
|
+
: Boolean(value.value_boolean),
|
|
1027
|
+
normalized_value: value.normalized_value ? String(value.normalized_value) : null,
|
|
1028
|
+
raw_value: value.raw_value ? String(value.raw_value) : null,
|
|
1029
|
+
option: null,
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
return {
|
|
1034
|
+
value: value.value_text ? String(value.value_text) : null,
|
|
1035
|
+
value_text: value.value_text ? String(value.value_text) : null,
|
|
1036
|
+
value_number: null,
|
|
1037
|
+
value_boolean: null,
|
|
1038
|
+
normalized_value: value.normalized_value ? String(value.normalized_value) : null,
|
|
1039
|
+
raw_value: value.raw_value ? String(value.raw_value) : null,
|
|
1040
|
+
option: null,
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
private toAttributeSnapshot(attribute: Record<string, any>): CatalogAttributeSnapshotPayload {
|
|
1045
|
+
const relation = (attribute.category_attribute ?? {}) as Record<string, any>;
|
|
1046
|
+
const snapshotValue = this.buildAttributeSnapshotValue(attribute);
|
|
1047
|
+
|
|
1048
|
+
return {
|
|
1049
|
+
slug: String(attribute.slug ?? ''),
|
|
1050
|
+
name: String(attribute.name ?? attribute.slug ?? ''),
|
|
1051
|
+
data_type: String(attribute.data_type ?? 'text'),
|
|
1052
|
+
unit: attribute.unit ? String(attribute.unit) : null,
|
|
1053
|
+
group_name: String(attribute.group_name ?? 'General'),
|
|
1054
|
+
is_required: Boolean(relation.is_required ?? false),
|
|
1055
|
+
is_highlight: Boolean(relation.is_highlight ?? false),
|
|
1056
|
+
is_filter_visible: Boolean(relation.is_filter_visible ?? false),
|
|
1057
|
+
is_comparison_visible: Boolean(relation.is_comparison_visible ?? false),
|
|
1058
|
+
...snapshotValue,
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
private async decorateProductRecords<T extends ProductRecordWithRelations>(records: T[]) {
|
|
1063
|
+
if (!records.length) {
|
|
1064
|
+
return records;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
const brandIds = [...new Set(
|
|
1068
|
+
records
|
|
1069
|
+
.map((record) => record.brand_id)
|
|
1070
|
+
.filter((value): value is number => typeof value === 'number'),
|
|
1071
|
+
)];
|
|
1072
|
+
const categoryIds = [...new Set(
|
|
1073
|
+
records
|
|
1074
|
+
.map((record) => record.catalog_category_id)
|
|
1075
|
+
.filter((value): value is number => typeof value === 'number'),
|
|
1076
|
+
)];
|
|
1077
|
+
|
|
1078
|
+
const [brands, categories] = await Promise.all([
|
|
1079
|
+
brandIds.length
|
|
1080
|
+
? this.prisma.catalog_brand.findMany({
|
|
1081
|
+
where: { id: { in: brandIds } },
|
|
1082
|
+
select: { id: true, name: true, slug: true, logo_file_id: true },
|
|
1083
|
+
})
|
|
1084
|
+
: Promise.resolve([]),
|
|
1085
|
+
categoryIds.length
|
|
1086
|
+
? this.prisma.catalog_category.findMany({
|
|
1087
|
+
where: { id: { in: categoryIds } },
|
|
1088
|
+
select: { id: true, name: true, slug: true },
|
|
1089
|
+
})
|
|
1090
|
+
: Promise.resolve([]),
|
|
1091
|
+
]);
|
|
1092
|
+
|
|
1093
|
+
const brandMap = new Map(brands.map((item) => [item.id, item]));
|
|
1094
|
+
const categoryMap = new Map(categories.map((item) => [item.id, item]));
|
|
1095
|
+
|
|
1096
|
+
return records.map((record) => {
|
|
1097
|
+
const brand = record.brand_id ? brandMap.get(record.brand_id) : null;
|
|
1098
|
+
const category = record.catalog_category_id
|
|
1099
|
+
? categoryMap.get(record.catalog_category_id)
|
|
1100
|
+
: null;
|
|
1101
|
+
|
|
1102
|
+
return {
|
|
1103
|
+
...record,
|
|
1104
|
+
brand_name: brand?.name ?? null,
|
|
1105
|
+
brand_slug: brand?.slug ?? null,
|
|
1106
|
+
brand_logo_file_id: brand?.logo_file_id ?? null,
|
|
1107
|
+
category_name: category?.name ?? null,
|
|
1108
|
+
category_slug: category?.slug ?? null,
|
|
1109
|
+
};
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
private validateAttributeValueByType(
|
|
1114
|
+
attribute: {
|
|
1115
|
+
id: number;
|
|
1116
|
+
slug: string;
|
|
1117
|
+
name: string;
|
|
1118
|
+
data_type: string;
|
|
1119
|
+
},
|
|
1120
|
+
value: CatalogAttributeValueInput,
|
|
1121
|
+
) {
|
|
1122
|
+
const hasText = String(value.value_text ?? '').trim().length > 0;
|
|
1123
|
+
const hasNumber = value.value_number !== undefined && value.value_number !== null;
|
|
1124
|
+
const hasBoolean = value.value_boolean !== undefined && value.value_boolean !== null;
|
|
1125
|
+
const hasOption = value.attribute_option_id !== undefined && value.attribute_option_id !== null;
|
|
1126
|
+
const filledKinds = [hasText, hasNumber, hasBoolean, hasOption].filter(Boolean).length;
|
|
1127
|
+
const attributeLabel = attribute.name || attribute.slug || `#${attribute.id}`;
|
|
1128
|
+
|
|
1129
|
+
if (filledKinds > 1) {
|
|
1130
|
+
throw new BadRequestException(
|
|
1131
|
+
`Attribute "${attributeLabel}" must use a single value field compatible with its type`,
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
switch (attribute.data_type) {
|
|
1136
|
+
case 'text':
|
|
1137
|
+
case 'long_text':
|
|
1138
|
+
if (hasNumber || hasBoolean || hasOption) {
|
|
1139
|
+
throw new BadRequestException(`Attribute "${attributeLabel}" only accepts text values`);
|
|
1140
|
+
}
|
|
1141
|
+
break;
|
|
1142
|
+
case 'number':
|
|
1143
|
+
if (hasText || hasBoolean || hasOption) {
|
|
1144
|
+
throw new BadRequestException(`Attribute "${attributeLabel}" only accepts number values`);
|
|
1145
|
+
}
|
|
1146
|
+
break;
|
|
1147
|
+
case 'boolean':
|
|
1148
|
+
if (hasText || hasNumber || hasOption) {
|
|
1149
|
+
throw new BadRequestException(`Attribute "${attributeLabel}" only accepts boolean values`);
|
|
1150
|
+
}
|
|
1151
|
+
break;
|
|
1152
|
+
case 'option':
|
|
1153
|
+
if (hasText || hasNumber || hasBoolean) {
|
|
1154
|
+
throw new BadRequestException(`Attribute "${attributeLabel}" only accepts option values`);
|
|
1155
|
+
}
|
|
1156
|
+
break;
|
|
1157
|
+
default:
|
|
1158
|
+
break;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
private async buildProductStructuredPayload(productId: number) {
|
|
1163
|
+
const product = await this.getById('products', productId, 'en');
|
|
1164
|
+
const attributes = await this.listProductAttributes(productId);
|
|
1165
|
+
const groups = (attributes.groups ?? []).map((group) => ({
|
|
1166
|
+
id: group.id,
|
|
1167
|
+
slug: group.slug,
|
|
1168
|
+
name: group.name,
|
|
1169
|
+
attributes: group.attributes.map((attribute) =>
|
|
1170
|
+
this.toAttributeSnapshot(attribute as Record<string, any>),
|
|
1171
|
+
),
|
|
1172
|
+
}));
|
|
1173
|
+
const flatAttributes = groups.flatMap((group) => group.attributes);
|
|
1174
|
+
|
|
1175
|
+
return {
|
|
1176
|
+
product,
|
|
1177
|
+
category: attributes.category,
|
|
1178
|
+
groups,
|
|
1179
|
+
attributes: flatAttributes,
|
|
1180
|
+
comparison: {
|
|
1181
|
+
category: {
|
|
1182
|
+
id: attributes.category?.id ?? null,
|
|
1183
|
+
slug: attributes.category?.slug ?? null,
|
|
1184
|
+
name: attributes.category?.name ?? null,
|
|
1185
|
+
},
|
|
1186
|
+
highlights: flatAttributes.filter((attribute) => attribute.is_highlight),
|
|
1187
|
+
comparable_attributes: flatAttributes.filter(
|
|
1188
|
+
(attribute) => attribute.is_comparison_visible,
|
|
1189
|
+
),
|
|
1190
|
+
filter_attributes: flatAttributes.filter(
|
|
1191
|
+
(attribute) => attribute.is_filter_visible,
|
|
1192
|
+
),
|
|
1193
|
+
},
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
async materializeProductSnapshots(productId: number) {
|
|
1198
|
+
const structured = await this.buildProductStructuredPayload(productId);
|
|
1199
|
+
const specSnapshot = {
|
|
1200
|
+
version: 1,
|
|
1201
|
+
source: 'catalog_product_attribute_value',
|
|
1202
|
+
category: structured.category
|
|
1203
|
+
? {
|
|
1204
|
+
id: structured.category.id,
|
|
1205
|
+
slug: structured.category.slug,
|
|
1206
|
+
name: structured.category.name,
|
|
1207
|
+
}
|
|
1208
|
+
: null,
|
|
1209
|
+
groups: structured.groups,
|
|
1210
|
+
attributes: structured.attributes.reduce<Record<string, CatalogAttributeSnapshotPayload>>(
|
|
1211
|
+
(acc, attribute) => {
|
|
1212
|
+
acc[attribute.slug] = attribute;
|
|
1213
|
+
return acc;
|
|
1214
|
+
},
|
|
1215
|
+
{},
|
|
1216
|
+
),
|
|
1217
|
+
generated_at: new Date().toISOString(),
|
|
1218
|
+
};
|
|
1219
|
+
const comparisonSnapshot = {
|
|
1220
|
+
version: 1,
|
|
1221
|
+
source: 'catalog_product_attribute_value',
|
|
1222
|
+
category: structured.comparison.category,
|
|
1223
|
+
highlights: structured.comparison.highlights,
|
|
1224
|
+
comparable_attributes: structured.comparison.comparable_attributes,
|
|
1225
|
+
generated_at: new Date().toISOString(),
|
|
1226
|
+
};
|
|
1227
|
+
|
|
1228
|
+
await this.prisma.catalog_product.update({
|
|
1229
|
+
where: { id: productId },
|
|
1230
|
+
data: {
|
|
1231
|
+
// Snapshots remain secondary/derived fields for legacy reads and fast payloads.
|
|
1232
|
+
spec_snapshot_json: specSnapshot,
|
|
1233
|
+
comparison_snapshot_json: comparisonSnapshot,
|
|
1234
|
+
},
|
|
1235
|
+
});
|
|
1236
|
+
|
|
1237
|
+
return {
|
|
1238
|
+
spec_snapshot_json: specSnapshot,
|
|
1239
|
+
comparison_snapshot_json: comparisonSnapshot,
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
async list(resource: string, locale: string, paginationParams: PaginationDTO, query: Record<string, unknown>) {
|
|
1244
|
+
const config = this.getConfig(resource, locale);
|
|
1245
|
+
const model = this.getModel(resource, locale);
|
|
1246
|
+
const where = this.buildWhere(config, paginationParams, query);
|
|
1247
|
+
|
|
1248
|
+
const result = await this.pagination.paginate(
|
|
1249
|
+
model,
|
|
1250
|
+
paginationParams,
|
|
1251
|
+
{
|
|
1252
|
+
where,
|
|
1253
|
+
orderBy: config.defaultOrderBy ?? { id: 'desc' },
|
|
1254
|
+
},
|
|
1255
|
+
locale,
|
|
1256
|
+
);
|
|
1257
|
+
|
|
1258
|
+
if (resource === 'products' && Array.isArray(result.data)) {
|
|
1259
|
+
return {
|
|
1260
|
+
...result,
|
|
1261
|
+
data: await this.decorateProductRecords(result.data as ProductRecordWithRelations[]),
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
return result;
|
|
1266
|
+
}
|
|
1259
1267
|
|
|
1260
1268
|
async stats(resource: string, locale: string) {
|
|
1261
1269
|
const config = this.getConfig(resource, locale);
|
|
1262
1270
|
const model = this.getModel(resource, locale);
|
|
1263
1271
|
const total = await model.count({});
|
|
1264
1272
|
const result: Record<string, unknown> = { total };
|
|
1273
|
+
const availableFields = Object.keys((model?.fields ?? {}) as Record<string, unknown>);
|
|
1265
1274
|
|
|
1266
|
-
if (
|
|
1275
|
+
if (
|
|
1276
|
+
config.statusField &&
|
|
1277
|
+
config.activeStatusValue &&
|
|
1278
|
+
availableFields.includes(config.statusField)
|
|
1279
|
+
) {
|
|
1267
1280
|
result.active = await model.count({
|
|
1268
1281
|
where: {
|
|
1269
1282
|
[config.statusField]: config.activeStatusValue,
|
|
@@ -1274,52 +1287,52 @@ export class CatalogService {
|
|
|
1274
1287
|
return result;
|
|
1275
1288
|
}
|
|
1276
1289
|
|
|
1277
|
-
async getById(resource: string, id: number, locale: string) {
|
|
1278
|
-
if (resource === 'products') {
|
|
1279
|
-
const item = await this.prisma.catalog_product.findUnique({
|
|
1280
|
-
where: { id },
|
|
1281
|
-
include: {
|
|
1282
|
-
catalog_brand: {
|
|
1283
|
-
select: {
|
|
1284
|
-
id: true,
|
|
1285
|
-
name: true,
|
|
1286
|
-
slug: true,
|
|
1287
|
-
logo_file_id: true,
|
|
1288
|
-
},
|
|
1289
|
-
},
|
|
1290
|
-
catalog_category: {
|
|
1291
|
-
select: {
|
|
1292
|
-
id: true,
|
|
1293
|
-
name: true,
|
|
1294
|
-
slug: true,
|
|
1295
|
-
},
|
|
1296
|
-
},
|
|
1297
|
-
catalog_product_image: {
|
|
1298
|
-
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
|
|
1299
|
-
select: {
|
|
1300
|
-
id: true,
|
|
1301
|
-
file_id: true,
|
|
1302
|
-
role: true,
|
|
1303
|
-
sort_order: true,
|
|
1304
|
-
is_primary: true,
|
|
1305
|
-
alt_text: true,
|
|
1306
|
-
},
|
|
1307
|
-
},
|
|
1308
|
-
},
|
|
1309
|
-
});
|
|
1310
|
-
|
|
1311
|
-
if (!item) {
|
|
1312
|
-
throw new NotFoundException(
|
|
1313
|
-
getLocaleText('resourceNotFound', locale, 'Catalog resource not found'),
|
|
1314
|
-
);
|
|
1315
|
-
}
|
|
1316
|
-
|
|
1317
|
-
const [decorated] = await this.decorateProductRecords([item]);
|
|
1318
|
-
return decorated;
|
|
1319
|
-
}
|
|
1320
|
-
|
|
1321
|
-
const model = this.getModel(resource, locale);
|
|
1322
|
-
const item = await model.findUnique({ where: { id } });
|
|
1290
|
+
async getById(resource: string, id: number, locale: string) {
|
|
1291
|
+
if (resource === 'products') {
|
|
1292
|
+
const item = await this.prisma.catalog_product.findUnique({
|
|
1293
|
+
where: { id },
|
|
1294
|
+
include: {
|
|
1295
|
+
catalog_brand: {
|
|
1296
|
+
select: {
|
|
1297
|
+
id: true,
|
|
1298
|
+
name: true,
|
|
1299
|
+
slug: true,
|
|
1300
|
+
logo_file_id: true,
|
|
1301
|
+
},
|
|
1302
|
+
},
|
|
1303
|
+
catalog_category: {
|
|
1304
|
+
select: {
|
|
1305
|
+
id: true,
|
|
1306
|
+
name: true,
|
|
1307
|
+
slug: true,
|
|
1308
|
+
},
|
|
1309
|
+
},
|
|
1310
|
+
catalog_product_image: {
|
|
1311
|
+
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
|
|
1312
|
+
select: {
|
|
1313
|
+
id: true,
|
|
1314
|
+
file_id: true,
|
|
1315
|
+
role: true,
|
|
1316
|
+
sort_order: true,
|
|
1317
|
+
is_primary: true,
|
|
1318
|
+
alt_text: true,
|
|
1319
|
+
},
|
|
1320
|
+
},
|
|
1321
|
+
},
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
if (!item) {
|
|
1325
|
+
throw new NotFoundException(
|
|
1326
|
+
getLocaleText('resourceNotFound', locale, 'Catalog resource not found'),
|
|
1327
|
+
);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
const [decorated] = await this.decorateProductRecords([item]);
|
|
1331
|
+
return decorated;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
const model = this.getModel(resource, locale);
|
|
1335
|
+
const item = await model.findUnique({ where: { id } });
|
|
1323
1336
|
|
|
1324
1337
|
if (!item) {
|
|
1325
1338
|
throw new NotFoundException(
|
|
@@ -1356,8 +1369,8 @@ export class CatalogService {
|
|
|
1356
1369
|
return model.delete({ where: { id } });
|
|
1357
1370
|
}
|
|
1358
1371
|
|
|
1359
|
-
async listProductImages(productId: number, locale: string, paginationParams: PaginationDTO) {
|
|
1360
|
-
const model = this.getModel('product-images', locale);
|
|
1372
|
+
async listProductImages(productId: number, locale: string, paginationParams: PaginationDTO) {
|
|
1373
|
+
const model = this.getModel('product-images', locale);
|
|
1361
1374
|
|
|
1362
1375
|
return this.pagination.paginate(
|
|
1363
1376
|
model,
|
|
@@ -1370,311 +1383,310 @@ export class CatalogService {
|
|
|
1370
1383
|
sort_order: 'asc',
|
|
1371
1384
|
},
|
|
1372
1385
|
},
|
|
1373
|
-
locale,
|
|
1374
|
-
);
|
|
1375
|
-
}
|
|
1376
|
-
|
|
1377
|
-
async getCategoriesTree() {
|
|
1378
|
-
const categories = await this.prisma.catalog_category.findMany({
|
|
1379
|
-
orderBy: [{ sort_order: 'asc' }, { name: 'asc' }],
|
|
1380
|
-
});
|
|
1381
|
-
|
|
1382
|
-
return this.normalizeCategoryTree(categories);
|
|
1383
|
-
}
|
|
1384
|
-
|
|
1385
|
-
async listCategoryAttributes(categoryId: number) {
|
|
1386
|
-
return this.buildCategoryAttributePayload(categoryId);
|
|
1387
|
-
}
|
|
1388
|
-
|
|
1389
|
-
async listProductAttributes(productId: number) {
|
|
1390
|
-
const product = await this.prisma.catalog_product.findUnique({
|
|
1391
|
-
where: { id: productId },
|
|
1392
|
-
});
|
|
1393
|
-
|
|
1394
|
-
if (!product) {
|
|
1395
|
-
throw new NotFoundException('Catalog product not found');
|
|
1396
|
-
}
|
|
1397
|
-
|
|
1398
|
-
return this.buildCategoryAttributePayload(product.catalog_category_id, productId);
|
|
1399
|
-
}
|
|
1400
|
-
|
|
1401
|
-
async getProductStructuredPayload(productId: number) {
|
|
1402
|
-
return this.buildProductStructuredPayload(productId);
|
|
1403
|
-
}
|
|
1404
|
-
|
|
1405
|
-
async getProductComparisonPayload(productId: number) {
|
|
1406
|
-
const structured = await this.buildProductStructuredPayload(productId);
|
|
1407
|
-
return structured.comparison;
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
|
-
async updateProductAttributes(productId: number, values: CatalogAttributeValueInput[]) {
|
|
1411
|
-
const product = await this.prisma.catalog_product.findUnique({
|
|
1412
|
-
where: { id: productId },
|
|
1413
|
-
});
|
|
1414
|
-
|
|
1415
|
-
if (!product) {
|
|
1416
|
-
throw new NotFoundException('Catalog product not found');
|
|
1417
|
-
}
|
|
1418
|
-
|
|
1419
|
-
const categoryAttributes = await this.prisma.catalog_category_attribute.findMany({
|
|
1420
|
-
where: { catalog_category_id: product.catalog_category_id },
|
|
1421
|
-
});
|
|
1422
|
-
const allowedAttributeIds = new Set(categoryAttributes.map((item) => item.attribute_id));
|
|
1423
|
-
const requiredAttributeIds = new Set(
|
|
1424
|
-
categoryAttributes
|
|
1425
|
-
.filter((item) => item.is_required)
|
|
1426
|
-
.map((item) => item.attribute_id),
|
|
1427
|
-
);
|
|
1428
|
-
const attributes = allowedAttributeIds.size
|
|
1429
|
-
? await this.prisma.catalog_attribute.findMany({
|
|
1430
|
-
where: { id: { in: [...allowedAttributeIds] } },
|
|
1431
|
-
select: {
|
|
1432
|
-
id: true,
|
|
1433
|
-
slug: true,
|
|
1434
|
-
name: true,
|
|
1435
|
-
data_type: true,
|
|
1436
|
-
},
|
|
1437
|
-
})
|
|
1438
|
-
: [];
|
|
1439
|
-
const attributeMap = new Map(attributes.map((item) => [item.id, item]));
|
|
1440
|
-
const existingValues = await this.prisma.catalog_product_attribute_value.findMany({
|
|
1441
|
-
where: { product_id: productId },
|
|
1442
|
-
});
|
|
1443
|
-
const optionIds = values
|
|
1444
|
-
.map((value) => value.attribute_option_id)
|
|
1445
|
-
.filter((value): value is number => typeof value === 'number');
|
|
1446
|
-
const options = optionIds.length
|
|
1447
|
-
? await this.prisma.catalog_attribute_option.findMany({
|
|
1448
|
-
where: { id: { in: optionIds } },
|
|
1449
|
-
})
|
|
1450
|
-
: [];
|
|
1451
|
-
const optionMap = new Map(options.map((item) => [item.id, item]));
|
|
1452
|
-
|
|
1453
|
-
const
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
const
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
const
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
const
|
|
1604
|
-
const
|
|
1605
|
-
const
|
|
1606
|
-
const
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
const
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
field,
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
.
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
...
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
}
|
|
1386
|
+
locale,
|
|
1387
|
+
);
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
async getCategoriesTree() {
|
|
1391
|
+
const categories = await this.prisma.catalog_category.findMany({
|
|
1392
|
+
orderBy: [{ sort_order: 'asc' }, { name: 'asc' }],
|
|
1393
|
+
});
|
|
1394
|
+
|
|
1395
|
+
return this.normalizeCategoryTree(categories);
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
async listCategoryAttributes(categoryId: number) {
|
|
1399
|
+
return this.buildCategoryAttributePayload(categoryId);
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
async listProductAttributes(productId: number) {
|
|
1403
|
+
const product = await this.prisma.catalog_product.findUnique({
|
|
1404
|
+
where: { id: productId },
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
if (!product) {
|
|
1408
|
+
throw new NotFoundException('Catalog product not found');
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
return this.buildCategoryAttributePayload(product.catalog_category_id, productId);
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
async getProductStructuredPayload(productId: number) {
|
|
1415
|
+
return this.buildProductStructuredPayload(productId);
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
async getProductComparisonPayload(productId: number) {
|
|
1419
|
+
const structured = await this.buildProductStructuredPayload(productId);
|
|
1420
|
+
return structured.comparison;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
async updateProductAttributes(productId: number, values: CatalogAttributeValueInput[]) {
|
|
1424
|
+
const product = await this.prisma.catalog_product.findUnique({
|
|
1425
|
+
where: { id: productId },
|
|
1426
|
+
});
|
|
1427
|
+
|
|
1428
|
+
if (!product) {
|
|
1429
|
+
throw new NotFoundException('Catalog product not found');
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
const categoryAttributes = await this.prisma.catalog_category_attribute.findMany({
|
|
1433
|
+
where: { catalog_category_id: product.catalog_category_id },
|
|
1434
|
+
});
|
|
1435
|
+
const allowedAttributeIds = new Set(categoryAttributes.map((item) => item.attribute_id));
|
|
1436
|
+
const requiredAttributeIds = new Set(
|
|
1437
|
+
categoryAttributes
|
|
1438
|
+
.filter((item) => item.is_required)
|
|
1439
|
+
.map((item) => item.attribute_id),
|
|
1440
|
+
);
|
|
1441
|
+
const attributes = allowedAttributeIds.size
|
|
1442
|
+
? await this.prisma.catalog_attribute.findMany({
|
|
1443
|
+
where: { id: { in: [...allowedAttributeIds] } },
|
|
1444
|
+
select: {
|
|
1445
|
+
id: true,
|
|
1446
|
+
slug: true,
|
|
1447
|
+
name: true,
|
|
1448
|
+
data_type: true,
|
|
1449
|
+
},
|
|
1450
|
+
})
|
|
1451
|
+
: [];
|
|
1452
|
+
const attributeMap = new Map(attributes.map((item) => [item.id, item]));
|
|
1453
|
+
const existingValues = await this.prisma.catalog_product_attribute_value.findMany({
|
|
1454
|
+
where: { product_id: productId },
|
|
1455
|
+
});
|
|
1456
|
+
const optionIds = values
|
|
1457
|
+
.map((value) => value.attribute_option_id)
|
|
1458
|
+
.filter((value): value is number => typeof value === 'number');
|
|
1459
|
+
const options = optionIds.length
|
|
1460
|
+
? await this.prisma.catalog_attribute_option.findMany({
|
|
1461
|
+
where: { id: { in: optionIds } },
|
|
1462
|
+
})
|
|
1463
|
+
: [];
|
|
1464
|
+
const optionMap = new Map(options.map((item) => [item.id, item]));
|
|
1465
|
+
const incomingByAttribute = new Map<number, CatalogAttributeValueInput>();
|
|
1466
|
+
for (const value of values) {
|
|
1467
|
+
const attributeId = Number(value.attribute_id ?? 0);
|
|
1468
|
+
|
|
1469
|
+
if (!attributeId || !allowedAttributeIds.has(attributeId)) {
|
|
1470
|
+
throw new BadRequestException(`Attribute ${attributeId} is not allowed for this category`);
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
const attribute = attributeMap.get(attributeId);
|
|
1474
|
+
|
|
1475
|
+
if (!attribute) {
|
|
1476
|
+
throw new BadRequestException(`Attribute ${attributeId} was not found`);
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
this.validateAttributeValueByType(attribute, value);
|
|
1480
|
+
|
|
1481
|
+
incomingByAttribute.set(attributeId, value);
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
const incomingAttributeIds = new Set(incomingByAttribute.keys());
|
|
1485
|
+
|
|
1486
|
+
for (const attributeId of requiredAttributeIds) {
|
|
1487
|
+
const requiredValue = incomingByAttribute.get(attributeId);
|
|
1488
|
+
|
|
1489
|
+
if (!requiredValue || !this.isMeaningfulAttributeValue(requiredValue)) {
|
|
1490
|
+
const attribute = attributeMap.get(attributeId);
|
|
1491
|
+
throw new BadRequestException(
|
|
1492
|
+
`Attribute "${attribute?.name ?? attribute?.slug ?? attributeId}" is required for this category`,
|
|
1493
|
+
);
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
for (const existingValue of existingValues) {
|
|
1498
|
+
if (!incomingAttributeIds.has(existingValue.attribute_id)) {
|
|
1499
|
+
await this.prisma.catalog_product_attribute_value.delete({
|
|
1500
|
+
where: { id: existingValue.id },
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
for (const [attributeId, value] of incomingByAttribute.entries()) {
|
|
1506
|
+
const option = value.attribute_option_id ? optionMap.get(value.attribute_option_id) : null;
|
|
1507
|
+
const attribute = attributeMap.get(attributeId);
|
|
1508
|
+
|
|
1509
|
+
if (value.attribute_option_id && (!option || option.attribute_id !== attributeId)) {
|
|
1510
|
+
throw new BadRequestException(
|
|
1511
|
+
`Option ${value.attribute_option_id} does not belong to attribute ${attribute?.name ?? attributeId}`,
|
|
1512
|
+
);
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
const normalizedText = value.normalized_text ?? option?.normalized_value ?? option?.option_value ?? value.value_text ?? null;
|
|
1516
|
+
const sourceType: catalog_product_attribute_value_source_type_8522ae66b0_enum =
|
|
1517
|
+
value.source_type === 'import' || value.source_type === 'computed'
|
|
1518
|
+
? value.source_type
|
|
1519
|
+
: 'manual';
|
|
1520
|
+
const payload = {
|
|
1521
|
+
attribute_option_id: value.attribute_option_id ?? null,
|
|
1522
|
+
value_text: option?.option_value ?? (String(value.value_text ?? '').trim() || null),
|
|
1523
|
+
value_number: value.value_number ?? null,
|
|
1524
|
+
value_boolean: value.value_boolean ?? null,
|
|
1525
|
+
raw_value:
|
|
1526
|
+
option?.option_value ??
|
|
1527
|
+
(value.value_number !== undefined && value.value_number !== null
|
|
1528
|
+
? String(value.value_number)
|
|
1529
|
+
: value.value_boolean !== undefined && value.value_boolean !== null
|
|
1530
|
+
? String(value.value_boolean)
|
|
1531
|
+
: String(value.value_text ?? '').trim() || null),
|
|
1532
|
+
value_unit: String(value.value_unit ?? '').trim() || null,
|
|
1533
|
+
normalized_value:
|
|
1534
|
+
normalizedText
|
|
1535
|
+
? String(normalizedText).trim()
|
|
1536
|
+
: value.value_number !== undefined && value.value_number !== null
|
|
1537
|
+
? String(value.value_number)
|
|
1538
|
+
: value.value_boolean !== undefined && value.value_boolean !== null
|
|
1539
|
+
? String(value.value_boolean)
|
|
1540
|
+
: null,
|
|
1541
|
+
normalized_text: normalizedText ? String(normalizedText).trim() : null,
|
|
1542
|
+
normalized_number: value.normalized_number ?? value.value_number ?? null,
|
|
1543
|
+
source_type: sourceType,
|
|
1544
|
+
confidence_score: value.confidence_score ?? null,
|
|
1545
|
+
is_verified: Boolean(value.is_verified ?? false),
|
|
1546
|
+
} satisfies CatalogAttributeValueInput & {
|
|
1547
|
+
source_type: catalog_product_attribute_value_source_type_8522ae66b0_enum;
|
|
1548
|
+
};
|
|
1549
|
+
|
|
1550
|
+
const existingValue = existingValues.find((item) => item.attribute_id === attributeId);
|
|
1551
|
+
|
|
1552
|
+
if (!this.isMeaningfulAttributeValue(payload)) {
|
|
1553
|
+
if (existingValue) {
|
|
1554
|
+
await this.prisma.catalog_product_attribute_value.delete({
|
|
1555
|
+
where: { id: existingValue.id },
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1558
|
+
continue;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
if (existingValue) {
|
|
1562
|
+
const updatePayload: Prisma.catalog_product_attribute_valueUncheckedUpdateInput = {
|
|
1563
|
+
attribute_option_id: payload.attribute_option_id,
|
|
1564
|
+
value_text: payload.value_text,
|
|
1565
|
+
value_number: payload.value_number,
|
|
1566
|
+
value_boolean: payload.value_boolean,
|
|
1567
|
+
raw_value: payload.raw_value,
|
|
1568
|
+
value_unit: payload.value_unit,
|
|
1569
|
+
normalized_value: payload.normalized_value,
|
|
1570
|
+
normalized_text: payload.normalized_text,
|
|
1571
|
+
normalized_number: payload.normalized_number,
|
|
1572
|
+
source_type: payload.source_type,
|
|
1573
|
+
confidence_score: payload.confidence_score,
|
|
1574
|
+
is_verified: payload.is_verified,
|
|
1575
|
+
};
|
|
1576
|
+
|
|
1577
|
+
await this.prisma.catalog_product_attribute_value.update({
|
|
1578
|
+
where: { id: existingValue.id },
|
|
1579
|
+
data: updatePayload,
|
|
1580
|
+
});
|
|
1581
|
+
} else {
|
|
1582
|
+
const createPayload: Prisma.catalog_product_attribute_valueUncheckedCreateInput = {
|
|
1583
|
+
product_id: productId,
|
|
1584
|
+
attribute_id: attributeId,
|
|
1585
|
+
attribute_option_id: payload.attribute_option_id,
|
|
1586
|
+
value_text: payload.value_text,
|
|
1587
|
+
value_number: payload.value_number,
|
|
1588
|
+
value_boolean: payload.value_boolean,
|
|
1589
|
+
raw_value: payload.raw_value,
|
|
1590
|
+
value_unit: payload.value_unit,
|
|
1591
|
+
normalized_value: payload.normalized_value,
|
|
1592
|
+
normalized_text: payload.normalized_text,
|
|
1593
|
+
normalized_number: payload.normalized_number,
|
|
1594
|
+
source_type: payload.source_type,
|
|
1595
|
+
confidence_score: payload.confidence_score,
|
|
1596
|
+
is_verified: payload.is_verified,
|
|
1597
|
+
};
|
|
1598
|
+
|
|
1599
|
+
await this.prisma.catalog_product_attribute_value.create({
|
|
1600
|
+
data: createPayload,
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
await this.materializeProductSnapshots(productId);
|
|
1606
|
+
|
|
1607
|
+
return this.listProductAttributes(productId);
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
async generateFormAssistSuggestion(
|
|
1611
|
+
resource: string,
|
|
1612
|
+
input: CatalogAiAssistInput,
|
|
1613
|
+
locale: string,
|
|
1614
|
+
) {
|
|
1615
|
+
const config = this.getConfig(resource, locale);
|
|
1616
|
+
const fieldContext = this.normalizeAiFieldContext(resource, input.fields ?? []);
|
|
1617
|
+
const currentValues = input.current_values ?? {};
|
|
1618
|
+
const warnings: string[] = [];
|
|
1619
|
+
const categoryId =
|
|
1620
|
+
resource === 'products'
|
|
1621
|
+
? Number(currentValues.catalog_category_id ?? 0) || null
|
|
1622
|
+
: null;
|
|
1623
|
+
const productAttributeContext = categoryId
|
|
1624
|
+
? this.buildAttributeContext(
|
|
1625
|
+
await this.buildCategoryAttributePayload(categoryId),
|
|
1626
|
+
input.current_attribute_values ?? [],
|
|
1627
|
+
)
|
|
1628
|
+
: [];
|
|
1629
|
+
const systemPrompt = this.buildAiSystemPrompt(
|
|
1630
|
+
resource,
|
|
1631
|
+
locale,
|
|
1632
|
+
fieldContext,
|
|
1633
|
+
currentValues,
|
|
1634
|
+
productAttributeContext,
|
|
1635
|
+
);
|
|
1636
|
+
const aiResponse = await this.aiService.chat({
|
|
1637
|
+
provider: 'openai',
|
|
1638
|
+
model: 'gpt-4o-mini',
|
|
1639
|
+
message: input.prompt,
|
|
1640
|
+
systemPrompt,
|
|
1641
|
+
});
|
|
1642
|
+
const parsed = this.extractJsonObject(String(aiResponse.content ?? ''));
|
|
1643
|
+
const rawFields =
|
|
1644
|
+
parsed.fields && typeof parsed.fields === 'object'
|
|
1645
|
+
? (parsed.fields as Record<string, unknown>)
|
|
1646
|
+
: {};
|
|
1647
|
+
const normalizedFields: Record<string, unknown> = {};
|
|
1648
|
+
|
|
1649
|
+
for (const field of fieldContext) {
|
|
1650
|
+
if (!Object.prototype.hasOwnProperty.call(rawFields, field.key)) {
|
|
1651
|
+
continue;
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
const normalizedValue = await this.normalizeAiFieldSuggestion(
|
|
1655
|
+
field,
|
|
1656
|
+
rawFields[field.key],
|
|
1657
|
+
warnings,
|
|
1658
|
+
);
|
|
1659
|
+
|
|
1660
|
+
if (normalizedValue !== undefined) {
|
|
1661
|
+
normalizedFields[field.key] = normalizedValue;
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
const productAttributes = await this.normalizeAiProductAttributeSuggestions(
|
|
1666
|
+
categoryId,
|
|
1667
|
+
parsed.product_attributes,
|
|
1668
|
+
input.current_attribute_values ?? [],
|
|
1669
|
+
warnings,
|
|
1670
|
+
);
|
|
1671
|
+
|
|
1672
|
+
const appliedFields = fieldContext
|
|
1673
|
+
.filter((field) => Object.prototype.hasOwnProperty.call(normalizedFields, field.key))
|
|
1674
|
+
.map((field) => ({
|
|
1675
|
+
key: field.key,
|
|
1676
|
+
label: field.label,
|
|
1677
|
+
type: field.type,
|
|
1678
|
+
value: normalizedFields[field.key],
|
|
1679
|
+
}));
|
|
1680
|
+
|
|
1681
|
+
return {
|
|
1682
|
+
resource: config.resource,
|
|
1683
|
+
fields: normalizedFields,
|
|
1684
|
+
applied_fields: appliedFields,
|
|
1685
|
+
product_attributes: productAttributes,
|
|
1686
|
+
warnings: [
|
|
1687
|
+
...warnings,
|
|
1688
|
+
...(Array.isArray(parsed.notes) ? parsed.notes.map((note) => String(note)) : []),
|
|
1689
|
+
],
|
|
1690
|
+
};
|
|
1691
|
+
}
|
|
1692
|
+
}
|