@open-mercato/core 0.10.1-develop.7774.1.9a13ef7580 → 0.10.1-develop.7776.1.3032ea0741

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.
@@ -86,6 +86,7 @@ import {
86
86
  resolveSuppliedOrderPaymentLedgerFields,
87
87
  amountsModeSchema,
88
88
  orderLineCreateSchema,
89
+ orderLineBulkUpsertSchema,
89
90
  orderHeaderTotalsSchema,
90
91
  orderAdjustmentCreateSchema,
91
92
  invoiceCreateSchema,
@@ -147,6 +148,7 @@ import {
147
148
  resolveUpsertDiscountFields,
148
149
  resolveUpsertTotalsOrigin,
149
150
  resolveUpsertCalculatedAmounts,
151
+ type SalesPersistedLineSnapshot,
150
152
  } from "../lib/lineSnapshots";
151
153
  import {
152
154
  assertAmountsModeUnsupportedOnQuote,
@@ -7769,24 +7771,42 @@ async function assertShippedOrderLineChangeAllowed(
7769
7771
  });
7770
7772
  }
7771
7773
 
7774
+ /**
7775
+ * Shipment guard for one line, against an already-loaded shipped-quantity map.
7776
+ * A bulk write loads that map once for the whole order instead of once per
7777
+ * line; the per-line command keeps loading it itself via
7778
+ * {@link assertShippedOrderLineEditable}.
7779
+ */
7780
+ async function assertShippedOrderLineEditableAgainst(
7781
+ shippedQuantityByLine: Map<string, number>,
7782
+ existingSnapshot: SalesLineSnapshot | null,
7783
+ parsed: OrderLineUpsertInput,
7784
+ ): Promise<void> {
7785
+ const lineId = existingSnapshot?.id;
7786
+ if (!existingSnapshot || !lineId) return;
7787
+ await assertShippedOrderLineChangeAllowed(
7788
+ existingSnapshot,
7789
+ parsed,
7790
+ shippedQuantityByLine.get(lineId) ?? 0,
7791
+ false,
7792
+ );
7793
+ }
7794
+
7772
7795
  async function assertShippedOrderLineEditable(
7773
7796
  em: EntityManager,
7774
7797
  order: SalesOrder,
7775
7798
  existingSnapshot: SalesLineSnapshot | null,
7776
7799
  parsed: z.infer<typeof orderLineUpsertSchema>,
7777
7800
  ): Promise<void> {
7778
- const lineId = existingSnapshot?.id;
7779
- if (!existingSnapshot || !lineId) return;
7780
- const shippedByLine = await loadShippedQuantityByLine(em, order.id, {
7801
+ if (!existingSnapshot?.id) return;
7802
+ const shippedQuantityByLine = await loadShippedQuantityByLine(em, order.id, {
7781
7803
  tenantId: order.tenantId,
7782
7804
  organizationId: order.organizationId,
7783
7805
  });
7784
- const shippedQuantity = shippedByLine.get(lineId) ?? 0;
7785
- await assertShippedOrderLineChangeAllowed(
7806
+ await assertShippedOrderLineEditableAgainst(
7807
+ shippedQuantityByLine,
7786
7808
  existingSnapshot,
7787
7809
  parsed,
7788
- shippedQuantity,
7789
- false,
7790
7810
  );
7791
7811
  }
7792
7812
 
@@ -7843,6 +7863,222 @@ async function assertOrderAcceptsNewLine(
7843
7863
  });
7844
7864
  }
7845
7865
 
7866
+ type OrderLineUpsertInput = Omit<
7867
+ z.infer<typeof orderLineUpsertSchema>,
7868
+ "orderId"
7869
+ > & { orderId?: string };
7870
+
7871
+ type OrderLineUpsertDependencies = {
7872
+ em: EntityManager;
7873
+ order: SalesOrder;
7874
+ uomResolver: UomResolver;
7875
+ resolveTaxCalculationService: () => TaxCalculationService | null;
7876
+ };
7877
+
7878
+ /**
7879
+ * Resolve `taxCalculationService` lazily and at most once. A line that already
7880
+ * carries both net and gross never needs it, and a bulk write must not resolve
7881
+ * it once per line.
7882
+ */
7883
+ function createTaxCalculationServiceResolver(container: {
7884
+ resolve: (name: string) => unknown;
7885
+ }): () => TaxCalculationService | null {
7886
+ let resolved = false;
7887
+ let service: TaxCalculationService | null = null;
7888
+ return () => {
7889
+ if (resolved) return service;
7890
+ resolved = true;
7891
+ try {
7892
+ service = container.resolve(
7893
+ "taxCalculationService",
7894
+ ) as TaxCalculationService;
7895
+ } catch {
7896
+ service = null;
7897
+ }
7898
+ return service;
7899
+ };
7900
+ }
7901
+
7902
+ /**
7903
+ * Resolve one order-line upsert entry into the line snapshot it should become:
7904
+ * price-mode net/gross fallback, metadata merge, UoM normalization and the
7905
+ * unit-price conversion that follows a unit change, then the merged snapshot.
7906
+ *
7907
+ * Shared by `sales.orders.lines.upsert` and `sales.orders.lines.upsert_many` so
7908
+ * the per-line semantics cannot drift between them. Guards, line numbering,
7909
+ * totals and persistence stay with the callers — this builds a value and
7910
+ * touches nothing.
7911
+ */
7912
+ async function buildUpsertedOrderLineSnapshot(
7913
+ dependencies: OrderLineUpsertDependencies,
7914
+ parsed: OrderLineUpsertInput,
7915
+ existingSnapshot: SalesPersistedLineSnapshot | null,
7916
+ fallbackLineNumber: number,
7917
+ ): Promise<SalesPersistedLineSnapshot> {
7918
+ const { em, order, uomResolver, resolveTaxCalculationService } =
7919
+ dependencies;
7920
+ const priceMode =
7921
+ parsed.priceMode === "gross"
7922
+ ? "gross"
7923
+ : parsed.priceMode === "net"
7924
+ ? "net"
7925
+ : null;
7926
+ let unitPriceNet =
7927
+ parsed.unitPriceNet ?? existingSnapshot?.unitPriceNet ?? null;
7928
+ let unitPriceGross =
7929
+ parsed.unitPriceGross ?? existingSnapshot?.unitPriceGross ?? null;
7930
+ let taxRate = parsed.taxRate ?? existingSnapshot?.taxRate ?? null;
7931
+ const unitPriceSupplied = unitPriceNet !== null || unitPriceGross !== null;
7932
+ if (priceMode && (unitPriceNet === null || unitPriceGross === null)) {
7933
+ const taxService = resolveTaxCalculationService();
7934
+ if (taxService) {
7935
+ const taxResult = await taxService.calculateUnitAmounts({
7936
+ amount:
7937
+ priceMode === "gross"
7938
+ ? (unitPriceGross ?? unitPriceNet ?? 0)
7939
+ : (unitPriceNet ?? unitPriceGross ?? 0),
7940
+ mode: priceMode,
7941
+ organizationId: parsed.organizationId,
7942
+ tenantId: parsed.tenantId,
7943
+ taxRateId: parsed.taxRateId ?? undefined,
7944
+ taxRate: taxRate ?? undefined,
7945
+ });
7946
+ unitPriceNet = unitPriceNet ?? taxResult.netAmount;
7947
+ unitPriceGross = unitPriceGross ?? taxResult.grossAmount;
7948
+ taxRate = taxResult.taxRate ?? taxRate;
7949
+ }
7950
+ }
7951
+
7952
+ const metadata =
7953
+ typeof parsed.metadata === "object" && parsed.metadata
7954
+ ? { ...parsed.metadata }
7955
+ : existingSnapshot?.metadata
7956
+ ? cloneJson(existingSnapshot.metadata)
7957
+ : {};
7958
+ if (parsed.priceId) metadata.priceId = parsed.priceId;
7959
+ if (priceMode) metadata.priceMode = priceMode;
7960
+
7961
+ const statusEntryId =
7962
+ parsed.statusEntryId ?? existingSnapshot?.statusEntryId ?? null;
7963
+ const lineId = parsed.id ?? existingSnapshot?.id ?? randomUUID();
7964
+ const lineUomInput = {
7965
+ productId: parsed.productId ?? existingSnapshot?.productId ?? null,
7966
+ productVariantId:
7967
+ parsed.productVariantId ?? existingSnapshot?.productVariantId ?? null,
7968
+ quantity: parsed.quantity ?? existingSnapshot?.quantity ?? 0,
7969
+ quantityUnit: parsed.quantityUnit ?? existingSnapshot?.quantityUnit ?? null,
7970
+ normalizedQuantity: existingSnapshot?.normalizedQuantity ?? null,
7971
+ normalizedUnit: existingSnapshot?.normalizedUnit ?? null,
7972
+ uomSnapshot: existingSnapshot?.uomSnapshot ?? null,
7973
+ };
7974
+ let normalizedUom = await normalizeLineUom({
7975
+ em,
7976
+ resolver: uomResolver,
7977
+ organizationId: order.organizationId,
7978
+ tenantId: order.tenantId,
7979
+ line: {
7980
+ ...lineUomInput,
7981
+ unitPriceNet: unitPriceNet ?? 0,
7982
+ unitPriceGross: unitPriceGross ?? unitPriceNet ?? 0,
7983
+ },
7984
+ });
7985
+ const convertedPrices = convertLineUnitPricesOnUnitChange({
7986
+ existingSnapshot,
7987
+ nextQuantityUnit: normalizedUom.quantityUnit,
7988
+ nextUomSnapshot: normalizedUom.uomSnapshot,
7989
+ unitPriceNet,
7990
+ unitPriceGross,
7991
+ });
7992
+ if (convertedPrices.didConvert) {
7993
+ unitPriceNet = convertedPrices.unitPriceNet;
7994
+ unitPriceGross = convertedPrices.unitPriceGross;
7995
+ normalizedUom = await normalizeLineUom({
7996
+ em,
7997
+ resolver: uomResolver,
7998
+ organizationId: order.organizationId,
7999
+ tenantId: order.tenantId,
8000
+ line: {
8001
+ ...lineUomInput,
8002
+ unitPriceNet: unitPriceNet ?? 0,
8003
+ unitPriceGross: unitPriceGross ?? unitPriceNet ?? 0,
8004
+ },
8005
+ });
8006
+ }
8007
+ const updatedSnapshot: SalesPersistedLineSnapshot = {
8008
+ id: lineId,
8009
+ lineNumber:
8010
+ parsed.lineNumber ??
8011
+ existingSnapshot?.lineNumber ??
8012
+ fallbackLineNumber,
8013
+ kind: parsed.kind ?? existingSnapshot?.kind ?? "product",
8014
+ productId: parsed.productId ?? existingSnapshot?.productId ?? null,
8015
+ productVariantId:
8016
+ parsed.productVariantId ?? existingSnapshot?.productVariantId ?? null,
8017
+ name: parsed.name ?? existingSnapshot?.name ?? null,
8018
+ description: parsed.description ?? existingSnapshot?.description ?? null,
8019
+ comment: parsed.comment ?? existingSnapshot?.comment ?? null,
8020
+ quantity: normalizedUom.quantity,
8021
+ quantityUnit: normalizedUom.quantityUnit,
8022
+ normalizedQuantity: normalizedUom.normalizedQuantity,
8023
+ normalizedUnit: normalizedUom.normalizedUnit,
8024
+ uomSnapshot: normalizedUom.uomSnapshot
8025
+ ? cloneJson(normalizedUom.uomSnapshot)
8026
+ : null,
8027
+ currencyCode:
8028
+ parsed.currencyCode ??
8029
+ existingSnapshot?.currencyCode ??
8030
+ order.currencyCode,
8031
+ unitPriceNet: unitPriceNet ?? 0,
8032
+ unitPriceGross: unitPriceGross ?? unitPriceNet ?? 0,
8033
+ ...resolveUpsertDiscountFields(
8034
+ parsed.discountAmount,
8035
+ parsed.discountAmountBasis,
8036
+ existingSnapshot,
8037
+ ),
8038
+ discountPercent:
8039
+ parsed.discountPercent ?? existingSnapshot?.discountPercent ?? 0,
8040
+ taxRate: taxRate ?? 0,
8041
+ totalNetAmount:
8042
+ parsed.totalNetAmount ?? existingSnapshot?.totalNetAmount ?? null,
8043
+ ...resolveUpsertTotalsOrigin(parsed.totalNetAmount, existingSnapshot),
8044
+ configuration:
8045
+ parsed.configuration ?? existingSnapshot?.configuration ?? null,
8046
+ promotionCode:
8047
+ parsed.promotionCode ?? existingSnapshot?.promotionCode ?? null,
8048
+ metadata,
8049
+ customFieldSetId:
8050
+ parsed.customFieldSetId ?? existingSnapshot?.customFieldSetId ?? null,
8051
+ customFields:
8052
+ parsed.customFields && typeof parsed.customFields === "object"
8053
+ ? cloneJson(parsed.customFields)
8054
+ : (existingSnapshot?.customFields ?? null),
8055
+ amountsMode: order.totalsMode ?? "computed",
8056
+ statusEntryId,
8057
+ catalogSnapshot:
8058
+ parsed.catalogSnapshot ?? existingSnapshot?.catalogSnapshot ?? null,
8059
+ promotionSnapshot:
8060
+ parsed.promotionSnapshot ?? existingSnapshot?.promotionSnapshot ?? null,
8061
+ };
8062
+ // `taxAmount` and `totalGrossAmount` are the engine's own output read back off
8063
+ // the row, so carrying them verbatim pinned a line's gross to its pre-edit
8064
+ // value whenever the caller changed quantity or pricing without supplying
8065
+ // totals (#6459). Dropping them when the pricing moved makes the calculation
8066
+ // recompute instead. Both commands go through here, so neither can drift.
8067
+ Object.assign(
8068
+ updatedSnapshot,
8069
+ resolveUpsertCalculatedAmounts(parsed, updatedSnapshot, existingSnapshot),
8070
+ );
8071
+
8072
+ if (isExternalAmountsMode(order.totalsMode ?? "computed")) {
8073
+ await assertExternalLineComplete(
8074
+ { ...updatedSnapshot, unitPriceNet: unitPriceSupplied ? unitPriceNet : null },
8075
+ updatedSnapshot.lineNumber ?? 1,
8076
+ );
8077
+ }
8078
+
8079
+ return updatedSnapshot;
8080
+ }
8081
+
7846
8082
  const orderLineUpsertCommand: CommandHandler<
7847
8083
  { body?: Record<string, unknown>; query?: Record<string, unknown> },
7848
8084
  { orderId: string; lineId: string }
@@ -7877,182 +8113,21 @@ const orderLineUpsertCommand: CommandHandler<
7877
8113
  : null;
7878
8114
  await assertOrderAcceptsNewLine(order, existingSnapshot);
7879
8115
  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
- }
7917
- }
7918
-
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
8116
  const uomResolver = createUomResolver();
