@open-mercato/core 0.10.1-develop.7775.1.2a1f553826 → 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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/sales/api/order-lines/batch/route.js +227 -0
- package/dist/modules/sales/api/order-lines/batch/route.js.map +7 -0
- package/dist/modules/sales/commands/documents.js +356 -129
- package/dist/modules/sales/commands/documents.js.map +2 -2
- package/dist/modules/sales/data/validators.js +53 -0
- package/dist/modules/sales/data/validators.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/sales/api/order-lines/batch/route.ts +312 -0
- package/src/modules/sales/commands/documents.ts +504 -179
- package/src/modules/sales/data/validators.ts +71 -0
- package/src/modules/sales/i18n/de.json +9 -0
- package/src/modules/sales/i18n/en.json +9 -0
- package/src/modules/sales/i18n/es.json +9 -0
- package/src/modules/sales/i18n/ko.json +9 -0
- package/src/modules/sales/i18n/pl.json +9 -0
|
@@ -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
|
-
|
|
7779
|
-
|
|
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
|
-
|
|
7785
|
-
|
|
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
|
-
|
|
7943
|
-
|
|
7944
|
-
|
|
7945
|
-
|
|
7946
|
-
|
|
7947
|
-
|
|
7948
|
-
|
|
7949
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|