@open-mercato/core 0.6.6-develop.6399.1.93ba8ca030 → 0.6.6-develop.6402.1.080aab8ec9

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.
Files changed (45) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/auth/lib/backendChrome.js +3 -2
  3. package/dist/modules/auth/lib/backendChrome.js.map +2 -2
  4. package/dist/modules/currencies/backend/config/currency-fetching/page.meta.js +4 -13
  5. package/dist/modules/currencies/backend/config/currency-fetching/page.meta.js.map +2 -2
  6. package/dist/modules/currencies/backend/currencies/page.meta.js +1 -1
  7. package/dist/modules/currencies/backend/currencies/page.meta.js.map +2 -2
  8. package/dist/modules/currencies/backend/exchange-rates/page.meta.js +1 -0
  9. package/dist/modules/currencies/backend/exchange-rates/page.meta.js.map +2 -2
  10. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js +9 -12
  11. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js.map +2 -2
  12. package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js +49 -10
  13. package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js.map +2 -2
  14. package/dist/modules/entities/api/definitions.batch.js +29 -2
  15. package/dist/modules/entities/api/definitions.batch.js.map +2 -2
  16. package/dist/modules/entities/api/definitions.js +9 -2
  17. package/dist/modules/entities/api/definitions.js.map +2 -2
  18. package/dist/modules/entities/api/definitions.manage.js +14 -3
  19. package/dist/modules/entities/api/definitions.manage.js.map +2 -2
  20. package/dist/modules/entities/backend/entities/user/[entityId]/page.js +43 -17
  21. package/dist/modules/entities/backend/entities/user/[entityId]/page.js.map +2 -2
  22. package/dist/modules/entities/lib/definitions-version.js +55 -0
  23. package/dist/modules/entities/lib/definitions-version.js.map +7 -0
  24. package/dist/modules/sales/cli.js +38 -1
  25. package/dist/modules/sales/cli.js.map +2 -2
  26. package/dist/modules/sales/lib/dictionaries.js +70 -0
  27. package/dist/modules/sales/lib/dictionaries.js.map +2 -2
  28. package/package.json +7 -7
  29. package/src/modules/auth/lib/backendChrome.tsx +3 -2
  30. package/src/modules/currencies/backend/config/currency-fetching/page.meta.ts +4 -15
  31. package/src/modules/currencies/backend/currencies/page.meta.ts +1 -1
  32. package/src/modules/currencies/backend/exchange-rates/page.meta.ts +1 -0
  33. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.ts +9 -13
  34. package/src/modules/customers/components/detail/ConfirmDealLostDialog.tsx +58 -8
  35. package/src/modules/customers/i18n/de.json +4 -0
  36. package/src/modules/customers/i18n/en.json +4 -0
  37. package/src/modules/customers/i18n/es.json +4 -0
  38. package/src/modules/customers/i18n/pl.json +4 -0
  39. package/src/modules/entities/api/definitions.batch.ts +38 -1
  40. package/src/modules/entities/api/definitions.manage.ts +14 -1
  41. package/src/modules/entities/api/definitions.ts +10 -1
  42. package/src/modules/entities/backend/entities/user/[entityId]/page.tsx +53 -17
  43. package/src/modules/entities/lib/definitions-version.ts +102 -0
  44. package/src/modules/sales/cli.ts +39 -1
  45. package/src/modules/sales/lib/dictionaries.ts +90 -0
@@ -33,6 +33,7 @@ import {
33
33
  resolveDefinitionMutationScope,
34
34
  selectVisibleDefinitionWinner,
35
35
  } from '../lib/definition-scope'
36
+ import { resolveEntityDefinitionsVersion } from '../lib/definitions-version'
36
37
 