7942
- let normalizedUom = await normalizeLineUom({
7943
- em,
7944
- resolver: uomResolver,
7945
- organizationId: order.organizationId,
7946
- tenantId: order.tenantId,
7947
- line: {
7948
- ...lineUomInput,
7949
- unitPriceNet: unitPriceNet ?? 0,
7950
- unitPriceGross: unitPriceGross ?? unitPriceNet ?? 0,
8117
+ const updatedSnapshot = await buildUpsertedOrderLineSnapshot(
8118
+ {
8119
+ em,
8120
+ order,
8121
+ uomResolver,
8122
+ resolveTaxCalculationService: createTaxCalculationServiceResolver(
8123
+ ctx.container,
8124
+ ),
7951
8125
  },
7952
- });
7953
- const convertedPrices = convertLineUnitPricesOnUnitChange({
8126
+ parsed,
7954
8127
  existingSnapshot,
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),
8128
+ lineSnapshots.length + 1,
8032
8129
  );
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
-
8130
+ const lineId = updatedSnapshot.id;
8056
8131
  let nextLines = parsed.id
8057
8132
  ? lineSnapshots.map((line) =>
8058
8133
  line.id === parsed.id ? updatedSnapshot : line,
@@ -8283,6 +8358,255 @@ const orderLineDeleteCommand: CommandHandler<
8283
8358
  },
8284
8359
  };
8285
8360
 
8361
+ /**
8362
+ * Place `line` in `lines` at the position the caller asked for.
8363
+ *
8364
+ * A supplied `targetPosition` is a 1-based destination, not a sort key: the
8365
+ * line is pulled out of its current slot and spliced back in there, so moving
8366
+ * a line one step in either direction is expressible (sorting a list that
8367
+ * contains ties is not — see the per-line command). Without a target position
8368
+ * an existing line keeps its slot and a new line is appended.
8369
+ */
8370
+ function placeOrderLine(
8371
+ lines: SalesPersistedLineSnapshot[],
8372
+ line: SalesPersistedLineSnapshot,
8373
+ targetPosition: number | null,
8374
+ ): SalesPersistedLineSnapshot[] {
8375
+ const currentIndex = lines.findIndex((entry) => entry.id === line.id);
8376
+ const remaining =
8377
+ currentIndex === -1
8378
+ ? [...lines]
8379
+ : [...lines.slice(0, currentIndex), ...lines.slice(currentIndex + 1)];
8380
+ if (targetPosition == null) {
8381
+ if (currentIndex === -1) {
8382
+ remaining.push(line);
8383
+ return remaining;
8384
+ }
8385
+ remaining.splice(currentIndex, 0, line);
8386
+ return remaining;
8387
+ }
8388
+ const index = Math.min(Math.max(targetPosition - 1, 0), remaining.length);
8389
+ remaining.splice(index, 0, line);
8390
+ return remaining;
8391
+ }
8392
+
8393
+ /**
8394
+ * Write a whole set of order-line changes in one aggregate load.
8395
+ *
8396
+ * `sales.orders.lines.upsert` is per line: each call reloads the order, all of
8397
+ * its lines and adjustments, recalculates the document and flushes, so writing
8398
+ * N lines costs O(N²) in rows read. This command takes the same per-line
8399
+ * bodies as a list, loads the aggregate once, resolves every line in memory
8400
+ * against one UoM resolver, calculates totals once and flushes once — O(N) —
8401
+ * and produces the end state N sequential single upserts would produce.
8402
+ *
8403
+ * All-or-nothing: a refusal on any line or delete aborts the whole batch.
8404
+ */
8405
+ const orderLineBulkUpsertCommand: CommandHandler<
8406
+ { body?: Record<string, unknown>; query?: Record<string, unknown> },
8407
+ { orderId: string; lineIds: string[] }
8408
+ > = {
8409
+ id: "sales.orders.lines.upsert_many",
8410
+ async prepare(input) {
8411
+ return markLockedGraphWrite(input);
8412
+ },
8413
+ async execute(input, ctx) {
8414
+ const { translate } = await resolveTranslations();
8415
+ const parsed = orderLineBulkUpsertSchema.parse(
8416
+ (input?.body as Record<string, unknown> | undefined) ?? {},
8417
+ );
8418
+ return writeLockedOrderGraph(input, ctx, parsed.orderId, "Sales order not found", async (em, order) => {
8419
+ const totalsMode: SalesAmountsMode = order.totalsMode ?? "computed";
8420
+ await assertUniformAmountsMode(totalsMode, parsed.lines);
8421
+ if (isExternalAmountsMode(totalsMode)) {
8422
+ await requireOrderTotalsForExternalWrite(parsed.orderTotals);
8423
+ }
8424
+ const suppliedTotals: Partial<SalesDocumentAmounts> | null = isExternalAmountsMode(totalsMode)
8425
+ ? buildExternalHeaderTotals(parsed.orderTotals)
8426
+ : null;
8427
+
8428
+ const [existingLines, adjustments] = await Promise.all([
8429
+ em.find(SalesOrderLine, { order }, { orderBy: { lineNumber: "asc" } }),
8430
+ em.find(
8431
+ SalesOrderAdjustment,
8432
+ { order },
8433
+ { orderBy: { position: "asc" } },
8434
+ ),
8435
+ ]);
8436
+ const existingSnapshotsById = new Map(
8437
+ existingLines.map((line) => [line.id, mapOrderLineEntityToSnapshot(line)]),
8438
+ );
8439
+
8440
+ // An entry id names a line to update. One this order does not have is
8441
+ // refused rather than created: it is a line deleted since the caller read
8442
+ // the order, or one belonging to another order.
8443
+ const deleteIds = new Set(parsed.deleteIds);
8444
+ const referencedIds = [
8445
+ ...deleteIds,
8446
+ ...parsed.lines.flatMap((line) => (line.id ? [line.id] : [])),
8447
+ ];
8448
+ for (const id of referencedIds) {
8449
+ if (existingSnapshotsById.has(id)) continue;
8450
+ throw notFound(
8451
+ translate(
8452
+ "sales.documents.detail.error",
8453
+ "Document not found or inaccessible.",
8454
+ ),
8455
+ );
8456
+ }
8457
+ if (deleteIds.size) {
8458
+ const shippedItems = await em.find(SalesShipmentItem, {
8459
+ orderLine: { $in: [...deleteIds] },
8460
+ shipment: { deletedAt: null },
8461
+ });
8462
+ if (shippedItems.length) {
8463
+ throw new CrudHttpError(409, {
8464
+ error: translate(
8465
+ "sales.documents.items.errorDeleteShipped",
8466
+ "Cannot delete a line that has shipped items.",
8467
+ ),
8468
+ });
8469
+ }
8470
+ }
8471
+
8472
+ const editsExistingLine = parsed.lines.some(
8473
+ (line) => line.id != null && existingSnapshotsById.has(line.id),
8474
+ );
8475
+ const shippedQuantityByLine = editsExistingLine
8476
+ ? await loadShippedQuantityByLine(em, order.id, {
8477
+ tenantId: order.tenantId,
8478
+ organizationId: order.organizationId,
8479
+ })
8480
+ : new Map<string, number>();
8481
+
8482
+ const dependencies: OrderLineUpsertDependencies = {
8483
+ em,
8484
+ order,
8485
+ uomResolver: createUomResolver(),
8486
+ resolveTaxCalculationService: createTaxCalculationServiceResolver(
8487
+ ctx.container,
8488
+ ),
8489
+ };
8490
+ let nextLines = [...existingSnapshotsById.values()].filter(
8491
+ (line) => !deleteIds.has(line.id),
8492
+ );
8493
+ const lineIds: string[] = [];
8494
+ for (const entry of parsed.lines) {
8495
+ const existingSnapshot = entry.id
8496
+ ? (existingSnapshotsById.get(entry.id) ?? null)
8497
+ : null;
8498
+ await assertOrderAcceptsNewLine(order, existingSnapshot);
8499
+ await assertShippedOrderLineEditableAgainst(
8500
+ shippedQuantityByLine,
8501
+ existingSnapshot,
8502
+ entry,
8503
+ );
8504
+ const upserted = await buildUpsertedOrderLineSnapshot(
8505
+ dependencies,
8506
+ {
8507
+ ...entry,
8508
+ organizationId: parsed.organizationId,
8509
+ tenantId: parsed.tenantId,
8510
+ },
8511
+ existingSnapshot,
8512
+ nextLines.length + 1,
8513
+ );
8514
+ nextLines = placeOrderLine(nextLines, upserted, entry.lineNumber ?? null);
8515
+ lineIds.push(upserted.id);
8516
+ }
8517
+
8518
+ if (!nextLines.length) {
8519
+ throw new CrudHttpError(409, {
8520
+ error: translate(
8521
+ "sales.documents.items.errorDeleteLast",
8522
+ "An order must contain at least one line item.",
8523
+ ),
8524
+ });
8525
+ }
8526
+
8527
+ const sourceInputs = nextLines.map((line, index) => ({
8528
+ ...line,
8529
+ statusEntryId: line.statusEntryId ?? null,
8530
+ catalogSnapshot: line.catalogSnapshot ?? null,
8531
+ promotionSnapshot: line.promotionSnapshot ?? null,
8532
+ organizationId: order.organizationId,
8533
+ tenantId: order.tenantId,
8534
+ orderId: order.id,
8535
+ lineNumber: index + 1,
8536
+ }));
8537
+ const calcLines: SalesLineSnapshot[] = sourceInputs.map((line, index) =>
8538
+ createLineSnapshotFromInput(line, line.lineNumber ?? index + 1),
8539
+ );
8540
+ const adjustmentDrafts = adjustments.map(mapOrderAdjustmentToDraft);
8541
+ const salesCalculationService =
8542
+ ctx.container.resolve<SalesCalculationService>("salesCalculationService");
8543
+ const calculationContext = buildCalculationContext({
8544
+ tenantId: order.tenantId,
8545
+ organizationId: order.organizationId,
8546
+ currencyCode: order.currencyCode,
8547
+ shippingSnapshot: order.shippingMethodSnapshot,
8548
+ paymentSnapshot: order.paymentMethodSnapshot,
8549
+ shippingMethodId: order.shippingMethodId ?? null,
8550
+ paymentMethodId: order.paymentMethodId ?? null,
8551
+ shippingMethodCode: order.shippingMethodCode ?? null,
8552
+ paymentMethodCode: order.paymentMethodCode ?? null,
8553
+ });
8554
+ const calculation = await salesCalculationService.calculateDocumentTotals({
8555
+ documentKind: "order",
8556
+ lines: calcLines,
8557
+ adjustments: adjustmentDrafts,
8558
+ context: calculationContext,
8559
+ existingTotals: resolveExistingPaymentTotals(order),
8560
+ totalsMode,
8561
+ suppliedTotals,
8562
+ });
8563
+ await applyOrderLineResults({
8564
+ em,
8565
+ order,
8566
+ calculation,
8567
+ sourceLines: sourceInputs,
8568
+ existingLines,
8569
+ });
8570
+ applyOrderTotals(order, calculation.totals, calculation.lines.length);
8571
+ return { result: { orderId: order.id, lineIds }, calculation };
8572
+ });
8573
+ },
8574
+ captureAfter: async (_input, result, ctx) =>
8575
+ captureLockedOrderGraphAfter(ctx, result, result.orderId),
8576
+ buildLog: async ({ result, snapshots }) => {
8577
+ const before = resolveLockedOrderGraphBefore(result, snapshots.before);
8578
+ const after = snapshots.after as OrderGraphSnapshot | undefined;
8579
+ if (!after) return null;
8580
+ const { translate } = await resolveTranslations();
8581
+ return {
8582
+ actionLabel: translate(
8583
+ "sales.audit.orders.lines.upsert_many",
8584
+ "Update order lines",
8585
+ ),
8586
+ resourceKind: "sales.order",
8587
+ resourceId: result.orderId,
8588
+ tenantId: after.order.tenantId,
8589
+ organizationId: after.order.organizationId,
8590
+ snapshotBefore: before ?? null,
8591
+ snapshotAfter: after,
8592
+ payload: {
8593
+ undo: { before, after } satisfies OrderUndoPayload,
8594
+ },
8595
+ };
8596
+ },
8597
+ undo: async ({ logEntry, ctx }) => {
8598
+ const payload = extractUndoPayload<OrderUndoPayload>(logEntry);
8599
+ const before = payload?.before;
8600
+ if (!before) return;
8601
+ // Same path as every other order-graph undo: it locks the order, refuses
8602
+ // with 409 when the graph moved since the batch ran — a payment, shipment
8603
+ // or note recorded afterwards would otherwise be hard-deleted by the
8604
+ // restore (#6462, #6833) — restores inside one transaction, and refreshes
8605
+ // the query index.
8606
+ await undoOrderGraph(ctx, before, payload?.after);
8607
+ },
8608
+ };
8609
+
8286
8610
  const quoteLineUpsertCommand: CommandHandler<
8287
8611
  { body?: Record<string, unknown>; query?: Record<string, unknown> },
8288
8612
  { quoteId: string; lineId: string }
@@ -10506,6 +10830,7 @@ registerCommand(updateOrderCommand);
10506
10830
  registerCommand(createOrderCommand);
10507
10831
  registerCommand(deleteOrderCommand);
10508
10832
  registerCommand(orderLineUpsertCommand);
10833
+ registerCommand(orderLineBulkUpsertCommand);
10509
10834
  registerCommand(orderLineDeleteCommand);
10510
10835
  registerCommand(quoteLineUpsertCommand);
10511
10836
  registerCommand(quoteLineDeleteCommand);