@open-mercato/core 0.10.1-develop.7787.1.96c7cfc66f → 0.10.1-develop.7792.1.b0ce6289cf

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.
@@ -356,6 +356,7 @@ type QuoteAdjustmentSnapshot = {
356
356
  type OrderGraphSnapshot = {
357
357
  order: {
358
358
  id: string;
359
+ updatedAt?: string;
359
360
  organizationId: string;
360
361
  tenantId: string;
361
362
  orderNumber: string;
@@ -2033,6 +2034,7 @@ async function loadOrderSnapshot(
2033
2034
  return {
2034
2035
  order: {
2035
2036
  id: order.id,
2037
+ updatedAt: order.updatedAt?.toISOString(),
2036
2038
  organizationId: order.organizationId,
2037
2039
  tenantId: order.tenantId,
2038
2040
  orderNumber: order.orderNumber,
@@ -6977,6 +6979,18 @@ const deleteOrderCommand: CommandHandler<
6977
6979
  em,
6978
6980
  [
6979
6981
  async () => {
6982
+ const lockedOrder = await findOneWithDecryption(
6983
+ em,
6984
+ SalesOrder,
6985
+ { id, tenantId: order.tenantId, organizationId: order.organizationId },
6986
+ { lockMode: LockMode.PESSIMISTIC_WRITE, refresh: true },
6987
+ { tenantId: order.tenantId, organizationId: order.organizationId },
6988
+ );
6989
+ if (!lockedOrder) {
6990
+ const { translate } = await resolveTranslations();
6991
+ throw notFound(translate("sales.documents.detail.notFound", "Document not found."));
6992
+ }
6993
+ await enforceSalesDocumentOptimisticLock(ctx, lockedOrder, SALES_RESOURCE_KIND_ORDER);
6980
6994
  if (shipmentIds.length) {
6981
6995
  await em.nativeDelete(SalesShipmentItem, {
6982
6996
  shipment: { $in: shipmentIds },
@@ -6999,7 +7013,7 @@ const deleteOrderCommand: CommandHandler<
6999
7013
  });
7000
7014
  await em.nativeDelete(SalesOrderAdjustment, { order: order.id });
7001
7015
  await em.nativeDelete(SalesOrderLine, { order: order.id });
7002
- em.remove(order);
7016
+ em.remove(lockedOrder);
7003
7017
  },
7004
7018
  ],
7005
7019
  { transaction: true },
@@ -11,6 +11,8 @@ import { invalidateCrudCache } from '@open-mercato/shared/lib/crud/cache'
11
11
  import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
12
12
  import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
13
13
  import { readSafeDecryptedString } from '../lib/decryptionSafety'
14
+ import { isInitialReplacementOrderApproval } from '../lib/replacementOrderUndo'
15
+ import { OPTIMISTIC_LOCK_HEADER_NAME } from '@open-mercato/shared/lib/crud/optimistic-lock-headers'
14
16
  import type { EntityId } from '@open-mercato/shared/modules/entities'
15
17
  import type { CrudEventsConfig } from '@open-mercato/shared/lib/crud/types'
16
18
  import { E } from '#generated/entities.ids.generated'
@@ -3062,13 +3064,20 @@ async function dispatchSalesOrderDelete(
3062
3064
  ctx: CommandRuntimeContext,
3063
3065
  scope: WarrantyClaimScope,
3064
3066
  replacementOrderId: string,
3067
+ expectedUpdatedAt?: string,
3065
3068
  ): Promise<void> {
3066
3069
  const commandBus = ctx.container.resolve('commandBus') as CommandBus
3070
+ const dispatchContext = scrubbedDispatchContext(ctx)
3071
+ if (expectedUpdatedAt) {
3072
+ dispatchContext.request = new Request(ctx.request?.url ?? 'http://localhost', {
3073
+ headers: { [OPTIMISTIC_LOCK_HEADER_NAME]: expectedUpdatedAt },
3074
+ })
3075
+ }
3067
3076
  await commandBus.execute(
3068
3077
  'sales.orders.delete',
3069
3078
  {
3070
3079
  input: { id: replacementOrderId, tenantId: scope.tenantId, organizationId: scope.organizationId },
3071
- ctx: scrubbedDispatchContext(ctx),
3080
+ ctx: dispatchContext,
3072
3081
  },
3073
3082
  )
3074
3083
  }
@@ -3076,6 +3085,7 @@ async function dispatchSalesOrderDelete(
3076
3085
  const createReplacementOrderCommand: CommandHandler<ClaimCreateReplacementOrderInput, ClaimCreateReplacementOrderResult> = {
3077
3086
  id: 'warranty_claims.claim.create_replacement_order',
3078
3087
  isUndoable: true,
3088
+ redo: ({ input, ctx }) => createReplacementOrderCommand.execute({ ...input, updatedAt: undefined }, ctx),
3079
3089
  prepare: async (rawInput, ctx) => {
3080
3090
  const input = parseCommandInput(claimCreateReplacementOrderSchema, rawInput)
3081
3091
  const scope = resolveScope(ctx, input)
@@ -3336,10 +3346,13 @@ const createReplacementOrderCommand: CommandHandler<ClaimCreateReplacementOrderI
3336
3346
  const currentUpdatedAt = currentRow.updated_at
3337
3347
  ? new Date(currentRow.updated_at as string | Date).toISOString()
3338
3348
  : null
3339
- if (!replacementOrder.updatedAt || !currentUpdatedAt || currentUpdatedAt !== replacementOrder.updatedAt) {
3349
+ if (!replacementOrder.updatedAt || !currentUpdatedAt || (
3350
+ currentUpdatedAt !== replacementOrder.updatedAt
3351
+ && !await isInitialReplacementOrderApproval(ctx, scope, replacementOrder.id, replacementOrder.updatedAt, currentUpdatedAt)
3352
+ )) {
3340
3353
  throw new CrudHttpError(409, { error: 'warranty_claims.errors.replacementOrderChangedUndoAborted' })
3341
3354
  }
3342
- await dispatchSalesOrderDelete(ctx, scope, replacementOrder.id)
3355
+ await dispatchSalesOrderDelete(ctx, scope, replacementOrder.id, currentUpdatedAt)
3343
3356
  } else {
3344
3357
  replacementOrderBridgeLogger.info('warranty_claims.claim.create_replacement_order undo found replacement order already absent', {
3345
3358
  claimId: before.id,
@@ -0,0 +1,67 @@
1
+ import { isDeepStrictEqual } from 'node:util'
2
+ import type { CommandRuntimeContext } from '@open-mercato/shared/lib/commands'
3
+ import type { ActionLogService } from '@open-mercato/core/modules/audit_logs/services/actionLogService'
4
+ import type { WarrantyClaimScope } from '../commands/shared'
5
+
6
+ type OrderAuditEntry = NonNullable<Awaited<ReturnType<ActionLogService['findById']>>>
7
+
8
+ function record(value: unknown): Record<string, unknown> | null {
9
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
10
+ ? value as Record<string, unknown>
11
+ : null
12
+ }
13
+
14
+ export async function isInitialReplacementOrderApproval(
15
+ ctx: CommandRuntimeContext,
16
+ scope: WarrantyClaimScope,
17
+ orderId: string,
18
+ savedVersion: string,
19
+ currentVersion: string,
20
+ ): Promise<boolean> {
21
+ let logs: ActionLogService
22
+ try {
23
+ logs = ctx.container.resolve<ActionLogService>('actionLogService')
24
+ } catch {
25
+ return false
26
+ }
27
+ if (!logs) return false
28
+ const history = await logs.list({
29
+ ...scope,
30
+ resourceKind: 'sales.order',
31
+ resourceId: orderId,
32
+ includeRelated: true,
33
+ after: new Date(savedVersion),
34
+ pageSize: 3,
35
+ })
36
+ const entries: OrderAuditEntry[] = history.items
37
+ if (history.total > 3 || entries.length !== history.total) return false
38
+ const edits = entries.filter((entry) => !(entry.commandId === 'sales.orders.create'
39
+ && entry.resourceKind === 'sales.order' && entry.resourceId === orderId
40
+ && entry.tenantId === scope.tenantId && entry.organizationId === scope.organizationId))
41
+ if (edits.length !== 1) return false
42
+ const entry = edits[0]
43
+ if (entry.commandId !== 'sales.orders.update' || entry.executionState !== 'done') return false
44
+ if (entry.tenantId !== scope.tenantId || entry.organizationId !== scope.organizationId
45
+ || entry.resourceKind !== 'sales.order' || entry.resourceId !== orderId) return false
46
+ const before = record(entry.snapshotBefore)
47
+ const after = record(entry.snapshotAfter)
48
+ const beforeOrder = record(before?.order)
49
+ const afterOrder = record(after?.order)
50
+ if (!beforeOrder || !afterOrder || beforeOrder.id !== orderId || afterOrder.id !== orderId) return false
51
+ if (beforeOrder.updatedAt !== savedVersion || afterOrder.updatedAt !== currentVersion) return false
52
+ if (beforeOrder.status !== null || beforeOrder.statusEntryId !== null || afterOrder.status !== 'pending_approval') return false
53
+ const comparableOrder = (order: Record<string, unknown>) => Object.fromEntries(
54
+ Object.entries(order).filter(([key]) => !['status', 'statusEntryId', 'updatedAt'].includes(key)),
55
+ )
56
+ if (!isDeepStrictEqual(comparableOrder(beforeOrder), comparableOrder(afterOrder))) return false
57
+ const input = record(record(entry.commandPayload)?.__redoInput)
58
+ if (!input || input.id !== orderId || typeof input.statusEntryId !== 'string'
59
+ || input.statusEntryId !== afterOrder.statusEntryId
60
+ || Object.keys(input).some((key) => !['id', 'statusEntryId'].includes(key))) return false
61
+ const beforeNotes = before?.notes
62
+ const afterNotes = after?.notes
63
+ if (!Array.isArray(beforeNotes) || !Array.isArray(afterNotes) || afterNotes.length !== beforeNotes.length + 1) return false
64
+ if (!beforeNotes.every((note) => afterNotes.some((candidate) => isDeepStrictEqual(note, candidate)))) return false
65
+ return ['lines', 'adjustments', 'addresses', 'tags', 'shipments', 'payments']
66
+ .every((key) => Array.isArray(before?.[key]) && isDeepStrictEqual(before?.[key], after?.[key]))
67
+ }