@open-mercato/core 0.10.1-develop.7728.1.6b52eb2849 → 0.10.1-develop.7741.1.61a2652671

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 (70) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/api_key/index.js +2 -0
  3. package/dist/generated/entities/api_key/index.js.map +2 -2
  4. package/dist/generated/entity-fields-registry.js +1 -0
  5. package/dist/generated/entity-fields-registry.js.map +2 -2
  6. package/dist/modules/api_keys/data/entities.js +7 -0
  7. package/dist/modules/api_keys/data/entities.js.map +2 -2
  8. package/dist/modules/api_keys/migrations/Migration20261012202110_api_keys.js +30 -0
  9. package/dist/modules/api_keys/migrations/Migration20261012202110_api_keys.js.map +7 -0
  10. package/dist/modules/api_keys/services/apiKeyService.js +31 -9
  11. package/dist/modules/api_keys/services/apiKeyService.js.map +2 -2
  12. package/dist/modules/attachments/lib/ocrService.js +8 -0
  13. package/dist/modules/attachments/lib/ocrService.js.map +2 -2
  14. package/dist/modules/auth/api/logout.js +3 -1
  15. package/dist/modules/auth/api/logout.js.map +2 -2
  16. package/dist/modules/sales/commands/documents.js +1190 -1408
  17. package/dist/modules/sales/commands/documents.js.map +2 -2
  18. package/dist/modules/sales/commands/returns.js +34 -11
  19. package/dist/modules/sales/commands/returns.js.map +2 -2
  20. package/dist/modules/sales/commands/shared.js +5 -0
  21. package/dist/modules/sales/commands/shared.js.map +2 -2
  22. package/dist/modules/sales/commands/shipments.js +9 -0
  23. package/dist/modules/sales/commands/shipments.js.map +2 -2
  24. package/dist/modules/staff/api/timesheets/time-projects/route.js +6 -2
  25. package/dist/modules/staff/api/timesheets/time-projects/route.js.map +2 -2
  26. package/dist/modules/workflows/api/portal/tasks/[id]/complete/route.js +1 -1
  27. package/dist/modules/workflows/api/portal/tasks/[id]/complete/route.js.map +2 -2
  28. package/dist/modules/workflows/api/tasks/[id]/complete/route.js +7 -1
  29. package/dist/modules/workflows/api/tasks/[id]/complete/route.js.map +2 -2
  30. package/dist/modules/workflows/backend/tasks/[id]/page.js +2 -1
  31. package/dist/modules/workflows/backend/tasks/[id]/page.js.map +2 -2
  32. package/dist/modules/workflows/lib/instance-status.js +16 -0
  33. package/dist/modules/workflows/lib/instance-status.js.map +7 -0
  34. package/dist/modules/workflows/lib/task-handler.js +16 -8
  35. package/dist/modules/workflows/lib/task-handler.js.map +2 -2
  36. package/dist/modules/workflows/lib/task-sla.js +10 -0
  37. package/dist/modules/workflows/lib/task-sla.js.map +2 -2
  38. package/dist/modules/workflows/lib/workflow-executor.js +29 -1
  39. package/dist/modules/workflows/lib/workflow-executor.js.map +2 -2
  40. package/generated/entities/api_key/index.ts +1 -0
  41. package/generated/entity-fields-registry.ts +1 -0
  42. package/package.json +7 -7
  43. package/src/modules/api_keys/data/entities.ts +25 -1
  44. package/src/modules/api_keys/migrations/.snapshot-open-mercato.json +234 -68
  45. package/src/modules/api_keys/migrations/Migration20261012202110_api_keys.ts +47 -0
  46. package/src/modules/api_keys/services/apiKeyService.ts +55 -9
  47. package/src/modules/attachments/lib/ocrService.ts +8 -0
  48. package/src/modules/auth/api/logout.ts +3 -1
  49. package/src/modules/auth/i18n/de.json +3 -0
  50. package/src/modules/auth/i18n/en.json +3 -0
  51. package/src/modules/auth/i18n/es.json +3 -0
  52. package/src/modules/auth/i18n/ko.json +3 -0
  53. package/src/modules/auth/i18n/pl.json +3 -0
  54. package/src/modules/sales/commands/documents.ts +1501 -1626
  55. package/src/modules/sales/commands/returns.ts +41 -14
  56. package/src/modules/sales/commands/shared.ts +40 -0
  57. package/src/modules/sales/commands/shipments.ts +10 -0
  58. package/src/modules/staff/api/timesheets/time-projects/route.ts +6 -2
  59. package/src/modules/workflows/api/portal/tasks/[id]/complete/route.ts +5 -1
  60. package/src/modules/workflows/api/tasks/[id]/complete/route.ts +7 -1
  61. package/src/modules/workflows/backend/tasks/[id]/page.tsx +6 -2
  62. package/src/modules/workflows/i18n/de.json +2 -0
  63. package/src/modules/workflows/i18n/en.json +2 -0
  64. package/src/modules/workflows/i18n/es.json +2 -0
  65. package/src/modules/workflows/i18n/ko.json +2 -0
  66. package/src/modules/workflows/i18n/pl.json +2 -0
  67. package/src/modules/workflows/lib/instance-status.ts +29 -0
  68. package/src/modules/workflows/lib/task-handler.ts +18 -10
  69. package/src/modules/workflows/lib/task-sla.ts +13 -0
  70. package/src/modules/workflows/lib/workflow-executor.ts +44 -1
@@ -111,6 +111,7 @@ import {
111
111
  reconcileLinePersistedTotals,
112
112
  deriveLineNetFromGross,
113
113
  enforceSalesDocumentOptimisticLock,
114
+ lockSalesDocumentRow,
114
115
  SALES_RESOURCE_KIND_ORDER,
115
116
  SALES_RESOURCE_KIND_QUOTE,
116
117
  } from "./shared";
@@ -1193,19 +1194,33 @@ async function applyDocumentUpdate({
1193
1194
  }
1194
1195
 
1195
1196
  if (input.customerEntityId !== undefined) {
1196
- entity.customerEntityId = input.customerEntityId ?? null;
1197
+ const nextCustomerEntityId = input.customerEntityId ?? null;
1198
+ const customerChanged =
1199
+ (nextCustomerEntityId?.toLowerCase() ?? null) !==
1200
+ (entity.customerEntityId?.toLowerCase() ?? null);
1201
+ const snapshotContactId =
1202
+ input.customerContactId !== undefined
1203
+ ? input.customerContactId
1204
+ : customerChanged
1205
+ ? null
1206
+ : entity.customerContactId ?? null;
1207
+ if (customerChanged) {
1208
+ entity.customerEntityId = nextCustomerEntityId;
1209
+ }
1197
1210
  entity.customerSnapshot = await resolveCustomerSnapshot(
1198
1211
  em,
1199
1212
  organizationId,
1200
1213
  tenantId,
1201
- input.customerEntityId,
1202
- input.customerContactId ?? entity.customerContactId ?? null,
1214
+ entity.customerEntityId ?? null,
1215
+ snapshotContactId,
1203
1216
  );
1204
- entity.customerContactId = input.customerContactId ?? null;
1205
- entity.billingAddressId = null;
1206
- entity.shippingAddressId = null;
1207
- entity.billingAddressSnapshot = null;
1208
- entity.shippingAddressSnapshot = null;
1217
+ if (customerChanged) {
1218
+ entity.customerContactId = input.customerContactId ?? null;
1219
+ entity.billingAddressId = null;
1220
+ entity.shippingAddressId = null;
1221
+ entity.billingAddressSnapshot = null;
1222
+ entity.shippingAddressSnapshot = null;
1223
+ }
1209
1224
  }
1210
1225
  if (input.customerContactId !== undefined) {
1211
1226
  entity.customerContactId = input.customerContactId ?? null;
@@ -5595,6 +5610,264 @@ const deleteQuoteCommand: CommandHandler<
5595
5610
  },
5596
5611
  };
5597
5612
 
5613
+ type TotalsCalculatedPayload = Parameters<typeof emitTotalsCalculated>[1];
5614
+ type LockedGraphWrite<TResult> = {
5615
+ result: TResult;
5616
+ calculation: SalesDocumentCalculationResult;
5617
+ };
5618
+ type LockedGraphSnapshots<TSnapshot> = {
5619
+ before: TSnapshot | null;
5620
+ after: TSnapshot | null;
5621
+ };
5622
+ type LockedGraphKind<TDocument, TSnapshot> = {
5623
+ documentKind: SalesDocumentKind;
5624
+ lock: (
5625
+ em: EntityManager,
5626
+ ctx: CommandRuntimeContext,
5627
+ documentId: string,
5628
+ missingMessage: string,
5629
+ ) => Promise<TDocument>;
5630
+ loadSnapshot: (em: EntityManager, documentId: string) => Promise<TSnapshot | null>;
5631
+ snapshots: WeakMap<object, LockedGraphSnapshots<TSnapshot>>;
5632
+ };
5633
+
5634
+ const lockedGraphWriteInputs = new WeakSet<object>();
5635
+ const lockedOrderGraphSnapshots = new WeakMap<object, LockedGraphSnapshots<OrderGraphSnapshot>>();
5636
+ const lockedQuoteGraphSnapshots = new WeakMap<object, LockedGraphSnapshots<QuoteGraphSnapshot>>();
5637
+
5638
+ function resolveRequestDecryptionScope(ctx: CommandRuntimeContext) {
5639
+ return {
5640
+ tenantId: ctx.auth?.tenantId ?? undefined,
5641
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? undefined,
5642
+ };
5643
+ }
5644
+
5645
+ async function lockOrderForTotalsWrite(
5646
+ em: EntityManager,
5647
+ ctx: CommandRuntimeContext,
5648
+ orderId: string,
5649
+ missingOrderMessage: string,
5650
+ ): Promise<SalesOrder> {
5651
+ const existingOrder = await findOneWithDecryption(
5652
+ em,
5653
+ SalesOrder,
5654
+ { id: orderId, deletedAt: null },
5655
+ {},
5656
+ resolveRequestDecryptionScope(ctx),
5657
+ );
5658
+ if (!existingOrder) throw notFound(missingOrderMessage);
5659
+ ensureOrderScope(ctx, existingOrder.organizationId, existingOrder.tenantId);
5660
+ const scope = {
5661
+ tenantId: existingOrder.tenantId,
5662
+ organizationId: existingOrder.organizationId,
5663
+ };
5664
+ if (!(await lockSalesDocumentRow(em, "sales_orders", orderId, scope))) {
5665
+ throw notFound(missingOrderMessage);
5666
+ }
5667
+ const order = await findOneWithDecryption(
5668
+ em,
5669
+ SalesOrder,
5670
+ { id: orderId, deletedAt: null, ...scope },
5671
+ { refresh: true },
5672
+ scope,
5673
+ );
5674
+ if (!order) throw notFound(missingOrderMessage);
5675
+ await enforceSalesDocumentOptimisticLock(ctx, order, SALES_RESOURCE_KIND_ORDER);
5676
+ return order;
5677
+ }
5678
+
5679
+ async function lockQuoteForTotalsWrite(
5680
+ em: EntityManager,
5681
+ ctx: CommandRuntimeContext,
5682
+ quoteId: string,
5683
+ missingQuoteMessage: string,
5684
+ ): Promise<SalesQuote> {
5685
+ const existingQuote = await findOneWithDecryption(
5686
+ em,
5687
+ SalesQuote,
5688
+ { id: quoteId, deletedAt: null },
5689
+ {},
5690
+ resolveRequestDecryptionScope(ctx),
5691
+ );
5692
+ if (!existingQuote) throw notFound(missingQuoteMessage);
5693
+ ensureQuoteScope(ctx, existingQuote.organizationId, existingQuote.tenantId);
5694
+ const scope = {
5695
+ tenantId: existingQuote.tenantId,
5696
+ organizationId: existingQuote.organizationId,
5697
+ };
5698
+ if (!(await lockSalesDocumentRow(em, "sales_quotes", quoteId, scope))) {
5699
+ throw notFound(missingQuoteMessage);
5700
+ }
5701
+ const quote = await findOneWithDecryption(
5702
+ em,
5703
+ SalesQuote,
5704
+ { id: quoteId, deletedAt: null, ...scope },
5705
+ { refresh: true },
5706
+ scope,
5707
+ );
5708
+ if (!quote) throw notFound(missingQuoteMessage);
5709
+ await enforceSalesDocumentOptimisticLock(ctx, quote, SALES_RESOURCE_KIND_QUOTE);
5710
+ return quote;
5711
+ }
5712
+
5713
+ function buildTotalsCalculatedPayload(
5714
+ documentKind: SalesDocumentKind,
5715
+ document: SalesOrder | SalesQuote,
5716
+ calculation: SalesDocumentCalculationResult,
5717
+ ): TotalsCalculatedPayload {
5718
+ return {
5719
+ documentKind,
5720
+ documentId: document.id,
5721
+ organizationId: document.organizationId,
5722
+ tenantId: document.tenantId,
5723
+ customerId: document.customerEntityId ?? null,
5724
+ totals: calculation.totals,
5725
+ lineCount: calculation.lines.length,
5726
+ };
5727
+ }
5728
+
5729
+ async function emitCommittedTotalsCalculated(
5730
+ ctx: CommandRuntimeContext,
5731
+ payload: TotalsCalculatedPayload | null,
5732
+ ): Promise<void> {
5733
+ if (!payload) return;
5734
+ let eventBus: EventBus | null = null;
5735
+ try {
5736
+ eventBus = ctx.container.resolve("eventBus") as EventBus;
5737
+ } catch {
5738
+ eventBus = null;
5739
+ }
5740
+ await emitTotalsCalculated(eventBus, payload);
5741
+ }
5742
+
5743
+ const lockedOrderGraphKind: LockedGraphKind<SalesOrder, OrderGraphSnapshot> = {
5744
+ documentKind: "order",
5745
+ lock: lockOrderForTotalsWrite,
5746
+ loadSnapshot: loadOrderSnapshot,
5747
+ snapshots: lockedOrderGraphSnapshots,
5748
+ };
5749
+
5750
+ const lockedQuoteGraphKind: LockedGraphKind<SalesQuote, QuoteGraphSnapshot> = {
5751
+ documentKind: "quote",
5752
+ lock: lockQuoteForTotalsWrite,
5753
+ loadSnapshot: loadQuoteSnapshot,
5754
+ snapshots: lockedQuoteGraphSnapshots,
5755
+ };
5756
+
5757
+ function markLockedGraphWrite(input: object): Record<string, never> {
5758
+ lockedGraphWriteInputs.add(input);
5759
+ return {};
5760
+ }
5761
+
5762
+ async function writeLockedDocumentGraph<
5763
+ TDocument extends SalesOrder | SalesQuote,
5764
+ TSnapshot,
5765
+ TResult extends object,
5766
+ >(
5767
+ kind: LockedGraphKind<TDocument, TSnapshot>,
5768
+ input: object,
5769
+ ctx: CommandRuntimeContext,
5770
+ documentId: string,
5771
+ missingMessage: string,
5772
+ write: (em: EntityManager, document: TDocument) => Promise<LockedGraphWrite<TResult>>,
5773
+ ): Promise<TResult> {
5774
+ const em = (ctx.container.resolve("em") as EntityManager).fork();
5775
+ const captureSnapshots = lockedGraphWriteInputs.has(input);
5776
+ let written!: LockedGraphWrite<TResult>;
5777
+ let totalsEvent!: TotalsCalculatedPayload;
5778
+ let before: TSnapshot | null = null;
5779
+ let after: TSnapshot | null = null;
5780
+ await withAtomicFlush(
5781
+ em,
5782
+ [
5783
+ async () => {
5784
+ const document = await kind.lock(em, ctx, documentId, missingMessage);
5785
+ if (captureSnapshots) before = await kind.loadSnapshot(em, document.id);
5786
+ written = await write(em, document);
5787
+ totalsEvent = buildTotalsCalculatedPayload(
5788
+ kind.documentKind,
5789
+ document,
5790
+ written.calculation,
5791
+ );
5792
+ },
5793
+ ...(captureSnapshots
5794
+ ? [
5795
+ async () => {
5796
+ em.clear();
5797
+ after = await kind.loadSnapshot(em, documentId);
5798
+ },
5799
+ ]
5800
+ : []),
5801
+ ],
5802
+ { transaction: true },
5803
+ );
5804
+ if (captureSnapshots) kind.snapshots.set(written.result, { before, after });
5805
+ await emitCommittedTotalsCalculated(ctx, totalsEvent);
5806
+ return written.result;
5807
+ }
5808
+
5809
+ function writeLockedOrderGraph<TResult extends object>(
5810
+ input: object,
5811
+ ctx: CommandRuntimeContext,
5812
+ orderId: string,
5813
+ missingOrderMessage: string,
5814
+ write: (em: EntityManager, order: SalesOrder) => Promise<LockedGraphWrite<TResult>>,
5815
+ ): Promise<TResult> {
5816
+ return writeLockedDocumentGraph(lockedOrderGraphKind, input, ctx, orderId, missingOrderMessage, write);
5817
+ }
5818
+
5819
+ function writeLockedQuoteGraph<TResult extends object>(
5820
+ input: object,
5821
+ ctx: CommandRuntimeContext,
5822
+ quoteId: string,
5823
+ missingQuoteMessage: string,
5824
+ write: (em: EntityManager, quote: SalesQuote) => Promise<LockedGraphWrite<TResult>>,
5825
+ ): Promise<TResult> {
5826
+ return writeLockedDocumentGraph(lockedQuoteGraphKind, input, ctx, quoteId, missingQuoteMessage, write);
5827
+ }
5828
+
5829
+ function resolveLockedOrderGraphBefore(
5830
+ result: object,
5831
+ preparedBefore: unknown,
5832
+ ): OrderGraphSnapshot | undefined {
5833
+ return (
5834
+ lockedOrderGraphSnapshots.get(result)?.before ??
5835
+ (preparedBefore as OrderGraphSnapshot | undefined)
5836
+ );
5837
+ }
5838
+
5839
+ function resolveLockedQuoteGraphBefore(
5840
+ result: object,
5841
+ preparedBefore: unknown,
5842
+ ): QuoteGraphSnapshot | undefined {
5843
+ return (
5844
+ lockedQuoteGraphSnapshots.get(result)?.before ??
5845
+ (preparedBefore as QuoteGraphSnapshot | undefined)
5846
+ );
5847
+ }
5848
+
5849
+ async function captureLockedOrderGraphAfter(
5850
+ ctx: CommandRuntimeContext,
5851
+ result: object,
5852
+ orderId: string,
5853
+ ): Promise<OrderGraphSnapshot | null> {
5854
+ const locked = lockedOrderGraphSnapshots.get(result);
5855
+ if (locked) return locked.after;
5856
+ const em = (ctx.container.resolve("em") as EntityManager).fork();
5857
+ return loadOrderSnapshot(em, orderId);
5858
+ }
5859
+
5860
+ async function captureLockedQuoteGraphAfter(
5861
+ ctx: CommandRuntimeContext,
5862
+ result: object,
5863
+ quoteId: string,
5864
+ ): Promise<QuoteGraphSnapshot | null> {
5865
+ const locked = lockedQuoteGraphSnapshots.get(result);
5866
+ if (locked) return locked.after;
5867
+ const em = (ctx.container.resolve("em") as EntityManager).fork();
5868
+ return loadQuoteSnapshot(em, quoteId);
5869
+ }
5870
+
5598
5871
  const updateQuoteCommand: CommandHandler<
5599
5872
  DocumentUpdateInput,
5600
5873
  { quote: SalesQuote }
@@ -5618,15 +5891,8 @@ const updateQuoteCommand: CommandHandler<
5618
5891
  await assertAmountsModeUnsupportedOnQuote([parsed]);
5619
5892
  documentEditSchema.parse(rawInput ?? {});
5620
5893
  const em = (ctx.container.resolve("em") as EntityManager).fork();
5621
- const quote = await findOneWithDecryption(em, SalesQuote, {
5622
- id: parsed.id,
5623
- deletedAt: null,
5624
- });
5625
- if (!quote)
5626
- throw notFound("Sales quote not found");
5627
- ensureQuoteScope(ctx, quote.organizationId, quote.tenantId);
5628
- await enforceSalesDocumentOptimisticLock(ctx, quote, SALES_RESOURCE_KIND_QUOTE);
5629
- const shouldInvalidateSentToken = (quote.status ?? null) === "sent";
5894
+ let quote!: SalesQuote;
5895
+ let totalsEvent: TotalsCalculatedPayload | null = null;
5630
5896
  const shouldRecalculateTotals =
5631
5897
  parsed.shippingMethodId !== undefined ||
5632
5898
  parsed.shippingMethodSnapshot !== undefined ||
@@ -5643,6 +5909,8 @@ const updateQuoteCommand: CommandHandler<
5643
5909
  em,
5644
5910
  [
5645
5911
  async () => {
5912
+ quote = await lockQuoteForTotalsWrite(em, ctx, parsed.id, "Sales quote not found");
5913
+ const shouldInvalidateSentToken = (quote.status ?? null) === "sent";
5646
5914
  await applyDocumentUpdate({
5647
5915
  kind: "quote",
5648
5916
  entity: quote,
@@ -5745,27 +6013,14 @@ const updateQuoteCommand: CommandHandler<
5745
6013
  adjustmentInputs,
5746
6014
  );
5747
6015
  applyQuoteTotals(quote, calculation.totals, calculation.lines.length);
5748
- let eventBus: EventBus | null = null;
5749
- try {
5750
- eventBus = ctx.container.resolve("eventBus") as EventBus;
5751
- } catch {
5752
- eventBus = null;
5753
- }
5754
- await emitTotalsCalculated(eventBus, {
5755
- documentKind: "quote",
5756
- documentId: quote.id,
5757
- organizationId: quote.organizationId,
5758
- tenantId: quote.tenantId,
5759
- customerId: quote.customerEntityId ?? null,
5760
- totals: calculation.totals,
5761
- lineCount: calculation.lines.length,
5762
- });
6016
+ totalsEvent = buildTotalsCalculatedPayload("quote", quote, calculation);
5763
6017
  }
5764
6018
  quote.updatedAt = new Date();
5765
6019
  },
5766
6020
  ],
