@open-mercato/core 0.6.6-develop.6401.1.8c07df34a7 → 0.6.6-develop.6403.1.b57089b6fe

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 (26) hide show
  1. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js +9 -12
  2. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.js.map +2 -2
  3. package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js +49 -10
  4. package/dist/modules/customers/components/detail/ConfirmDealLostDialog.js.map +2 -2
  5. package/dist/modules/planner/api/availability-weekly.js +11 -1
  6. package/dist/modules/planner/api/availability-weekly.js.map +2 -2
  7. package/dist/modules/planner/commands/availability-weekly.js +34 -3
  8. package/dist/modules/planner/commands/availability-weekly.js.map +2 -2
  9. package/dist/modules/planner/components/AvailabilityRulesEditor.js +14 -6
  10. package/dist/modules/planner/components/AvailabilityRulesEditor.js.map +2 -2
  11. package/dist/modules/sales/cli.js +38 -1
  12. package/dist/modules/sales/cli.js.map +2 -2
  13. package/dist/modules/sales/lib/dictionaries.js +70 -0
  14. package/dist/modules/sales/lib/dictionaries.js.map +2 -2
  15. package/package.json +7 -7
  16. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealClosure.ts +9 -13
  17. package/src/modules/customers/components/detail/ConfirmDealLostDialog.tsx +58 -8
  18. package/src/modules/customers/i18n/de.json +4 -0
  19. package/src/modules/customers/i18n/en.json +4 -0
  20. package/src/modules/customers/i18n/es.json +4 -0
  21. package/src/modules/customers/i18n/pl.json +4 -0
  22. package/src/modules/planner/api/availability-weekly.ts +10 -0
  23. package/src/modules/planner/commands/availability-weekly.ts +46 -8
  24. package/src/modules/planner/components/AvailabilityRulesEditor.tsx +15 -6
  25. package/src/modules/sales/cli.ts +39 -1
  26. package/src/modules/sales/lib/dictionaries.ts +90 -0
@@ -8,14 +8,24 @@ import {
8
8
  type PlannerAvailabilityWeeklyReplaceInput,
9
9
  } from '../data/validators'
10
10
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
11
- import type { PlannerAvailabilityKind, PlannerAvailabilitySubjectType } from '../data/entities'
11
+ import { emitCrudSideEffects } from '@open-mercato/shared/lib/commands/helpers'
12
12
  import {
13
13
  enforceCommandOptimisticLock,
14
14
  enforceRecordGoneIsConflict,
15
15
  } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
16
+ import type { DataEngine } from '@open-mercato/shared/lib/data/engine'
17
+ import type { CrudIndexerConfig } from '@open-mercato/shared/lib/crud/types'
18
+ import type { PlannerAvailabilityKind, PlannerAvailabilitySubjectType } from '../data/entities'
16
19
  import { ensureOrganizationScope, ensureTenantScope, extractUndoPayload } from './shared'
20
+ import { plannerAvailabilityRuleSetCrudEvents } from '../lib/crud'
21
+ import { E } from '#generated/entities.ids.generated'
17
22
 
18
23
  const AVAILABILITY_RULE_RESOURCE_KIND = 'planner.availability.rule'
24
+ const AVAILABILITY_RULE_SET_CACHE_RESOURCE_KIND = 'planner.availability-rule-set'
25
+
26
+ const availabilityRuleSetCrudIndexer: CrudIndexerConfig<PlannerAvailabilityRuleSet> = {
27
+ entityType: E.planner.planner_availability_rule_set,
28
+ }
19
29
 
20
30
  // Canonical resource kind for the parent rule set, matching the tag the CRUD
21
31
  // factory derives for `planner.availability-rule-sets.*` commands. Weekly
@@ -95,6 +105,14 @@ function toAvailabilityRuleSnapshot(record: PlannerAvailabilityRule): Availabili
95
105
  }
96
106
  }
97
107
 