37
38
  /**
38
39
  * Validate defaultValue against the field kind. Returns an error message string
@@ -620,7 +621,14 @@ export async function DELETE(req: Request) {
620
621
  entityIds: [entityId],
621
622
  })
622
623
  // Changing field definitions may impact forms but not sidebar items; no nav cache touch
623
- return NextResponse.json({ ok: true })
624
+ // Return the post-delete aggregate version so the edit form keeps its optimistic-lock
625
+ // token in sync after removing a field out-of-band (issue #3152).
626
+ const version = await resolveEntityDefinitionsVersion(em, {
627
+ entityId,
628
+ tenantId: scope.tenantId,
629
+ organizationId: scope.organizationId,
630
+ })
631
+ return NextResponse.json({ ok: true, version })
624
632
  }
625
633
 
626
634
  const definitionsQuerySchema = z
@@ -716,6 +724,7 @@ const deleteDefinitionRequestSchema = z.object({
716
724
 
717
725
  const deleteDefinitionResponseSchema = z.object({
718
726
  ok: z.literal(true),
727
+ version: z.string().nullable().optional(),
719
728
  })
720
729
 
721
730
  export const openApi: OpenApiRouteDoc = {
@@ -4,7 +4,9 @@ import { useRouter, useSearchParams } from 'next/navigation'
4
4
  import { useQueryClient } from '@tanstack/react-query'
5
5
  import { CrudForm, type CrudField, type CrudFormGroup } from '@open-mercato/ui/backend/CrudForm'
6
6
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
7
- import { apiCall, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
7
+ import { apiCall, readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
8
+ import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
9
+ import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
8
10
  import { invalidateCustomFieldDefs } from '@open-mercato/ui/backend/utils/customFieldDefs'
9
11
  import { upsertCustomEntitySchema, upsertCustomFieldDefSchema } from '@open-mercato/core/modules/entities/data/validators'
10
12
  import { z } from 'zod'
@@ -31,7 +33,12 @@ export type EntitySource = 'code' | 'custom'
31
33
  type EntitiesListResponse = { items?: Array<Record<string, unknown>> }
32
34
  type FieldsetGroup = { code: string; title?: string; hint?: string }
33
35
  type FieldsetDefinition = { code: string; label: string; icon?: string; description?: string; groups?: FieldsetGroup[] }
34
- type DefinitionsManageResponse = { items?: any[]; deletedKeys?: string[]; fieldsets?: FieldsetDefinition[]; settings?: { singleFieldsetPerRecord?: boolean } }
36
+ type DefinitionsManageResponse = { items?: any[]; deletedKeys?: string[]; fieldsets?: FieldsetDefinition[]; settings?: { singleFieldsetPerRecord?: boolean }; version?: string | null }
37
+ type DefinitionsBatchResponse = { ok?: boolean; version?: string | null }
38
+
39
+ function readVersionToken(value: unknown): string | null {
40
+ return typeof value === 'string' && value.length > 0 ? value : null
41
+ }
35
42
 
36
43
  type DefErrors = FieldDefinitionError
37
44
 
@@ -48,6 +55,7 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
48
55
  const [entityFormLoading, setEntityFormLoading] = useState(true)
49
56
  const [entityInitial, setEntityInitial] = useState<{ label?: string; description?: string; labelField?: string; defaultEditor?: string; showInSidebar?: boolean; updatedAt?: string }>({})
50
57
  const [defs, setDefs] = useState<Def[]>([])
58
+ const [defsVersion, setDefsVersion] = useState<string | null>(null)
51
59
  const [orderDirty, setOrderDirty] = useState(false)
52
60
  const [orderSaving, setOrderSaving] = useState(false)
53
61
  const listRef = React.useRef<HTMLDivElement | null>(null)
@@ -197,6 +205,7 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
197
205
  (a, b) => Number(a.configJson?.priority ?? 0) - Number(b.configJson?.priority ?? 0)
198
206
  )
199
207
  setDefs(loaded)
208
+ setDefsVersion(readVersionToken(json.version))
200
209
  setDefErrors({})
201
210
  setDeletedKeys(Array.isArray(json.deletedKeys) ? json.deletedKeys : [])
202
211
  const loadedFieldsets = Array.isArray(json.fieldsets) ? json.fieldsets : []
@@ -253,6 +262,7 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
253
262
  (a, b) => Number(a.configJson?.priority ?? 0) - Number(b.configJson?.priority ?? 0)
254
263
  )
255
264
  setDefs(loaded)
265
+ setDefsVersion(readVersionToken(j2.version))
256
266
  setDeletedKeys(Array.isArray(j2.deletedKeys) ? j2.deletedKeys : [])
257
267
  flash(`Restored ${key}`, 'success')
258
268
  await invalidateCustomFieldDefs(queryClient, entityId)
@@ -278,14 +288,19 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
278
288
  isActive: d.isActive !== false,
279
289
  })),
280
290
  }
281
- const call = await apiCall('/api/entities/definitions.batch', {
282
- method: 'POST',
283
- headers: { 'content-type': 'application/json' },
284
- body: JSON.stringify(payload),
285
- })
291
+ const call = await withScopedApiRequestHeaders(
292
+ buildOptimisticLockHeader(defsVersion),
293
+ () => apiCall<DefinitionsBatchResponse>('/api/entities/definitions.batch', {
294
+ method: 'POST',
295
+ headers: { 'content-type': 'application/json' },
296
+ body: JSON.stringify(payload),
297
+ }),
298
+ )
286
299
  if (!call.ok) {
300
+ if (surfaceRecordConflict({ status: call.status, body: call.result }, t)) return
287
301
  await raiseCrudError(call.response, 'Failed to save definitions')
288
302
  }
303
+ setDefsVersion(readVersionToken(call.result?.version))
289
304
  await invalidateCustomFieldDefs(queryClient, entityId)
290
305
  router.push(`/backend/entities/user?flash=Definitions%20saved&type=success`)
291
306
  } catch (e: any) {
@@ -300,7 +315,7 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
300
315
  if (!def) return
301
316
  if (def.key) {
302
317
  try {
303
- const call = await apiCall('/api/entities/definitions', {
318
+ const call = await apiCall<DefinitionsBatchResponse>('/api/entities/definitions', {
304
319
  method: 'DELETE',
305
320
  headers: { 'content-type': 'application/json' },
306
321
  body: JSON.stringify({ entityId, key: def.key }),
@@ -308,6 +323,9 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
308
323
  if (!call.ok) {
309
324
  await raiseCrudError(call.response, 'Failed to delete field')
310
325
  }
326
+ // Keep the optimistic-lock token in sync after an out-of-band delete so a
327
+ // later Save does not falsely 409 against our own change (issue #3152).
328
+ setDefsVersion(readVersionToken(call.result?.version))
311
329
  } catch (error) {
312
330
  const message = error instanceof Error ? error.message : 'Failed to delete field'
313
331
  flash(message, 'error')
@@ -364,12 +382,23 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
364
382
  isActive: d.isActive !== false,
365
383
  })),
366
384
  }
367
- const call = await apiCall('/api/entities/definitions.batch', {
368
- method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload)
369
- })
385
+ const call = await withScopedApiRequestHeaders(
386
+ buildOptimisticLockHeader(defsVersion),
387
+ () => apiCall<DefinitionsBatchResponse>('/api/entities/definitions.batch', {
388
+ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload),
389
+ }),
390
+ )
370
391
  if (!call.ok) {
392
+ // A stale reorder auto-save (another tab changed the schema first) returns a
393
+ // 409; surface the shared conflict bar and stop the retry loop instead of a
394
+ // generic flash (issue #3152).
395
+ if (surfaceRecordConflict({ status: call.status, body: call.result }, t)) {
396
+ setOrderDirty(false)
397
+ return
398
+ }
371
399
  await raiseCrudError(call.response, 'Failed to save order')
372
400
  }
401
+ setDefsVersion(readVersionToken(call.result?.version))
373
402
  setOrderDirty(false)
374
403
  flash('Order saved', 'success')
375
404
  await invalidateCustomFieldDefs(queryClient, entityId)
@@ -502,18 +531,25 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
502
531
  fieldsets: buildFieldsetPayload(),
503
532
  singleFieldsetPerRecord,
504
533
  })
505
- const callDefs = await apiCall('/api/entities/definitions.batch', {
506
- method: 'POST',
507
- headers: { 'content-type': 'application/json' },
508
- body: JSON.stringify(defsPayload),
509
- })
534
+ // Send the aggregate schema version so a concurrent edit is rejected with a 409
535
+ // instead of silently overwriting the other tab (issue #3152). CrudForm's submit
536
+ // catch detects the optimistic-lock conflict and shows the shared conflict bar.
537
+ const callDefs = await withScopedApiRequestHeaders(
538
+ buildOptimisticLockHeader(defsVersion),
539
+ () => apiCall<DefinitionsBatchResponse>('/api/entities/definitions.batch', {
540
+ method: 'POST',
541
+ headers: { 'content-type': 'application/json' },
542
+ body: JSON.stringify(defsPayload),
543
+ }),
544
+ )
510
545
  if (!callDefs.ok) {
511
546
  await raiseCrudError(callDefs.response, 'Failed to save definitions')
512
547
  }
548
+ setDefsVersion(readVersionToken(callDefs.result?.version))
513
549
  try { window.dispatchEvent(new Event('om:refresh-sidebar')) } catch {}
514
550
  await invalidateCustomFieldDefs(queryClient, entityId)
515
551
  flash('Definitions saved', 'success')
516
- }, [buildFieldsetPayload, defs, entityId, entitySource, queryClient, singleFieldsetPerRecord, validateAll])
552
+ }, [buildFieldsetPayload, defs, defsVersion, entityId, entitySource, queryClient, singleFieldsetPerRecord, validateAll])
517
553
 
518
554
  if (!entityId) {
519
555
  return (
@@ -0,0 +1,102 @@
1
+ import type { EntityManager } from '@mikro-orm/postgresql'
2
+ import {
3
+ CustomFieldDef,
4
+ CustomFieldEntityConfig,
5
+ } from '@open-mercato/core/modules/entities/data/entities'
6
+
7
+ /**
8
+ * Aggregate optimistic-lock version for an entity's *definition schema*
9
+ * (issue #3152).
10
+ *
11
+ * The entity definition edit form (`/backend/entities/user|system/<entityId>`)
12
+ * saves a whole set of `CustomFieldDef` rows (plus fieldset config) via
13
+ * `POST /api/entities/definitions.batch`. There is no single row to lock, so the
14
+ * classic per-record `updated_at` guard cannot apply. Instead we derive one
15
+ * aggregate version token = the newest `updated_at` across the rows that make up
16
+ * the editable schema for the entity in the caller's scope:
17
+ *
18
+ * - `CustomFieldDef` (active + tombstoned) — a field add/edit/delete bumps it,
19
+ * - `CustomFieldEntityConfig` — a fieldset/settings change bumps it.
20
+ *
21
+ * The `CustomEntity` metadata row is deliberately NOT part of this token: the
22
+ * form's Save first PUTs entity metadata (`POST /api/entities/entities`, which
23
+ * bumps `CustomEntity.updatedAt` and enforces its own optimistic lock) and only
24
+ * then PUTs the definitions batch — folding the metadata row in here would make
25
+ * the batch 409 against the form's OWN just-completed metadata save. Custom
26
+ * entities therefore keep their concurrent-edit protection from the metadata
27
+ * lock (primary) with the definitions token as defense-in-depth; system/code
28
+ * entities (no metadata row, no metadata PUT) rely on the definitions token.
29
+ * A schema with zero definitions and no config yields `null`, so locking
30
+ * degrades to a no-op for that first-ever concurrent add edge case.
31
+ *
32
+ * Both the read path (`definitions.manage` GET) and the mutating endpoints must
33
+ * compute this with identical scope inputs so the token round-trips without
34
+ * false conflicts.
35
+ */
36
+ export type EntityDefinitionsVersionScope = {
37
+ entityId: string
38
+ tenantId: string | null
39
+ organizationId: string | null
40
+ }
41
+
42
+ function buildVisibleScopeWhere(scope: EntityDefinitionsVersionScope): Record<string, unknown> {
43
+ const organizationCandidates: Array<{ organizationId: string | null }> = [{ organizationId: null }]
44
+ if (scope.organizationId) organizationCandidates.unshift({ organizationId: scope.organizationId })
45
+
46
+ const tenantCandidates: Array<{ tenantId: string | null }> = [{ tenantId: null }]
47
+ if (scope.tenantId) tenantCandidates.unshift({ tenantId: scope.tenantId })
48
+
49
+ return {
50
+ entityId: scope.entityId,
51
+ $and: [
52
+ { $or: organizationCandidates },
53
+ { $or: tenantCandidates },
54
+ ],
55
+ }
56
+ }
57
+
58
+ function toMillis(value: unknown): number {
59
+ if (value instanceof Date) {
60
+ const ms = value.getTime()
61
+ return Number.isFinite(ms) ? ms : 0
62
+ }
63
+ if (typeof value === 'string' && value.length > 0) {
64
+ const ms = Date.parse(value)
65
+ return Number.isFinite(ms) ? ms : 0
66
+ }
67
+ return 0
68
+ }
69
+
70
+ async function latestUpdatedAtMillis(
71
+ em: EntityManager,
72
+ entity: unknown,
73
+ where: Record<string, unknown>,
74
+ ): Promise<number> {
75
+ if (!entity) return 0
76
+ try {
77
+ const row = await em.findOne(entity as never, where as never, {
78
+ orderBy: { updatedAt: 'desc' } as never,
79
+ fields: ['updatedAt'] as never,
80
+ })
81
+ if (!row || typeof row !== 'object') return 0
82
+ return toMillis((row as Record<string, unknown>).updatedAt)
83
+ } catch {
84
+ // Fail-open: a projection/schema hiccup must never 500 a save — it degrades
85
+ // to "no version available", which the guard treats as a no-op.
86
+ return 0
87
+ }
88
+ }
89
+
90
+ export async function resolveEntityDefinitionsVersion(
91
+ em: EntityManager,
92
+ scope: EntityDefinitionsVersionScope,
93
+ ): Promise<string | null> {
94
+ const where = buildVisibleScopeWhere(scope)
95
+ const [defMillis, configMillis] = await Promise.all([
96
+ latestUpdatedAtMillis(em, CustomFieldDef, where),
97
+ latestUpdatedAtMillis(em, CustomFieldEntityConfig, where),
98
+ ])
99
+ const newest = Math.max(defMillis, configMillis)
100
+ if (!newest) return null
101
+ return new Date(newest).toISOString()
102
+ }
@@ -1,7 +1,7 @@
1
1
  import type { ModuleCli } from '@open-mercato/shared/modules/registry'
