@open-mercato/sync-akeneo 0.10.1-develop.8075.1.f2d38008c0 → 0.10.1-develop.8099.1.debd225eaa
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/modules/sync_akeneo/lib/catalog-importer.js +38 -22
- package/dist/modules/sync_akeneo/lib/catalog-importer.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/sync_akeneo/__tests__/catalog-importer.custom-fields.test.ts +419 -0
- package/src/modules/sync_akeneo/lib/catalog-importer.ts +44 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/sync-akeneo",
|
|
3
|
-
"version": "0.10.1-develop.
|
|
3
|
+
"version": "0.10.1-develop.8099.1.debd225eaa",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -53,21 +53,21 @@
|
|
|
53
53
|
}
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@open-mercato/core": "0.10.1-develop.
|
|
57
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
56
|
+
"@open-mercato/core": "0.10.1-develop.8099.1.debd225eaa",
|
|
57
|
+
"@open-mercato/ui": "0.10.1-develop.8099.1.debd225eaa",
|
|
58
58
|
"node-html-markdown": "^2.0.0"
|
|
59
59
|
},
|
|
60
60
|
"peerDependencies": {
|
|
61
61
|
"@mikro-orm/postgresql": "^7.0.14",
|
|
62
|
-
"@open-mercato/ai-assistant": "0.10.1-develop.
|
|
63
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
62
|
+
"@open-mercato/ai-assistant": "0.10.1-develop.8099.1.debd225eaa",
|
|
63
|
+
"@open-mercato/shared": "0.10.1-develop.8099.1.debd225eaa",
|
|
64
64
|
"react": "^19.0.0",
|
|
65
65
|
"react-dom": "^19.0.0",
|
|
66
66
|
"react-is": ">=18.0.0"
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
|
-
"@open-mercato/ai-assistant": "0.10.1-develop.
|
|
70
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
69
|
+
"@open-mercato/ai-assistant": "0.10.1-develop.8099.1.debd225eaa",
|
|
70
|
+
"@open-mercato/shared": "0.10.1-develop.8099.1.debd225eaa",
|
|
71
71
|
"@types/jest": "^30.0.0",
|
|
72
72
|
"@types/react": "^19.2.18",
|
|
73
73
|
"@types/react-dom": "^19.2.5",
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
import { MAX_CUSTOM_FIELD_KEYS_PER_RECORD } from '@open-mercato/shared/modules/entities/validation'
|
|
2
|
+
import { createAkeneoImporter } from '../lib/catalog-importer'
|
|
3
|
+
import {
|
|
4
|
+
buildDefaultAkeneoMapping,
|
|
5
|
+
type AkeneoAttribute,
|
|
6
|
+
type AkeneoCustomFieldMapping,
|
|
7
|
+
type AkeneoDataMapping,
|
|
8
|
+
type AkeneoProduct,
|
|
9
|
+
type AkeneoValues,
|
|
10
|
+
} from '../lib/shared'
|
|
11
|
+
|
|
12
|
+
const mockContainerResolve = jest.fn()
|
|
13
|
+
const mockFindOneWithDecryption = jest.fn()
|
|
14
|
+
const mockFindWithDecryption = jest.fn()
|
|
15
|
+
const mockLoadCustomFieldSnapshot = jest.fn()
|
|
16
|
+
|
|
17
|
+
jest.mock('@open-mercato/shared/lib/commands/customFieldSnapshots', () => ({
|
|
18
|
+
loadCustomFieldSnapshot: (...args: unknown[]) => mockLoadCustomFieldSnapshot(...args),
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
22
|
+
createRequestContainer: jest.fn(async () => ({
|
|
23
|
+
resolve: mockContainerResolve,
|
|
24
|
+
})),
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
|
|
28
|
+
findOneWithDecryption: (...args: unknown[]) => mockFindOneWithDecryption(...args),
|
|
29
|
+
findWithDecryption: (...args: unknown[]) => mockFindWithDecryption(...args),
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
jest.mock('@open-mercato/core/modules/entities/lib/field-definitions', () => ({
|
|
33
|
+
ensureCustomFieldDefinitions: jest.fn(async () => undefined),
|
|
34
|
+
}))
|
|
35
|
+
|
|
36
|
+
jest.mock('@open-mercato/core/modules/entities/api/definitions.cache', () => ({
|
|
37
|
+
invalidateDefinitionsCache: jest.fn(async () => undefined),
|
|
38
|
+
}))
|
|
39
|
+
|
|
40
|
+
const scope = { organizationId: 'org-1', tenantId: 'tenant-1' }
|
|
41
|
+
const PRODUCT_ENTITY_ID = 'catalog:catalog_product'
|
|
42
|
+
const VARIANT_ENTITY_ID = 'catalog:catalog_product_variant'
|
|
43
|
+
const MODEL_CODE = 'jacket'
|
|
44
|
+
const FAMILY_CODE = 'outdoor'
|
|
45
|
+
const FAMILY_VARIANT_CODE = 'outdoor_by_batch'
|
|
46
|
+
|
|
47
|
+
const SPEC_CODES = Array.from({ length: MAX_CUSTOM_FIELD_KEYS_PER_RECORD + 2 }, (_, index) => `spec_${index + 1}`)
|
|
48
|
+
|
|
49
|
+
const ATTRIBUTES: Record<string, AkeneoAttribute> = {
|
|
50
|
+
warranty_notes: { code: 'warranty_notes', type: 'pim_catalog_text' },
|
|
51
|
+
certifications: { code: 'certifications', type: 'pim_catalog_multiselect' },
|
|
52
|
+
batch_code: { code: 'batch_code', type: 'pim_catalog_text' },
|
|
53
|
+
internal_memo: { code: 'internal_memo', type: 'pim_catalog_text' },
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type SetCustomFieldsCall = { entityId: string; recordId: string; values: Record<string, unknown> }
|
|
57
|
+
|
|
58
|
+
function createWorld(extraAttributeCodes: string[] = []) {
|
|
59
|
+
const attributes: Record<string, AkeneoAttribute> = {
|
|
60
|
+
...ATTRIBUTES,
|
|
61
|
+
...Object.fromEntries(extraAttributeCodes.map((code) => [code, { code, type: 'pim_catalog_text' }])),
|
|
62
|
+
}
|
|
63
|
+
const products: string[] = []
|
|
64
|
+
const variants: Array<{ id: string; product: string; sku: string; isDefault: boolean }> = []
|
|
65
|
+
const mappings: Array<{ entityType: string; localId: string; externalId: string }> = []
|
|
66
|
+
let sequence = 0
|
|
67
|
+
const nextId = (prefix: string) => {
|
|
68
|
+
sequence += 1
|
|
69
|
+
return `${prefix}-${sequence}`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const commandBus = {
|
|
73
|
+
execute: jest.fn(async (commandId: string, { input }: { input: Record<string, unknown> }) => {
|
|
74
|
+
switch (commandId) {
|
|
75
|
+
case 'catalog.products.create': {
|
|
76
|
+
const id = nextId('product')
|
|
77
|
+
products.push(id)
|
|
78
|
+
return { result: { productId: id } }
|
|
79
|
+
}
|
|
80
|
+
case 'catalog.products.update':
|
|
81
|
+
return { result: { productId: input.id } }
|
|
82
|
+
case 'catalog.variants.create': {
|
|
83
|
+
const id = nextId('variant')
|
|
84
|
+
variants.push({ id, product: String(input.productId), sku: String(input.sku), isDefault: input.isDefault === true })
|
|
85
|
+
return { result: { variantId: id } }
|
|
86
|
+
}
|
|
87
|
+
case 'catalog.variants.update':
|
|
88
|
+
return { result: { variantId: input.id } }
|
|
89
|
+
default:
|
|
90
|
+
throw new Error(`[internal] unexpected command: ${commandId}`)
|
|
91
|
+
}
|
|
92
|
+
}),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const externalIdMappingService = {
|
|
96
|
+
lookupLocalId: jest.fn(async (_integrationId: string, entityType: string, externalId: string) => (
|
|
97
|
+
mappings.find((row) => row.entityType === entityType && row.externalId === externalId)?.localId ?? null
|
|
98
|
+
)),
|
|
99
|
+
lookupExternalId: jest.fn(async (_integrationId: string, entityType: string, localId: string) => (
|
|
100
|
+
mappings.find((row) => row.entityType === entityType && row.localId === localId)?.externalId ?? null
|
|
101
|
+
)),
|
|
102
|
+
storeExternalIdMapping: jest.fn(async (_integrationId: string, entityType: string, localId: string, externalId: string) => {
|
|
103
|
+
const existing = mappings.find((row) => row.entityType === entityType && row.externalId === externalId)
|
|
104
|
+
if (existing) {
|
|
105
|
+
existing.localId = localId
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
mappings.push({ entityType, localId, externalId })
|
|
109
|
+
}),
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const storedValues = new Map<string, Record<string, unknown>>()
|
|
113
|
+
let writesInFlight = 0
|
|
114
|
+
let maxWritesInFlight = 0
|
|
115
|
+
const setCustomFields = jest.fn(async (input: SetCustomFieldsCall) => {
|
|
116
|
+
if (Object.keys(input.values).length > MAX_CUSTOM_FIELD_KEYS_PER_RECORD) {
|
|
117
|
+
throw new Error('[internal] too many custom fields')
|
|
118
|
+
}
|
|
119
|
+
writesInFlight += 1
|
|
120
|
+
maxWritesInFlight = Math.max(maxWritesInFlight, writesInFlight)
|
|
121
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
122
|
+
const recordKey = `${input.entityId}:${input.recordId}`
|
|
123
|
+
storedValues.set(recordKey, { ...storedValues.get(recordKey), ...input.values })
|
|
124
|
+
writesInFlight -= 1
|
|
125
|
+
})
|
|
126
|
+
mockLoadCustomFieldSnapshot.mockImplementation(async (_em: unknown, options: { entityId: string; recordId: string }) => (
|
|
127
|
+
{ ...storedValues.get(`${options.entityId}:${options.recordId}`) }
|
|
128
|
+
))
|
|
129
|
+
const model: { values: AkeneoValues; available: boolean } = {
|
|
130
|
+
values: { name: text('Jacket') },
|
|
131
|
+
available: true,
|
|
132
|
+
}
|
|
133
|
+
const em = {
|
|
134
|
+
clear: jest.fn(),
|
|
135
|
+
create: jest.fn((_entity: unknown, data: Record<string, unknown>) => ({ ...data })),
|
|
136
|
+
persist: jest.fn(),
|
|
137
|
+
flush: jest.fn(async () => undefined),
|
|
138
|
+
getConnection: () => ({ execute: jest.fn(async () => []) }),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
mockContainerResolve.mockImplementation((key: string) => {
|
|
142
|
+
if (key === 'em') return em
|
|
143
|
+
if (key === 'commandBus') return commandBus
|
|
144
|
+
if (key === 'dataEngine') return { setCustomFields }
|
|
145
|
+
if (key === 'externalIdMappingService') return externalIdMappingService
|
|
146
|
+
if (key === 'cache') throw new Error('[internal] no cache in test')
|
|
147
|
+
throw new Error(`[internal] unexpected dependency: ${key}`)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
mockFindOneWithDecryption.mockImplementation(async (_em: unknown, _entity: unknown, where: Record<string, unknown>) => {
|
|
151
|
+
if (typeof where.product === 'string') {
|
|
152
|
+
return variants.find((variant) => (
|
|
153
|
+
variant.product === where.product
|
|
154
|
+
&& (where.isDefault === undefined || variant.isDefault === where.isDefault)
|
|
155
|
+
&& (where.sku === undefined || variant.sku === where.sku)
|
|
156
|
+
)) ?? null
|
|
157
|
+
}
|
|
158
|
+
if (typeof where.id === 'string') {
|
|
159
|
+
if (products.includes(where.id)) return { id: where.id }
|
|
160
|
+
return variants.find((variant) => variant.id === where.id) ?? null
|
|
161
|
+
}
|
|
162
|
+
return null
|
|
163
|
+
})
|
|
164
|
+
mockFindWithDecryption.mockImplementation(async () => [])
|
|
165
|
+
|
|
166
|
+
const client = {
|
|
167
|
+
getFamily: jest.fn(async (code: string) => (
|
|
168
|
+
code === FAMILY_CODE
|
|
169
|
+
? { code: FAMILY_CODE, attributes: ['sku', 'name', ...Object.keys(attributes)] }
|
|
170
|
+
: null
|
|
171
|
+
)),
|
|
172
|
+
getFamilyVariant: jest.fn(async (familyCode: string, code: string) => (
|
|
173
|
+
familyCode === FAMILY_CODE && code === FAMILY_VARIANT_CODE
|
|
174
|
+
? { code: FAMILY_VARIANT_CODE, variant_attribute_sets: [{ level: 1, axes: [], attributes: ['sku', 'batch_code'] }] }
|
|
175
|
+
: null
|
|
176
|
+
)),
|
|
177
|
+
getAttribute: jest.fn(async (code: string) => attributes[code] ?? null),
|
|
178
|
+
listAttributeOptions: jest.fn(async () => []),
|
|
179
|
+
getProductModel: jest.fn(async (code: string) => (
|
|
180
|
+
code === MODEL_CODE && model.available
|
|
181
|
+
? {
|
|
182
|
+
code: MODEL_CODE,
|
|
183
|
+
family: FAMILY_CODE,
|
|
184
|
+
family_variant: FAMILY_VARIANT_CODE,
|
|
185
|
+
values: model.values,
|
|
186
|
+
categories: [],
|
|
187
|
+
}
|
|
188
|
+
: null
|
|
189
|
+
)),
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const writesFor = (entityId: string, keys?: string[]): Array<Record<string, unknown>> => setCustomFields.mock.calls
|
|
193
|
+
.map(([input]) => input)
|
|
194
|
+
.filter((input) => input.entityId === entityId)
|
|
195
|
+
.map((input) => (keys
|
|
196
|
+
? Object.fromEntries(Object.entries(input.values).filter(([key]) => keys.includes(key)))
|
|
197
|
+
: input.values))
|
|
198
|
+
|
|
199
|
+
return { client, model, setCustomFields, writesFor, maxWritesInFlight: () => maxWritesInFlight }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function buildMapping(customFieldMappings: AkeneoCustomFieldMapping[]): AkeneoDataMapping {
|
|
203
|
+
const base = buildDefaultAkeneoMapping('products')
|
|
204
|
+
const products = base.settings?.products
|
|
205
|
+
if (!products) throw new Error('[internal] default Akeneo product mapping is missing')
|
|
206
|
+
return {
|
|
207
|
+
...base,
|
|
208
|
+
settings: {
|
|
209
|
+
...base.settings,
|
|
210
|
+
products: {
|
|
211
|
+
...products,
|
|
212
|
+
priceMappings: [],
|
|
213
|
+
mediaMappings: [],
|
|
214
|
+
fieldsetMappings: [],
|
|
215
|
+
customFieldMappings,
|
|
216
|
+
syncAssociations: false,
|
|
217
|
+
createMissingChannels: false,
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function text(data: string): AkeneoValues[string] {
|
|
224
|
+
return [{ locale: null, scope: null, data }]
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function buildSimpleProduct(values: AkeneoValues): AkeneoProduct {
|
|
228
|
+
return {
|
|
229
|
+
uuid: 'uuid-simple',
|
|
230
|
+
identifier: 'TENT-1',
|
|
231
|
+
family: FAMILY_CODE,
|
|
232
|
+
parent: null,
|
|
233
|
+
enabled: true,
|
|
234
|
+
values: { sku: text('TENT-1'), name: text('Tent'), ...values },
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function buildVariant(values: AkeneoValues): AkeneoProduct {
|
|
239
|
+
return {
|
|
240
|
+
uuid: 'uuid-variant',
|
|
241
|
+
identifier: 'JACKET-B1',
|
|
242
|
+
family: FAMILY_CODE,
|
|
243
|
+
parent: MODEL_CODE,
|
|
244
|
+
enabled: true,
|
|
245
|
+
values: { sku: text('JACKET-B1'), ...values },
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
describe('akeneo catalog importer custom field values', () => {
|
|
250
|
+
beforeEach(() => {
|
|
251
|
+
mockContainerResolve.mockReset()
|
|
252
|
+
mockFindOneWithDecryption.mockReset()
|
|
253
|
+
mockFindWithDecryption.mockReset()
|
|
254
|
+
mockLoadCustomFieldSnapshot.mockReset()
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
it('clears a product custom field once its Akeneo value is removed', async () => {
|
|
258
|
+
const world = createWorld()
|
|
259
|
+
const importer = await createAkeneoImporter(world.client as never, scope)
|
|
260
|
+
const mapping = buildMapping([
|
|
261
|
+
{ attributeCode: 'warranty_notes', fieldKey: 'warranty_notes', target: 'product' },
|
|
262
|
+
])
|
|
263
|
+
|
|
264
|
+
await importer.upsertProduct(buildSimpleProduct({ warranty_notes: text('Two years') }), mapping)
|
|
265
|
+
await importer.upsertProduct(buildSimpleProduct({}), mapping)
|
|
266
|
+
|
|
267
|
+
const writes = world.writesFor(PRODUCT_ENTITY_ID)
|
|
268
|
+
expect(writes).toHaveLength(2)
|
|
269
|
+
expect(writes[0]).toMatchObject({ warranty_notes: 'Two years' })
|
|
270
|
+
expect(writes[1]).toHaveProperty('warranty_notes', null)
|
|
271
|
+
expect(mockLoadCustomFieldSnapshot).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({
|
|
272
|
+
entityId: PRODUCT_ENTITY_ID,
|
|
273
|
+
tenantId: scope.tenantId,
|
|
274
|
+
organizationId: scope.organizationId,
|
|
275
|
+
}))
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
it('clears a multi-select product custom field once every Akeneo option is removed', async () => {
|
|
279
|
+
const world = createWorld()
|
|
280
|
+
const importer = await createAkeneoImporter(world.client as never, scope)
|
|
281
|
+
const mapping = buildMapping([
|
|
282
|
+
{ attributeCode: 'certifications', fieldKey: 'certifications', target: 'product' },
|
|
283
|
+
])
|
|
284
|
+
|
|
285
|
+
await importer.upsertProduct(buildSimpleProduct({
|
|
286
|
+
certifications: [{ locale: null, scope: null, data: ['bluesign', 'oeko_tex'] }],
|
|
287
|
+
}), mapping)
|
|
288
|
+
await importer.upsertProduct(buildSimpleProduct({
|
|
289
|
+
certifications: [{ locale: null, scope: null, data: [] }],
|
|
290
|
+
}), mapping)
|
|
291
|
+
|
|
292
|
+
const writes = world.writesFor(PRODUCT_ENTITY_ID)
|
|
293
|
+
expect(writes).toHaveLength(2)
|
|
294
|
+
expect(writes[0]).toMatchObject({ certifications: ['bluesign', 'oeko_tex'] })
|
|
295
|
+
expect(writes[1]).toHaveProperty('certifications', null)
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
it('clears a variant custom field once its Akeneo value is removed', async () => {
|
|
299
|
+
const world = createWorld()
|
|
300
|
+
const importer = await createAkeneoImporter(world.client as never, scope)
|
|
301
|
+
const mapping = buildMapping([
|
|
302
|
+
{ attributeCode: 'batch_code', fieldKey: 'batch_code', target: 'variant' },
|
|
303
|
+
])
|
|
304
|
+
|
|
305
|
+
await importer.upsertProduct(buildVariant({ batch_code: text('B-2026-11') }), mapping)
|
|
306
|
+
await importer.upsertProduct(buildVariant({}), mapping)
|
|
307
|
+
|
|
308
|
+
const writes = world.writesFor(VARIANT_ENTITY_ID)
|
|
309
|
+
expect(writes).toHaveLength(2)
|
|
310
|
+
expect(writes[0]).toMatchObject({ batch_code: 'B-2026-11' })
|
|
311
|
+
expect(writes[1]).toHaveProperty('batch_code', null)
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
it('leaves skipped mappings and attributes unknown to Akeneo untouched', async () => {
|
|
315
|
+
const world = createWorld()
|
|
316
|
+
const importer = await createAkeneoImporter(world.client as never, scope)
|
|
317
|
+
const syncedMapping = buildMapping([
|
|
318
|
+
{ attributeCode: 'warranty_notes', fieldKey: 'warranty_notes', target: 'product' },
|
|
319
|
+
{ attributeCode: 'internal_memo', fieldKey: 'internal_memo', target: 'product' },
|
|
320
|
+
])
|
|
321
|
+
const mapping = buildMapping([
|
|
322
|
+
{ attributeCode: 'warranty_notes', fieldKey: 'warranty_notes', target: 'product' },
|
|
323
|
+
{ attributeCode: 'internal_memo', fieldKey: 'internal_memo', target: 'product', skip: true },
|
|
324
|
+
{ attributeCode: 'name', fieldKey: 'legacy_name', target: 'product' },
|
|
325
|
+
])
|
|
326
|
+
|
|
327
|
+
await importer.upsertProduct(buildSimpleProduct({ warranty_notes: text('Two years'), internal_memo: text('Fragile') }), syncedMapping)
|
|
328
|
+
await importer.upsertProduct(buildSimpleProduct({}), mapping)
|
|
329
|
+
|
|
330
|
+
const writes = world.writesFor(PRODUCT_ENTITY_ID, ['warranty_notes', 'internal_memo', 'legacy_name'])
|
|
331
|
+
expect(writes).toEqual([{ warranty_notes: 'Two years', internal_memo: 'Fragile' }, { warranty_notes: null }])
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('clears a product custom field inherited from the parent model once the model value is removed', async () => {
|
|
335
|
+
const world = createWorld()
|
|
336
|
+
const importer = await createAkeneoImporter(world.client as never, scope)
|
|
337
|
+
const mapping = buildMapping([
|
|
338
|
+
{ attributeCode: 'warranty_notes', fieldKey: 'warranty_notes', target: 'product' },
|
|
339
|
+
])
|
|
340
|
+
|
|
341
|
+
world.model.values = { name: text('Jacket'), warranty_notes: text('Lifetime') }
|
|
342
|
+
await importer.upsertProduct(buildVariant({}), mapping)
|
|
343
|
+
const secondImporter = await createAkeneoImporter(world.client as never, scope)
|
|
344
|
+
world.model.values = { name: text('Jacket') }
|
|
345
|
+
await secondImporter.upsertProduct(buildVariant({}), mapping)
|
|
346
|
+
|
|
347
|
+
expect(world.writesFor(PRODUCT_ENTITY_ID, ['warranty_notes'])).toEqual([{ warranty_notes: 'Lifetime' }, { warranty_notes: null }])
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('does not clear inherited custom fields when the parent model cannot be loaded', async () => {
|
|
351
|
+
const world = createWorld()
|
|
352
|
+
const importer = await createAkeneoImporter(world.client as never, scope)
|
|
353
|
+
const mapping = buildMapping([
|
|
354
|
+
{ attributeCode: 'warranty_notes', fieldKey: 'warranty_notes', target: 'variant' },
|
|
355
|
+
{ attributeCode: 'batch_code', fieldKey: 'batch_code', target: 'variant' },
|
|
356
|
+
])
|
|
357
|
+
|
|
358
|
+
world.model.values = { name: text('Jacket'), warranty_notes: text('Lifetime') }
|
|
359
|
+
await importer.upsertProduct(buildVariant({ batch_code: text('B-2026-11') }), mapping)
|
|
360
|
+
const secondImporter = await createAkeneoImporter(world.client as never, scope)
|
|
361
|
+
world.model.available = false
|
|
362
|
+
await secondImporter.upsertProduct(buildVariant({ batch_code: text('B-2026-12') }), mapping)
|
|
363
|
+
|
|
364
|
+
expect(world.writesFor(VARIANT_ENTITY_ID, ['warranty_notes', 'batch_code'])).toEqual([
|
|
365
|
+
{ warranty_notes: 'Lifetime', batch_code: 'B-2026-11' },
|
|
366
|
+
{ batch_code: 'B-2026-12' },
|
|
367
|
+
])
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
it('does not write empty Akeneo values for fields that hold no local value', async () => {
|
|
371
|
+
const world = createWorld()
|
|
372
|
+
const importer = await createAkeneoImporter(world.client as never, scope)
|
|
373
|
+
const mapping = buildMapping([
|
|
374
|
+
{ attributeCode: 'warranty_notes', fieldKey: 'warranty_notes', target: 'product' },
|
|
375
|
+
{ attributeCode: 'internal_memo', fieldKey: 'internal_memo', target: 'product' },
|
|
376
|
+
])
|
|
377
|
+
|
|
378
|
+
await importer.upsertProduct(buildSimpleProduct({ warranty_notes: text('Two years') }), mapping)
|
|
379
|
+
await importer.upsertProduct(buildSimpleProduct({ warranty_notes: text('Two years') }), mapping)
|
|
380
|
+
await importer.upsertProduct(buildSimpleProduct({}), mapping)
|
|
381
|
+
await importer.upsertProduct(buildSimpleProduct({}), mapping)
|
|
382
|
+
|
|
383
|
+
expect(world.writesFor(PRODUCT_ENTITY_ID)).toEqual([
|
|
384
|
+
{ warranty_notes: 'Two years' },
|
|
385
|
+
{ warranty_notes: 'Two years' },
|
|
386
|
+
{ warranty_notes: null },
|
|
387
|
+
])
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
it('keeps the write under the per-record custom field limit when most mapped Akeneo values are empty', async () => {
|
|
391
|
+
const world = createWorld(SPEC_CODES)
|
|
392
|
+
const importer = await createAkeneoImporter(world.client as never, scope)
|
|
393
|
+
const mapping = buildMapping(SPEC_CODES.map((code) => ({ attributeCode: code, fieldKey: code, target: 'product' as const })))
|
|
394
|
+
|
|
395
|
+
await importer.upsertProduct(buildSimpleProduct({ spec_1: text('Waterproof'), spec_2: text('Breathable') }), mapping)
|
|
396
|
+
await importer.upsertProduct(buildSimpleProduct({ spec_1: text('Waterproof') }), mapping)
|
|
397
|
+
|
|
398
|
+
expect(world.writesFor(PRODUCT_ENTITY_ID)).toEqual([
|
|
399
|
+
{ spec_1: 'Waterproof', spec_2: 'Breathable' },
|
|
400
|
+
{ spec_1: 'Waterproof', spec_2: null },
|
|
401
|
+
])
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
it('writes product and variant custom fields one after the other', async () => {
|
|
405
|
+
const world = createWorld()
|
|
406
|
+
const importer = await createAkeneoImporter(world.client as never, scope)
|
|
407
|
+
const mapping = buildMapping([
|
|
408
|
+
{ attributeCode: 'warranty_notes', fieldKey: 'warranty_notes', target: 'product' },
|
|
409
|
+
{ attributeCode: 'batch_code', fieldKey: 'batch_code', target: 'variant' },
|
|
410
|
+
])
|
|
411
|
+
|
|
412
|
+
world.model.values = { name: text('Jacket'), warranty_notes: text('Lifetime') }
|
|
413
|
+
await importer.upsertProduct(buildVariant({ batch_code: text('B-2026-11') }), mapping)
|
|
414
|
+
|
|
415
|
+
expect(world.writesFor(PRODUCT_ENTITY_ID, ['warranty_notes'])).toEqual([{ warranty_notes: 'Lifetime' }])
|
|
416
|
+
expect(world.writesFor(VARIANT_ENTITY_ID, ['batch_code'])).toEqual([{ batch_code: 'B-2026-11' }])
|
|
417
|
+
expect(world.maxWritesInFlight()).toBe(1)
|
|
418
|
+
})
|
|
419
|
+
})
|
|
@@ -4,6 +4,7 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
|
4
4
|
import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
|
|
5
5
|
import type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
|
|
6
6
|
import { emitCrudSideEffects, setCustomFieldsIfAny } from '@open-mercato/shared/lib/commands/helpers'
|
|
7
|
+
import { loadCustomFieldSnapshot } from '@open-mercato/shared/lib/commands/customFieldSnapshots'
|
|
7
8
|
import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
|
|
8
9
|
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
9
10
|
import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
@@ -551,6 +552,10 @@ export function resolveAkeneoMediaTarget(params: {
|
|
|
551
552
|
return params.mappingTarget
|
|
552
553
|
}
|
|
553
554
|
|
|
555
|
+
function isEmptyCustomFieldValue(value: unknown): boolean {
|
|
556
|
+
return value === null || value === undefined || (Array.isArray(value) && value.length === 0)
|
|
557
|
+
}
|
|
558
|
+
|
|
554
559
|
function resolveCustomFieldValue(value: unknown, attribute: AkeneoAttribute, kind: AkeneoCustomFieldKind): unknown {
|
|
555
560
|
if (kind === 'boolean') return coerceBoolean(value)
|
|
556
561
|
if (kind === 'integer') return coerceNumber(value) === null ? null : Math.trunc(Number(value))
|
|
@@ -2022,6 +2027,23 @@ export async function createAkeneoImporter(client: AkeneoClient, scope: ImportSc
|
|
|
2022
2027
|
}
|
|
2023
2028
|
}
|
|
2024
2029
|
|
|
2030
|
+
async function omitClearsOfEmptyFields(
|
|
2031
|
+
entityId: string,
|
|
2032
|
+
recordId: string,
|
|
2033
|
+
values: Record<string, unknown>,
|
|
2034
|
+
): Promise<Record<string, unknown>> {
|
|
2035
|
+
if (!Object.values(values).some((value) => value === null)) return values
|
|
2036
|
+
const current = await loadCustomFieldSnapshot(em, {
|
|
2037
|
+
entityId,
|
|
2038
|
+
recordId,
|
|
2039
|
+
tenantId: scope.tenantId,
|
|
2040
|
+
organizationId: scope.organizationId,
|
|
2041
|
+
})
|
|
2042
|
+
return Object.fromEntries(
|
|
2043
|
+
Object.entries(values).filter(([key, value]) => value !== null || !isEmptyCustomFieldValue(current[key])),
|
|
2044
|
+
)
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2025
2047
|
async function applyCustomFieldMappings(params: {
|
|
2026
2048
|
mapping: AkeneoDataMapping
|
|
2027
2049
|
hierarchy: ResolvedHierarchy
|
|
@@ -2048,8 +2070,8 @@ export async function createAkeneoImporter(client: AkeneoClient, scope: ImportSc
|
|
|
2048
2070
|
? [params.hierarchy.rootValues, params.hierarchy.mergedValues]
|
|
2049
2071
|
: [params.product.values ?? {}, params.hierarchy.mergedValues]
|
|
2050
2072
|
const rawValue = readLayeredAkeneoValue(sourceLayers, fieldMapping.attributeCode, settings.locale, settings.channel ?? null)
|
|
2051
|
-
const
|
|
2052
|
-
|
|
2073
|
+
const resolved = resolveCustomFieldValue(rawValue, attribute, kind)
|
|
2074
|
+
const value = isEmptyCustomFieldValue(resolved) ? null : resolved
|
|
2053
2075
|
if (fieldMapping.target === 'product') {
|
|
2054
2076
|
productCustomFields[normalizeFieldKey(fieldMapping.fieldKey)] = value
|
|
2055
2077
|
} else {
|
|
@@ -2057,24 +2079,24 @@ export async function createAkeneoImporter(client: AkeneoClient, scope: ImportSc
|
|
|
2057
2079
|
}
|
|
2058
2080
|
}
|
|
2059
2081
|
|
|
2060
|
-
await
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2082
|
+
const productValues = await omitClearsOfEmptyFields(PRODUCT_ENTITY_ID, params.localProductId, productCustomFields)
|
|
2083
|
+
await setCustomFieldsIfAny({
|
|
2084
|
+
dataEngine,
|
|
2085
|
+
entityId: PRODUCT_ENTITY_ID,
|
|
2086
|
+
recordId: params.localProductId,
|
|
2087
|
+
organizationId: scope.organizationId,
|
|
2088
|
+
tenantId: scope.tenantId,
|
|
2089
|
+
values: productValues,
|
|
2090
|
+
})
|
|
2091
|
+
const variantValues = await omitClearsOfEmptyFields(VARIANT_ENTITY_ID, params.localVariantId, variantCustomFields)
|
|
2092
|
+
await setCustomFieldsIfAny({
|
|
2093
|
+
dataEngine,
|
|
2094
|
+
entityId: VARIANT_ENTITY_ID,
|
|
2095
|
+
recordId: params.localVariantId,
|
|
2096
|
+
organizationId: scope.organizationId,
|
|
2097
|
+
tenantId: scope.tenantId,
|
|
2098
|
+
values: variantValues,
|
|
2099
|
+
})
|
|
2078
2100
|
|
|
2079
2101
|
await Promise.all([
|
|
2080
2102
|
params.fieldsetPlan?.product?.code
|
|
@@ -2091,7 +2113,7 @@ export async function createAkeneoImporter(client: AkeneoClient, scope: ImportSc
|
|
|
2091
2113
|
: Promise.resolve(),
|
|
2092
2114
|
])
|
|
2093
2115
|
|
|
2094
|
-
if (Object.keys(
|
|
2116
|
+
if (Object.keys(productValues).length > 0) {
|
|
2095
2117
|
await emitCatalogQueryIndexEvent(buildCommandContext(), {
|
|
2096
2118
|
entityType: PRODUCT_ENTITY_ID,
|
|
2097
2119
|
recordId: params.localProductId,
|
|
@@ -2101,7 +2123,7 @@ export async function createAkeneoImporter(client: AkeneoClient, scope: ImportSc
|
|
|
2101
2123
|
})
|
|
2102
2124
|
}
|
|
2103
2125
|
|
|
2104
|
-
if (Object.keys(
|
|
2126
|
+
if (Object.keys(variantValues).length > 0) {
|
|
2105
2127
|
await emitCatalogQueryIndexEvent(buildCommandContext(), {
|
|
2106
2128
|
entityType: VARIANT_ENTITY_ID,
|
|
2107
2129
|
recordId: params.localVariantId,
|