108
+ function nextRuleSetUpdatedAt(current: Date | null | undefined, fallback: Date): Date {
109
+ const currentMs = current instanceof Date ? current.getTime() : Number.NaN
110
+ if (Number.isFinite(currentMs) && fallback.getTime() <= currentMs) {
111
+ return new Date(currentMs + 1)
112
+ }
113
+ return fallback
114
+ }
115
+
98
116
  async function loadWeeklySnapshots(
99
117
  em: EntityManager,
100
118
  params: {
@@ -173,12 +191,12 @@ const replaceWeeklyAvailabilityCommand: CommandHandler<PlannerAvailabilityWeekly
173
191
  const em = (ctx.container.resolve('em') as EntityManager).fork()
174
192
  const now = new Date()
175
193
 
176
- await em.transactional(async (trx) => {
177
- // The weekly rules of a rule set are a sub-resource of that rule set: the
178
- // parent is the optimistic-lock consistency boundary. Guard the parent's
179
- // version (so a stale weekly save loses to a concurrent rule-set
180
- // change/delete) and bump its `updated_at` after the replace (so a
181
- // concurrent rule-set delete/update with a stale token conflicts). See #2927.
194
+ // The weekly rules of a rule set are a sub-resource of that rule set: the
195
+ // parent is the optimistic-lock consistency boundary. Guard the parent's
196
+ // version (so a stale weekly save loses to a concurrent rule-set
197
+ // change/delete) and bump its `updated_at` after the replace (so a
198
+ // concurrent rule-set delete/update with a stale token conflicts). See #2927.
199
+ const touchedRuleSet = await em.transactional(async (trx): Promise<PlannerAvailabilityRuleSet | null> => {
182
200
  let ruleSet: PlannerAvailabilityRuleSet | null = null
183
201
  if (parsed.subjectType === 'ruleset') {
184
202
  ruleSet = await trx.findOne(PlannerAvailabilityRuleSet, {
@@ -250,13 +268,30 @@ const replaceWeeklyAvailabilityCommand: CommandHandler<PlannerAvailabilityWeekly
250
268
  })
251
269
 
252
270
  if (ruleSet) {
253
- ruleSet.updatedAt = now
271
+ ruleSet.updatedAt = nextRuleSetUpdatedAt(ruleSet.updatedAt, now)
254
272
  trx.persist(ruleSet)
255
273
  }
256
274
 
257
275
  await trx.flush()
276
+ return ruleSet
258
277
  })
259
278
 
279
+ if (touchedRuleSet) {
280
+ const dataEngine = ctx.container.resolve('dataEngine') as DataEngine
281
+ await emitCrudSideEffects({
282
+ dataEngine,
283
+ action: 'updated',
284
+ entity: touchedRuleSet,
285
+ identifiers: {
286
+ id: touchedRuleSet.id,
287
+ organizationId: touchedRuleSet.organizationId,
288
+ tenantId: touchedRuleSet.tenantId,
289
+ },
290
+ events: plannerAvailabilityRuleSetCrudEvents,
291
+ indexer: availabilityRuleSetCrudIndexer,
292
+ })
293
+ }
294
+
260
295
  return { ok: true }
261
296
  },
262
297
  buildLog: async ({ input, snapshots, ctx }) => {
@@ -284,6 +319,9 @@ const replaceWeeklyAvailabilityCommand: CommandHandler<PlannerAvailabilityWeekly
284
319
  after,
285
320
  } satisfies WeeklyUndoPayload,
286
321
  },
322
+ context: parsed.subjectType === 'ruleset'
323
+ ? { cacheAliases: [AVAILABILITY_RULE_SET_CACHE_RESOURCE_KIND] }
324
+ : null,
287
325
  }
288
326
  },
289
327
  undo: async ({ logEntry, ctx }) => {
@@ -19,6 +19,7 @@ import { createCrud, deleteCrud, updateCrud } from '@open-mercato/ui/backend/uti
19
19
  import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
20
20
  import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
21
21
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
22
+ import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
22
23
  import { Spinner } from '@open-mercato/ui/primitives/spinner'
23
24
  import { ComboboxInput, TimePicker } from '@open-mercato/ui/backend/inputs'
24
25
  import { DictionaryEntrySelect, type DictionarySelectLabels } from '@open-mercato/core/modules/dictionaries/components/DictionaryEntrySelect'
@@ -30,7 +31,6 @@ import {
30
31
  import { useT } from '@open-mercato/shared/lib/i18n/context'
31
32
  import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
32
33
  import { normalizeCrudServerError } from '@open-mercato/ui/backend/utils/serverErrors'
33
- import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
34
34
  import { parseAvailabilityRuleWindow } from '@open-mercato/core/modules/planner/lib/availabilitySchedule'
35
35
  import { deleteAvailabilityRuleSet } from '@open-mercato/core/modules/planner/lib/deleteAvailabilityRuleSet'
36
36
  import { CrudForm, type CrudField } from '@open-mercato/ui/backend/CrudForm'
@@ -778,6 +778,12 @@ export function AvailabilityRulesEditor({
778
778
  void refreshBookedEvents()
779
779
  }, [refreshBookedEvents])
780
780
 
781
+ const refreshAfterRuleSetConflict = React.useCallback(() => {
782
+ void refreshRuleSets()
783
+ void refreshRuleSetRules()
784
+ void refreshAvailability()
785
+ }, [refreshAvailability, refreshRuleSetRules, refreshRuleSets])
786
+
781
787
  const weeklyDraft = React.useMemo(() => buildWeeklyDraft(activeRules), [activeRules])
782
788
  const [weeklyWindows, setWeeklyWindows] = React.useState<WeeklyWindows>(() => cloneWeeklyWindows(weeklyDraft))
783
789
  const weeklyWindowsRef = React.useRef<WeeklyWindows>(cloneWeeklyWindows(weeklyDraft))
@@ -893,14 +899,15 @@ export function AvailabilityRulesEditor({
893
899
  if (!shouldSkipRefresh) {
894
900
  await refreshAvailability()
895
901
  await refreshRuleSetRules()
902
+ if (usingRuleSet) {
903
+ await refreshRuleSets()
904
+ }
896
905
  }
897
906
  if (parentRuleSet) {
898
907
  await refreshRuleSets()
899
908
  }
900
909
  } catch (error) {
901
- if (surfaceRecordConflict(error, t)) {
902
- return
903
- }
910
+ if (surfaceRecordConflict(error, t, { onRefresh: refreshAfterRuleSetConflict })) return
904
911
  const message = error instanceof Error ? error.message : listLabels.saveWeeklyError
905
912
  flash(message, 'error')
906
913
  } finally {
@@ -912,8 +919,9 @@ export function AvailabilityRulesEditor({
912
919
  refreshAvailability,
913
920
  refreshRuleSetRules,
914
921
  refreshRuleSets,
915
- ruleSets,
922
+ refreshAfterRuleSetConflict,
916
923
  effectiveRulesetId,
924
+ ruleSets,
917
925
  subjectId,
918
926
  subjectType,
919
927
  t,
@@ -1180,7 +1188,7 @@ export function AvailabilityRulesEditor({
1180
1188
  refreshRuleSets,
1181
1189
  onSuccess: () => flash(listLabels.ruleSetDeleteSuccess, 'success'),
1182
1190
  onError: (error) => {
1183
- if (surfaceRecordConflict(error, t)) return
1191
+ if (surfaceRecordConflict(error, t, { onRefresh: refreshAfterRuleSetConflict })) return
1184
1192
  console.error('planner.availability-rule-sets.delete', error)
1185
1193
  const normalized = normalizeCrudServerError(error)
1186
1194
  flash(normalized.message ?? listLabels.ruleSetDeleteError, 'error')
@@ -1195,6 +1203,7 @@ export function AvailabilityRulesEditor({
1195
1203
  listLabels.ruleSetDeleteSuccess,
1196
1204
  onRulesetChange,
1197
1205
  refreshAvailability,
1206
+ refreshAfterRuleSetConflict,
1198
1207
  refreshRuleSets,
1199
1208
  ruleSets,
1200
1209
  rulesetId,
@@ -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