2
2
  import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
3
3
  import type { EntityManager } from '@mikro-orm/postgresql'
4
- import { seedSalesAdjustmentKinds, seedSalesStatusDictionaries } from './lib/dictionaries'
4
+ import { backfillDealLossReasonDictionary, seedSalesAdjustmentKinds, seedSalesStatusDictionaries } from './lib/dictionaries'
5
5
  import { seedSalesTaxRates } from './lib/seeds'
6
6
  import { ensureExamplePaymentMethods, ensureExampleShippingMethods } from './seed/examples-data'
7
7
  import { seedSalesExamples } from './seed/examples'
@@ -103,6 +103,43 @@ const seedAdjustmentKindsCommand: ModuleCli = {
103
103
  },
104
104
  }
105
105
 
106
+ const backfillDealLossReasonsCommand: ModuleCli = {
107
+ command: 'backfill-deal-loss-reasons',
108
+ async run(rest) {
109
+ const args = parseArgs(rest)
110
+ const tenantId = String(args.tenantId ?? args.tenant ?? '')
111
+ const organizationId = String(args.organizationId ?? args.org ?? args.orgId ?? '')
112
+ if (!tenantId || !organizationId) {
113
+ console.error('Usage: mercato sales backfill-deal-loss-reasons --tenant <tenantId> --org <organizationId>')
114
+ return
115
+ }
116
+ const container = await createRequestContainer()
117
+ try {
118
+ const em = container.resolve<EntityManager>('em')
119
+ const result = await em.transactional(async (tem) => {
120
+ const backfillResult = await backfillDealLossReasonDictionary(tem, { tenantId, organizationId })
121
+ await tem.flush()
122
+ return backfillResult
123
+ })
124
+ console.log(
125
+ [
126
+ `Deal loss reasons dictionary ready for organization ${organizationId}.`,
127
+ `dictionaryId=${result.dictionaryId}`,
128
+ `createdDictionary=${String(result.createdDictionary)}`,
129
+ `copiedLegacyEntries=${String(result.copiedLegacyEntries)}`,
130
+ `seededDefaultEntries=${String(result.seededDefaultEntries)}`,
131
+ `existingEntries=${String(result.existingEntries)}`,
132
+ ].join(' '),
133
+ )
134
+ } finally {
135
+ const disposable = container as unknown as { dispose?: () => Promise<void> }
136
+ if (typeof disposable.dispose === 'function') {
137
+ await disposable.dispose()
138
+ }
139
+ }
140
+ },
141
+ }
142
+
106
143
  const seedShippingMethodsCommand: ModuleCli = {
107
144
  command: 'seed-shipping-methods',
108
145
  async run(rest) {
@@ -189,6 +226,7 @@ export default [
189
226
  seedTaxRatesCommand,
190
227
  seedStatusesCommand,
191
228
  seedAdjustmentKindsCommand,
229
+ backfillDealLossReasonsCommand,
192
230
  seedShippingMethodsCommand,
193
231
  seedPaymentMethodsCommand,
194
232
  seedExamplesCommand,
@@ -133,6 +133,14 @@ type SalesDictionarySeed = {
133
133
 
134
134
  type SeedScope = { tenantId: string; organizationId: string }
135
135
 
136
+ export type BackfillDealLossReasonDictionaryResult = {
137
+ dictionaryId: string
138
+ createdDictionary: boolean
139
+ copiedLegacyEntries: number
140
+ seededDefaultEntries: number
141
+ existingEntries: number
142
+ }
143
+
136
144
  const ORDER_STATUS_DEFAULTS: SalesDictionarySeed[] = [
137
145
  { value: 'draft', label: 'Draft', color: '#94a3b8', icon: 'lucide:file-pen-line' },
138
146
  { value: 'pending_approval', label: 'Pending Approval', color: '#f59e0b', icon: 'lucide:hourglass' },
@@ -262,6 +270,88 @@ const DEAL_LOSS_REASON_DEFAULTS: SalesDictionarySeed[] = [
262
270
  { value: 'other', label: 'Other', color: '#71717a', icon: 'lucide:minus-circle' },
263
271
  ]
264
272
 
273
+ export async function backfillDealLossReasonDictionary(
274
+ em: EntityManager,
275
+ scope: SeedScope
276
+ ): Promise<BackfillDealLossReasonDictionaryResult> {
277
+ const definition = getSalesDictionaryDefinition('deal-loss-reasons')
278
+ const existingDictionary = await em.findOne(Dictionary, {
279
+ tenantId: scope.tenantId,
280
+ organizationId: scope.organizationId,
281
+ key: definition.key,
282
+ deletedAt: null,
283
+ })
284
+ const dictionary = existingDictionary ?? await ensureSalesDictionary({
285
+ em,
286
+ tenantId: scope.tenantId,
287
+ organizationId: scope.organizationId,
288
+ kind: 'deal-loss-reasons',
289
+ })
290
+ const targetEntries = await em.find(DictionaryEntry, {
291
+ dictionary,
292
+ tenantId: scope.tenantId,
293
+ organizationId: scope.organizationId,
294
+ })
295
+
296
+ if (targetEntries.length > 0) {
297
+ return {
298
+ dictionaryId: dictionary.id,
299
+ createdDictionary: !existingDictionary,
300
+ copiedLegacyEntries: 0,
301
+ seededDefaultEntries: 0,
302
+ existingEntries: targetEntries.length,
303
+ }
304
+ }
305
+
306
+ const legacyDictionary = await em.findOne(Dictionary, {
307
+ tenantId: scope.tenantId,
308
+ organizationId: scope.organizationId,
309
+ key: 'customer_lost_reason',
310
+ deletedAt: null,
311
+ })
312
+ const legacyEntries = legacyDictionary
313
+ ? await em.find(
314
+ DictionaryEntry,
315
+ {
316
+ dictionary: legacyDictionary,
317
+ tenantId: scope.tenantId,
318
+ organizationId: scope.organizationId,
319
+ },
320
+ { orderBy: { position: 'asc', label: 'asc' } },
321
+ )
322
+ : []
323
+
324
+ let copiedLegacyEntries = 0
325
+ let seededDefaultEntries = 0
326
+ const processedValues = new Set<string>()
327
+ const seeds = legacyEntries.length
328
+ ? legacyEntries.map((entry) => ({
329
+ value: entry.value,
330
+ label: entry.label,
331
+ color: entry.color,
332
+ icon: entry.icon,
333
+ }))
334
+ : DEAL_LOSS_REASON_DEFAULTS
335
+
336
+ for (const seed of seeds) {
337
+ const normalizedValue = normalizeDictionaryValue(seed.value ?? '')
338
+ if (!normalizedValue || processedValues.has(normalizedValue)) continue
339
+ const entry = await ensureSalesDictionaryEntry(em, scope, 'deal-loss-reasons', seed)
340
+ if (!entry) continue
341
+ processedValues.add(normalizedValue)
342
+ if (legacyEntries.length) copiedLegacyEntries += 1
343
+ else seededDefaultEntries += 1
344
+ }
345
+
346
+ return {
347
+ dictionaryId: dictionary.id,
348
+ createdDictionary: !existingDictionary,
349
+ copiedLegacyEntries,
350
+ seededDefaultEntries,
351
+ existingEntries: 0,
352
+ }
353
+ }
354
+
265
355
  export async function seedSalesStatusDictionaries(
266
356
  em: EntityManager,
267
357
  scope: SeedScope