@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/auth/lib/backendChrome.js +3 -2
- package/dist/modules/auth/lib/backendChrome.js.map +2 -2
- package/dist/modules/currencies/backend/config/currency-fetching/page.meta.js +4 -13
- package/dist/modules/currencies/backend/config/currency-fetching/page.meta.js.map +2 -2
- package/dist/modules/currencies/backend/currencies/page.meta.js +1 -1
- package/dist/modules/currencies/backend/currencies/page.meta.js.map +2 -2
- package/dist/modules/currencies/backend/exchange-rates/page.meta.js +1 -0
- package/dist/modules/currencies/backend/exchange-rates/page.meta.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js +9 -12
- package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js.map +2 -2
- package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js +49 -10
- package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js.map +2 -2
- package/dist/modules/entities/api/definitions.batch.js +29 -2
- package/dist/modules/entities/api/definitions.batch.js.map +2 -2
- package/dist/modules/entities/api/definitions.js +9 -2
- package/dist/modules/entities/api/definitions.js.map +2 -2
- package/dist/modules/entities/api/definitions.manage.js +14 -3
- package/dist/modules/entities/api/definitions.manage.js.map +2 -2
- package/dist/modules/entities/backend/entities/user/[entityId]/page.js +43 -17
- package/dist/modules/entities/backend/entities/user/[entityId]/page.js.map +2 -2
- package/dist/modules/entities/lib/definitions-version.js +55 -0
- package/dist/modules/entities/lib/definitions-version.js.map +7 -0
- package/dist/modules/sales/cli.js +38 -1
- package/dist/modules/sales/cli.js.map +2 -2
- package/dist/modules/sales/lib/dictionaries.js +70 -0
- package/dist/modules/sales/lib/dictionaries.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/auth/lib/backendChrome.tsx +3 -2
- package/src/modules/currencies/backend/config/currency-fetching/page.meta.ts +4 -15
- package/src/modules/currencies/backend/currencies/page.meta.ts +1 -1
- package/src/modules/currencies/backend/exchange-rates/page.meta.ts +1 -0
- package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.ts +9 -13
- package/src/modules/customers/components/detail/ConfirmDealLostDialog.tsx +58 -8
- package/src/modules/customers/i18n/de.json +4 -0
- package/src/modules/customers/i18n/en.json +4 -0
- package/src/modules/customers/i18n/es.json +4 -0
- package/src/modules/customers/i18n/pl.json +4 -0
- package/src/modules/entities/api/definitions.batch.ts +38 -1
- package/src/modules/entities/api/definitions.manage.ts +14 -1
- package/src/modules/entities/api/definitions.ts +10 -1
- package/src/modules/entities/backend/entities/user/[entityId]/page.tsx +53 -17
- package/src/modules/entities/lib/definitions-version.ts +102 -0
- package/src/modules/sales/cli.ts +39 -1
- 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
|
-
|
|
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
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
368
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
+
}
|
package/src/modules/sales/cli.ts
CHANGED
|
@@ -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
|