5767
6021
  { transaction: true },
5768
6022
  );
6023
+ await emitCommittedTotalsCalculated(ctx, totalsEvent);
5769
6024
  // Same as sales.orders.update (#6217): refresh the query-index projection so
5770
6025
  // customFields written in applyDocumentUpdate are visible on the next list GET.
5771
6026
  const dataEngine = ctx.container.resolve("dataEngine") as DataEngine;
@@ -5907,44 +6162,14 @@ const updateOrderCommand: CommandHandler<
5907
6162
  async execute(rawInput, ctx) {
5908
6163
  const parsed = documentUpdateSchema.parse(rawInput ?? {});
5909
6164
  const em = (ctx.container.resolve("em") as EntityManager).fork();
5910
- const order = await findOneWithDecryption(em, SalesOrder, {
5911
- id: parsed.id,
5912
- deletedAt: null,
5913
- });
5914
- if (!order)
5915
- throw notFound("Sales order not found");
5916
- ensureOrderScope(ctx, order.organizationId, order.tenantId);
5917
- const currentTotalsMode: SalesAmountsMode = order.totalsMode ?? "computed";
5918
- const nextTotalsMode: SalesAmountsMode = parsed.totalsMode ?? currentTotalsMode;
5919
- const totalsModeChanged = nextTotalsMode !== currentTotalsMode;
5920
- const externalHeaderSupplied =
5921
- isExternalAmountsMode(nextTotalsMode) && hasAnySuppliedHeaderTotal(parsed);
5922
- if (!totalsModeChanged && !externalHeaderSupplied) {
5923
- documentEditSchema.parse(rawInput ?? {});
5924
- }
5925
- if (isExternalAmountsMode(nextTotalsMode) && (totalsModeChanged || externalHeaderSupplied)) {
5926
- await requireOrderTotalsForExternalWrite(parsed);
5927
- }
5928
- await enforceSalesDocumentOptimisticLock(ctx, order, SALES_RESOURCE_KIND_ORDER);
5929
- const previousStatus = normalizeStatusValue(order.status);
5930
- if (parsed.statusEntryId && !isCancelledOrderStatus(previousStatus)) {
5931
- const requestedStatus = normalizeStatusValue(
5932
- await resolveDictionaryEntryValue(em, parsed.statusEntryId, {
5933
- tenantId: order.tenantId,
5934
- }),
5935
- );
5936
- if (isCancelledOrderStatus(requestedStatus)) {
5937
- await assertOrderCanBeCancelled(em, order);
5938
- }
5939
- }
6165
+ let order!: SalesOrder;
6166
+ let previousStatus: string | null = null;
6167
+ let nextTotalsMode: SalesAmountsMode = "computed";
6168
+ let totalsModeChanged = false;
6169
+ let externalHeaderSupplied = false;
6170
+ let suppliedTotals: Partial<SalesDocumentAmounts> | null = null;
5940
6171
  let statusChangeNote: SalesNote | null = null;
5941
- const suppliedTotals: Partial<SalesDocumentAmounts> | null = isExternalAmountsMode(
5942
- nextTotalsMode,
5943
- )
5944
- ? externalHeaderSupplied
5945
- ? buildExternalHeaderTotals(parsed)
5946
- : readPersistedHeaderTotals(order)
5947
- : null;
6172
+ let totalsEvent: TotalsCalculatedPayload | null = null;
5948
6173
  const shouldRecalculateTotals =
5949
6174
  parsed.shippingMethodId !== undefined ||
5950
6175
  parsed.shippingMethodSnapshot !== undefined ||
@@ -5952,9 +6177,7 @@ const updateOrderCommand: CommandHandler<
5952
6177
  parsed.paymentMethodId !== undefined ||
5953
6178
  parsed.paymentMethodSnapshot !== undefined ||
5954
6179
  parsed.paymentMethodCode !== undefined ||
5955
- parsed.currencyCode !== undefined ||
5956
- totalsModeChanged ||
5957
- externalHeaderSupplied;
6180
+ parsed.currencyCode !== undefined;
5958
6181
  // Apply the scalar update, totals recalc, and status-change note atomically.
5959
6182
  // applyDocumentUpdate/replace*/setRecordCustomFields flush mid-build, so
5960
6183
  // without a transaction a later failure would leave a half-updated order
@@ -5963,6 +6186,34 @@ const updateOrderCommand: CommandHandler<
5963
6186
  em,
5964
6187
  [
5965
6188
  async () => {
6189
+ order = await lockOrderForTotalsWrite(em, ctx, parsed.id, "Sales order not found");
6190
+ const currentTotalsMode: SalesAmountsMode = order.totalsMode ?? "computed";
6191
+ nextTotalsMode = parsed.totalsMode ?? currentTotalsMode;
6192
+ totalsModeChanged = nextTotalsMode !== currentTotalsMode;
6193
+ externalHeaderSupplied =
6194
+ isExternalAmountsMode(nextTotalsMode) && hasAnySuppliedHeaderTotal(parsed);
6195
+ if (!totalsModeChanged && !externalHeaderSupplied) {
6196
+ documentEditSchema.parse(rawInput ?? {});
6197
+ }
6198
+ if (isExternalAmountsMode(nextTotalsMode) && (totalsModeChanged || externalHeaderSupplied)) {
6199
+ await requireOrderTotalsForExternalWrite(parsed);
6200
+ }
6201
+ suppliedTotals = isExternalAmountsMode(nextTotalsMode)
6202
+ ? externalHeaderSupplied
6203
+ ? buildExternalHeaderTotals(parsed)
6204
+ : readPersistedHeaderTotals(order)
6205
+ : null;
6206
+ previousStatus = normalizeStatusValue(order.status);
6207
+ if (parsed.statusEntryId && !isCancelledOrderStatus(previousStatus)) {
6208
+ const requestedStatus = normalizeStatusValue(
6209
+ await resolveDictionaryEntryValue(em, parsed.statusEntryId, {
6210
+ tenantId: order.tenantId,
6211
+ }),
6212
+ );
6213
+ if (isCancelledOrderStatus(requestedStatus)) {
6214
+ await assertOrderCanBeCancelled(em, order);
6215
+ }
6216
+ }
5966
6217
  await applyDocumentUpdate({
5967
6218
  kind: "order",
5968
6219
  entity: order,
@@ -5977,7 +6228,7 @@ const updateOrderCommand: CommandHandler<
5977
6228
  // `em.find` lines/adjustments or appendOrderStatusChangeNote queries
5978
6229
  // reset the changeset (see SPEC-018).
5979
6230
  async () => {
5980
- if (shouldRecalculateTotals) {
6231
+ if (shouldRecalculateTotals || totalsModeChanged || externalHeaderSupplied) {
5981
6232
  const [existingLines, adjustments] = await Promise.all([
5982
6233
  em.find(
5983
6234
  SalesOrderLine,
@@ -6072,21 +6323,7 @@ const updateOrderCommand: CommandHandler<
6072
6323
  order.totalsMode = nextTotalsMode;
6073
6324
  }
6074
6325
  applyOrderTotals(order, calculation.totals, calculation.lines.length);
6075
- let eventBus: EventBus | null = null;
6076
- try {
6077
- eventBus = ctx.container.resolve("eventBus") as EventBus;
6078
- } catch {
6079
- eventBus = null;
6080
- }
6081
- await emitTotalsCalculated(eventBus, {
6082
- documentKind: "order",
6083
- documentId: order.id,
6084
- organizationId: order.organizationId,
6085
- tenantId: order.tenantId,
6086
- customerId: order.customerEntityId ?? null,
6087
- totals: calculation.totals,
6088
- lineCount: calculation.lines.length,
6089
- });
6326
+ totalsEvent = buildTotalsCalculatedPayload("order", order, calculation);
6090
6327
  }
6091
6328
  statusChangeNote = await appendOrderStatusChangeNote({
6092
6329
  em,
@@ -6099,6 +6336,7 @@ const updateOrderCommand: CommandHandler<
6099
6336
  ],
6100
6337
  { transaction: true },
6101
6338
  );
6339
+ await emitCommittedTotalsCalculated(ctx, totalsEvent);
6102
6340
  emitOrderLifecycleEventsForTransition({ order, previousStatus });
6103
6341
  // Refresh the query-index projection (including customValues). Create already
6104
6342
  // did this; update used to only invalidate the HTTP CRUD cache, so a PUT that
@@ -7610,139 +7848,98 @@ const orderLineUpsertCommand: CommandHandler<
7610
7848
  { orderId: string; lineId: string }
7611
7849
  > = {
7612
7850
  id: "sales.orders.lines.upsert",
7613
- async prepare(input, ctx) {
7614
- const raw = (input?.body as Record<string, unknown> | undefined) ?? {};
7615
- const orderId = typeof raw.orderId === "string" ? raw.orderId : null;
7616
- if (!orderId) return {};
7617
- const em = ctx.container.resolve("em") as EntityManager;
7618
- const snapshot = await loadOrderSnapshot(em, orderId);
7619
- if (snapshot)
7620
- ensureOrderScope(
7621
- ctx,
7622
- snapshot.order.organizationId,
7623
- snapshot.order.tenantId,
7624
- );
7625
- return snapshot ? { before: snapshot } : {};
7851
+ async prepare(input) {
7852
+ return markLockedGraphWrite(input);
7626
7853
  },
7627
7854
  async execute(input, ctx) {
7628
7855
  const rawBody = (input?.body as Record<string, unknown> | undefined) ?? {};
7629
7856
  const parsed = orderLineUpsertSchema.parse(rawBody);
7630
- const em = (ctx.container.resolve("em") as EntityManager).fork();
7631
- const order = await findOneWithDecryption(em, SalesOrder, {
7632
- id: parsed.orderId,
7633
- deletedAt: null,
7634
- });
7635
- if (!order)
7636
- throw notFound("Sales order not found");
7637
- ensureOrderScope(ctx, order.organizationId, order.tenantId);
7638
- await enforceSalesDocumentOptimisticLock(ctx, order, SALES_RESOURCE_KIND_ORDER);
7639
-
7640
- const [existingLines, adjustments] = await Promise.all([
7641
- em.find(SalesOrderLine, { order }, { orderBy: { lineNumber: "asc" } }),
7642
- em.find(
7643
- SalesOrderAdjustment,
7644
- { order },
7645
- { orderBy: { position: "asc" } },
7646
- ),
7647
- ]);
7648
- const lineSnapshots = existingLines.map(mapOrderLineEntityToSnapshot);
7649
- const existingSnapshot = parsed.id
7650
- ? (lineSnapshots.find((line) => line.id === parsed.id) ?? null)
7651
- : null;
7652
- const totalsMode: SalesAmountsMode = order.totalsMode ?? "computed";
7653
- await assertUniformAmountsMode(totalsMode, [parsed]);
7654
- if (isExternalAmountsMode(totalsMode)) {
7655
- await requireOrderTotalsForExternalWrite(parsed.orderTotals);
7656
- }
7657
- const suppliedTotals: Partial<SalesDocumentAmounts> | null = isExternalAmountsMode(totalsMode)
7658
- ? buildExternalHeaderTotals(parsed.orderTotals)
7659
- : null;
7660
- await assertOrderAcceptsNewLine(order, existingSnapshot);
7661
- await assertShippedOrderLineEditable(em, order, existingSnapshot, parsed);
7662
- const priceMode =
7663
- parsed.priceMode === "gross"
7664
- ? "gross"
7665
- : parsed.priceMode === "net"
7666
- ? "net"
7667
- : null;
7668
- let unitPriceNet =
7669
- parsed.unitPriceNet ?? existingSnapshot?.unitPriceNet ?? null;
7670
- let unitPriceGross =
7671
- parsed.unitPriceGross ?? existingSnapshot?.unitPriceGross ?? null;
7672
- let taxRate = parsed.taxRate ?? existingSnapshot?.taxRate ?? null;
7673
- const unitPriceSupplied = unitPriceNet !== null || unitPriceGross !== null;
7674
- if (priceMode && (unitPriceNet === null || unitPriceGross === null)) {
7675
- let taxService: TaxCalculationService | null = null;
7676
- try {
7677
- taxService = ctx.container.resolve(
7678
- "taxCalculationService",
7679
- ) as TaxCalculationService;
7680
- } catch {
7681
- taxService = null;
7857
+ return writeLockedOrderGraph(input, ctx, parsed.orderId, "Sales order not found", async (em, order) => {
7858
+ const [existingLines, adjustments] = await Promise.all([
7859
+ em.find(SalesOrderLine, { order }, { orderBy: { lineNumber: "asc" } }),
7860
+ em.find(
7861
+ SalesOrderAdjustment,
7862
+ { order },
7863
+ { orderBy: { position: "asc" } },
7864
+ ),
7865
+ ]);
7866
+ const lineSnapshots = existingLines.map(mapOrderLineEntityToSnapshot);
7867
+ const existingSnapshot = parsed.id
7868
+ ? (lineSnapshots.find((line) => line.id === parsed.id) ?? null)
7869
+ : null;
7870
+ const totalsMode: SalesAmountsMode = order.totalsMode ?? "computed";
7871
+ await assertUniformAmountsMode(totalsMode, [parsed]);
7872
+ if (isExternalAmountsMode(totalsMode)) {
7873
+ await requireOrderTotalsForExternalWrite(parsed.orderTotals);
7682
7874
  }
7683
- if (taxService) {
7684
- const taxResult = await taxService.calculateUnitAmounts({
7685
- amount:
7686
- priceMode === "gross"
7687
- ? (unitPriceGross ?? unitPriceNet ?? 0)
7688
- : (unitPriceNet ?? unitPriceGross ?? 0),
7689
- mode: priceMode,
7690
- organizationId: parsed.organizationId,
7691
- tenantId: parsed.tenantId,
7692
- taxRateId: parsed.taxRateId ?? undefined,
7693
- taxRate: taxRate ?? undefined,
7694
- });
7695
- unitPriceNet = unitPriceNet ?? taxResult.netAmount;
7696
- unitPriceGross = unitPriceGross ?? taxResult.grossAmount;
7697
- taxRate = taxResult.taxRate ?? taxRate;
7875
+ const suppliedTotals: Partial<SalesDocumentAmounts> | null = isExternalAmountsMode(totalsMode)
7876
+ ? buildExternalHeaderTotals(parsed.orderTotals)
7877
+ : null;
7878
+ await assertOrderAcceptsNewLine(order, existingSnapshot);
7879
+ await assertShippedOrderLineEditable(em, order, existingSnapshot, parsed);
7880
+ const priceMode =
7881
+ parsed.priceMode === "gross"
7882
+ ? "gross"
7883
+ : parsed.priceMode === "net"
7884
+ ? "net"
7885
+ : null;
7886
+ let unitPriceNet =
7887
+ parsed.unitPriceNet ?? existingSnapshot?.unitPriceNet ?? null;
7888
+ let unitPriceGross =
7889
+ parsed.unitPriceGross ?? existingSnapshot?.unitPriceGross ?? null;
7890
+ let taxRate = parsed.taxRate ?? existingSnapshot?.taxRate ?? null;
7891
+ const unitPriceSupplied = unitPriceNet !== null || unitPriceGross !== null;
7892
+ if (priceMode && (unitPriceNet === null || unitPriceGross === null)) {
7893
+ let taxService: TaxCalculationService | null = null;
7894
+ try {
7895
+ taxService = ctx.container.resolve(
7896
+ "taxCalculationService",
7897
+ ) as TaxCalculationService;
7898
+ } catch {
7899
+ taxService = null;
7900
+ }
7901
+ if (taxService) {
7902
+ const taxResult = await taxService.calculateUnitAmounts({
7903
+ amount:
7904
+ priceMode === "gross"
7905
+ ? (unitPriceGross ?? unitPriceNet ?? 0)
7906
+ : (unitPriceNet ?? unitPriceGross ?? 0),
7907
+ mode: priceMode,
7908
+ organizationId: parsed.organizationId,
7909
+ tenantId: parsed.tenantId,
7910
+ taxRateId: parsed.taxRateId ?? undefined,
7911
+ taxRate: taxRate ?? undefined,
7912
+ });
7913
+ unitPriceNet = unitPriceNet ?? taxResult.netAmount;
7914
+ unitPriceGross = unitPriceGross ?? taxResult.grossAmount;
7915
+ taxRate = taxResult.taxRate ?? taxRate;
7916
+ }
7698
7917
  }
7699
- }
7700
7918
 
7701
- const metadata =
7702
- typeof parsed.metadata === "object" && parsed.metadata
7703
- ? { ...parsed.metadata }
7704
- : existingSnapshot?.metadata
7705
- ? cloneJson(existingSnapshot.metadata)
7706
- : {};
7707
- if (parsed.priceId) metadata.priceId = parsed.priceId;
7708
- if (priceMode) metadata.priceMode = priceMode;
7709
-
7710
- const statusEntryId =
7711
- parsed.statusEntryId ?? (existingSnapshot as any)?.statusEntryId ?? null;
7712
- const lineId = parsed.id ?? existingSnapshot?.id ?? randomUUID();
7713
- const lineUomInput = {
7714
- productId: parsed.productId ?? existingSnapshot?.productId ?? null,
7715
- productVariantId:
7716
- parsed.productVariantId ?? existingSnapshot?.productVariantId ?? null,
7717
- quantity: parsed.quantity ?? existingSnapshot?.quantity ?? 0,
7718
- quantityUnit: parsed.quantityUnit ?? existingSnapshot?.quantityUnit ?? null,
7719
- normalizedQuantity: existingSnapshot?.normalizedQuantity ?? null,
7720
- normalizedUnit: existingSnapshot?.normalizedUnit ?? null,
7721
- uomSnapshot: existingSnapshot?.uomSnapshot ?? null,
7722
- };
7723
- const uomResolver = createUomResolver();
7724
- let normalizedUom = await normalizeLineUom({
7725
- em,
7726
- resolver: uomResolver,
7727
- organizationId: order.organizationId,
7728
- tenantId: order.tenantId,
7729
- line: {
7730
- ...lineUomInput,
7731
- unitPriceNet: unitPriceNet ?? 0,
7732
- unitPriceGross: unitPriceGross ?? unitPriceNet ?? 0,
7733
- },
7734
- });
7735
- const convertedPrices = convertLineUnitPricesOnUnitChange({
7736
- existingSnapshot,
7737
- nextQuantityUnit: normalizedUom.quantityUnit,
7738
- nextUomSnapshot: normalizedUom.uomSnapshot,
7739
- unitPriceNet,
7740
- unitPriceGross,
7741
- });
7742
- if (convertedPrices.didConvert) {
7743
- unitPriceNet = convertedPrices.unitPriceNet;
7744
- unitPriceGross = convertedPrices.unitPriceGross;
7745
- normalizedUom = await normalizeLineUom({
7919
+ const metadata =
7920
+ typeof parsed.metadata === "object" && parsed.metadata
7921
+ ? { ...parsed.metadata }
7922
+ : existingSnapshot?.metadata
7923
+ ? cloneJson(existingSnapshot.metadata)
7924
+ : {};
7925
+ if (parsed.priceId) metadata.priceId = parsed.priceId;
7926
+ if (priceMode) metadata.priceMode = priceMode;
7927
+
7928
+ const statusEntryId =
7929
+ parsed.statusEntryId ?? (existingSnapshot as any)?.statusEntryId ?? null;
7930
+ const lineId = parsed.id ?? existingSnapshot?.id ?? randomUUID();
7931
+ const lineUomInput = {
7932
+ productId: parsed.productId ?? existingSnapshot?.productId ?? null,
7933
+ productVariantId:
7934
+ parsed.productVariantId ?? existingSnapshot?.productVariantId ?? null,
7935
+ quantity: parsed.quantity ?? existingSnapshot?.quantity ?? 0,
7936
+ quantityUnit: parsed.quantityUnit ?? existingSnapshot?.quantityUnit ?? null,
7937
+ normalizedQuantity: existingSnapshot?.normalizedQuantity ?? null,
7938
+ normalizedUnit: existingSnapshot?.normalizedUnit ?? null,
7939
+ uomSnapshot: existingSnapshot?.uomSnapshot ?? null,
7940
+ };
7941
+ const uomResolver = createUomResolver();
7942
+ let normalizedUom = await normalizeLineUom({
7746
7943
  em,
7747
7944
  resolver: uomResolver,
7748
7945
  organizationId: order.organizationId,
@@ -7753,174 +7950,169 @@ const orderLineUpsertCommand: CommandHandler<
7753
7950
  unitPriceGross: unitPriceGross ?? unitPriceNet ?? 0,
7754
7951
  },
7755
7952
  });
7756
- }
7757
- const updatedSnapshot: SalesLineSnapshot & {
7758
- statusEntryId?: string | null;
7759
- catalogSnapshot?: Record<string, unknown> | null;
7760
- promotionSnapshot?: Record<string, unknown> | null;
7761
- } = {
7762
- id: lineId,
7763
- lineNumber:
7764
- parsed.lineNumber ??
7765
- existingSnapshot?.lineNumber ??
7766
- lineSnapshots.length + 1,
7767
- kind: parsed.kind ?? existingSnapshot?.kind ?? "product",
7768
- productId: parsed.productId ?? existingSnapshot?.productId ?? null,
7769
- productVariantId:
7770
- parsed.productVariantId ?? existingSnapshot?.productVariantId ?? null,
7771
- name: parsed.name ?? existingSnapshot?.name ?? null,
7772
- description: parsed.description ?? existingSnapshot?.description ?? null,
7773
- comment: parsed.comment ?? existingSnapshot?.comment ?? null,
7774
- quantity: normalizedUom.quantity,
7775
- quantityUnit: normalizedUom.quantityUnit,
7776
- normalizedQuantity: normalizedUom.normalizedQuantity,
7777
- normalizedUnit: normalizedUom.normalizedUnit,
7778
- uomSnapshot: normalizedUom.uomSnapshot
7779
- ? cloneJson(normalizedUom.uomSnapshot)
7780
- : null,
7781
- currencyCode:
7782
- parsed.currencyCode ??
7783
- existingSnapshot?.currencyCode ??
7784
- order.currencyCode,
7785
- unitPriceNet: unitPriceNet ?? 0,
7786
- unitPriceGross: unitPriceGross ?? unitPriceNet ?? 0,
7787
- ...resolveUpsertDiscountFields(
7788
- parsed.discountAmount,
7789
- parsed.discountAmountBasis,
7953
+ const convertedPrices = convertLineUnitPricesOnUnitChange({
7790
7954
  existingSnapshot,
7791
- ),
7792
- discountPercent:
7793
- parsed.discountPercent ?? existingSnapshot?.discountPercent ?? 0,
7794
- taxRate: taxRate ?? 0,
7795
- totalNetAmount:
7796
- parsed.totalNetAmount ?? existingSnapshot?.totalNetAmount ?? null,
7797
- ...resolveUpsertTotalsOrigin(parsed.totalNetAmount, existingSnapshot),
7798
- configuration:
7799
- parsed.configuration ?? existingSnapshot?.configuration ?? null,
7800
- promotionCode:
7801
- parsed.promotionCode ?? existingSnapshot?.promotionCode ?? null,
7802
- metadata,
7803
- customFieldSetId:
7804
- parsed.customFieldSetId ?? existingSnapshot?.customFieldSetId ?? null,
7805
- customFields:
7806
- parsed.customFields && typeof parsed.customFields === "object"
7807
- ? cloneJson(parsed.customFields)
7808
- : ((existingSnapshot as any)?.customFields ?? null),
7809
- amountsMode: totalsMode,
7810
- };
7811
- Object.assign(
7812
- updatedSnapshot,
7813
- resolveUpsertCalculatedAmounts(parsed, updatedSnapshot, existingSnapshot),
7814
- );
7815
- // After the line above, never before: tax and gross only exist once it runs,
7816
- // and it nulls them when pricing changed, which an external line must restate.
7817
- // The unit price is checked as resolved, not as defaulted to zero above,
7818
- // and a price the tax service derived from no source price counts as absent.
7819
- if (isExternalAmountsMode(totalsMode)) {
7820
- await assertExternalLineComplete(
7821
- {
7822
- ...(updatedSnapshot as Record<string, unknown>),
7823
- unitPriceNet: unitPriceSupplied ? unitPriceNet : null,
7824
- },
7825
- updatedSnapshot.lineNumber ?? 1,
7955
+ nextQuantityUnit: normalizedUom.quantityUnit,
7956
+ nextUomSnapshot: normalizedUom.uomSnapshot,
7957
+ unitPriceNet,
7958
+ unitPriceGross,
7959
+ });
7960
+ if (convertedPrices.didConvert) {
7961
+ unitPriceNet = convertedPrices.unitPriceNet;
7962
+ unitPriceGross = convertedPrices.unitPriceGross;
7963
+ normalizedUom = await normalizeLineUom({
7964
+ em,
7965
+ resolver: uomResolver,
7966
+ organizationId: order.organizationId,
7967
+ tenantId: order.tenantId,
7968
+ line: {
7969
+ ...lineUomInput,
7970
+ unitPriceNet: unitPriceNet ?? 0,
7971
+ unitPriceGross: unitPriceGross ?? unitPriceNet ?? 0,
7972
+ },
7973
+ });
7974
+ }
7975
+ const updatedSnapshot: SalesLineSnapshot & {
7976
+ statusEntryId?: string | null;
7977
+ catalogSnapshot?: Record<string, unknown> | null;
7978
+ promotionSnapshot?: Record<string, unknown> | null;
7979
+ } = {
7980
+ id: lineId,
7981
+ lineNumber:
7982
+ parsed.lineNumber ??
7983
+ existingSnapshot?.lineNumber ??
7984
+ lineSnapshots.length + 1,
7985
+ kind: parsed.kind ?? existingSnapshot?.kind ?? "product",
7986
+ productId: parsed.productId ?? existingSnapshot?.productId ?? null,
7987
+ productVariantId:
7988
+ parsed.productVariantId ?? existingSnapshot?.productVariantId ?? null,
7989
+ name: parsed.name ?? existingSnapshot?.name ?? null,
7990
+ description: parsed.description ?? existingSnapshot?.description ?? null,
7991
+ comment: parsed.comment ?? existingSnapshot?.comment ?? null,
7992
+ quantity: normalizedUom.quantity,
7993
+ quantityUnit: normalizedUom.quantityUnit,
7994
+ normalizedQuantity: normalizedUom.normalizedQuantity,
7995
+ normalizedUnit: normalizedUom.normalizedUnit,
7996
+ uomSnapshot: normalizedUom.uomSnapshot
7997
+ ? cloneJson(normalizedUom.uomSnapshot)
7998
+ : null,
7999
+ currencyCode:
8000
+ parsed.currencyCode ??
8001
+ existingSnapshot?.currencyCode ??
8002
+ order.currencyCode,
8003
+ unitPriceNet: unitPriceNet ?? 0,
8004
+ unitPriceGross: unitPriceGross ?? unitPriceNet ?? 0,
8005
+ ...resolveUpsertDiscountFields(
8006
+ parsed.discountAmount,
8007
+ parsed.discountAmountBasis,
8008
+ existingSnapshot,
8009
+ ),
8010
+ discountPercent:
8011
+ parsed.discountPercent ?? existingSnapshot?.discountPercent ?? 0,
8012
+ taxRate: taxRate ?? 0,
8013
+ totalNetAmount:
8014
+ parsed.totalNetAmount ?? existingSnapshot?.totalNetAmount ?? null,
8015
+ ...resolveUpsertTotalsOrigin(parsed.totalNetAmount, existingSnapshot),
8016
+ configuration:
8017
+ parsed.configuration ?? existingSnapshot?.configuration ?? null,
8018
+ promotionCode:
8019
+ parsed.promotionCode ?? existingSnapshot?.promotionCode ?? null,
8020
+ metadata,
8021
+ customFieldSetId:
8022
+ parsed.customFieldSetId ?? existingSnapshot?.customFieldSetId ?? null,
8023
+ customFields:
8024
+ parsed.customFields && typeof parsed.customFields === "object"
8025
+ ? cloneJson(parsed.customFields)
8026
+ : ((existingSnapshot as any)?.customFields ?? null),
8027
+ amountsMode: totalsMode,
8028
+ };
8029
+ Object.assign(
8030
+ updatedSnapshot,
8031
+ resolveUpsertCalculatedAmounts(parsed, updatedSnapshot, existingSnapshot),
7826
8032
  );
7827
- }
7828
- (updatedSnapshot as any).statusEntryId = statusEntryId;
7829
- (updatedSnapshot as any).catalogSnapshot =
7830
- parsed.catalogSnapshot ??
7831
- (existingSnapshot as any)?.catalogSnapshot ??
7832
- null;
7833
- (updatedSnapshot as any).promotionSnapshot =
7834
- parsed.promotionSnapshot ??
7835
- (existingSnapshot as any)?.promotionSnapshot ??
7836
- null;
7837
-
7838
- let nextLines = parsed.id
7839
- ? lineSnapshots.map((line) =>
7840
- line.id === parsed.id ? updatedSnapshot : line,
7841
- )
7842
- : [...lineSnapshots, updatedSnapshot];
7843
- nextLines = nextLines
7844
- .sort((a, b) => (a.lineNumber ?? 0) - (b.lineNumber ?? 0))
7845
- .map((line, index) => ({ ...line, lineNumber: index + 1 }));
8033
+ // After the line above, never before: tax and gross only exist once it runs,
8034
+ // and it nulls them when pricing changed, which an external line must restate.
8035
+ // The unit price is checked as resolved, not as defaulted to zero above,
8036
+ // and a price the tax service derived from no source price counts as absent.
8037
+ if (isExternalAmountsMode(totalsMode)) {
8038
+ await assertExternalLineComplete(
8039
+ {
8040
+ ...(updatedSnapshot as Record<string, unknown>),
8041
+ unitPriceNet: unitPriceSupplied ? unitPriceNet : null,
8042
+ },
8043
+ updatedSnapshot.lineNumber ?? 1,
8044
+ );
8045
+ }
8046
+ (updatedSnapshot as any).statusEntryId = statusEntryId;
8047
+ (updatedSnapshot as any).catalogSnapshot =
8048
+ parsed.catalogSnapshot ??
8049
+ (existingSnapshot as any)?.catalogSnapshot ??
8050
+ null;
8051
+ (updatedSnapshot as any).promotionSnapshot =
8052
+ parsed.promotionSnapshot ??
8053
+ (existingSnapshot as any)?.promotionSnapshot ??
8054
+ null;
8055
+
8056
+ let nextLines = parsed.id
8057
+ ? lineSnapshots.map((line) =>
8058
+ line.id === parsed.id ? updatedSnapshot : line,
8059
+ )
8060
+ : [...lineSnapshots, updatedSnapshot];
8061
+ nextLines = nextLines
8062
+ .sort((a, b) => (a.lineNumber ?? 0) - (b.lineNumber ?? 0))
8063
+ .map((line, index) => ({ ...line, lineNumber: index + 1 }));
7846
8064
 
7847
- const sourceInputs = nextLines.map((line, index) => ({
7848
- ...line,
7849
- statusEntryId: (line as any).statusEntryId ?? null,
7850
- catalogSnapshot: (line as any).catalogSnapshot ?? null,
7851
- promotionSnapshot: (line as any).promotionSnapshot ?? null,
7852
- organizationId: order.organizationId,
7853
- tenantId: order.tenantId,
7854
- orderId: order.id,
7855
- lineNumber: line.lineNumber ?? index + 1,
7856
- }));
7857
- const calcLines: SalesLineSnapshot[] = sourceInputs.map((line, index) =>
7858
- createLineSnapshotFromInput(line, line.lineNumber ?? index + 1),
7859
- );
7860
- const adjustmentDrafts = adjustments.map(mapOrderAdjustmentToDraft);
7861
- const salesCalculationService =
7862
- ctx.container.resolve<SalesCalculationService>("salesCalculationService");
7863
- const calculationContext = buildCalculationContext({
7864
- tenantId: order.tenantId,
7865
- organizationId: order.organizationId,
7866
- currencyCode: order.currencyCode,
7867
- shippingSnapshot: order.shippingMethodSnapshot,
7868
- paymentSnapshot: order.paymentMethodSnapshot,
7869
- shippingMethodId: order.shippingMethodId ?? null,
7870
- paymentMethodId: order.paymentMethodId ?? null,
7871
- shippingMethodCode: order.shippingMethodCode ?? null,
7872
- paymentMethodCode: order.paymentMethodCode ?? null,
8065
+ const sourceInputs = nextLines.map((line, index) => ({
8066
+ ...line,
8067
+ statusEntryId: (line as any).statusEntryId ?? null,
8068
+ catalogSnapshot: (line as any).catalogSnapshot ?? null,
8069
+ promotionSnapshot: (line as any).promotionSnapshot ?? null,
8070
+ organizationId: order.organizationId,
8071
+ tenantId: order.tenantId,
8072
+ orderId: order.id,
8073
+ lineNumber: line.lineNumber ?? index + 1,
8074
+ }));
8075
+ const calcLines: SalesLineSnapshot[] = sourceInputs.map((line, index) =>
8076
+ createLineSnapshotFromInput(line, line.lineNumber ?? index + 1),
8077
+ );
8078
+ const adjustmentDrafts = adjustments.map(mapOrderAdjustmentToDraft);
8079
+ const salesCalculationService =
8080
+ ctx.container.resolve<SalesCalculationService>("salesCalculationService");
8081
+ const calculationContext = buildCalculationContext({
8082
+ tenantId: order.tenantId,
8083
+ organizationId: order.organizationId,
8084
+ currencyCode: order.currencyCode,
8085
+ shippingSnapshot: order.shippingMethodSnapshot,
8086
+ paymentSnapshot: order.paymentMethodSnapshot,
8087
+ shippingMethodId: order.shippingMethodId ?? null,
8088
+ paymentMethodId: order.paymentMethodId ?? null,
8089
+ shippingMethodCode: order.shippingMethodCode ?? null,
8090
+ paymentMethodCode: order.paymentMethodCode ?? null,
8091
+ });
8092
+ const calculation = await salesCalculationService.calculateDocumentTotals({
8093
+ documentKind: "order",
8094
+ lines: calcLines,
8095
+ adjustments: adjustmentDrafts,
8096
+ context: calculationContext,
8097
+ existingTotals: resolveExistingPaymentTotals(order),
8098
+ totalsMode,
8099
+ suppliedTotals,
8100
+ });
8101
+ await applyOrderLineResults({
8102
+ em,
8103
+ order,
8104
+ calculation,
8105
+ sourceLines: sourceInputs,
8106
+ existingLines,
8107
+ });
8108
+ applyOrderTotals(order, calculation.totals, calculation.lines.length);
8109
+ return { result: { orderId: order.id, lineId }, calculation };
7873
8110
  });
7874
- const calculation = await salesCalculationService.calculateDocumentTotals({
7875
- documentKind: "order",
7876
- lines: calcLines,
7877
- adjustments: adjustmentDrafts,
7878
- context: calculationContext,
7879
- existingTotals: resolveExistingPaymentTotals(order),
7880
- totalsMode,
7881
- suppliedTotals,
7882
- });
7883
- let eventBus: EventBus | null = null;
7884
- try {
7885
- eventBus = ctx.container.resolve("eventBus") as EventBus;
7886
- } catch {
7887
- eventBus = null;
7888
- }
7889
- // Persist the line changes and recalculated totals atomically so a
7890
- // mid-build failure cannot leave a half-updated order committed (#2336).
7891
- await withAtomicFlush(
7892
- em,
7893
- [
7894
- async () => {
7895
- await applyOrderLineResults({
7896
- em,
7897
- order,
7898
- calculation,
7899
- sourceLines: sourceInputs,
7900
- existingLines,
7901
- });
7902
- applyOrderTotals(order, calculation.totals, calculation.lines.length);
7903
- await emitTotalsCalculated(eventBus, {
7904
- documentKind: "order",
7905
- documentId: order.id,
7906
- organizationId: order.organizationId,
7907
- tenantId: order.tenantId,
7908
- customerId: order.customerEntityId ?? null,
7909
- totals: calculation.totals,
7910
- lineCount: calculation.lines.length,
7911
- });
7912
- },
7913
- ],
7914
- { transaction: true },
7915
- );
7916
- return { orderId: order.id, lineId };
7917
- },
7918
- captureAfter: async (_input, result, ctx) => {
7919
- const em = (ctx.container.resolve("em") as EntityManager).fork();
7920
- return loadOrderSnapshot(em, result.orderId);
7921
8111
  },
8112
+ captureAfter: async (_input, result, ctx) =>
8113
+ captureLockedOrderGraphAfter(ctx, result, result.orderId),
7922
8114
  buildLog: async ({ result, snapshots }) => {
7923
- const before = snapshots.before as OrderGraphSnapshot | undefined;
8115
+ const before = resolveLockedOrderGraphBefore(result, snapshots.before);
7924
8116
  const after = snapshots.after as OrderGraphSnapshot | undefined;
7925
8117
  if (!after) return null;
7926
8118
  const { translate } = await resolveTranslations();
@@ -7953,162 +8145,117 @@ const orderLineDeleteCommand: CommandHandler<
7953
8145
  { orderId: string; lineId: string }
7954
8146
  > = {
7955
8147
  id: "sales.orders.lines.delete",
7956
- async prepare(input, ctx) {
7957
- const raw = (input?.body as Record<string, unknown> | undefined) ?? {};
7958
- const orderId = typeof raw.orderId === "string" ? raw.orderId : null;
7959
- if (!orderId) return {};
7960
- const em = ctx.container.resolve("em") as EntityManager;
7961
- const snapshot = await loadOrderSnapshot(em, orderId);
7962
- if (snapshot)
7963
- ensureOrderScope(
7964
- ctx,
7965
- snapshot.order.organizationId,
7966
- snapshot.order.tenantId,
7967
- );
7968
- return snapshot ? { before: snapshot } : {};
8148
+ async prepare(input) {
8149
+ return markLockedGraphWrite(input);
7969
8150
  },
7970
8151
  async execute(input, ctx) {
7971
8152
  const { translate } = await resolveTranslations();
7972
8153
  const parsed = orderLineDeleteSchema.parse(
7973
8154
  (input?.body as Record<string, unknown> | undefined) ?? {},
7974
8155
  );
7975
- const em = (ctx.container.resolve("em") as EntityManager).fork();
7976
- const order = await findOneWithDecryption(em, SalesOrder, {
7977
- id: parsed.orderId,
7978
- deletedAt: null,
7979
- });
7980
- if (!order)
7981
- throw notFound(translate(
7982
- "sales.documents.detail.error",
7983
- "Document not found or inaccessible.",
7984
- ));
7985
- ensureOrderScope(ctx, order.organizationId, order.tenantId);
7986
- await enforceSalesDocumentOptimisticLock(ctx, order, SALES_RESOURCE_KIND_ORDER);
7987
- const shipmentCount = await em.count(SalesShipmentItem, {
7988
- orderLine: parsed.id,
7989
- shipment: { deletedAt: null },
7990
- });
7991
- if (shipmentCount > 0) {
7992
- throw new CrudHttpError(409, {
7993
- error: translate(
7994
- "sales.documents.items.errorDeleteShipped",
7995
- "Cannot delete a line that has shipped items.",
7996
- ),
8156
+ return writeLockedOrderGraph(input, ctx, parsed.orderId, translate(
8157
+ "sales.documents.detail.error",
8158
+ "Document not found or inaccessible.",
8159
+ ), async (em, order) => {
8160
+ const shipmentCount = await em.count(SalesShipmentItem, {
8161
+ orderLine: parsed.id,
8162
+ shipment: { deletedAt: null },
7997
8163
  });
7998
- }
7999
- const existingLines = await em.find(
8000
- SalesOrderLine,
8001
- { order },
8002
- { orderBy: { lineNumber: "asc" } },
8003
- );
8004
- const adjustments = await em.find(
8005
- SalesOrderAdjustment,
8006
- { order },
8007
- { orderBy: { position: "asc" } },
8008
- );
8009
- const filtered = existingLines.filter((line) => line.id !== parsed.id);
8010
- if (filtered.length === existingLines.length) {
8011
- throw notFound(translate(
8012
- "sales.documents.detail.error",
8013
- "Document not found or inaccessible.",
8014
- ));
8015
- }
8016
- if (filtered.length === 0) {
8017
- throw new CrudHttpError(409, {
8018
- error: translate(
8019
- "sales.documents.items.errorDeleteLast",
8020
- "An order must contain at least one line item.",
8021
- ),
8164
+ if (shipmentCount > 0) {
8165
+ throw new CrudHttpError(409, {
8166
+ error: translate(
8167
+ "sales.documents.items.errorDeleteShipped",
8168
+ "Cannot delete a line that has shipped items.",
8169
+ ),
8170
+ });
8171
+ }
8172
+ const existingLines = await em.find(
8173
+ SalesOrderLine,
8174
+ { order },
8175
+ { orderBy: { lineNumber: "asc" } },
8176
+ );
8177
+ const adjustments = await em.find(
8178
+ SalesOrderAdjustment,
8179
+ { order },
8180
+ { orderBy: { position: "asc" } },
8181
+ );
8182
+ const filtered = existingLines.filter((line) => line.id !== parsed.id);
8183
+ if (filtered.length === existingLines.length) {
8184
+ throw notFound(translate(
8185
+ "sales.documents.detail.error",
8186
+ "Document not found or inaccessible.",
8187
+ ));
8188
+ }
8189
+ if (filtered.length === 0) {
8190
+ throw new CrudHttpError(409, {
8191
+ error: translate(
8192
+ "sales.documents.items.errorDeleteLast",
8193
+ "An order must contain at least one line item.",
8194
+ ),
8195
+ });
8196
+ }
8197
+ const totalsMode: SalesAmountsMode = order.totalsMode ?? "computed";
8198
+ if (isExternalAmountsMode(totalsMode)) {
8199
+ await requireOrderTotalsForExternalWrite(parsed.orderTotals);
8200
+ }
8201
+ const suppliedTotals: Partial<SalesDocumentAmounts> | null = isExternalAmountsMode(totalsMode)
8202
+ ? buildExternalHeaderTotals(parsed.orderTotals)
8203
+ : null;
8204
+ const sourceInputs = filtered.map((line, index) => ({
8205
+ ...mapOrderLineEntityToSnapshot(line),
8206
+ statusEntryId: line.statusEntryId ?? null,
8207
+ catalogSnapshot: line.catalogSnapshot
8208
+ ? cloneJson(line.catalogSnapshot)
8209
+ : null,
8210
+ promotionSnapshot: line.promotionSnapshot
8211
+ ? cloneJson(line.promotionSnapshot)
8212
+ : null,
8213
+ organizationId: order.organizationId,
8214
+ tenantId: order.tenantId,
8215
+ orderId: order.id,
8216
+ lineNumber: index + 1,
8217
+ }));
8218
+ const calcLines = sourceInputs.map((line, index) =>
8219
+ createLineSnapshotFromInput(line, line.lineNumber ?? index + 1),
8220
+ );
8221
+ const adjustmentDrafts = adjustments.map(mapOrderAdjustmentToDraft);
8222
+ const salesCalculationService =
8223
+ ctx.container.resolve<SalesCalculationService>("salesCalculationService");
8224
+ const calculationContext = buildCalculationContext({
8225
+ tenantId: order.tenantId,
8226
+ organizationId: order.organizationId,
8227
+ currencyCode: order.currencyCode,
8228
+ shippingSnapshot: order.shippingMethodSnapshot,
8229
+ paymentSnapshot: order.paymentMethodSnapshot,
8230
+ shippingMethodId: order.shippingMethodId ?? null,
8231
+ paymentMethodId: order.paymentMethodId ?? null,
8232
+ shippingMethodCode: order.shippingMethodCode ?? null,
8233
+ paymentMethodCode: order.paymentMethodCode ?? null,
8022
8234
  });
8023
- }
8024
- const totalsMode: SalesAmountsMode = order.totalsMode ?? "computed";
8025
- if (isExternalAmountsMode(totalsMode)) {
8026
- await requireOrderTotalsForExternalWrite(parsed.orderTotals);
8027
- }
8028
- const suppliedTotals: Partial<SalesDocumentAmounts> | null = isExternalAmountsMode(totalsMode)
8029
- ? buildExternalHeaderTotals(parsed.orderTotals)
8030
- : null;
8031
- const sourceInputs = filtered.map((line, index) => ({
8032
- ...mapOrderLineEntityToSnapshot(line),
8033
- statusEntryId: line.statusEntryId ?? null,
8034
- catalogSnapshot: line.catalogSnapshot
8035
- ? cloneJson(line.catalogSnapshot)
8036
- : null,
8037
- promotionSnapshot: line.promotionSnapshot
8038
- ? cloneJson(line.promotionSnapshot)
8039
- : null,
8040
- organizationId: order.organizationId,
8041
- tenantId: order.tenantId,
8042
- orderId: order.id,
8043
- lineNumber: index + 1,
8044
- }));
8045
- const calcLines = sourceInputs.map((line, index) =>
8046
- createLineSnapshotFromInput(line, line.lineNumber ?? index + 1),
8047
- );
8048
- const adjustmentDrafts = adjustments.map(mapOrderAdjustmentToDraft);
8049
- const salesCalculationService =
8050
- ctx.container.resolve<SalesCalculationService>("salesCalculationService");
8051
- const calculationContext = buildCalculationContext({
8052
- tenantId: order.tenantId,
8053
- organizationId: order.organizationId,
8054
- currencyCode: order.currencyCode,
8055
- shippingSnapshot: order.shippingMethodSnapshot,
8056
- paymentSnapshot: order.paymentMethodSnapshot,
8057
- shippingMethodId: order.shippingMethodId ?? null,
8058
- paymentMethodId: order.paymentMethodId ?? null,
8059
- shippingMethodCode: order.shippingMethodCode ?? null,
8060
- paymentMethodCode: order.paymentMethodCode ?? null,
8061
- });
8062
- const calculation = await salesCalculationService.calculateDocumentTotals({
8063
- documentKind: "order",
8064
- lines: calcLines,
8065
- adjustments: adjustmentDrafts,
8066
- context: calculationContext,
8067
- existingTotals: resolveExistingPaymentTotals(order),
8068
- totalsMode,
8069
- suppliedTotals,
8235
+ const calculation = await salesCalculationService.calculateDocumentTotals({
8236
+ documentKind: "order",
8237
+ lines: calcLines,
8238
+ adjustments: adjustmentDrafts,
8239
+ context: calculationContext,
8240
+ existingTotals: resolveExistingPaymentTotals(order),
8241
+ totalsMode,
8242
+ suppliedTotals,
8243
+ });
8244
+ await applyOrderLineResults({
8245
+ em,
8246
+ order,
8247
+ calculation,
8248
+ sourceLines: sourceInputs,
8249
+ existingLines,
8250
+ });
8251
+ applyOrderTotals(order, calculation.totals, calculation.lines.length);
8252
+ return { result: { orderId: order.id, lineId: parsed.id }, calculation };
8070
8253
  });
8071
- let eventBus: EventBus | null = null;
8072
- try {
8073
- eventBus = ctx.container.resolve("eventBus") as EventBus;
8074
- } catch {
8075
- eventBus = null;
8076
- }
8077
- // Persist the line removal and recalculated totals atomically so a
8078
- // mid-build failure cannot leave a half-updated order committed (#2336).
8079
- await withAtomicFlush(
8080
- em,
8081
- [
8082
- async () => {
8083
- await applyOrderLineResults({
8084
- em,
8085
- order,
8086
- calculation,
8087
- sourceLines: sourceInputs,
8088
- existingLines,
8089
- });
8090
- applyOrderTotals(order, calculation.totals, calculation.lines.length);
8091
- await emitTotalsCalculated(eventBus, {
8092
- documentKind: "order",
8093
- documentId: order.id,
8094
- organizationId: order.organizationId,
8095
- tenantId: order.tenantId,
8096
- customerId: order.customerEntityId ?? null,
8097
- totals: calculation.totals,
8098
- lineCount: calculation.lines.length,
8099
- });
8100
- },
8101
- ],
8102
- { transaction: true },
8103
- );
8104
- return { orderId: order.id, lineId: parsed.id };
8105
- },
8106
- captureAfter: async (_input, result, ctx) => {
8107
- const em = (ctx.container.resolve("em") as EntityManager).fork();
8108
- return loadOrderSnapshot(em, result.orderId);
8109
8254
  },
8255
+ captureAfter: async (_input, result, ctx) =>
8256
+ captureLockedOrderGraphAfter(ctx, result, result.orderId),
8110
8257
  buildLog: async ({ result, snapshots }) => {
8111
- const before = snapshots.before as OrderGraphSnapshot | undefined;
8258
+ const before = resolveLockedOrderGraphBefore(result, snapshots.before);
8112
8259
  const after = snapshots.after as OrderGraphSnapshot | undefined;
8113
8260
  if (!after) return null;
8114
8261
  const { translate } = await resolveTranslations();
@@ -8141,127 +8288,87 @@ const quoteLineUpsertCommand: CommandHandler<
8141
8288
  { quoteId: string; lineId: string }
8142
8289
  > = {
8143
8290
  id: "sales.quotes.lines.upsert",
8144
- async prepare(input, ctx) {
8145
- const raw = (input?.body as Record<string, unknown> | undefined) ?? {};
8146
- const quoteId = typeof raw.quoteId === "string" ? raw.quoteId : null;
8147
- if (!quoteId) return {};
8148
- const em = ctx.container.resolve("em") as EntityManager;
8149
- const snapshot = await loadQuoteSnapshot(em, quoteId);
8150
- if (snapshot)
8151
- ensureQuoteScope(
8152
- ctx,
8153
- snapshot.quote.organizationId,
8154
- snapshot.quote.tenantId,
8155
- );
8156
- return snapshot ? { before: snapshot } : {};
8291
+ async prepare(input) {
8292
+ return markLockedGraphWrite(input);
8157
8293
  },
8158
8294
  async execute(input, ctx) {
8159
8295
  const rawBody = (input?.body as Record<string, unknown> | undefined) ?? {};
8160
8296
  const parsed = quoteLineUpsertSchema.parse(rawBody);
8161
8297
  await assertAmountsModeUnsupportedOnQuote([parsed]);
8162
- const em = (ctx.container.resolve("em") as EntityManager).fork();
8163
- const quote = await findOneWithDecryption(em, SalesQuote, {
8164
- id: parsed.quoteId,
8165
- deletedAt: null,
8166
- });
8167
- if (!quote)
8168
- throw notFound("Sales quote not found");
8169
- ensureQuoteScope(ctx, quote.organizationId, quote.tenantId);
8170
- await enforceSalesDocumentOptimisticLock(ctx, quote, SALES_RESOURCE_KIND_QUOTE);
8171
- const [existingLines, adjustments] = await Promise.all([
8172
- em.find(SalesQuoteLine, { quote }, { orderBy: { lineNumber: "asc" } }),
8173
- em.find(
8174
- SalesQuoteAdjustment,
8175
- { quote },
8176
- { orderBy: { position: "asc" } },
8177
- ),
8178
- ]);
8179
- const lineSnapshots = existingLines.map(mapQuoteLineEntityToSnapshot);
8180
- const existingSnapshot = parsed.id
8181
- ? (lineSnapshots.find((line) => line.id === parsed.id) ?? null)
8182
- : null;
8183
- const priceMode =
8184
- parsed.priceMode === "gross"
8185
- ? "gross"
8186
- : parsed.priceMode === "net"
8187
- ? "net"
8188
- : null;
8189
- let unitPriceNet =
8190
- parsed.unitPriceNet ?? existingSnapshot?.unitPriceNet ?? null;
8191
- let unitPriceGross =
8192
- parsed.unitPriceGross ?? existingSnapshot?.unitPriceGross ?? null;
8193
- let taxRate = parsed.taxRate ?? existingSnapshot?.taxRate ?? null;
8194
- if (priceMode && (unitPriceNet === null || unitPriceGross === null)) {
8195
- let taxService: TaxCalculationService | null = null;
8196
- try {
8197
- taxService = ctx.container.resolve(
8198
- "taxCalculationService",
8199
- ) as TaxCalculationService;
8200
- } catch {
8201
- taxService = null;
8202
- }
8203
- if (taxService) {
8204
- const taxResult = await taxService.calculateUnitAmounts({
8205
- amount:
8206
- priceMode === "gross"
8207
- ? (unitPriceGross ?? unitPriceNet ?? 0)
8208
- : (unitPriceNet ?? unitPriceGross ?? 0),
8209
- mode: priceMode,
8210
- organizationId: parsed.organizationId,
8211
- tenantId: parsed.tenantId,
8212
- taxRateId: parsed.taxRateId ?? undefined,
8213
- taxRate: taxRate ?? undefined,
8214
- });
8215
- unitPriceNet = unitPriceNet ?? taxResult.netAmount;
8216
- unitPriceGross = unitPriceGross ?? taxResult.grossAmount;
8217
- taxRate = taxResult.taxRate ?? taxRate;
8298
+ return writeLockedQuoteGraph(input, ctx, parsed.quoteId, "Sales quote not found", async (em, quote) => {
8299
+ const [existingLines, adjustments] = await Promise.all([
8300
+ em.find(SalesQuoteLine, { quote }, { orderBy: { lineNumber: "asc" } }),
8301
+ em.find(
8302
+ SalesQuoteAdjustment,
8303
+ { quote },
8304
+ { orderBy: { position: "asc" } },
8305
+ ),
8306
+ ]);
8307
+ const lineSnapshots = existingLines.map(mapQuoteLineEntityToSnapshot);
8308
+ const existingSnapshot = parsed.id
8309
+ ? (lineSnapshots.find((line) => line.id === parsed.id) ?? null)
8310
+ : null;
8311
+ const priceMode =
8312
+ parsed.priceMode === "gross"
8313
+ ? "gross"
8314
+ : parsed.priceMode === "net"
8315
+ ? "net"
8316
+ : null;
8317
+ let unitPriceNet =
8318
+ parsed.unitPriceNet ?? existingSnapshot?.unitPriceNet ?? null;
8319
+ let unitPriceGross =
8320
+ parsed.unitPriceGross ?? existingSnapshot?.unitPriceGross ?? null;
8321
+ let taxRate = parsed.taxRate ?? existingSnapshot?.taxRate ?? null;
8322
+ if (priceMode && (unitPriceNet === null || unitPriceGross === null)) {
8323
+ let taxService: TaxCalculationService | null = null;
8324
+ try {
8325
+ taxService = ctx.container.resolve(
8326
+ "taxCalculationService",
8327
+ ) as TaxCalculationService;
8328
+ } catch {
8329
+ taxService = null;
8330
+ }
8331
+ if (taxService) {
8332
+ const taxResult = await taxService.calculateUnitAmounts({
8333
+ amount:
8334
+ priceMode === "gross"
8335
+ ? (unitPriceGross ?? unitPriceNet ?? 0)
8336
+ : (unitPriceNet ?? unitPriceGross ?? 0),
8337
+ mode: priceMode,
8338
+ organizationId: parsed.organizationId,
8339
+ tenantId: parsed.tenantId,
8340
+ taxRateId: parsed.taxRateId ?? undefined,
8341
+ taxRate: taxRate ?? undefined,
8342
+ });
8343
+ unitPriceNet = unitPriceNet ?? taxResult.netAmount;
8344
+ unitPriceGross = unitPriceGross ?? taxResult.grossAmount;
8345
+ taxRate = taxResult.taxRate ?? taxRate;
8346
+ }
8218
8347
  }
8219
- }
8220
- const metadata =
8221
- typeof parsed.metadata === "object" && parsed.metadata
8222
- ? { ...parsed.metadata }
8223
- : existingSnapshot?.metadata
8224
- ? cloneJson(existingSnapshot.metadata)
8225
- : {};
8226
- if (parsed.priceId) metadata.priceId = parsed.priceId;
8227
- if (priceMode) metadata.priceMode = priceMode;
8228
-
8229
- const statusEntryId =
8230
- parsed.statusEntryId ?? (existingSnapshot as any)?.statusEntryId ?? null;
8231
- const lineId = parsed.id ?? existingSnapshot?.id ?? randomUUID();
8232
- const lineUomInput = {
8233
- productId: parsed.productId ?? existingSnapshot?.productId ?? null,
8234
- productVariantId:
8235
- parsed.productVariantId ?? existingSnapshot?.productVariantId ?? null,
8236
- quantity: parsed.quantity ?? existingSnapshot?.quantity ?? 0,
8237
- quantityUnit: parsed.quantityUnit ?? existingSnapshot?.quantityUnit ?? null,
8238
- normalizedQuantity: existingSnapshot?.normalizedQuantity ?? null,
8239
- normalizedUnit: existingSnapshot?.normalizedUnit ?? null,
8240
- uomSnapshot: existingSnapshot?.uomSnapshot ?? null,
8241
- };
8242
- const uomResolver = createUomResolver();
8243
- let normalizedUom = await normalizeLineUom({
8244
- em,
8245
- resolver: uomResolver,
8246
- organizationId: quote.organizationId,
8247
- tenantId: quote.tenantId,
8248
- line: {
8249
- ...lineUomInput,
8250
- unitPriceNet: unitPriceNet ?? 0,
8251
- unitPriceGross: unitPriceGross ?? unitPriceNet ?? 0,
8252
- },
8253
- });
8254
- const convertedPrices = convertLineUnitPricesOnUnitChange({
8255
- existingSnapshot,
8256
- nextQuantityUnit: normalizedUom.quantityUnit,
8257
- nextUomSnapshot: normalizedUom.uomSnapshot,
8258
- unitPriceNet,
8259
- unitPriceGross,
8260
- });
8261
- if (convertedPrices.didConvert) {
8262
- unitPriceNet = convertedPrices.unitPriceNet;
8263
- unitPriceGross = convertedPrices.unitPriceGross;
8264
- normalizedUom = await normalizeLineUom({
8348
+ const metadata =
8349
+ typeof parsed.metadata === "object" && parsed.metadata
8350
+ ? { ...parsed.metadata }
8351
+ : existingSnapshot?.metadata
8352
+ ? cloneJson(existingSnapshot.metadata)
8353
+ : {};
8354
+ if (parsed.priceId) metadata.priceId = parsed.priceId;
8355
+ if (priceMode) metadata.priceMode = priceMode;
8356
+
8357
+ const statusEntryId =
8358
+ parsed.statusEntryId ?? (existingSnapshot as any)?.statusEntryId ?? null;
8359
+ const lineId = parsed.id ?? existingSnapshot?.id ?? randomUUID();
8360
+ const lineUomInput = {
8361
+ productId: parsed.productId ?? existingSnapshot?.productId ?? null,
8362
+ productVariantId:
8363
+ parsed.productVariantId ?? existingSnapshot?.productVariantId ?? null,
8364
+ quantity: parsed.quantity ?? existingSnapshot?.quantity ?? 0,
8365
+ quantityUnit: parsed.quantityUnit ?? existingSnapshot?.quantityUnit ?? null,
8366
+ normalizedQuantity: existingSnapshot?.normalizedQuantity ?? null,
8367
+ normalizedUnit: existingSnapshot?.normalizedUnit ?? null,
8368
+ uomSnapshot: existingSnapshot?.uomSnapshot ?? null,
8369
+ };
8370
+ const uomResolver = createUomResolver();
8371
+ let normalizedUom = await normalizeLineUom({
8265
8372
  em,
8266
8373
  resolver: uomResolver,
8267
8374
  organizationId: quote.organizationId,
@@ -8272,157 +8379,152 @@ const quoteLineUpsertCommand: CommandHandler<
8272
8379
  unitPriceGross: unitPriceGross ?? unitPriceNet ?? 0,
8273
8380
  },
8274
8381
  });
8275
- }
8276
- const updatedSnapshot: SalesLineSnapshot & {
8277
- statusEntryId?: string | null;
8278
- catalogSnapshot?: Record<string, unknown> | null;
8279
- promotionSnapshot?: Record<string, unknown> | null;
8280
- } = {
8281
- id: lineId,
8282
- lineNumber:
8283
- parsed.lineNumber ??
8284
- existingSnapshot?.lineNumber ??
8285
- lineSnapshots.length + 1,
8286
- kind: parsed.kind ?? existingSnapshot?.kind ?? "product",
8287
- productId: parsed.productId ?? existingSnapshot?.productId ?? null,
8288
- productVariantId:
8289
- parsed.productVariantId ?? existingSnapshot?.productVariantId ?? null,
8290
- name: parsed.name ?? existingSnapshot?.name ?? null,
8291
- description: parsed.description ?? existingSnapshot?.description ?? null,
8292
- comment: parsed.comment ?? existingSnapshot?.comment ?? null,
8293
- quantity: normalizedUom.quantity,
8294
- quantityUnit: normalizedUom.quantityUnit,
8295
- normalizedQuantity: normalizedUom.normalizedQuantity,
8296
- normalizedUnit: normalizedUom.normalizedUnit,
8297
- uomSnapshot: normalizedUom.uomSnapshot
8298
- ? cloneJson(normalizedUom.uomSnapshot)
8299
- : null,
8300
- currencyCode:
8301
- parsed.currencyCode ??
8302
- existingSnapshot?.currencyCode ??
8303
- quote.currencyCode,
8304
- unitPriceNet: unitPriceNet ?? 0,
8305
- unitPriceGross: unitPriceGross ?? unitPriceNet ?? 0,
8306
- ...resolveUpsertDiscountFields(
8307
- parsed.discountAmount,
8308
- parsed.discountAmountBasis,
8382
+ const convertedPrices = convertLineUnitPricesOnUnitChange({
8309
8383
  existingSnapshot,
8310
- ),
8311
- discountPercent:
8312
- parsed.discountPercent ?? existingSnapshot?.discountPercent ?? 0,
8313
- taxRate: taxRate ?? 0,
8314
- totalNetAmount:
8315
- parsed.totalNetAmount ?? existingSnapshot?.totalNetAmount ?? null,
8316
- ...resolveUpsertTotalsOrigin(parsed.totalNetAmount, existingSnapshot),
8317
- configuration:
8318
- parsed.configuration ?? existingSnapshot?.configuration ?? null,
8319
- promotionCode:
8320
- parsed.promotionCode ?? existingSnapshot?.promotionCode ?? null,
8321
- metadata,
8322
- customFieldSetId:
8323
- parsed.customFieldSetId ?? existingSnapshot?.customFieldSetId ?? null,
8324
- customFields:
8325
- parsed.customFields && typeof parsed.customFields === "object"
8326
- ? cloneJson(parsed.customFields)
8327
- : ((existingSnapshot as any)?.customFields ?? null),
8328
- };
8329
- Object.assign(
8330
- updatedSnapshot,
8331
- resolveUpsertCalculatedAmounts(parsed, updatedSnapshot, existingSnapshot),
8332
- );
8333
- (updatedSnapshot as any).statusEntryId = statusEntryId;
8334
- (updatedSnapshot as any).catalogSnapshot =
8335
- parsed.catalogSnapshot ??
8336
- (existingSnapshot as any)?.catalogSnapshot ??
8337
- null;
8338
- (updatedSnapshot as any).promotionSnapshot =
8339
- parsed.promotionSnapshot ??
8340
- (existingSnapshot as any)?.promotionSnapshot ??
8341
- null;
8342
-
8343
- let nextLines = parsed.id
8344
- ? lineSnapshots.map((line) =>
8345
- line.id === parsed.id ? updatedSnapshot : line,
8346
- )
8347
- : [...lineSnapshots, updatedSnapshot];
8348
- nextLines = nextLines
8349
- .sort((a, b) => (a.lineNumber ?? 0) - (b.lineNumber ?? 0))
8350
- .map((line, index) => ({ ...line, lineNumber: index + 1 }));
8384
+ nextQuantityUnit: normalizedUom.quantityUnit,
8385
+ nextUomSnapshot: normalizedUom.uomSnapshot,
8386
+ unitPriceNet,
8387
+ unitPriceGross,
8388
+ });
8389
+ if (convertedPrices.didConvert) {
8390
+ unitPriceNet = convertedPrices.unitPriceNet;
8391
+ unitPriceGross = convertedPrices.unitPriceGross;
8392
+ normalizedUom = await normalizeLineUom({
8393
+ em,
8394
+ resolver: uomResolver,
8395
+ organizationId: quote.organizationId,
8396
+ tenantId: quote.tenantId,
8397
+ line: {
8398
+ ...lineUomInput,
8399
+ unitPriceNet: unitPriceNet ?? 0,
8400
+ unitPriceGross: unitPriceGross ?? unitPriceNet ?? 0,
8401
+ },
8402
+ });
8403
+ }
8404
+ const updatedSnapshot: SalesLineSnapshot & {
8405
+ statusEntryId?: string | null;
8406
+ catalogSnapshot?: Record<string, unknown> | null;
8407
+ promotionSnapshot?: Record<string, unknown> | null;
8408
+ } = {
8409
+ id: lineId,
8410
+ lineNumber:
8411
+ parsed.lineNumber ??
8412
+ existingSnapshot?.lineNumber ??
8413
+ lineSnapshots.length + 1,
8414
+ kind: parsed.kind ?? existingSnapshot?.kind ?? "product",
8415
+ productId: parsed.productId ?? existingSnapshot?.productId ?? null,
8416
+ productVariantId:
8417
+ parsed.productVariantId ?? existingSnapshot?.productVariantId ?? null,
8418
+ name: parsed.name ?? existingSnapshot?.name ?? null,
8419
+ description: parsed.description ?? existingSnapshot?.description ?? null,
8420
+ comment: parsed.comment ?? existingSnapshot?.comment ?? null,
8421
+ quantity: normalizedUom.quantity,
8422
+ quantityUnit: normalizedUom.quantityUnit,
8423
+ normalizedQuantity: normalizedUom.normalizedQuantity,
8424
+ normalizedUnit: normalizedUom.normalizedUnit,
8425
+ uomSnapshot: normalizedUom.uomSnapshot
8426
+ ? cloneJson(normalizedUom.uomSnapshot)
8427
+ : null,
8428
+ currencyCode:
8429
+ parsed.currencyCode ??
8430
+ existingSnapshot?.currencyCode ??
8431
+ quote.currencyCode,
8432
+ unitPriceNet: unitPriceNet ?? 0,
8433
+ unitPriceGross: unitPriceGross ?? unitPriceNet ?? 0,
8434
+ ...resolveUpsertDiscountFields(
8435
+ parsed.discountAmount,
8436
+ parsed.discountAmountBasis,
8437
+ existingSnapshot,
8438
+ ),
8439
+ discountPercent:
8440
+ parsed.discountPercent ?? existingSnapshot?.discountPercent ?? 0,
8441
+ taxRate: taxRate ?? 0,
8442
+ totalNetAmount:
8443
+ parsed.totalNetAmount ?? existingSnapshot?.totalNetAmount ?? null,
8444
+ ...resolveUpsertTotalsOrigin(parsed.totalNetAmount, existingSnapshot),
8445
+ configuration:
8446
+ parsed.configuration ?? existingSnapshot?.configuration ?? null,
8447
+ promotionCode:
8448
+ parsed.promotionCode ?? existingSnapshot?.promotionCode ?? null,
8449
+ metadata,
8450
+ customFieldSetId:
8451
+ parsed.customFieldSetId ?? existingSnapshot?.customFieldSetId ?? null,
8452
+ customFields:
8453
+ parsed.customFields && typeof parsed.customFields === "object"
8454
+ ? cloneJson(parsed.customFields)
8455
+ : ((existingSnapshot as any)?.customFields ?? null),
8456
+ };
8457
+ Object.assign(
8458
+ updatedSnapshot,
8459
+ resolveUpsertCalculatedAmounts(parsed, updatedSnapshot, existingSnapshot),
8460
+ );
8461
+ (updatedSnapshot as any).statusEntryId = statusEntryId;
8462
+ (updatedSnapshot as any).catalogSnapshot =
8463
+ parsed.catalogSnapshot ??
8464
+ (existingSnapshot as any)?.catalogSnapshot ??
8465
+ null;
8466
+ (updatedSnapshot as any).promotionSnapshot =
8467
+ parsed.promotionSnapshot ??
8468
+ (existingSnapshot as any)?.promotionSnapshot ??
8469
+ null;
8470
+
8471
+ let nextLines = parsed.id
8472
+ ? lineSnapshots.map((line) =>
8473
+ line.id === parsed.id ? updatedSnapshot : line,
8474
+ )
8475
+ : [...lineSnapshots, updatedSnapshot];
8476
+ nextLines = nextLines
8477
+ .sort((a, b) => (a.lineNumber ?? 0) - (b.lineNumber ?? 0))
8478
+ .map((line, index) => ({ ...line, lineNumber: index + 1 }));
8351
8479
 
8352
- const sourceInputs = nextLines.map((line, index) => ({
8353
- ...line,
8354
- statusEntryId: (line as any).statusEntryId ?? null,
8355
- catalogSnapshot: (line as any).catalogSnapshot ?? null,
8356
- promotionSnapshot: (line as any).promotionSnapshot ?? null,
8357
- organizationId: quote.organizationId,
8358
- tenantId: quote.tenantId,
8359
- quoteId: quote.id,
8360
- lineNumber: line.lineNumber ?? index + 1,
8361
- }));
8362
- const calcLines: SalesLineSnapshot[] = sourceInputs.map((line, index) =>
8363
- createLineSnapshotFromInput(line, line.lineNumber ?? index + 1),
8364
- );
8365
- const adjustmentDrafts = adjustments.map(mapQuoteAdjustmentToDraft);
8366
- const salesCalculationService =
8367
- ctx.container.resolve<SalesCalculationService>("salesCalculationService");
8368
- const calculationContext = buildCalculationContext({
8369
- tenantId: quote.tenantId,
8370
- organizationId: quote.organizationId,
8371
- currencyCode: quote.currencyCode,
8372
- shippingSnapshot: quote.shippingMethodSnapshot,
8373
- paymentSnapshot: quote.paymentMethodSnapshot,
8374
- shippingMethodId: quote.shippingMethodId ?? null,
8375
- paymentMethodId: quote.paymentMethodId ?? null,
8376
- shippingMethodCode: quote.shippingMethodCode ?? null,
8377
- paymentMethodCode: quote.paymentMethodCode ?? null,
8378
- });
8379
- const calculation = await salesCalculationService.calculateDocumentTotals({
8380
- documentKind: "quote",
8381
- lines: calcLines,
8382
- adjustments: adjustmentDrafts,
8383
- context: calculationContext,
8480
+ const sourceInputs = nextLines.map((line, index) => ({
8481
+ ...line,
8482
+ statusEntryId: (line as any).statusEntryId ?? null,
8483
+ catalogSnapshot: (line as any).catalogSnapshot ?? null,
8484
+ promotionSnapshot: (line as any).promotionSnapshot ?? null,
8485
+ organizationId: quote.organizationId,
8486
+ tenantId: quote.tenantId,
8487
+ quoteId: quote.id,
8488
+ lineNumber: line.lineNumber ?? index + 1,
8489
+ }));
8490
+ const calcLines: SalesLineSnapshot[] = sourceInputs.map((line, index) =>
8491
+ createLineSnapshotFromInput(line, line.lineNumber ?? index + 1),
8492
+ );
8493
+ const adjustmentDrafts = adjustments.map(mapQuoteAdjustmentToDraft);
8494
+ const salesCalculationService =
8495
+ ctx.container.resolve<SalesCalculationService>("salesCalculationService");
8496
+ const calculationContext = buildCalculationContext({
8497
+ tenantId: quote.tenantId,
8498
+ organizationId: quote.organizationId,
8499
+ currencyCode: quote.currencyCode,
8500
+ shippingSnapshot: quote.shippingMethodSnapshot,
8501
+ paymentSnapshot: quote.paymentMethodSnapshot,
8502
+ shippingMethodId: quote.shippingMethodId ?? null,
8503
+ paymentMethodId: quote.paymentMethodId ?? null,
8504
+ shippingMethodCode: quote.shippingMethodCode ?? null,
8505
+ paymentMethodCode: quote.paymentMethodCode ?? null,
8506
+ });
8507
+ const calculation = await salesCalculationService.calculateDocumentTotals({
8508
+ documentKind: "quote",
8509
+ lines: calcLines,
8510
+ adjustments: adjustmentDrafts,
8511
+ context: calculationContext,
8512
+ });
8513
+ await applyQuoteLineResults({
8514
+ em,
8515
+ quote,
8516
+ calculation,
8517
+ sourceLines: sourceInputs,
8518
+ existingLines,
8519
+ });
8520
+ applyQuoteTotals(quote, calculation.totals, calculation.lines.length);
8521
+ return { result: { quoteId: quote.id, lineId }, calculation };
8384
8522
  });
8385
- let eventBus: EventBus | null = null;
8386
- try {
8387
- eventBus = ctx.container.resolve("eventBus") as EventBus;
8388
- } catch {
8389
- eventBus = null;
8390
- }
8391
- // Persist the line changes and recalculated totals atomically so a
8392
- // mid-build failure cannot leave a half-updated quote committed (#2336).
8393
- await withAtomicFlush(
8394
- em,
8395
- [
8396
- async () => {
8397
- await applyQuoteLineResults({
8398
- em,
8399
- quote,
8400
- calculation,
8401
- sourceLines: sourceInputs,
8402
- existingLines,
8403
- });
8404
- applyQuoteTotals(quote, calculation.totals, calculation.lines.length);
8405
- await emitTotalsCalculated(eventBus, {
8406
- documentKind: "quote",
8407
- documentId: quote.id,
8408
- organizationId: quote.organizationId,
8409
- tenantId: quote.tenantId,
8410
- customerId: quote.customerEntityId ?? null,
8411
- totals: calculation.totals,
8412
- lineCount: calculation.lines.length,
8413
- });
8414
- },
8415
- ],
8416
- { transaction: true },
8417
- );
8418
- return { quoteId: quote.id, lineId };
8419
- },
8420
- captureAfter: async (_input, result, ctx) => {
8421
- const em = (ctx.container.resolve("em") as EntityManager).fork();
8422
- return loadQuoteSnapshot(em, result.quoteId);
8423
8523
  },
8524
+ captureAfter: async (_input, result, ctx) =>
8525
+ captureLockedQuoteGraphAfter(ctx, result, result.quoteId),
8424
8526
  buildLog: async ({ result, snapshots }) => {
8425
- const before = snapshots.before as QuoteGraphSnapshot | undefined;
8527
+ const before = resolveLockedQuoteGraphBefore(result, snapshots.before);
8426
8528
  const after = snapshots.after as QuoteGraphSnapshot | undefined;
8427
8529
  if (!after) return null;
8428
8530
  const { translate } = await resolveTranslations();
@@ -8455,125 +8557,80 @@ const quoteLineDeleteCommand: CommandHandler<
8455
8557
  { quoteId: string; lineId: string }
8456
8558
  > = {
8457
8559
  id: "sales.quotes.lines.delete",
8458
- async prepare(input, ctx) {
8459
- const raw = (input?.body as Record<string, unknown> | undefined) ?? {};
8460
- const quoteId = typeof raw.quoteId === "string" ? raw.quoteId : null;
8461
- if (!quoteId) return {};
8462
- const em = ctx.container.resolve("em") as EntityManager;
8463
- const snapshot = await loadQuoteSnapshot(em, quoteId);
8464
- if (snapshot)
8465
- ensureQuoteScope(
8466
- ctx,
8467
- snapshot.quote.organizationId,
8468
- snapshot.quote.tenantId,
8469
- );
8470
- return snapshot ? { before: snapshot } : {};
8560
+ async prepare(input) {
8561
+ return markLockedGraphWrite(input);
8471
8562
  },
8472
8563
  async execute(input, ctx) {
8473
8564
  const parsed = quoteLineDeleteSchema.parse(
8474
8565
  (input?.body as Record<string, unknown> | undefined) ?? {},
8475
8566
  );
8476
- const em = (ctx.container.resolve("em") as EntityManager).fork();
8477
- const quote = await findOneWithDecryption(em, SalesQuote, {
8478
- id: parsed.quoteId,
8479
- deletedAt: null,
8480
- });
8481
- if (!quote)
8482
- throw notFound("Sales quote not found");
8483
- ensureQuoteScope(ctx, quote.organizationId, quote.tenantId);
8484
- await enforceSalesDocumentOptimisticLock(ctx, quote, SALES_RESOURCE_KIND_QUOTE);
8485
- const existingLines = await em.find(
8486
- SalesQuoteLine,
8487
- { quote },
8488
- { orderBy: { lineNumber: "asc" } },
8489
- );
8490
- const adjustments = await em.find(
8491
- SalesQuoteAdjustment,
8492
- { quote },
8493
- { orderBy: { position: "asc" } },
8494
- );
8495
- const filtered = existingLines.filter((line) => line.id !== parsed.id);
8496
- if (filtered.length === existingLines.length) {
8497
- throw notFound("Quote line not found");
8498
- }
8499
- const sourceInputs = filtered.map((line, index) => ({
8500
- ...mapQuoteLineEntityToSnapshot(line),
8501
- statusEntryId: line.statusEntryId ?? null,
8502
- catalogSnapshot: line.catalogSnapshot
8503
- ? cloneJson(line.catalogSnapshot)
8504
- : null,
8505
- promotionSnapshot: line.promotionSnapshot
8506
- ? cloneJson(line.promotionSnapshot)
8507
- : null,
8508
- organizationId: quote.organizationId,
8509
- tenantId: quote.tenantId,
8510
- quoteId: quote.id,
8511
- lineNumber: index + 1,
8512
- }));
8513
- const calcLines = sourceInputs.map((line, index) =>
8514
- createLineSnapshotFromInput(line, line.lineNumber ?? index + 1),
8515
- );
8516
- const adjustmentDrafts = adjustments.map(mapQuoteAdjustmentToDraft);
8517
- const salesCalculationService =
8518
- ctx.container.resolve<SalesCalculationService>("salesCalculationService");
8519
- const calculationContext = buildCalculationContext({
8520
- tenantId: quote.tenantId,
8521
- organizationId: quote.organizationId,
8522
- currencyCode: quote.currencyCode,
8523
- shippingSnapshot: quote.shippingMethodSnapshot,
8524
- paymentSnapshot: quote.paymentMethodSnapshot,
8525
- shippingMethodId: quote.shippingMethodId ?? null,
8526
- paymentMethodId: quote.paymentMethodId ?? null,
8527
- shippingMethodCode: quote.shippingMethodCode ?? null,
8528
- paymentMethodCode: quote.paymentMethodCode ?? null,
8529
- });
8530
- const calculation = await salesCalculationService.calculateDocumentTotals({
8531
- documentKind: "quote",
8532
- lines: calcLines,
8533
- adjustments: adjustmentDrafts,
8534
- context: calculationContext,
8567
+ return writeLockedQuoteGraph(input, ctx, parsed.quoteId, "Sales quote not found", async (em, quote) => {
8568
+ const existingLines = await em.find(
8569
+ SalesQuoteLine,
8570
+ { quote },
8571
+ { orderBy: { lineNumber: "asc" } },
8572
+ );
8573
+ const adjustments = await em.find(
8574
+ SalesQuoteAdjustment,
8575
+ { quote },
8576
+ { orderBy: { position: "asc" } },
8577
+ );
8578
+ const filtered = existingLines.filter((line) => line.id !== parsed.id);
8579
+ if (filtered.length === existingLines.length) {
8580
+ throw notFound("Quote line not found");
8581
+ }
8582
+ const sourceInputs = filtered.map((line, index) => ({
8583
+ ...mapQuoteLineEntityToSnapshot(line),
8584
+ statusEntryId: line.statusEntryId ?? null,
8585
+ catalogSnapshot: line.catalogSnapshot
8586
+ ? cloneJson(line.catalogSnapshot)
8587
+ : null,
8588
+ promotionSnapshot: line.promotionSnapshot
8589
+ ? cloneJson(line.promotionSnapshot)
8590
+ : null,
8591
+ organizationId: quote.organizationId,
8592
+ tenantId: quote.tenantId,
8593
+ quoteId: quote.id,
8594
+ lineNumber: index + 1,
8595
+ }));
8596
+ const calcLines = sourceInputs.map((line, index) =>
8597
+ createLineSnapshotFromInput(line, line.lineNumber ?? index + 1),
8598
+ );
8599
+ const adjustmentDrafts = adjustments.map(mapQuoteAdjustmentToDraft);
8600
+ const salesCalculationService =
8601
+ ctx.container.resolve<SalesCalculationService>("salesCalculationService");
8602
+ const calculationContext = buildCalculationContext({
8603
+ tenantId: quote.tenantId,
8604
+ organizationId: quote.organizationId,
8605
+ currencyCode: quote.currencyCode,
8606
+ shippingSnapshot: quote.shippingMethodSnapshot,
8607
+ paymentSnapshot: quote.paymentMethodSnapshot,
8608
+ shippingMethodId: quote.shippingMethodId ?? null,
8609
+ paymentMethodId: quote.paymentMethodId ?? null,
8610
+ shippingMethodCode: quote.shippingMethodCode ?? null,
8611
+ paymentMethodCode: quote.paymentMethodCode ?? null,
8612
+ });
8613
+ const calculation = await salesCalculationService.calculateDocumentTotals({
8614
+ documentKind: "quote",
8615
+ lines: calcLines,
8616
+ adjustments: adjustmentDrafts,
8617
+ context: calculationContext,
8618
+ });
8619
+ await applyQuoteLineResults({
8620
+ em,
8621
+ quote,
8622
+ calculation,
8623
+ sourceLines: sourceInputs,
8624
+ existingLines,
8625
+ });
8626
+ applyQuoteTotals(quote, calculation.totals, calculation.lines.length);
8627
+ return { result: { quoteId: quote.id, lineId: parsed.id }, calculation };
8535
8628
  });
8536
- let eventBus: EventBus | null = null;
8537
- try {
8538
- eventBus = ctx.container.resolve("eventBus") as EventBus;
8539
- } catch {
8540
- eventBus = null;
8541
- }
8542
- // Persist the line removal and recalculated totals atomically so a
8543
- // mid-build failure cannot leave a half-updated quote committed (#2336).
8544
- await withAtomicFlush(
8545
- em,
8546
- [
8547
- async () => {
8548
- await applyQuoteLineResults({
8549
- em,
8550
- quote,
8551
- calculation,
8552
- sourceLines: sourceInputs,
8553
- existingLines,
8554
- });
8555
- applyQuoteTotals(quote, calculation.totals, calculation.lines.length);
8556
- await emitTotalsCalculated(eventBus, {
8557
- documentKind: "quote",
8558
- documentId: quote.id,
8559
- organizationId: quote.organizationId,
8560
- tenantId: quote.tenantId,
8561
- customerId: quote.customerEntityId ?? null,
8562
- totals: calculation.totals,
8563
- lineCount: calculation.lines.length,
8564
- });
8565
- },
8566
- ],
8567
- { transaction: true },
8568
- );
8569
- return { quoteId: quote.id, lineId: parsed.id };
8570
- },
8571
- captureAfter: async (_input, result, ctx) => {
8572
- const em = (ctx.container.resolve("em") as EntityManager).fork();
8573
- return loadQuoteSnapshot(em, result.quoteId);
8574
8629
  },
8630
+ captureAfter: async (_input, result, ctx) =>
8631
+ captureLockedQuoteGraphAfter(ctx, result, result.quoteId),
8575
8632
  buildLog: async ({ result, snapshots }) => {
8576
- const before = snapshots.before as QuoteGraphSnapshot | undefined;
8633
+ const before = resolveLockedQuoteGraphBefore(result, snapshots.before);
8577
8634
  const after = snapshots.after as QuoteGraphSnapshot | undefined;
8578
8635
  if (!after) return null;
8579
8636
  const { translate } = await resolveTranslations();
@@ -8606,266 +8663,221 @@ const orderAdjustmentUpsertCommand: CommandHandler<
8606
8663
  { orderId: string; adjustmentId: string }
8607
8664
  > = {
8608
8665
  id: "sales.orders.adjustments.upsert",
8609
- async prepare(input, ctx) {
8610
- const raw = (input?.body as Record<string, unknown> | undefined) ?? {};
8611
- const orderId = typeof raw.orderId === "string" ? raw.orderId : null;
8612
- if (!orderId) return {};
8613
- const em = ctx.container.resolve("em") as EntityManager;
8614
- const snapshot = await loadOrderSnapshot(em, orderId);
8615
- if (snapshot)
8616
- ensureOrderScope(
8617
- ctx,
8618
- snapshot.order.organizationId,
8619
- snapshot.order.tenantId,
8620
- );
8621
- return snapshot ? { before: snapshot } : {};
8666
+ async prepare(input) {
8667
+ return markLockedGraphWrite(input);
8622
8668
  },
8623
8669
  async execute(input, ctx) {
8624
8670
  const parsed = orderAdjustmentUpsertSchema.parse(
8625
8671
  (input?.body as Record<string, unknown> | undefined) ?? {},
8626
8672
  );
8627
- const em = (ctx.container.resolve("em") as EntityManager).fork();
8628
- const order = await findOneWithDecryption(em, SalesOrder, {
8629
- id: parsed.orderId,
8630
- deletedAt: null,
8631
- });
8632
- if (!order)
8633
- throw notFound("Sales order not found");
8634
- ensureOrderScope(ctx, order.organizationId, order.tenantId);
8635
- await enforceSalesDocumentOptimisticLock(ctx, order, SALES_RESOURCE_KIND_ORDER);
8636
- if (isExternalAmountsMode(order.totalsMode ?? "computed")) await refuseOnExternalOrder();
8637
- if (parsed.scope === "line") {
8638
- throw new CrudHttpError(400, {
8639
- error: "Line-scoped adjustments are not supported yet.",
8640
- });
8641
- }
8642
-
8643
- const [existingLines, existingAdjustments] = await Promise.all([
8644
- em.find(SalesOrderLine, { order }, { orderBy: { lineNumber: "asc" } }),
8645
- em.find(
8646
- SalesOrderAdjustment,
8647
- { order },
8648
- { orderBy: { position: "asc" } },
8649
- ),
8650
- ]);
8651
- const lineSnapshots = existingLines.map(mapOrderLineEntityToSnapshot);
8652
- const adjustmentDrafts = existingAdjustments.map(mapOrderAdjustmentToDraft);
8653
- const existingSnapshot = parsed.id
8654
- ? (adjustmentDrafts.find((adj) => adj.id === parsed.id) ?? null)
8655
- : null;
8656
- const adjustmentId = parsed.id ?? existingSnapshot?.id ?? randomUUID();
8657
- let metadata =
8658
- typeof parsed.metadata === "object" && parsed.metadata
8659
- ? cloneJson(parsed.metadata)
8660
- : existingSnapshot?.metadata
8661
- ? cloneJson(existingSnapshot.metadata)
8662
- : null;
8663
- const calculatorKey =
8664
- parsed.calculatorKey ?? existingSnapshot?.calculatorKey ?? null;
8665
- if (
8666
- parsed.id &&
8667
- calculatorKey &&
8668
- (calculatorKey.startsWith("shipping-provider:") ||
8669
- calculatorKey.startsWith("payment-provider:"))
8670
- ) {
8671
- metadata = { ...(metadata ?? {}), manualOverride: true };
8672
- }
8673
- let nextAdjustments = parsed.id
8674
- ? adjustmentDrafts.map((adj) =>
8675
- adj.id === parsed.id
8676
- ? {
8677
- ...adj,
8678
- id: adjustmentId,
8679
- scope: parsed.scope ?? adj.scope ?? "order",
8680
- kind: parsed.kind ?? adj.kind ?? "custom",
8681
- code: parsed.code ?? adj.code ?? null,
8682
- label: parsed.label ?? adj.label ?? null,
8683
- calculatorKey:
8684
- parsed.calculatorKey ?? adj.calculatorKey ?? null,
8685
- promotionId: parsed.promotionId ?? adj.promotionId ?? null,
8686
- rate: parsed.rate ?? adj.rate ?? null,
8687
- amountNet: parsed.amountNet ?? adj.amountNet ?? null,
8688
- amountGross: parsed.amountGross ?? adj.amountGross ?? null,
8689
- currencyCode:
8690
- parsed.currencyCode ?? adj.currencyCode ?? order.currencyCode,
8691
- metadata,
8692
- customFields:
8693
- parsed.customFields !== undefined
8694
- ? parsed.customFields
8695
- : ((adj as any).customFields ?? null),
8696
- position:
8697
- parsed.position ?? adj.position ?? adjustmentDrafts.length,
8698
- }
8699
- : adj,
8700
- )
8701
- : [
8702
- ...adjustmentDrafts,
8703
- {
8704
- id: adjustmentId,
8705
- scope: parsed.scope ?? "order",
8706
- kind: parsed.kind ?? "custom",
8707
- code: parsed.code ?? null,
8708
- label: parsed.label ?? null,
8709
- calculatorKey: parsed.calculatorKey ?? null,
8710
- promotionId: parsed.promotionId ?? null,
8711
- rate: parsed.rate ?? null,
8712
- amountNet: parsed.amountNet ?? null,
8713
- amountGross: parsed.amountGross ?? null,
8714
- currencyCode: parsed.currencyCode ?? order.currencyCode,
8715
- metadata,
8716
- customFields: parsed.customFields ?? null,
8717
- position: parsed.position ?? adjustmentDrafts.length,
8718
- },
8719
- ];
8720
-
8721
- nextAdjustments = nextAdjustments
8722
- .sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
8723
- .map((adj, index) => ({ ...adj, position: adj.position ?? index }));
8724
-
8725
- const sourceLines = lineSnapshots.map((line, index) => ({
8726
- ...line,
8727
- statusEntryId: (line as any).statusEntryId ?? null,
8728
- catalogSnapshot: (line as any).catalogSnapshot ?? null,
8729
- promotionSnapshot: (line as any).promotionSnapshot ?? null,
8730
- organizationId: order.organizationId,
8731
- tenantId: order.tenantId,
8732
- orderId: order.id,
8733
- lineNumber: line.lineNumber ?? index + 1,
8734
- }));
8735
- const calcLines = sourceLines.map((line, index) =>
8736
- createLineSnapshotFromInput(line, line.lineNumber ?? index + 1),
8737
- );
8738
- const salesCalculationService =
8739
- ctx.container.resolve<SalesCalculationService>("salesCalculationService");
8740
- const calculationContext = buildCalculationContext({
8741
- tenantId: order.tenantId,
8742
- organizationId: order.organizationId,
8743
- currencyCode: order.currencyCode,
8744
- shippingSnapshot: order.shippingMethodSnapshot,
8745
- paymentSnapshot: order.paymentMethodSnapshot,
8746
- shippingMethodId: order.shippingMethodId ?? null,
8747
- paymentMethodId: order.paymentMethodId ?? null,
8748
- shippingMethodCode: order.shippingMethodCode ?? null,
8749
- paymentMethodCode: order.paymentMethodCode ?? null,
8750
- });
8751
- const effectiveAdjustment = nextAdjustments.find(
8752
- (adj) => adj.id === adjustmentId,
8753
- );
8754
- if (effectiveAdjustment?.kind === "return") {
8755
- const baselineAdjustments = nextAdjustments.filter(
8756
- (adj) => adj.id !== adjustmentId,
8757
- );
8758
- const baselineCalculation =
8759
- await salesCalculationService.calculateDocumentTotals({
8760
- documentKind: "order",
8761
- lines: calcLines,
8762
- adjustments: baselineAdjustments,
8763
- context: calculationContext,
8764
- existingTotals: resolveExistingPaymentTotals(order),
8673
+ return writeLockedOrderGraph(input, ctx, parsed.orderId, "Sales order not found", async (em, order) => {
8674
+ if (isExternalAmountsMode(order.totalsMode ?? "computed")) await refuseOnExternalOrder();
8675
+ if (parsed.scope === "line") {
8676
+ throw new CrudHttpError(400, {
8677
+ error: "Line-scoped adjustments are not supported yet.",
8765
8678
  });
8766
- const issues = validateReturnAdjustmentWithinRemaining({
8767
- kind: "return",
8768
- amountNet:
8769
- effectiveAdjustment.amountNet ?? effectiveAdjustment.amountGross ?? 0,
8770
- amountGross:
8771
- effectiveAdjustment.amountGross ?? effectiveAdjustment.amountNet ?? 0,
8772
- remainingNet: Number(
8773
- baselineCalculation.totals?.grandTotalNetAmount ?? 0,
8774
- ),
8775
- remainingGross: Number(
8776
- baselineCalculation.totals?.grandTotalGrossAmount ?? 0,
8679
+ }
8680
+
8681
+ const [existingLines, existingAdjustments] = await Promise.all([
8682
+ em.find(SalesOrderLine, { order }, { orderBy: { lineNumber: "asc" } }),
8683
+ em.find(
8684
+ SalesOrderAdjustment,
8685
+ { order },
8686
+ { orderBy: { position: "asc" } },
8777
8687
  ),
8688
+ ]);
8689
+ const lineSnapshots = existingLines.map(mapOrderLineEntityToSnapshot);
8690
+ const adjustmentDrafts = existingAdjustments.map(mapOrderAdjustmentToDraft);
8691
+ const existingSnapshot = parsed.id
8692
+ ? (adjustmentDrafts.find((adj) => adj.id === parsed.id) ?? null)
8693
+ : null;
8694
+ const adjustmentId = parsed.id ?? existingSnapshot?.id ?? randomUUID();
8695
+ let metadata =
8696
+ typeof parsed.metadata === "object" && parsed.metadata
8697
+ ? cloneJson(parsed.metadata)
8698
+ : existingSnapshot?.metadata
8699
+ ? cloneJson(existingSnapshot.metadata)
8700
+ : null;
8701
+ const calculatorKey =
8702
+ parsed.calculatorKey ?? existingSnapshot?.calculatorKey ?? null;
8703
+ if (
8704
+ parsed.id &&
8705
+ calculatorKey &&
8706
+ (calculatorKey.startsWith("shipping-provider:") ||
8707
+ calculatorKey.startsWith("payment-provider:"))
8708
+ ) {
8709
+ metadata = { ...(metadata ?? {}), manualOverride: true };
8710
+ }
8711
+ let nextAdjustments = parsed.id
8712
+ ? adjustmentDrafts.map((adj) =>
8713
+ adj.id === parsed.id
8714
+ ? {
8715
+ ...adj,
8716
+ id: adjustmentId,
8717
+ scope: parsed.scope ?? adj.scope ?? "order",
8718
+ kind: parsed.kind ?? adj.kind ?? "custom",
8719
+ code: parsed.code ?? adj.code ?? null,
8720
+ label: parsed.label ?? adj.label ?? null,
8721
+ calculatorKey:
8722
+ parsed.calculatorKey ?? adj.calculatorKey ?? null,
8723
+ promotionId: parsed.promotionId ?? adj.promotionId ?? null,
8724
+ rate: parsed.rate ?? adj.rate ?? null,
8725
+ amountNet: parsed.amountNet ?? adj.amountNet ?? null,
8726
+ amountGross: parsed.amountGross ?? adj.amountGross ?? null,
8727
+ currencyCode:
8728
+ parsed.currencyCode ?? adj.currencyCode ?? order.currencyCode,
8729
+ metadata,
8730
+ customFields:
8731
+ parsed.customFields !== undefined
8732
+ ? parsed.customFields
8733
+ : ((adj as any).customFields ?? null),
8734
+ position:
8735
+ parsed.position ?? adj.position ?? adjustmentDrafts.length,
8736
+ }
8737
+ : adj,
8738
+ )
8739
+ : [
8740
+ ...adjustmentDrafts,
8741
+ {
8742
+ id: adjustmentId,
8743
+ scope: parsed.scope ?? "order",
8744
+ kind: parsed.kind ?? "custom",
8745
+ code: parsed.code ?? null,
8746
+ label: parsed.label ?? null,
8747
+ calculatorKey: parsed.calculatorKey ?? null,
8748
+ promotionId: parsed.promotionId ?? null,
8749
+ rate: parsed.rate ?? null,
8750
+ amountNet: parsed.amountNet ?? null,
8751
+ amountGross: parsed.amountGross ?? null,
8752
+ currencyCode: parsed.currencyCode ?? order.currencyCode,
8753
+ metadata,
8754
+ customFields: parsed.customFields ?? null,
8755
+ position: parsed.position ?? adjustmentDrafts.length,
8756
+ },
8757
+ ];
8758
+
8759
+ nextAdjustments = nextAdjustments
8760
+ .sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
8761
+ .map((adj, index) => ({ ...adj, position: adj.position ?? index }));
8762
+
8763
+ const sourceLines = lineSnapshots.map((line, index) => ({
8764
+ ...line,
8765
+ statusEntryId: (line as any).statusEntryId ?? null,
8766
+ catalogSnapshot: (line as any).catalogSnapshot ?? null,
8767
+ promotionSnapshot: (line as any).promotionSnapshot ?? null,
8768
+ organizationId: order.organizationId,
8769
+ tenantId: order.tenantId,
8770
+ orderId: order.id,
8771
+ lineNumber: line.lineNumber ?? index + 1,
8772
+ }));
8773
+ const calcLines = sourceLines.map((line, index) =>
8774
+ createLineSnapshotFromInput(line, line.lineNumber ?? index + 1),
8775
+ );
8776
+ const salesCalculationService =
8777
+ ctx.container.resolve<SalesCalculationService>("salesCalculationService");
8778
+ const calculationContext = buildCalculationContext({
8779
+ tenantId: order.tenantId,
8780
+ organizationId: order.organizationId,
8781
+ currencyCode: order.currencyCode,
8782
+ shippingSnapshot: order.shippingMethodSnapshot,
8783
+ paymentSnapshot: order.paymentMethodSnapshot,
8784
+ shippingMethodId: order.shippingMethodId ?? null,
8785
+ paymentMethodId: order.paymentMethodId ?? null,
8786
+ shippingMethodCode: order.shippingMethodCode ?? null,
8787
+ paymentMethodCode: order.paymentMethodCode ?? null,
8778
8788
  });
8779
- if (issues.length > 0) {
8780
- const { translate } = await resolveTranslations();
8781
- const grossIssue = issues.find((issue) => issue.path === "amountGross");
8782
- const netIssue = issues.find((issue) => issue.path === "amountNet");
8783
- const fieldErrors: Record<string, string> = {};
8784
- if (grossIssue) {
8785
- fieldErrors.amountGross = translate(
8786
- "sales.adjustments.error.returnExceedsRemainingGrandTotalGross",
8787
- RETURN_ADJUSTMENT_EXCEEDS_REMAINING_GROSS_MESSAGE,
8788
- );
8789
- }
8790
- if (netIssue) {
8791
- fieldErrors.amountNet = translate(
8792
- "sales.adjustments.error.returnExceedsRemainingGrandTotalNet",
8793
- RETURN_ADJUSTMENT_EXCEEDS_REMAINING_NET_MESSAGE,
8794
- );
8795
- }
8796
- throw new CrudHttpError(400, {
8797
- error:
8798
- fieldErrors.amountGross ??
8799
- fieldErrors.amountNet ??
8800
- translate(
8789
+ const effectiveAdjustment = nextAdjustments.find(
8790
+ (adj) => adj.id === adjustmentId,
8791
+ );
8792
+ if (effectiveAdjustment?.kind === "return") {
8793
+ const baselineAdjustments = nextAdjustments.filter(
8794
+ (adj) => adj.id !== adjustmentId,
8795
+ );
8796
+ const baselineCalculation =
8797
+ await salesCalculationService.calculateDocumentTotals({
8798
+ documentKind: "order",
8799
+ lines: calcLines,
8800
+ adjustments: baselineAdjustments,
8801
+ context: calculationContext,
8802
+ existingTotals: resolveExistingPaymentTotals(order),
8803
+ });
8804
+ const issues = validateReturnAdjustmentWithinRemaining({
8805
+ kind: "return",
8806
+ amountNet:
8807
+ effectiveAdjustment.amountNet ?? effectiveAdjustment.amountGross ?? 0,
8808
+ amountGross:
8809
+ effectiveAdjustment.amountGross ?? effectiveAdjustment.amountNet ?? 0,
8810
+ remainingNet: Number(
8811
+ baselineCalculation.totals?.grandTotalNetAmount ?? 0,
8812
+ ),
8813
+ remainingGross: Number(
8814
+ baselineCalculation.totals?.grandTotalGrossAmount ?? 0,
8815
+ ),
8816
+ });
8817
+ if (issues.length > 0) {
8818
+ const { translate } = await resolveTranslations();
8819
+ const grossIssue = issues.find((issue) => issue.path === "amountGross");
8820
+ const netIssue = issues.find((issue) => issue.path === "amountNet");
8821
+ const fieldErrors: Record<string, string> = {};
8822
+ if (grossIssue) {
8823
+ fieldErrors.amountGross = translate(
8801
8824
  "sales.adjustments.error.returnExceedsRemainingGrandTotalGross",
8802
8825
  RETURN_ADJUSTMENT_EXCEEDS_REMAINING_GROSS_MESSAGE,
8803
- ),
8804
- fieldErrors,
8805
- });
8826
+ );
8827
+ }
8828
+ if (netIssue) {
8829
+ fieldErrors.amountNet = translate(
8830
+ "sales.adjustments.error.returnExceedsRemainingGrandTotalNet",
8831
+ RETURN_ADJUSTMENT_EXCEEDS_REMAINING_NET_MESSAGE,
8832
+ );
8833
+ }
8834
+ throw new CrudHttpError(400, {
8835
+ error:
8836
+ fieldErrors.amountGross ??
8837
+ fieldErrors.amountNet ??
8838
+ translate(
8839
+ "sales.adjustments.error.returnExceedsRemainingGrandTotalGross",
8840
+ RETURN_ADJUSTMENT_EXCEEDS_REMAINING_GROSS_MESSAGE,
8841
+ ),
8842
+ fieldErrors,
8843
+ });
8844
+ }
8806
8845
  }
8807
- }
8808
- const calculation = await salesCalculationService.calculateDocumentTotals({
8809
- documentKind: "order",
8810
- lines: calcLines,
8811
- adjustments: nextAdjustments,
8812
- context: calculationContext,
8813
- existingTotals: resolveExistingPaymentTotals(order),
8846
+ const calculation = await salesCalculationService.calculateDocumentTotals({
8847
+ documentKind: "order",
8848
+ lines: calcLines,
8849
+ adjustments: nextAdjustments,
8850
+ context: calculationContext,
8851
+ existingTotals: resolveExistingPaymentTotals(order),
8852
+ });
8853
+ const adjustmentInputs = nextAdjustments.map((adj, index) => ({
8854
+ organizationId: order.organizationId,
8855
+ tenantId: order.tenantId,
8856
+ orderId: order.id,
8857
+ scope: adj.scope ?? "order",
8858
+ kind: adj.kind ?? "custom",
8859
+ code: adj.code ?? undefined,
8860
+ label: adj.label ?? undefined,
8861
+ calculatorKey: adj.calculatorKey ?? undefined,
8862
+ promotionId: adj.promotionId ?? undefined,
8863
+ rate: adj.rate ?? undefined,
8864
+ amountNet: adj.amountNet ?? undefined,
8865
+ amountGross: adj.amountGross ?? undefined,
8866
+ currencyCode: adj.currencyCode ?? order.currencyCode,
8867
+ metadata: adj.metadata ?? undefined,
8868
+ customFields: (adj as any).customFields ?? undefined,
8869
+ position: adj.position ?? index,
8870
+ }));
8871
+ await replaceOrderAdjustments(em, order, calculation, adjustmentInputs);
8872
+ applyOrderTotals(order, calculation.totals, calculation.lines.length);
8873
+ order.updatedAt = new Date();
8874
+ return { result: { orderId: order.id, adjustmentId }, calculation };
8814
8875
  });
8815
- const adjustmentInputs = nextAdjustments.map((adj, index) => ({
8816
- organizationId: order.organizationId,
8817
- tenantId: order.tenantId,
8818
- orderId: order.id,
8819
- scope: adj.scope ?? "order",
8820
- kind: adj.kind ?? "custom",
8821
- code: adj.code ?? undefined,
8822
- label: adj.label ?? undefined,
8823
- calculatorKey: adj.calculatorKey ?? undefined,
8824
- promotionId: adj.promotionId ?? undefined,
8825
- rate: adj.rate ?? undefined,
8826
- amountNet: adj.amountNet ?? undefined,
8827
- amountGross: adj.amountGross ?? undefined,
8828
- currencyCode: adj.currencyCode ?? order.currencyCode,
8829
- metadata: adj.metadata ?? undefined,
8830
- customFields: (adj as any).customFields ?? undefined,
8831
- position: adj.position ?? index,
8832
- }));
8833
- let eventBus: EventBus | null = null;
8834
- try {
8835
- eventBus = ctx.container.resolve("eventBus") as EventBus;
8836
- } catch {
8837
- eventBus = null;
8838
- }
8839
- // Persist the adjustment change and recalculated totals atomically so a
8840
- // mid-build failure cannot leave a half-updated order committed (#2336).
8841
- await withAtomicFlush(
8842
- em,
8843
- [
8844
- async () => {
8845
- await replaceOrderAdjustments(em, order, calculation, adjustmentInputs);
8846
- applyOrderTotals(order, calculation.totals, calculation.lines.length);
8847
- order.updatedAt = new Date();
8848
- await emitTotalsCalculated(eventBus, {
8849
- documentKind: "order",
8850
- documentId: order.id,
8851
- organizationId: order.organizationId,
8852
- tenantId: order.tenantId,
8853
- customerId: order.customerEntityId ?? null,
8854
- totals: calculation.totals,
8855
- lineCount: calculation.lines.length,
8856
- });
8857
- },
8858
- ],
8859
- { transaction: true },
8860
- );
8861
- return { orderId: order.id, adjustmentId };
8862
- },
8863
- captureAfter: async (_input, result, ctx) => {
8864
- const em = (ctx.container.resolve("em") as EntityManager).fork();
8865
- return loadOrderSnapshot(em, result.orderId);
8866
8876
  },
8877
+ captureAfter: async (_input, result, ctx) =>
8878
+ captureLockedOrderGraphAfter(ctx, result, result.orderId),
8867
8879
  buildLog: async ({ snapshots, result }) => {
8868
- const before = snapshots.before as OrderGraphSnapshot | undefined;
8880
+ const before = resolveLockedOrderGraphBefore(result, snapshots.before);
8869
8881
  const after = snapshots.after as OrderGraphSnapshot | undefined;
8870
8882
  if (!after) return null;
8871
8883
  const { translate } = await resolveTranslations();
@@ -8898,137 +8910,91 @@ const orderAdjustmentDeleteCommand: CommandHandler<
8898
8910
  { orderId: string; adjustmentId: string }
8899
8911
  > = {
8900
8912
  id: "sales.orders.adjustments.delete",
8901
- async prepare(input, ctx) {
8902
- const raw = (input?.body as Record<string, unknown> | undefined) ?? {};
8903
- const orderId = typeof raw.orderId === "string" ? raw.orderId : null;
8904
- if (!orderId) return {};
8905
- const em = ctx.container.resolve("em") as EntityManager;
8906
- const snapshot = await loadOrderSnapshot(em, orderId);
8907
- if (snapshot)
8908
- ensureOrderScope(
8909
- ctx,
8910
- snapshot.order.organizationId,
8911
- snapshot.order.tenantId,
8912
- );
8913
- return snapshot ? { before: snapshot } : {};
8913
+ async prepare(input) {
8914
+ return markLockedGraphWrite(input);
8914
8915
  },
8915
8916
  async execute(input, ctx) {
8916
8917
  const parsed = orderAdjustmentDeleteSchema.parse(
8917
8918
  (input?.body as Record<string, unknown> | undefined) ?? {},
8918
8919
  );
8919
- const em = (ctx.container.resolve("em") as EntityManager).fork();
8920
- const order = await findOneWithDecryption(em, SalesOrder, {
8921
- id: parsed.orderId,
8922
- deletedAt: null,
8923
- });
8924
- if (!order)
8925
- throw notFound("Sales order not found");
8926
- ensureOrderScope(ctx, order.organizationId, order.tenantId);
8927
- await enforceSalesDocumentOptimisticLock(ctx, order, SALES_RESOURCE_KIND_ORDER);
8928
-
8929
- if (isExternalAmountsMode(order.totalsMode ?? "computed")) await refuseOnExternalOrder();
8930
- const [existingLines, adjustments] = await Promise.all([
8931
- em.find(SalesOrderLine, { order }, { orderBy: { lineNumber: "asc" } }),
8932
- em.find(
8933
- SalesOrderAdjustment,
8934
- { order },
8935
- { orderBy: { position: "asc" } },
8936
- ),
8937
- ]);
8938
- const filtered = adjustments.filter((adj) => adj.id !== parsed.id);
8939
- if (filtered.length === adjustments.length) {
8940
- throw notFound("Adjustment not found");
8941
- }
8942
- const lineSnapshots = existingLines.map(mapOrderLineEntityToSnapshot);
8943
- const calcLines = lineSnapshots.map((line, index) =>
8944
- createLineSnapshotFromInput(
8945
- {
8946
- ...line,
8947
- organizationId: order.organizationId,
8948
- tenantId: order.tenantId,
8949
- orderId: order.id,
8950
- lineNumber: line.lineNumber ?? index + 1,
8951
- statusEntryId: (line as any).statusEntryId ?? null,
8952
- catalogSnapshot: (line as any).catalogSnapshot ?? null,
8953
- promotionSnapshot: (line as any).promotionSnapshot ?? null,
8954
- },
8955
- line.lineNumber ?? index + 1,
8956
- ),
8957
- );
8958
- const adjustmentDrafts = filtered.map(mapOrderAdjustmentToDraft);
8959
- const salesCalculationService =
8960
- ctx.container.resolve<SalesCalculationService>("salesCalculationService");
8961
- const calculationContext = buildCalculationContext({
8962
- tenantId: order.tenantId,
8963
- organizationId: order.organizationId,
8964
- currencyCode: order.currencyCode,
8965
- shippingSnapshot: order.shippingMethodSnapshot,
8966
- paymentSnapshot: order.paymentMethodSnapshot,
8967
- shippingMethodId: order.shippingMethodId ?? null,
8968
- paymentMethodId: order.paymentMethodId ?? null,
8969
- shippingMethodCode: order.shippingMethodCode ?? null,
8970
- paymentMethodCode: order.paymentMethodCode ?? null,
8971
- });
8972
- const calculation = await salesCalculationService.calculateDocumentTotals({
8973
- documentKind: "order",
8974
- lines: calcLines,
8975
- adjustments: adjustmentDrafts,
8976
- context: calculationContext,
8977
- existingTotals: resolveExistingPaymentTotals(order),
8978
- });
8979
- const adjustmentInputs = adjustmentDrafts.map((adj, index) => ({
8980
- organizationId: order.organizationId,
8981
- tenantId: order.tenantId,
8982
- orderId: order.id,
8983
- scope: adj.scope ?? "order",
8984
- kind: adj.kind ?? "custom",
8985
- code: adj.code ?? undefined,
8986
- label: adj.label ?? undefined,
8987
- calculatorKey: adj.calculatorKey ?? undefined,
8988
- promotionId: adj.promotionId ?? undefined,
8989
- rate: adj.rate ?? undefined,
8990
- amountNet: adj.amountNet ?? undefined,
8991
- amountGross: adj.amountGross ?? undefined,
8992
- currencyCode: adj.currencyCode ?? order.currencyCode,
8993
- metadata: adj.metadata ?? undefined,
8994
- position: adj.position ?? index,
8995
- }));
8996
- let eventBus: EventBus | null = null;
8997
- try {
8998
- eventBus = ctx.container.resolve("eventBus") as EventBus;
8999
- } catch {
9000
- eventBus = null;
9001
- }
9002
- // Persist the adjustment removal and recalculated totals atomically so a
9003
- // mid-build failure cannot leave a half-updated order committed (#2336).
9004
- await withAtomicFlush(
9005
- em,
9006
- [
9007
- async () => {
9008
- await replaceOrderAdjustments(em, order, calculation, adjustmentInputs);
9009
- applyOrderTotals(order, calculation.totals, calculation.lines.length);
9010
- order.updatedAt = new Date();
9011
- await emitTotalsCalculated(eventBus, {
9012
- documentKind: "order",
9013
- documentId: order.id,
9014
- organizationId: order.organizationId,
9015
- tenantId: order.tenantId,
9016
- customerId: order.customerEntityId ?? null,
9017
- totals: calculation.totals,
9018
- lineCount: calculation.lines.length,
9019
- });
9020
- },
9021
- ],
9022
- { transaction: true },
9023
- );
9024
- return { orderId: order.id, adjustmentId: parsed.id };
9025
- },
9026
- captureAfter: async (_input, result, ctx) => {
9027
- const em = (ctx.container.resolve("em") as EntityManager).fork();
9028
- return loadOrderSnapshot(em, result.orderId);
8920
+ return writeLockedOrderGraph(input, ctx, parsed.orderId, "Sales order not found", async (em, order) => {
8921
+ if (isExternalAmountsMode(order.totalsMode ?? "computed")) await refuseOnExternalOrder();
8922
+ const [existingLines, adjustments] = await Promise.all([
8923
+ em.find(SalesOrderLine, { order }, { orderBy: { lineNumber: "asc" } }),
8924
+ em.find(
8925
+ SalesOrderAdjustment,
8926
+ { order },
8927
+ { orderBy: { position: "asc" } },
8928
+ ),
8929
+ ]);
8930
+ const filtered = adjustments.filter((adj) => adj.id !== parsed.id);
8931
+ if (filtered.length === adjustments.length) {
8932
+ throw notFound("Adjustment not found");
8933
+ }
8934
+ const lineSnapshots = existingLines.map(mapOrderLineEntityToSnapshot);
8935
+ const calcLines = lineSnapshots.map((line, index) =>
8936
+ createLineSnapshotFromInput(
8937
+ {
8938
+ ...line,
8939
+ organizationId: order.organizationId,
8940
+ tenantId: order.tenantId,
8941
+ orderId: order.id,
8942
+ lineNumber: line.lineNumber ?? index + 1,
8943
+ statusEntryId: (line as any).statusEntryId ?? null,
8944
+ catalogSnapshot: (line as any).catalogSnapshot ?? null,
8945
+ promotionSnapshot: (line as any).promotionSnapshot ?? null,
8946
+ },
8947
+ line.lineNumber ?? index + 1,
8948
+ ),
8949
+ );
8950
+ const adjustmentDrafts = filtered.map(mapOrderAdjustmentToDraft);
8951
+ const salesCalculationService =
8952
+ ctx.container.resolve<SalesCalculationService>("salesCalculationService");
8953
+ const calculationContext = buildCalculationContext({
8954
+ tenantId: order.tenantId,
8955
+ organizationId: order.organizationId,
8956
+ currencyCode: order.currencyCode,
8957
+ shippingSnapshot: order.shippingMethodSnapshot,
8958
+ paymentSnapshot: order.paymentMethodSnapshot,
8959
+ shippingMethodId: order.shippingMethodId ?? null,
8960
+ paymentMethodId: order.paymentMethodId ?? null,
8961
+ shippingMethodCode: order.shippingMethodCode ?? null,
8962
+ paymentMethodCode: order.paymentMethodCode ?? null,
8963
+ });
8964
+ const calculation = await salesCalculationService.calculateDocumentTotals({
8965
+ documentKind: "order",
8966
+ lines: calcLines,
8967
+ adjustments: adjustmentDrafts,
8968
+ context: calculationContext,
8969
+ existingTotals: resolveExistingPaymentTotals(order),
8970
+ });
8971
+ const adjustmentInputs = adjustmentDrafts.map((adj, index) => ({
8972
+ organizationId: order.organizationId,
8973
+ tenantId: order.tenantId,
8974
+ orderId: order.id,
8975
+ scope: adj.scope ?? "order",
8976
+ kind: adj.kind ?? "custom",
8977
+ code: adj.code ?? undefined,
8978
+ label: adj.label ?? undefined,
8979
+ calculatorKey: adj.calculatorKey ?? undefined,
8980
+ promotionId: adj.promotionId ?? undefined,
8981
+ rate: adj.rate ?? undefined,
8982
+ amountNet: adj.amountNet ?? undefined,
8983
+ amountGross: adj.amountGross ?? undefined,
8984
+ currencyCode: adj.currencyCode ?? order.currencyCode,
8985
+ metadata: adj.metadata ?? undefined,
8986
+ position: adj.position ?? index,
8987
+ }));
8988
+ await replaceOrderAdjustments(em, order, calculation, adjustmentInputs);
8989
+ applyOrderTotals(order, calculation.totals, calculation.lines.length);
8990
+ order.updatedAt = new Date();
8991
+ return { result: { orderId: order.id, adjustmentId: parsed.id }, calculation };
8992
+ });
9029
8993
  },
8994
+ captureAfter: async (_input, result, ctx) =>
8995
+ captureLockedOrderGraphAfter(ctx, result, result.orderId),
9030
8996
  buildLog: async ({ snapshots, result }) => {
9031
- const before = snapshots.before as OrderGraphSnapshot | undefined;
8997
+ const before = resolveLockedOrderGraphBefore(result, snapshots.before);
9032
8998
  const after = snapshots.after as OrderGraphSnapshot | undefined;
9033
8999
  if (!after) return null;
9034
9000
  const { translate } = await resolveTranslations();
@@ -9061,263 +9027,218 @@ const quoteAdjustmentUpsertCommand: CommandHandler<
9061
9027
  { quoteId: string; adjustmentId: string }
9062
9028
  > = {
9063
9029
  id: "sales.quotes.adjustments.upsert",
9064
- async prepare(input, ctx) {
9065
- const raw = (input?.body as Record<string, unknown> | undefined) ?? {};
9066
- const quoteId = typeof raw.quoteId === "string" ? raw.quoteId : null;
9067
- if (!quoteId) return {};
9068
- const em = ctx.container.resolve("em") as EntityManager;
9069
- const snapshot = await loadQuoteSnapshot(em, quoteId);
9070
- if (snapshot)
9071
- ensureQuoteScope(
9072
- ctx,
9073
- snapshot.quote.organizationId,
9074
- snapshot.quote.tenantId,
9075
- );
9076
- return snapshot ? { before: snapshot } : {};
9030
+ async prepare(input) {
9031
+ return markLockedGraphWrite(input);
9077
9032
  },
9078
9033
  async execute(input, ctx) {
9079
9034
  const parsed = quoteAdjustmentUpsertSchema.parse(
9080
9035
  (input?.body as Record<string, unknown> | undefined) ?? {},
9081
9036
  );
9082
- const em = (ctx.container.resolve("em") as EntityManager).fork();
9083
- const quote = await findOneWithDecryption(em, SalesQuote, {
9084
- id: parsed.quoteId,
9085
- deletedAt: null,
9086
- });
9087
- if (!quote)
9088
- throw notFound("Sales quote not found");
9089
- ensureQuoteScope(ctx, quote.organizationId, quote.tenantId);
9090
- await enforceSalesDocumentOptimisticLock(ctx, quote, SALES_RESOURCE_KIND_QUOTE);
9091
- if (parsed.scope === "line") {
9092
- throw new CrudHttpError(400, {
9093
- error: "Line-scoped adjustments are not supported yet.",
9094
- });
9095
- }
9096
-
9097
- const [existingLines, existingAdjustments] = await Promise.all([
9098
- em.find(SalesQuoteLine, { quote }, { orderBy: { lineNumber: "asc" } }),
9099
- em.find(
9100
- SalesQuoteAdjustment,
9101
- { quote },
9102
- { orderBy: { position: "asc" } },
9103
- ),
9104
- ]);
9105
- const lineSnapshots = existingLines.map(mapQuoteLineEntityToSnapshot);
9106
- const adjustmentDrafts = existingAdjustments.map(mapQuoteAdjustmentToDraft);
9107
- const existingSnapshot = parsed.id
9108
- ? (adjustmentDrafts.find((adj) => adj.id === parsed.id) ?? null)
9109
- : null;
9110
- const adjustmentId = parsed.id ?? existingSnapshot?.id ?? randomUUID();
9111
- let metadata =
9112
- typeof parsed.metadata === "object" && parsed.metadata
9113
- ? cloneJson(parsed.metadata)
9114
- : existingSnapshot?.metadata
9115
- ? cloneJson(existingSnapshot.metadata)
9116
- : null;
9117
- const calculatorKey =
9118
- parsed.calculatorKey ?? existingSnapshot?.calculatorKey ?? null;
9119
- if (
9120
- parsed.id &&
9121
- calculatorKey &&
9122
- (calculatorKey.startsWith("shipping-provider:") ||
9123
- calculatorKey.startsWith("payment-provider:"))
9124
- ) {
9125
- metadata = { ...(metadata ?? {}), manualOverride: true };
9126
- }
9127
- let nextAdjustments = parsed.id
9128
- ? adjustmentDrafts.map((adj) =>
9129
- adj.id === parsed.id
9130
- ? {
9131
- ...adj,
9132
- id: adjustmentId,
9133
- scope: parsed.scope ?? adj.scope ?? "order",
9134
- kind: parsed.kind ?? adj.kind ?? "custom",
9135
- code: parsed.code ?? adj.code ?? null,
9136
- label: parsed.label ?? adj.label ?? null,
9137
- calculatorKey:
9138
- parsed.calculatorKey ?? adj.calculatorKey ?? null,
9139
- promotionId: parsed.promotionId ?? adj.promotionId ?? null,
9140
- rate: parsed.rate ?? adj.rate ?? null,
9141
- amountNet: parsed.amountNet ?? adj.amountNet ?? null,
9142
- amountGross: parsed.amountGross ?? adj.amountGross ?? null,
9143
- currencyCode:
9144
- parsed.currencyCode ?? adj.currencyCode ?? quote.currencyCode,
9145
- metadata,
9146
- customFields:
9147
- parsed.customFields !== undefined
9148
- ? parsed.customFields
9149
- : ((adj as any).customFields ?? null),
9150
- position:
9151
- parsed.position ?? adj.position ?? adjustmentDrafts.length,
9152
- }
9153
- : adj,
9154
- )
9155
- : [
9156
- ...adjustmentDrafts,
9157
- {
9158
- id: adjustmentId,
9159
- scope: parsed.scope ?? "order",
9160
- kind: parsed.kind ?? "custom",
9161
- code: parsed.code ?? null,
9162
- label: parsed.label ?? null,
9163
- calculatorKey: parsed.calculatorKey ?? null,
9164
- promotionId: parsed.promotionId ?? null,
9165
- rate: parsed.rate ?? null,
9166
- amountNet: parsed.amountNet ?? null,
9167
- amountGross: parsed.amountGross ?? null,
9168
- currencyCode: parsed.currencyCode ?? quote.currencyCode,
9169
- metadata,
9170
- customFields: parsed.customFields ?? null,
9171
- position: parsed.position ?? adjustmentDrafts.length,
9172
- },
9173
- ];
9174
-
9175
- nextAdjustments = nextAdjustments
9176
- .sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
9177
- .map((adj, index) => ({ ...adj, position: adj.position ?? index }));
9178
-
9179
- const sourceLines = lineSnapshots.map((line, index) => ({
9180
- ...line,
9181
- statusEntryId: (line as any).statusEntryId ?? null,
9182
- catalogSnapshot: (line as any).catalogSnapshot ?? null,
9183
- promotionSnapshot: (line as any).promotionSnapshot ?? null,
9184
- organizationId: quote.organizationId,
9185
- tenantId: quote.tenantId,
9186
- quoteId: quote.id,
9187
- lineNumber: line.lineNumber ?? index + 1,
9188
- }));
9189
- const calcLines = sourceLines.map((line, index) =>
9190
- createLineSnapshotFromInput(line, line.lineNumber ?? index + 1),
9191
- );
9192
- const salesCalculationService =
9193
- ctx.container.resolve<SalesCalculationService>("salesCalculationService");
9194
- const calculationContext = buildCalculationContext({
9195
- tenantId: quote.tenantId,
9196
- organizationId: quote.organizationId,
9197
- currencyCode: quote.currencyCode,
9198
- shippingSnapshot: quote.shippingMethodSnapshot,
9199
- paymentSnapshot: quote.paymentMethodSnapshot,
9200
- shippingMethodId: quote.shippingMethodId ?? null,
9201
- paymentMethodId: quote.paymentMethodId ?? null,
9202
- shippingMethodCode: quote.shippingMethodCode ?? null,
9203
- paymentMethodCode: quote.paymentMethodCode ?? null,
9204
- });
9205
- const effectiveAdjustment = nextAdjustments.find(
9206
- (adj) => adj.id === adjustmentId,
9207
- );
9208
- if (effectiveAdjustment?.kind === "return") {
9209
- const baselineAdjustments = nextAdjustments.filter(
9210
- (adj) => adj.id !== adjustmentId,
9211
- );
9212
- const baselineCalculation =
9213
- await salesCalculationService.calculateDocumentTotals({
9214
- documentKind: "quote",
9215
- lines: calcLines,
9216
- adjustments: baselineAdjustments,
9217
- context: calculationContext,
9037
+ return writeLockedQuoteGraph(input, ctx, parsed.quoteId, "Sales quote not found", async (em, quote) => {
9038
+ if (parsed.scope === "line") {
9039
+ throw new CrudHttpError(400, {
9040
+ error: "Line-scoped adjustments are not supported yet.",
9218
9041
  });
9219
- const issues = validateReturnAdjustmentWithinRemaining({
9220
- kind: "return",
9221
- amountNet:
9222
- effectiveAdjustment.amountNet ?? effectiveAdjustment.amountGross ?? 0,
9223
- amountGross:
9224
- effectiveAdjustment.amountGross ?? effectiveAdjustment.amountNet ?? 0,
9225
- remainingNet: Number(
9226
- baselineCalculation.totals?.grandTotalNetAmount ?? 0,
9227
- ),
9228
- remainingGross: Number(
9229
- baselineCalculation.totals?.grandTotalGrossAmount ?? 0,
9042
+ }
9043
+
9044
+ const [existingLines, existingAdjustments] = await Promise.all([
9045
+ em.find(SalesQuoteLine, { quote }, { orderBy: { lineNumber: "asc" } }),
9046
+ em.find(
9047
+ SalesQuoteAdjustment,
9048
+ { quote },
9049
+ { orderBy: { position: "asc" } },
9230
9050
  ),
9051
+ ]);
9052
+ const lineSnapshots = existingLines.map(mapQuoteLineEntityToSnapshot);
9053
+ const adjustmentDrafts = existingAdjustments.map(mapQuoteAdjustmentToDraft);
9054
+ const existingSnapshot = parsed.id
9055
+ ? (adjustmentDrafts.find((adj) => adj.id === parsed.id) ?? null)
9056
+ : null;
9057
+ const adjustmentId = parsed.id ?? existingSnapshot?.id ?? randomUUID();
9058
+ let metadata =
9059
+ typeof parsed.metadata === "object" && parsed.metadata
9060
+ ? cloneJson(parsed.metadata)
9061
+ : existingSnapshot?.metadata
9062
+ ? cloneJson(existingSnapshot.metadata)
9063
+ : null;
9064
+ const calculatorKey =
9065
+ parsed.calculatorKey ?? existingSnapshot?.calculatorKey ?? null;
9066
+ if (
9067
+ parsed.id &&
9068
+ calculatorKey &&
9069
+ (calculatorKey.startsWith("shipping-provider:") ||
9070
+ calculatorKey.startsWith("payment-provider:"))
9071
+ ) {
9072
+ metadata = { ...(metadata ?? {}), manualOverride: true };
9073
+ }
9074
+ let nextAdjustments = parsed.id
9075
+ ? adjustmentDrafts.map((adj) =>
9076
+ adj.id === parsed.id
9077
+ ? {
9078
+ ...adj,
9079
+ id: adjustmentId,
9080
+ scope: parsed.scope ?? adj.scope ?? "order",
9081
+ kind: parsed.kind ?? adj.kind ?? "custom",
9082
+ code: parsed.code ?? adj.code ?? null,
9083
+ label: parsed.label ?? adj.label ?? null,
9084
+ calculatorKey:
9085
+ parsed.calculatorKey ?? adj.calculatorKey ?? null,
9086
+ promotionId: parsed.promotionId ?? adj.promotionId ?? null,
9087
+ rate: parsed.rate ?? adj.rate ?? null,
9088
+ amountNet: parsed.amountNet ?? adj.amountNet ?? null,
9089
+ amountGross: parsed.amountGross ?? adj.amountGross ?? null,
9090
+ currencyCode:
9091
+ parsed.currencyCode ?? adj.currencyCode ?? quote.currencyCode,
9092
+ metadata,
9093
+ customFields:
9094
+ parsed.customFields !== undefined
9095
+ ? parsed.customFields
9096
+ : ((adj as any).customFields ?? null),
9097
+ position:
9098
+ parsed.position ?? adj.position ?? adjustmentDrafts.length,
9099
+ }
9100
+ : adj,
9101
+ )
9102
+ : [
9103
+ ...adjustmentDrafts,
9104
+ {
9105
+ id: adjustmentId,
9106
+ scope: parsed.scope ?? "order",
9107
+ kind: parsed.kind ?? "custom",
9108
+ code: parsed.code ?? null,
9109
+ label: parsed.label ?? null,
9110
+ calculatorKey: parsed.calculatorKey ?? null,
9111
+ promotionId: parsed.promotionId ?? null,
9112
+ rate: parsed.rate ?? null,
9113
+ amountNet: parsed.amountNet ?? null,
9114
+ amountGross: parsed.amountGross ?? null,
9115
+ currencyCode: parsed.currencyCode ?? quote.currencyCode,
9116
+ metadata,
9117
+ customFields: parsed.customFields ?? null,
9118
+ position: parsed.position ?? adjustmentDrafts.length,
9119
+ },
9120
+ ];
9121
+
9122
+ nextAdjustments = nextAdjustments
9123
+ .sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
9124
+ .map((adj, index) => ({ ...adj, position: adj.position ?? index }));
9125
+
9126
+ const sourceLines = lineSnapshots.map((line, index) => ({
9127
+ ...line,
9128
+ statusEntryId: (line as any).statusEntryId ?? null,
9129
+ catalogSnapshot: (line as any).catalogSnapshot ?? null,
9130
+ promotionSnapshot: (line as any).promotionSnapshot ?? null,
9131
+ organizationId: quote.organizationId,
9132
+ tenantId: quote.tenantId,
9133
+ quoteId: quote.id,
9134
+ lineNumber: line.lineNumber ?? index + 1,
9135
+ }));
9136
+ const calcLines = sourceLines.map((line, index) =>
9137
+ createLineSnapshotFromInput(line, line.lineNumber ?? index + 1),
9138
+ );
9139
+ const salesCalculationService =
9140
+ ctx.container.resolve<SalesCalculationService>("salesCalculationService");
9141
+ const calculationContext = buildCalculationContext({
9142
+ tenantId: quote.tenantId,
9143
+ organizationId: quote.organizationId,
9144
+ currencyCode: quote.currencyCode,
9145
+ shippingSnapshot: quote.shippingMethodSnapshot,
9146
+ paymentSnapshot: quote.paymentMethodSnapshot,
9147
+ shippingMethodId: quote.shippingMethodId ?? null,
9148
+ paymentMethodId: quote.paymentMethodId ?? null,
9149
+ shippingMethodCode: quote.shippingMethodCode ?? null,
9150
+ paymentMethodCode: quote.paymentMethodCode ?? null,
9231
9151
  });
9232
- if (issues.length > 0) {
9233
- const { translate } = await resolveTranslations();
9234
- const grossIssue = issues.find((issue) => issue.path === "amountGross");
9235
- const netIssue = issues.find((issue) => issue.path === "amountNet");
9236
- const fieldErrors: Record<string, string> = {};
9237
- if (grossIssue) {
9238
- fieldErrors.amountGross = translate(
9239
- "sales.adjustments.error.returnExceedsRemainingGrandTotalGross",
9240
- RETURN_ADJUSTMENT_EXCEEDS_REMAINING_GROSS_MESSAGE,
9241
- );
9242
- }
9243
- if (netIssue) {
9244
- fieldErrors.amountNet = translate(
9245
- "sales.adjustments.error.returnExceedsRemainingGrandTotalNet",
9246
- RETURN_ADJUSTMENT_EXCEEDS_REMAINING_NET_MESSAGE,
9247
- );
9248
- }
9249
- throw new CrudHttpError(400, {
9250
- error:
9251
- fieldErrors.amountGross ??
9252
- fieldErrors.amountNet ??
9253
- translate(
9152
+ const effectiveAdjustment = nextAdjustments.find(
9153
+ (adj) => adj.id === adjustmentId,
9154
+ );
9155
+ if (effectiveAdjustment?.kind === "return") {
9156
+ const baselineAdjustments = nextAdjustments.filter(
9157
+ (adj) => adj.id !== adjustmentId,
9158
+ );
9159
+ const baselineCalculation =
9160
+ await salesCalculationService.calculateDocumentTotals({
9161
+ documentKind: "quote",
9162
+ lines: calcLines,
9163
+ adjustments: baselineAdjustments,
9164
+ context: calculationContext,
9165
+ });
9166
+ const issues = validateReturnAdjustmentWithinRemaining({
9167
+ kind: "return",
9168
+ amountNet:
9169
+ effectiveAdjustment.amountNet ?? effectiveAdjustment.amountGross ?? 0,
9170
+ amountGross:
9171
+ effectiveAdjustment.amountGross ?? effectiveAdjustment.amountNet ?? 0,
9172
+ remainingNet: Number(
9173
+ baselineCalculation.totals?.grandTotalNetAmount ?? 0,
9174
+ ),
9175
+ remainingGross: Number(
9176
+ baselineCalculation.totals?.grandTotalGrossAmount ?? 0,
9177
+ ),
9178
+ });
9179
+ if (issues.length > 0) {
9180
+ const { translate } = await resolveTranslations();
9181
+ const grossIssue = issues.find((issue) => issue.path === "amountGross");
9182
+ const netIssue = issues.find((issue) => issue.path === "amountNet");
9183
+ const fieldErrors: Record<string, string> = {};
9184
+ if (grossIssue) {
9185
+ fieldErrors.amountGross = translate(
9254
9186
  "sales.adjustments.error.returnExceedsRemainingGrandTotalGross",
9255
9187
  RETURN_ADJUSTMENT_EXCEEDS_REMAINING_GROSS_MESSAGE,
9256
- ),
9257
- fieldErrors,
9258
- });
9188
+ );
9189
+ }
9190
+ if (netIssue) {
9191
+ fieldErrors.amountNet = translate(
9192
+ "sales.adjustments.error.returnExceedsRemainingGrandTotalNet",
9193
+ RETURN_ADJUSTMENT_EXCEEDS_REMAINING_NET_MESSAGE,
9194
+ );
9195
+ }
9196
+ throw new CrudHttpError(400, {
9197
+ error:
9198
+ fieldErrors.amountGross ??
9199
+ fieldErrors.amountNet ??
9200
+ translate(
9201
+ "sales.adjustments.error.returnExceedsRemainingGrandTotalGross",
9202
+ RETURN_ADJUSTMENT_EXCEEDS_REMAINING_GROSS_MESSAGE,
9203
+ ),
9204
+ fieldErrors,
9205
+ });
9206
+ }
9259
9207
  }
9260
- }
9261
- const calculation = await salesCalculationService.calculateDocumentTotals({
9262
- documentKind: "quote",
9263
- lines: calcLines,
9264
- adjustments: nextAdjustments,
9265
- context: calculationContext,
9208
+ const calculation = await salesCalculationService.calculateDocumentTotals({
9209
+ documentKind: "quote",
9210
+ lines: calcLines,
9211
+ adjustments: nextAdjustments,
9212
+ context: calculationContext,
9213
+ });
9214
+ const adjustmentInputs = nextAdjustments.map((adj, index) => ({
9215
+ organizationId: quote.organizationId,
9216
+ tenantId: quote.tenantId,
9217
+ quoteId: quote.id,
9218
+ scope: adj.scope ?? "order",
9219
+ kind: adj.kind ?? "custom",
9220
+ code: adj.code ?? undefined,
9221
+ label: adj.label ?? undefined,
9222
+ calculatorKey: adj.calculatorKey ?? undefined,
9223
+ promotionId: adj.promotionId ?? undefined,
9224
+ rate: adj.rate ?? undefined,
9225
+ amountNet: adj.amountNet ?? undefined,
9226
+ amountGross: adj.amountGross ?? undefined,
9227
+ currencyCode: adj.currencyCode ?? quote.currencyCode,
9228
+ metadata: adj.metadata ?? undefined,
9229
+ customFields: (adj as any).customFields ?? undefined,
9230
+ position: adj.position ?? index,
9231
+ }));
9232
+ await replaceQuoteAdjustments(em, quote, calculation, adjustmentInputs);
9233
+ applyQuoteTotals(quote, calculation.totals, calculation.lines.length);
9234
+ quote.updatedAt = new Date();
9235
+ return { result: { quoteId: quote.id, adjustmentId }, calculation };
9266
9236
  });
9267
- const adjustmentInputs = nextAdjustments.map((adj, index) => ({
9268
- organizationId: quote.organizationId,
9269
- tenantId: quote.tenantId,
9270
- quoteId: quote.id,
9271
- scope: adj.scope ?? "order",
9272
- kind: adj.kind ?? "custom",
9273
- code: adj.code ?? undefined,
9274
- label: adj.label ?? undefined,
9275
- calculatorKey: adj.calculatorKey ?? undefined,
9276
- promotionId: adj.promotionId ?? undefined,
9277
- rate: adj.rate ?? undefined,
9278
- amountNet: adj.amountNet ?? undefined,
9279
- amountGross: adj.amountGross ?? undefined,
9280
- currencyCode: adj.currencyCode ?? quote.currencyCode,
9281
- metadata: adj.metadata ?? undefined,
9282
- customFields: (adj as any).customFields ?? undefined,
9283
- position: adj.position ?? index,
9284
- }));
9285
- let eventBus: EventBus | null = null;
9286
- try {
9287
- eventBus = ctx.container.resolve("eventBus") as EventBus;
9288
- } catch {
9289
- eventBus = null;
9290
- }
9291
- // Persist the adjustment change and recalculated totals atomically so a
9292
- // mid-build failure cannot leave a half-updated quote committed (#2336).
9293
- await withAtomicFlush(
9294
- em,
9295
- [
9296
- async () => {
9297
- await replaceQuoteAdjustments(em, quote, calculation, adjustmentInputs);
9298
- applyQuoteTotals(quote, calculation.totals, calculation.lines.length);
9299
- quote.updatedAt = new Date();
9300
- await emitTotalsCalculated(eventBus, {
9301
- documentKind: "quote",
9302
- documentId: quote.id,
9303
- organizationId: quote.organizationId,
9304
- tenantId: quote.tenantId,
9305
- customerId: quote.customerEntityId ?? null,
9306
- totals: calculation.totals,
9307
- lineCount: calculation.lines.length,
9308
- });
9309
- },
9310
- ],
9311
- { transaction: true },
9312
- );
9313
- return { quoteId: quote.id, adjustmentId };
9314
- },
9315
- captureAfter: async (_input, result, ctx) => {
9316
- const em = (ctx.container.resolve("em") as EntityManager).fork();
9317
- return loadQuoteSnapshot(em, result.quoteId);
9318
9237
  },
9238
+ captureAfter: async (_input, result, ctx) =>
9239
+ captureLockedQuoteGraphAfter(ctx, result, result.quoteId),
9319
9240
  buildLog: async ({ snapshots, result }) => {
9320
- const before = snapshots.before as QuoteGraphSnapshot | undefined;
9241
+ const before = resolveLockedQuoteGraphBefore(result, snapshots.before);
9321
9242
  const after = snapshots.after as QuoteGraphSnapshot | undefined;
9322
9243
  if (!after) return null;
9323
9244
  const { translate } = await resolveTranslations();
@@ -9350,135 +9271,89 @@ const quoteAdjustmentDeleteCommand: CommandHandler<
9350
9271
  { quoteId: string; adjustmentId: string }
9351
9272
  > = {
9352
9273
  id: "sales.quotes.adjustments.delete",
9353
- async prepare(input, ctx) {
9354
- const raw = (input?.body as Record<string, unknown> | undefined) ?? {};
9355
- const quoteId = typeof raw.quoteId === "string" ? raw.quoteId : null;
9356
- if (!quoteId) return {};
9357
- const em = ctx.container.resolve("em") as EntityManager;
9358
- const snapshot = await loadQuoteSnapshot(em, quoteId);
9359
- if (snapshot)
9360
- ensureQuoteScope(
9361
- ctx,
9362
- snapshot.quote.organizationId,
9363
- snapshot.quote.tenantId,
9364
- );
9365
- return snapshot ? { before: snapshot } : {};
9274
+ async prepare(input) {
9275
+ return markLockedGraphWrite(input);
9366
9276
  },
9367
9277
  async execute(input, ctx) {
9368
9278
  const parsed = quoteAdjustmentDeleteSchema.parse(
9369
9279
  (input?.body as Record<string, unknown> | undefined) ?? {},
9370
9280
  );
9371
- const em = (ctx.container.resolve("em") as EntityManager).fork();
9372
- const quote = await findOneWithDecryption(em, SalesQuote, {
9373
- id: parsed.quoteId,
9374
- deletedAt: null,
9375
- });
9376
- if (!quote)
9377
- throw notFound("Sales quote not found");
9378
- ensureQuoteScope(ctx, quote.organizationId, quote.tenantId);
9379
- await enforceSalesDocumentOptimisticLock(ctx, quote, SALES_RESOURCE_KIND_QUOTE);
9380
-
9381
- const [existingLines, adjustments] = await Promise.all([
9382
- em.find(SalesQuoteLine, { quote }, { orderBy: { lineNumber: "asc" } }),
9383
- em.find(
9384
- SalesQuoteAdjustment,
9385
- { quote },
9386
- { orderBy: { position: "asc" } },
9387
- ),
9388
- ]);
9389
- const filtered = adjustments.filter((adj) => adj.id !== parsed.id);
9390
- if (filtered.length === adjustments.length) {
9391
- throw notFound("Adjustment not found");
9392
- }
9393
- const lineSnapshots = existingLines.map(mapQuoteLineEntityToSnapshot);
9394
- const calcLines = lineSnapshots.map((line, index) =>
9395
- createLineSnapshotFromInput(
9396
- {
9397
- ...line,
9398
- organizationId: quote.organizationId,
9399
- tenantId: quote.tenantId,
9400
- quoteId: quote.id,
9401
- lineNumber: line.lineNumber ?? index + 1,
9402
- statusEntryId: (line as any).statusEntryId ?? null,
9403
- catalogSnapshot: (line as any).catalogSnapshot ?? null,
9404
- promotionSnapshot: (line as any).promotionSnapshot ?? null,
9405
- },
9406
- line.lineNumber ?? index + 1,
9407
- ),
9408
- );
9409
- const adjustmentDrafts = filtered.map(mapQuoteAdjustmentToDraft);
9410
- const salesCalculationService =
9411
- ctx.container.resolve<SalesCalculationService>("salesCalculationService");
9412
- const calculationContext = buildCalculationContext({
9413
- tenantId: quote.tenantId,
9414
- organizationId: quote.organizationId,
9415
- currencyCode: quote.currencyCode,
9416
- shippingSnapshot: quote.shippingMethodSnapshot,
9417
- paymentSnapshot: quote.paymentMethodSnapshot,
9418
- shippingMethodId: quote.shippingMethodId ?? null,
9419
- paymentMethodId: quote.paymentMethodId ?? null,
9420
- shippingMethodCode: quote.shippingMethodCode ?? null,
9421
- paymentMethodCode: quote.paymentMethodCode ?? null,
9422
- });
9423
- const calculation = await salesCalculationService.calculateDocumentTotals({
9424
- documentKind: "quote",
9425
- lines: calcLines,
9426
- adjustments: adjustmentDrafts,
9427
- context: calculationContext,
9428
- });
9429
- const adjustmentInputs = adjustmentDrafts.map((adj, index) => ({
9430
- organizationId: quote.organizationId,
9431
- tenantId: quote.tenantId,
9432
- quoteId: quote.id,
9433
- scope: adj.scope ?? "order",
9434
- kind: adj.kind ?? "custom",
9435
- code: adj.code ?? undefined,
9436
- label: adj.label ?? undefined,
9437
- calculatorKey: adj.calculatorKey ?? undefined,
9438
- promotionId: adj.promotionId ?? undefined,
9439
- rate: adj.rate ?? undefined,
9440
- amountNet: adj.amountNet ?? undefined,
9441
- amountGross: adj.amountGross ?? undefined,
9442
- currencyCode: adj.currencyCode ?? quote.currencyCode,
9443
- metadata: adj.metadata ?? undefined,
9444
- position: adj.position ?? index,
9445
- }));
9446
- let eventBus: EventBus | null = null;
9447
- try {
9448
- eventBus = ctx.container.resolve("eventBus") as EventBus;
9449
- } catch {
9450
- eventBus = null;
9451
- }
9452
- // Persist the adjustment removal and recalculated totals atomically so a
9453
- // mid-build failure cannot leave a half-updated quote committed (#2336).
9454
- await withAtomicFlush(
9455
- em,
9456
- [
9457
- async () => {
9458
- await replaceQuoteAdjustments(em, quote, calculation, adjustmentInputs);
9459
- applyQuoteTotals(quote, calculation.totals, calculation.lines.length);
9460
- quote.updatedAt = new Date();
9461
- await emitTotalsCalculated(eventBus, {
9462
- documentKind: "quote",
9463
- documentId: quote.id,
9281
+ return writeLockedQuoteGraph(input, ctx, parsed.quoteId, "Sales quote not found", async (em, quote) => {
9282
+ const [existingLines, adjustments] = await Promise.all([
9283
+ em.find(SalesQuoteLine, { quote }, { orderBy: { lineNumber: "asc" } }),
9284
+ em.find(
9285
+ SalesQuoteAdjustment,
9286
+ { quote },
9287
+ { orderBy: { position: "asc" } },
9288
+ ),
9289
+ ]);
9290
+ const filtered = adjustments.filter((adj) => adj.id !== parsed.id);
9291
+ if (filtered.length === adjustments.length) {
9292
+ throw notFound("Adjustment not found");
9293
+ }
9294
+ const lineSnapshots = existingLines.map(mapQuoteLineEntityToSnapshot);
9295
+ const calcLines = lineSnapshots.map((line, index) =>
9296
+ createLineSnapshotFromInput(
9297
+ {
9298
+ ...line,
9464
9299
  organizationId: quote.organizationId,
9465
9300
  tenantId: quote.tenantId,
9466
- customerId: quote.customerEntityId ?? null,
9467
- totals: calculation.totals,
9468
- lineCount: calculation.lines.length,
9469
- });
9470
- },
9471
- ],
9472
- { transaction: true },
9473
- );
9474
- return { quoteId: quote.id, adjustmentId: parsed.id };
9475
- },
9476
- captureAfter: async (_input, result, ctx) => {
9477
- const em = (ctx.container.resolve("em") as EntityManager).fork();
9478
- return loadQuoteSnapshot(em, result.quoteId);
9301
+ quoteId: quote.id,
9302
+ lineNumber: line.lineNumber ?? index + 1,
9303
+ statusEntryId: (line as any).statusEntryId ?? null,
9304
+ catalogSnapshot: (line as any).catalogSnapshot ?? null,
9305
+ promotionSnapshot: (line as any).promotionSnapshot ?? null,
9306
+ },
9307
+ line.lineNumber ?? index + 1,
9308
+ ),
9309
+ );
9310
+ const adjustmentDrafts = filtered.map(mapQuoteAdjustmentToDraft);
9311
+ const salesCalculationService =
9312
+ ctx.container.resolve<SalesCalculationService>("salesCalculationService");
9313
+ const calculationContext = buildCalculationContext({
9314
+ tenantId: quote.tenantId,
9315
+ organizationId: quote.organizationId,
9316
+ currencyCode: quote.currencyCode,
9317
+ shippingSnapshot: quote.shippingMethodSnapshot,
9318
+ paymentSnapshot: quote.paymentMethodSnapshot,
9319
+ shippingMethodId: quote.shippingMethodId ?? null,
9320
+ paymentMethodId: quote.paymentMethodId ?? null,
9321
+ shippingMethodCode: quote.shippingMethodCode ?? null,
9322
+ paymentMethodCode: quote.paymentMethodCode ?? null,
9323
+ });
9324
+ const calculation = await salesCalculationService.calculateDocumentTotals({
9325
+ documentKind: "quote",
9326
+ lines: calcLines,
9327
+ adjustments: adjustmentDrafts,
9328
+ context: calculationContext,
9329
+ });
9330
+ const adjustmentInputs = adjustmentDrafts.map((adj, index) => ({
9331
+ organizationId: quote.organizationId,
9332
+ tenantId: quote.tenantId,
9333
+ quoteId: quote.id,
9334
+ scope: adj.scope ?? "order",
9335
+ kind: adj.kind ?? "custom",
9336
+ code: adj.code ?? undefined,
9337
+ label: adj.label ?? undefined,
9338
+ calculatorKey: adj.calculatorKey ?? undefined,
9339
+ promotionId: adj.promotionId ?? undefined,
9340
+ rate: adj.rate ?? undefined,
9341
+ amountNet: adj.amountNet ?? undefined,
9342
+ amountGross: adj.amountGross ?? undefined,
9343
+ currencyCode: adj.currencyCode ?? quote.currencyCode,
9344
+ metadata: adj.metadata ?? undefined,
9345
+ position: adj.position ?? index,
9346
+ }));
9347
+ await replaceQuoteAdjustments(em, quote, calculation, adjustmentInputs);
9348
+ applyQuoteTotals(quote, calculation.totals, calculation.lines.length);
9349
+ quote.updatedAt = new Date();
9350
+ return { result: { quoteId: quote.id, adjustmentId: parsed.id }, calculation };
9351
+ });
9479
9352
  },
9353
+ captureAfter: async (_input, result, ctx) =>
9354
+ captureLockedQuoteGraphAfter(ctx, result, result.quoteId),
9480
9355
  buildLog: async ({ snapshots, result }) => {
9481
- const before = snapshots.before as QuoteGraphSnapshot | undefined;
9356
+ const before = resolveLockedQuoteGraphBefore(result, snapshots.before);
9482
9357
  const after = snapshots.after as QuoteGraphSnapshot | undefined;
9483
9358
  if (!after) return null;
9484
9359
  const { translate } = await resolveTranslations();