@open-mercato/sync-akeneo 0.10.1-develop.8075.1.f2d38008c0 → 0.10.1-develop.8098.1.f4decc8a57

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/sync-akeneo",
3
- "version": "0.10.1-develop.8075.1.f2d38008c0",
3
+ "version": "0.10.1-develop.8098.1.f4decc8a57",
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.8075.1.f2d38008c0",
57
- "@open-mercato/ui": "0.10.1-develop.8075.1.f2d38008c0",
56
+ "@open-mercato/core": "0.10.1-develop.8098.1.f4decc8a57",
57
+ "@open-mercato/ui": "0.10.1-develop.8098.1.f4decc8a57",
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.8075.1.f2d38008c0",
63
- "@open-mercato/shared": "0.10.1-develop.8075.1.f2d38008c0",
62
+ "@open-mercato/ai-assistant": "0.10.1-develop.8098.1.f4decc8a57",
63
+ "@open-mercato/shared": "0.10.1-develop.8098.1.f4decc8a57",
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.8075.1.f2d38008c0",
70
- "@open-mercato/shared": "0.10.1-develop.8075.1.f2d38008c0",
69
+ "@open-mercato/ai-assistant": "0.10.1-develop.8098.1.f4decc8a57",
70
+ "@open-mercato/shared": "0.10.1-develop.8098.1.f4decc8a57",
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 value = resolveCustomFieldValue(rawValue, attribute, kind)
2052
- if (value === null || value === undefined || (Array.isArray(value) && value.length === 0)) continue
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 Promise.all([
2061
- setCustomFieldsIfAny({
2062
- dataEngine,
2063
- entityId: PRODUCT_ENTITY_ID,
2064
- recordId: params.localProductId,
2065
- organizationId: scope.organizationId,
2066
- tenantId: scope.tenantId,
2067
- values: productCustomFields,
2068
- }),
2069
- setCustomFieldsIfAny({
2070
- dataEngine,
2071
- entityId: VARIANT_ENTITY_ID,
2072
- recordId: params.localVariantId,
2073
- organizationId: scope.organizationId,
2074
- tenantId: scope.tenantId,
2075
- values: variantCustomFields,
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(productCustomFields).length > 0) {
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(variantCustomFields).length > 0) {
2126
+ if (Object.keys(variantValues).length > 0) {
2105
2127
  await emitCatalogQueryIndexEvent(buildCommandContext(), {
2106
2128
  entityType: VARIANT_ENTITY_ID,
2107
2129
  recordId: params.localVariantId,