@open-mercato/core 0.6.8-develop.7057.1.61440fc3bc → 0.6.8-develop.7059.1.f45d1ae6a1
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/components/documents/ItemsSection.js +37 -15
- package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
- package/dist/modules/sales/components/documents/LineItemDialog.js +76 -12
- package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
- package/dist/modules/sales/components/documents/lineItemShipmentLock.js +54 -0
- package/dist/modules/sales/components/documents/lineItemShipmentLock.js.map +7 -0
- package/package.json +7 -7
- package/src/modules/sales/components/documents/ItemsSection.tsx +48 -17
- package/src/modules/sales/components/documents/LineItemDialog.tsx +129 -13
- package/src/modules/sales/components/documents/lineItemShipmentLock.ts +74 -0
- package/src/modules/sales/i18n/de.json +3 -0
- package/src/modules/sales/i18n/en.json +3 -0
- package/src/modules/sales/i18n/es.json +3 -0
- package/src/modules/sales/i18n/ko.json +3 -0
- package/src/modules/sales/i18n/pl.json +3 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
const SHIPPED_LINE_IMMUTABLE_PAYLOAD_FIELDS = [
|
|
2
|
+
"kind",
|
|
3
|
+
"productId",
|
|
4
|
+
"productVariantId",
|
|
5
|
+
"quantityUnit",
|
|
6
|
+
"unitPriceNet",
|
|
7
|
+
"unitPriceGross",
|
|
8
|
+
"priceId",
|
|
9
|
+
"priceMode",
|
|
10
|
+
"taxRateId",
|
|
11
|
+
"taxRate",
|
|
12
|
+
"taxAmount",
|
|
13
|
+
"discountAmount",
|
|
14
|
+
"discountPercent",
|
|
15
|
+
"catalogSnapshot",
|
|
16
|
+
"metadata"
|
|
17
|
+
];
|
|
18
|
+
function scaleTotal(total, previousQuantity, nextQuantity) {
|
|
19
|
+
if (!Number.isFinite(total) || !Number.isFinite(previousQuantity) || previousQuantity <= 0) {
|
|
20
|
+
return void 0;
|
|
21
|
+
}
|
|
22
|
+
return total * (nextQuantity / previousQuantity);
|
|
23
|
+
}
|
|
24
|
+
function prepareShippedLineUpdatePayload(payload, currentLine) {
|
|
25
|
+
if (!currentLine) return payload;
|
|
26
|
+
const nextPayload = { ...payload };
|
|
27
|
+
for (const field of SHIPPED_LINE_IMMUTABLE_PAYLOAD_FIELDS) {
|
|
28
|
+
delete nextPayload[field];
|
|
29
|
+
}
|
|
30
|
+
delete nextPayload.totalNetAmount;
|
|
31
|
+
delete nextPayload.totalGrossAmount;
|
|
32
|
+
const nextQuantity = Number(nextPayload.quantity);
|
|
33
|
+
if (!Number.isFinite(nextQuantity) || nextQuantity === currentLine.quantity) {
|
|
34
|
+
return nextPayload;
|
|
35
|
+
}
|
|
36
|
+
const scaledNetTotal = scaleTotal(
|
|
37
|
+
currentLine.totalNetAmount,
|
|
38
|
+
currentLine.quantity,
|
|
39
|
+
nextQuantity
|
|
40
|
+
);
|
|
41
|
+
const scaledGrossTotal = scaleTotal(
|
|
42
|
+
currentLine.totalGrossAmount,
|
|
43
|
+
currentLine.quantity,
|
|
44
|
+
nextQuantity
|
|
45
|
+
);
|
|
46
|
+
if (scaledNetTotal !== void 0) nextPayload.totalNetAmount = scaledNetTotal;
|
|
47
|
+
if (scaledGrossTotal !== void 0)
|
|
48
|
+
nextPayload.totalGrossAmount = scaledGrossTotal;
|
|
49
|
+
return nextPayload;
|
|
50
|
+
}
|
|
51
|
+
export {
|
|
52
|
+
prepareShippedLineUpdatePayload
|
|
53
|
+
};
|
|
54
|
+
//# sourceMappingURL=lineItemShipmentLock.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../../src/modules/sales/components/documents/lineItemShipmentLock.ts"],
|
|
4
|
+
"sourcesContent": ["type ShippedLineSnapshot = {\n quantity: number;\n totalNetAmount?: number | null;\n totalGrossAmount?: number | null;\n};\n\nconst SHIPPED_LINE_IMMUTABLE_PAYLOAD_FIELDS = [\n \"kind\",\n \"productId\",\n \"productVariantId\",\n \"quantityUnit\",\n \"unitPriceNet\",\n \"unitPriceGross\",\n \"priceId\",\n \"priceMode\",\n \"taxRateId\",\n \"taxRate\",\n \"taxAmount\",\n \"discountAmount\",\n \"discountPercent\",\n \"catalogSnapshot\",\n \"metadata\",\n] as const;\n\nfunction scaleTotal(\n total: number | null | undefined,\n previousQuantity: number,\n nextQuantity: number,\n): number | undefined {\n if (\n !Number.isFinite(total) ||\n !Number.isFinite(previousQuantity) ||\n previousQuantity <= 0\n ) {\n return undefined;\n }\n return (total as number) * (nextQuantity / previousQuantity);\n}\n\nexport function prepareShippedLineUpdatePayload(\n payload: Record<string, unknown>,\n currentLine: ShippedLineSnapshot | null,\n): Record<string, unknown> {\n if (!currentLine) return payload;\n\n const nextPayload = { ...payload };\n for (const field of SHIPPED_LINE_IMMUTABLE_PAYLOAD_FIELDS) {\n delete nextPayload[field];\n }\n\n delete nextPayload.totalNetAmount;\n delete nextPayload.totalGrossAmount;\n\n const nextQuantity = Number(nextPayload.quantity);\n if (!Number.isFinite(nextQuantity) || nextQuantity === currentLine.quantity) {\n return nextPayload;\n }\n\n const scaledNetTotal = scaleTotal(\n currentLine.totalNetAmount,\n currentLine.quantity,\n nextQuantity,\n );\n const scaledGrossTotal = scaleTotal(\n currentLine.totalGrossAmount,\n currentLine.quantity,\n nextQuantity,\n );\n if (scaledNetTotal !== undefined) nextPayload.totalNetAmount = scaledNetTotal;\n if (scaledGrossTotal !== undefined)\n nextPayload.totalGrossAmount = scaledGrossTotal;\n\n return nextPayload;\n}\n"],
|
|
5
|
+
"mappings": "AAMA,MAAM,wCAAwC;AAAA,EAC5C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAEA,SAAS,WACP,OACA,kBACA,cACoB;AACpB,MACE,CAAC,OAAO,SAAS,KAAK,KACtB,CAAC,OAAO,SAAS,gBAAgB,KACjC,oBAAoB,GACpB;AACA,WAAO;AAAA,EACT;AACA,SAAQ,SAAoB,eAAe;AAC7C;AAEO,SAAS,gCACd,SACA,aACyB;AACzB,MAAI,CAAC,YAAa,QAAO;AAEzB,QAAM,cAAc,EAAE,GAAG,QAAQ;AACjC,aAAW,SAAS,uCAAuC;AACzD,WAAO,YAAY,KAAK;AAAA,EAC1B;AAEA,SAAO,YAAY;AACnB,SAAO,YAAY;AAEnB,QAAM,eAAe,OAAO,YAAY,QAAQ;AAChD,MAAI,CAAC,OAAO,SAAS,YAAY,KAAK,iBAAiB,YAAY,UAAU;AAC3E,WAAO;AAAA,EACT;AAEA,QAAM,iBAAiB;AAAA,IACrB,YAAY;AAAA,IACZ,YAAY;AAAA,IACZ;AAAA,EACF;AACA,QAAM,mBAAmB;AAAA,IACvB,YAAY;AAAA,IACZ,YAAY;AAAA,IACZ;AAAA,EACF;AACA,MAAI,mBAAmB,OAAW,aAAY,iBAAiB;AAC/D,MAAI,qBAAqB;AACvB,gBAAY,mBAAmB;AAEjC,SAAO;AACT;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.6.8-develop.
|
|
3
|
+
"version": "0.6.8-develop.7059.1.f45d1ae6a1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -252,16 +252,16 @@
|
|
|
252
252
|
"zod": "^4.4.3"
|
|
253
253
|
},
|
|
254
254
|
"peerDependencies": {
|
|
255
|
-
"@open-mercato/ai-assistant": "0.6.8-develop.
|
|
256
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
257
|
-
"@open-mercato/ui": "0.6.8-develop.
|
|
255
|
+
"@open-mercato/ai-assistant": "0.6.8-develop.7059.1.f45d1ae6a1",
|
|
256
|
+
"@open-mercato/shared": "0.6.8-develop.7059.1.f45d1ae6a1",
|
|
257
|
+
"@open-mercato/ui": "0.6.8-develop.7059.1.f45d1ae6a1",
|
|
258
258
|
"react": "^19.0.0",
|
|
259
259
|
"react-dom": "^19.0.0"
|
|
260
260
|
},
|
|
261
261
|
"devDependencies": {
|
|
262
|
-
"@open-mercato/ai-assistant": "0.6.8-develop.
|
|
263
|
-
"@open-mercato/shared": "0.6.8-develop.
|
|
264
|
-
"@open-mercato/ui": "0.6.8-develop.
|
|
262
|
+
"@open-mercato/ai-assistant": "0.6.8-develop.7059.1.f45d1ae6a1",
|
|
263
|
+
"@open-mercato/shared": "0.6.8-develop.7059.1.f45d1ae6a1",
|
|
264
|
+
"@open-mercato/ui": "0.6.8-develop.7059.1.f45d1ae6a1",
|
|
265
265
|
"@testing-library/dom": "^10.4.1",
|
|
266
266
|
"@testing-library/jest-dom": "^7.0.0",
|
|
267
267
|
"@testing-library/react": "^16.3.1",
|
|
@@ -131,6 +131,11 @@ function resolveInjectedColumnValue(
|
|
|
131
131
|
return current;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
const SHIPMENTS_PAGE_SIZE = 100;
|
|
135
|
+
// A single order beyond this many shipment pages is pathological; stopping there
|
|
136
|
+
// keeps the shipped state explicitly unresolved rather than silently partial.
|
|
137
|
+
const SHIPMENTS_MAX_PAGES = 50;
|
|
138
|
+
|
|
134
139
|
type SalesDocumentItemsSectionProps = {
|
|
135
140
|
documentId: string;
|
|
136
141
|
kind: "order" | "quote";
|
|
@@ -168,6 +173,12 @@ export function SalesDocumentItemsSection({
|
|
|
168
173
|
const [shippedTotals, setShippedTotals] = React.useState<Map<string, number>>(
|
|
169
174
|
new Map(),
|
|
170
175
|
);
|
|
176
|
+
// Quotes have no shipments at all, so their shipped state is known up front.
|
|
177
|
+
// For orders it stays unknown until every shipment page has been read — an
|
|
178
|
+
// empty map from a pending or failed load must never read as "nothing shipped".
|
|
179
|
+
const [shippedTotalsResolved, setShippedTotalsResolved] = React.useState(
|
|
180
|
+
kind !== "order",
|
|
181
|
+
);
|
|
171
182
|
|
|
172
183
|
const { widgets: allColumnWidgets } = useInjectionDataWidgets(
|
|
173
184
|
extensionPoints.hosts.orderItemColumns.spotId,
|
|
@@ -379,22 +390,31 @@ export function SalesDocumentItemsSection({
|
|
|
379
390
|
const loadShippedTotals = React.useCallback(async () => {
|
|
380
391
|
if (kind !== "order") {
|
|
381
392
|
setShippedTotals(new Map());
|
|
393
|
+
setShippedTotalsResolved(true);
|
|
382
394
|
return;
|
|
383
395
|
}
|
|
396
|
+
setShippedTotals(new Map());
|
|
397
|
+
setShippedTotalsResolved(false);
|
|
384
398
|
try {
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
399
|
+
const totals = new Map<string, number>();
|
|
400
|
+
let page = 1;
|
|
401
|
+
let collected = 0;
|
|
402
|
+
let complete = false;
|
|
403
|
+
while (page <= SHIPMENTS_MAX_PAGES) {
|
|
404
|
+
const params = new URLSearchParams({
|
|
405
|
+
page: String(page),
|
|
406
|
+
pageSize: String(SHIPMENTS_PAGE_SIZE),
|
|
407
|
+
orderId: documentId,
|
|
408
|
+
});
|
|
409
|
+
const response = await apiCall<{
|
|
410
|
+
items?: Array<Record<string, unknown>>;
|
|
411
|
+
total?: unknown;
|
|
412
|
+
}>(`/api/sales/shipments?${params.toString()}`, undefined, {
|
|
413
|
+
fallback: { items: [] },
|
|
414
|
+
});
|
|
415
|
+
if (!response.ok || !Array.isArray(response.result?.items)) return;
|
|
416
|
+
const shipments = response.result.items;
|
|
417
|
+
shipments.forEach((shipment) => {
|
|
398
418
|
const entries = Array.isArray(shipment.items)
|
|
399
419
|
? (shipment.items as Array<Record<string, unknown>>)
|
|
400
420
|
: [];
|
|
@@ -412,13 +432,22 @@ export function SalesDocumentItemsSection({
|
|
|
412
432
|
totals.set(lineId, current + quantity);
|
|
413
433
|
});
|
|
414
434
|
});
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
435
|
+
collected += shipments.length;
|
|
436
|
+
const reportedTotal = normalizeNumber(response.result?.total, Number.NaN);
|
|
437
|
+
const hasMore =
|
|
438
|
+
shipments.length >= SHIPMENTS_PAGE_SIZE &&
|
|
439
|
+
(!Number.isFinite(reportedTotal) || collected < reportedTotal);
|
|
440
|
+
if (!hasMore) {
|
|
441
|
+
complete = true;
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
page += 1;
|
|
418
445
|
}
|
|
446
|
+
if (!complete) return;
|
|
447
|
+
setShippedTotals(totals);
|
|
448
|
+
setShippedTotalsResolved(true);
|
|
419
449
|
} catch (err) {
|
|
420
450
|
logger.error('sales.document.shipments.load', { err });
|
|
421
|
-
setShippedTotals(new Map());
|
|
422
451
|
}
|
|
423
452
|
}, [documentId, kind]);
|
|
424
453
|
|
|
@@ -439,6 +468,7 @@ export function SalesDocumentItemsSection({
|
|
|
439
468
|
if (kind !== "order") {
|
|
440
469
|
shipmentsLoadedForDocument.current = null;
|
|
441
470
|
setShippedTotals(new Map());
|
|
471
|
+
setShippedTotalsResolved(true);
|
|
442
472
|
return;
|
|
443
473
|
}
|
|
444
474
|
const key = `${kind}:${documentId}`;
|
|
@@ -964,6 +994,7 @@ export function SalesDocumentItemsSection({
|
|
|
964
994
|
? Math.max(0, shippedTotals.get(lineForEdit.id) ?? 0)
|
|
965
995
|
: 0
|
|
966
996
|
}
|
|
997
|
+
shippedQuantityResolved={shippedTotalsResolved}
|
|
967
998
|
onSaved={async () => {
|
|
968
999
|
await loadItems();
|
|
969
1000
|
emitSalesDocumentTotalsRefresh({ documentId, kind });
|
|
@@ -50,6 +50,7 @@ import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
|
50
50
|
import { useOrganizationScopeDetail } from "@open-mercato/shared/lib/frontend/useOrganizationScope";
|
|
51
51
|
import { formatMoney, normalizeNumber } from "./lineItemUtils";
|
|
52
52
|
import type { SalesLineRecord } from "./lineItemTypes";
|
|
53
|
+
import { prepareShippedLineUpdatePayload } from "./lineItemShipmentLock";
|
|
53
54
|
import {
|
|
54
55
|
normalizeCustomFieldSubmitValue,
|
|
55
56
|
extractCustomFieldValues,
|
|
@@ -279,6 +280,13 @@ type SalesLineDialogProps = {
|
|
|
279
280
|
tenantId: string | null;
|
|
280
281
|
initialLine?: SalesLineRecord | null;
|
|
281
282
|
shippedQuantity?: number;
|
|
283
|
+
/**
|
|
284
|
+
* Whether `shippedQuantity` reflects a fully resolved shipment state. Defaults
|
|
285
|
+
* to `true` so callers that genuinely know the value keep working; pass `false`
|
|
286
|
+
* while the host is still loading shipments or after the load failed, and the
|
|
287
|
+
* dialog locks pricing instead of assuming the line is unshipped.
|
|
288
|
+
*/
|
|
289
|
+
shippedQuantityResolved?: boolean;
|
|
282
290
|
onOpenChange: (open: boolean) => void;
|
|
283
291
|
onSaved?: () => Promise<void> | void;
|
|
284
292
|
onDraftSaved?: (payload: Record<string, unknown>, lineId: string | null) => Promise<void> | void;
|
|
@@ -475,6 +483,7 @@ export function LineItemDialog({
|
|
|
475
483
|
tenantId,
|
|
476
484
|
initialLine,
|
|
477
485
|
shippedQuantity = 0,
|
|
486
|
+
shippedQuantityResolved = true,
|
|
478
487
|
onOpenChange,
|
|
479
488
|
onSaved,
|
|
480
489
|
onDraftSaved,
|
|
@@ -513,6 +522,23 @@ export function LineItemDialog({
|
|
|
513
522
|
() => (kind === "order" ? "sales/order-lines" : "sales/quote-lines"),
|
|
514
523
|
[kind],
|
|
515
524
|
);
|
|
525
|
+
// A line the caller has not resolved shipment state for is treated as shipped:
|
|
526
|
+
// the server rejects pricing changes on shipped lines, so guessing "unshipped"
|
|
527
|
+
// from a pending or failed shipments load hands the user an edit that cannot
|
|
528
|
+
// be saved. A brand-new line has no shipments by construction.
|
|
529
|
+
const hasExistingLine = Boolean(initialLine);
|
|
530
|
+
const shipmentStateUnknown = kind === "order" && hasExistingLine && !shippedQuantityResolved;
|
|
531
|
+
const isShippedOrderLine =
|
|
532
|
+
kind === "order" &&
|
|
533
|
+
hasExistingLine &&
|
|
534
|
+
(shipmentStateUnknown || shippedQuantity > 0);
|
|
535
|
+
// While the shipments read is unresolved the shipped quantity is unknown, so the
|
|
536
|
+
// only safe floor for a quantity edit is the quantity already stored on the line:
|
|
537
|
+
// whatever turns out to be shipped can never exceed it. Raising stays allowed,
|
|
538
|
+
// exactly as it is once the state resolves.
|
|
539
|
+
const storedQuantity = Number(initialLine?.quantity ?? 0);
|
|
540
|
+
const safeStoredQuantity = Number.isFinite(storedQuantity) ? storedQuantity : 0;
|
|
541
|
+
const quantityFloor = shipmentStateUnknown ? safeStoredQuantity : shippedQuantity;
|
|
516
542
|
const documentKey = kind === "order" ? "orderId" : "quoteId";
|
|
517
543
|
const customFieldEntityId =
|
|
518
544
|
kind === "order" ? E.sales.sales_order_line : E.sales.sales_quote_line;
|
|
@@ -1330,12 +1356,17 @@ export function LineItemDialog({
|
|
|
1330
1356
|
},
|
|
1331
1357
|
);
|
|
1332
1358
|
}
|
|
1333
|
-
if (
|
|
1334
|
-
const message =
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1359
|
+
if (quantityFloor > 0 && qtyNumber < quantityFloor) {
|
|
1360
|
+
const message = shipmentStateUnknown
|
|
1361
|
+
? t(
|
|
1362
|
+
"sales.documents.items.errorQuantityShipmentsUnknown",
|
|
1363
|
+
"The quantity cannot be lowered until this order's shipments have been read. Reopen the order to try again.",
|
|
1364
|
+
)
|
|
1365
|
+
: t(
|
|
1366
|
+
"sales.documents.items.errorQuantityBelowShipped",
|
|
1367
|
+
"You cannot lower the quantity below the {{shipped}} already shipped.",
|
|
1368
|
+
{ shipped: shippedQuantity },
|
|
1369
|
+
);
|
|
1339
1370
|
throw createCrudFormError(message, { quantity: message });
|
|
1340
1371
|
}
|
|
1341
1372
|
const resolvedQuantityUnit = (() => {
|
|
@@ -1509,9 +1540,20 @@ export function LineItemDialog({
|
|
|
1509
1540
|
}
|
|
1510
1541
|
if (resolvedName) payload.name = resolvedName;
|
|
1511
1542
|
|
|
1543
|
+
const submittedPayload = prepareShippedLineUpdatePayload(
|
|
1544
|
+
payload,
|
|
1545
|
+
isShippedOrderLine && initialLine
|
|
1546
|
+
? {
|
|
1547
|
+
quantity: initialLine.quantity,
|
|
1548
|
+
totalNetAmount: initialLine.totalNet,
|
|
1549
|
+
totalGrossAmount: initialLine.totalGross,
|
|
1550
|
+
}
|
|
1551
|
+
: null,
|
|
1552
|
+
);
|
|
1553
|
+
|
|
1512
1554
|
try {
|
|
1513
1555
|
if (onDraftSaved) {
|
|
1514
|
-
await onDraftSaved(
|
|
1556
|
+
await onDraftSaved(submittedPayload, editingId);
|
|
1515
1557
|
closeDialog();
|
|
1516
1558
|
return;
|
|
1517
1559
|
}
|
|
@@ -1521,7 +1563,9 @@ export function LineItemDialog({
|
|
|
1521
1563
|
() =>
|
|
1522
1564
|
action(
|
|
1523
1565
|
resourcePath,
|
|
1524
|
-
editingId
|
|
1566
|
+
editingId
|
|
1567
|
+
? { id: editingId, ...submittedPayload }
|
|
1568
|
+
: submittedPayload,
|
|
1525
1569
|
{
|
|
1526
1570
|
errorMessage: t(
|
|
1527
1571
|
"sales.documents.items.errorSave",
|
|
@@ -1552,10 +1596,15 @@ export function LineItemDialog({
|
|
|
1552
1596
|
documentKey,
|
|
1553
1597
|
documentUpdatedAt,
|
|
1554
1598
|
editingId,
|
|
1599
|
+
initialLine,
|
|
1600
|
+
isShippedOrderLine,
|
|
1555
1601
|
onDraftSaved,
|
|
1556
1602
|
priceOptions,
|
|
1557
1603
|
productOption,
|
|
1604
|
+
quantityFloor,
|
|
1558
1605
|
resourcePath,
|
|
1606
|
+
shipmentStateUnknown,
|
|
1607
|
+
shippedQuantity,
|
|
1559
1608
|
t,
|
|
1560
1609
|
variantOption,
|
|
1561
1610
|
onSaved,
|
|
@@ -1613,6 +1662,7 @@ export function LineItemDialog({
|
|
|
1613
1662
|
type="button"
|
|
1614
1663
|
size="sm"
|
|
1615
1664
|
variant={mode === "catalog" ? "default" : "ghost"}
|
|
1665
|
+
disabled={isShippedOrderLine}
|
|
1616
1666
|
onClick={() => switchMode("catalog")}
|
|
1617
1667
|
>
|
|
1618
1668
|
{t("sales.documents.items.lineMode.catalog", "Catalog item")}
|
|
@@ -1621,6 +1671,7 @@ export function LineItemDialog({
|
|
|
1621
1671
|
type="button"
|
|
1622
1672
|
size="sm"
|
|
1623
1673
|
variant={mode === "custom" ? "default" : "ghost"}
|
|
1674
|
+
disabled={isShippedOrderLine}
|
|
1624
1675
|
onClick={() => switchMode("custom")}
|
|
1625
1676
|
>
|
|
1626
1677
|
{t("sales.documents.items.lineMode.custom", "Custom line")}
|
|
@@ -1801,6 +1852,7 @@ export function LineItemDialog({
|
|
|
1801
1852
|
},
|
|
1802
1853
|
)
|
|
1803
1854
|
}
|
|
1855
|
+
disabled={isShippedOrderLine}
|
|
1804
1856
|
/>
|
|
1805
1857
|
),
|
|
1806
1858
|
} satisfies CrudField,
|
|
@@ -1960,7 +2012,7 @@ export function LineItemDialog({
|
|
|
1960
2012
|
},
|
|
1961
2013
|
)
|
|
1962
2014
|
}
|
|
1963
|
-
disabled={!productId}
|
|
2015
|
+
disabled={isShippedOrderLine || !productId}
|
|
1964
2016
|
/>
|
|
1965
2017
|
);
|
|
1966
2018
|
},
|
|
@@ -1984,6 +2036,51 @@ export function LineItemDialog({
|
|
|
1984
2036
|
typeof values?.variantId === "string"
|
|
1985
2037
|
? values.variantId
|
|
1986
2038
|
: null;
|
|
2039
|
+
const selectedPriceId =
|
|
2040
|
+
typeof value === "string" ? value : null;
|
|
2041
|
+
const selectedPrice = selectedPriceId
|
|
2042
|
+
? (priceOptions.find(
|
|
2043
|
+
(entry) => entry.id === selectedPriceId,
|
|
2044
|
+
) ?? null)
|
|
2045
|
+
: null;
|
|
2046
|
+
if (isShippedOrderLine) {
|
|
2047
|
+
const lockedAmount = normalizeNumber(
|
|
2048
|
+
values?.unitPrice,
|
|
2049
|
+
Number.NaN,
|
|
2050
|
+
);
|
|
2051
|
+
const lockedCurrency =
|
|
2052
|
+
selectedPrice?.currencyCode ??
|
|
2053
|
+
(typeof values?.currencyCode === "string"
|
|
2054
|
+
? values.currencyCode
|
|
2055
|
+
: currencyCode) ??
|
|
2056
|
+
undefined;
|
|
2057
|
+
const lockedModeLabel =
|
|
2058
|
+
values?.priceMode === "net"
|
|
2059
|
+
? t("sales.documents.items.priceNet", "Net")
|
|
2060
|
+
: t("sales.documents.items.priceGross", "Gross");
|
|
2061
|
+
const lockedAmountLabel = Number.isFinite(lockedAmount)
|
|
2062
|
+
? `${formatMoney(lockedAmount, lockedCurrency)} — ${lockedModeLabel}`
|
|
2063
|
+
: lockedModeLabel;
|
|
2064
|
+
const lockedPriceDetail =
|
|
2065
|
+
selectedPrice?.priceKindTitle ??
|
|
2066
|
+
selectedPrice?.priceKindCode ??
|
|
2067
|
+
null;
|
|
2068
|
+
return (
|
|
2069
|
+
<div className="space-y-2">
|
|
2070
|
+
<Input
|
|
2071
|
+
readOnly
|
|
2072
|
+
disabled
|
|
2073
|
+
value={lockedAmountLabel}
|
|
2074
|
+
aria-label={t("sales.documents.items.price", "Price")}
|
|
2075
|
+
/>
|
|
2076
|
+
{lockedPriceDetail ? (
|
|
2077
|
+
<p className="text-xs text-muted-foreground">
|
|
2078
|
+
{lockedPriceDetail}
|
|
2079
|
+
</p>
|
|
2080
|
+
) : null}
|
|
2081
|
+
</div>
|
|
2082
|
+
);
|
|
2083
|
+
}
|
|
1987
2084
|
return (
|
|
1988
2085
|
<LookupSelect
|
|
1989
2086
|
key={
|
|
@@ -1991,7 +2088,7 @@ export function LineItemDialog({
|
|
|
1991
2088
|
? `${productId}-${variantId ?? "no-variant"}`
|
|
1992
2089
|
: "price"
|
|
1993
2090
|
}
|
|
1994
|
-
value={
|
|
2091
|
+
value={selectedPriceId}
|
|
1995
2092
|
onChange={(next) => {
|
|
1996
2093
|
setValue(next ?? null);
|
|
1997
2094
|
const selected = next
|
|
@@ -2133,9 +2230,11 @@ export function LineItemDialog({
|
|
|
2133
2230
|
}
|
|
2134
2231
|
onChange={(event) => setValue(event.target.value)}
|
|
2135
2232
|
placeholder="0.00"
|
|
2233
|
+
disabled={isShippedOrderLine}
|
|
2136
2234
|
/>
|
|
2137
2235
|
<Select
|
|
2138
2236
|
value={mode}
|
|
2237
|
+
disabled={isShippedOrderLine}
|
|
2139
2238
|
onValueChange={(value) => {
|
|
2140
2239
|
const nextMode = value === "net" ? "net" : "gross";
|
|
2141
2240
|
setFormValue?.("priceMode", nextMode);
|
|
@@ -2222,7 +2321,7 @@ export function LineItemDialog({
|
|
|
2222
2321
|
<Select
|
|
2223
2322
|
value={resolvedValue || undefined}
|
|
2224
2323
|
onValueChange={(value) => handleChange({ target: { value } } as React.ChangeEvent<HTMLSelectElement>)}
|
|
2225
|
-
disabled={!taxRates.length}
|
|
2324
|
+
disabled={isShippedOrderLine || !taxRates.length}
|
|
2226
2325
|
>
|
|
2227
2326
|
<SelectTrigger>
|
|
2228
2327
|
<SelectValue
|
|
@@ -2292,6 +2391,7 @@ export function LineItemDialog({
|
|
|
2292
2391
|
<Input
|
|
2293
2392
|
value={typeof value === "string" ? value : ""}
|
|
2294
2393
|
onChange={(event) => setValue(event.target.value || null)}
|
|
2394
|
+
disabled={isShippedOrderLine}
|
|
2295
2395
|
placeholder={t(
|
|
2296
2396
|
"sales.documents.items.quantityUnitPlaceholder",
|
|
2297
2397
|
"e.g. pc",
|
|
@@ -2349,7 +2449,7 @@ export function LineItemDialog({
|
|
|
2349
2449
|
});
|
|
2350
2450
|
}
|
|
2351
2451
|
}}
|
|
2352
|
-
disabled={!productId}
|
|
2452
|
+
disabled={isShippedOrderLine || !productId}
|
|
2353
2453
|
>
|
|
2354
2454
|
<SelectTrigger>
|
|
2355
2455
|
<SelectValue
|
|
@@ -2394,7 +2494,7 @@ export function LineItemDialog({
|
|
|
2394
2494
|
typeof values?.quantityUnit === "string"
|
|
2395
2495
|
? values.quantityUnit
|
|
2396
2496
|
: null;
|
|
2397
|
-
if (productId) {
|
|
2497
|
+
if (productId && !isShippedOrderLine) {
|
|
2398
2498
|
const selectedPriceId =
|
|
2399
2499
|
typeof values?.priceId === "string" ? values.priceId : null;
|
|
2400
2500
|
const selectedPriceKindId =
|
|
@@ -2549,6 +2649,7 @@ export function LineItemDialog({
|
|
|
2549
2649
|
resolveTaxSelection,
|
|
2550
2650
|
selectPriceAfterRefresh,
|
|
2551
2651
|
hasTaxMetadata,
|
|
2652
|
+
isShippedOrderLine,
|
|
2552
2653
|
]);
|
|
2553
2654
|
|
|
2554
2655
|
const groups = React.useMemo<CrudFormGroup[]>(() => {
|
|
@@ -2914,6 +3015,21 @@ export function LineItemDialog({
|
|
|
2914
3015
|
: t("sales.documents.items.addTitle", "Add line")}
|
|
2915
3016
|
</DialogTitle>
|
|
2916
3017
|
</DialogHeader>
|
|
3018
|
+
{isShippedOrderLine ? (
|
|
3019
|
+
<Alert status="information" style="lighter">
|
|
3020
|
+
<AlertDescription>
|
|
3021
|
+
{shipmentStateUnknown
|
|
3022
|
+
? t(
|
|
3023
|
+
"sales.documents.items.shippedLineLockPending",
|
|
3024
|
+
"Pricing is locked until this order's shipments have been read. Reopen the order to try again — you can still edit the name and raise the quantity.",
|
|
3025
|
+
)
|
|
3026
|
+
: t(
|
|
3027
|
+
"sales.documents.items.shippedLineLocked",
|
|
3028
|
+
"Pricing is locked on this line because it already has shipped items. You can still edit the name and quantity.",
|
|
3029
|
+
)}
|
|
3030
|
+
</AlertDescription>
|
|
3031
|
+
</Alert>
|
|
3032
|
+
) : null}
|
|
2917
3033
|
<CrudForm<LineFormState>
|
|
2918
3034
|
key={formResetKey}
|
|
2919
3035
|
embedded
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
type ShippedLineSnapshot = {
|
|
2
|
+
quantity: number;
|
|
3
|
+
totalNetAmount?: number | null;
|
|
4
|
+
totalGrossAmount?: number | null;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
const SHIPPED_LINE_IMMUTABLE_PAYLOAD_FIELDS = [
|
|
8
|
+
"kind",
|
|
9
|
+
"productId",
|
|
10
|
+
"productVariantId",
|
|
11
|
+
"quantityUnit",
|
|
12
|
+
"unitPriceNet",
|
|
13
|
+
"unitPriceGross",
|
|
14
|
+
"priceId",
|
|
15
|
+
"priceMode",
|
|
16
|
+
"taxRateId",
|
|
17
|
+
"taxRate",
|
|
18
|
+
"taxAmount",
|
|
19
|
+
"discountAmount",
|
|
20
|
+
"discountPercent",
|
|
21
|
+
"catalogSnapshot",
|
|
22
|
+
"metadata",
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
function scaleTotal(
|
|
26
|
+
total: number | null | undefined,
|
|
27
|
+
previousQuantity: number,
|
|
28
|
+
nextQuantity: number,
|
|
29
|
+
): number | undefined {
|
|
30
|
+
if (
|
|
31
|
+
!Number.isFinite(total) ||
|
|
32
|
+
!Number.isFinite(previousQuantity) ||
|
|
33
|
+
previousQuantity <= 0
|
|
34
|
+
) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
return (total as number) * (nextQuantity / previousQuantity);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function prepareShippedLineUpdatePayload(
|
|
41
|
+
payload: Record<string, unknown>,
|
|
42
|
+
currentLine: ShippedLineSnapshot | null,
|
|
43
|
+
): Record<string, unknown> {
|
|
44
|
+
if (!currentLine) return payload;
|
|
45
|
+
|
|
46
|
+
const nextPayload = { ...payload };
|
|
47
|
+
for (const field of SHIPPED_LINE_IMMUTABLE_PAYLOAD_FIELDS) {
|
|
48
|
+
delete nextPayload[field];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
delete nextPayload.totalNetAmount;
|
|
52
|
+
delete nextPayload.totalGrossAmount;
|
|
53
|
+
|
|
54
|
+
const nextQuantity = Number(nextPayload.quantity);
|
|
55
|
+
if (!Number.isFinite(nextQuantity) || nextQuantity === currentLine.quantity) {
|
|
56
|
+
return nextPayload;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const scaledNetTotal = scaleTotal(
|
|
60
|
+
currentLine.totalNetAmount,
|
|
61
|
+
currentLine.quantity,
|
|
62
|
+
nextQuantity,
|
|
63
|
+
);
|
|
64
|
+
const scaledGrossTotal = scaleTotal(
|
|
65
|
+
currentLine.totalGrossAmount,
|
|
66
|
+
currentLine.quantity,
|
|
67
|
+
nextQuantity,
|
|
68
|
+
);
|
|
69
|
+
if (scaledNetTotal !== undefined) nextPayload.totalNetAmount = scaledNetTotal;
|
|
70
|
+
if (scaledGrossTotal !== undefined)
|
|
71
|
+
nextPayload.totalGrossAmount = scaledGrossTotal;
|
|
72
|
+
|
|
73
|
+
return nextPayload;
|
|
74
|
+
}
|
|
@@ -979,6 +979,7 @@
|
|
|
979
979
|
"sales.documents.items.errorProductRequired": "Wähle ein Produkt aus, um fortzufahren.",
|
|
980
980
|
"sales.documents.items.errorQuantity": "Die Menge muss größer als 0 sein.",
|
|
981
981
|
"sales.documents.items.errorQuantityBelowShipped": "Die Menge kann nicht unter die bereits versandten {{shipped}} gesenkt werden.",
|
|
982
|
+
"sales.documents.items.errorQuantityShipmentsUnknown": "Die Menge kann erst gesenkt werden, wenn die Sendungen dieses Auftrags geladen wurden. Öffnen Sie den Auftrag erneut, um es noch einmal zu versuchen.",
|
|
982
983
|
"sales.documents.items.errorSave": "Position konnte nicht gespeichert werden.",
|
|
983
984
|
"sales.documents.items.errorScope": "Organisation und Mandant sind erforderlich.",
|
|
984
985
|
"sales.documents.items.errorUnitPrice": "Der Stückpreis muss größer als 0 sein.",
|
|
@@ -1022,6 +1023,8 @@
|
|
|
1022
1023
|
"sales.documents.items.save": "Änderungen speichern",
|
|
1023
1024
|
"sales.documents.items.selectedProduct": "Ausgewählt {{id}}",
|
|
1024
1025
|
"sales.documents.items.selectedVariant": "Ausgewählt {{id}}",
|
|
1026
|
+
"sales.documents.items.shippedLineLockPending": "Die Preisangaben sind gesperrt, bis die Sendungen dieses Auftrags geladen wurden. Öffnen Sie den Auftrag erneut, um es noch einmal zu versuchen — Name und eine Erhöhung der Menge können weiterhin bearbeitet werden.",
|
|
1027
|
+
"sales.documents.items.shippedLineLocked": "Die Preisangaben dieser Position sind gesperrt, da bereits Artikel versandt wurden. Name und Menge können weiterhin bearbeitet werden.",
|
|
1025
1028
|
"sales.documents.items.status": "Status",
|
|
1026
1029
|
"sales.documents.items.statusEmpty": "Kein Status",
|
|
1027
1030
|
"sales.documents.items.statusLoading": "Status werden geladen…",
|
|
@@ -979,6 +979,7 @@
|
|
|
979
979
|
"sales.documents.items.errorProductRequired": "Select a product to continue.",
|
|
980
980
|
"sales.documents.items.errorQuantity": "Quantity must be greater than 0.",
|
|
981
981
|
"sales.documents.items.errorQuantityBelowShipped": "You cannot lower the quantity below the {{shipped}} already shipped.",
|
|
982
|
+
"sales.documents.items.errorQuantityShipmentsUnknown": "The quantity cannot be lowered until this order's shipments have been read. Reopen the order to try again.",
|
|
982
983
|
"sales.documents.items.errorSave": "Failed to save line.",
|
|
983
984
|
"sales.documents.items.errorScope": "Organization and tenant are required.",
|
|
984
985
|
"sales.documents.items.errorUnitPrice": "Unit price must be greater than 0.",
|
|
@@ -1022,6 +1023,8 @@
|
|
|
1022
1023
|
"sales.documents.items.save": "Save changes",
|
|
1023
1024
|
"sales.documents.items.selectedProduct": "Selected {{id}}",
|
|
1024
1025
|
"sales.documents.items.selectedVariant": "Selected {{id}}",
|
|
1026
|
+
"sales.documents.items.shippedLineLockPending": "Pricing is locked until this order's shipments have been read. Reopen the order to try again — you can still edit the name and raise the quantity.",
|
|
1027
|
+
"sales.documents.items.shippedLineLocked": "Pricing is locked on this line because it already has shipped items. You can still edit the name and quantity.",
|
|
1025
1028
|
"sales.documents.items.status": "Status",
|
|
1026
1029
|
"sales.documents.items.statusEmpty": "No status",
|
|
1027
1030
|
"sales.documents.items.statusLoading": "Loading statuses…",
|
|
@@ -979,6 +979,7 @@
|
|
|
979
979
|
"sales.documents.items.errorProductRequired": "Selecciona un producto para continuar.",
|
|
980
980
|
"sales.documents.items.errorQuantity": "La cantidad debe ser mayor que 0.",
|
|
981
981
|
"sales.documents.items.errorQuantityBelowShipped": "No puedes reducir la cantidad por debajo de las {{shipped}} ya enviadas.",
|
|
982
|
+
"sales.documents.items.errorQuantityShipmentsUnknown": "La cantidad no se puede reducir hasta que se carguen los envíos de este pedido. Vuelve a abrir el pedido para intentarlo de nuevo.",
|
|
982
983
|
"sales.documents.items.errorSave": "No se pudo guardar la línea.",
|
|
983
984
|
"sales.documents.items.errorScope": "Se requieren organización e inquilino.",
|
|
984
985
|
"sales.documents.items.errorUnitPrice": "El precio unitario debe ser mayor que 0.",
|
|
@@ -1022,6 +1023,8 @@
|
|
|
1022
1023
|
"sales.documents.items.save": "Guardar cambios",
|
|
1023
1024
|
"sales.documents.items.selectedProduct": "Seleccionado {{id}}",
|
|
1024
1025
|
"sales.documents.items.selectedVariant": "Seleccionado {{id}}",
|
|
1026
|
+
"sales.documents.items.shippedLineLockPending": "Los precios están bloqueados hasta que se carguen los envíos de este pedido. Vuelve a abrir el pedido para intentarlo de nuevo — todavía puedes editar el nombre y aumentar la cantidad.",
|
|
1027
|
+
"sales.documents.items.shippedLineLocked": "Los precios de esta línea están bloqueados porque ya tiene envíos. Todavía puedes editar el nombre y la cantidad.",
|
|
1025
1028
|
"sales.documents.items.status": "Estado",
|
|
1026
1029
|
"sales.documents.items.statusEmpty": "Sin estado",
|
|
1027
1030
|
"sales.documents.items.statusLoading": "Cargando estados…",
|
|
@@ -979,6 +979,7 @@
|
|
|
979
979
|
"sales.documents.items.errorProductRequired": "계속하려면 상품을 선택하세요.",
|
|
980
980
|
"sales.documents.items.errorQuantity": "수량은 0보다 커야 합니다.",
|
|
981
981
|
"sales.documents.items.errorQuantityBelowShipped": "이미 배송된 {{shipped}} 이하로 수량을 줄일 수 없습니다.",
|
|
982
|
+
"sales.documents.items.errorQuantityShipmentsUnknown": "이 주문의 배송 정보를 불러올 때까지 수량을 줄일 수 없습니다. 주문을 다시 열어 재시도하세요.",
|
|
982
983
|
"sales.documents.items.errorSave": "품목을 저장하지 못했습니다.",
|
|
983
984
|
"sales.documents.items.errorScope": "조직 및 테넌트 정보가 필요합니다.",
|
|
984
985
|
"sales.documents.items.errorUnitPrice": "단가는 0보다 커야 합니다.",
|
|
@@ -1022,6 +1023,8 @@
|
|
|
1022
1023
|
"sales.documents.items.save": "변경사항 저장",
|
|
1023
1024
|
"sales.documents.items.selectedProduct": "선택됨 {{id}}",
|
|
1024
1025
|
"sales.documents.items.selectedVariant": "선택됨 {{id}}",
|
|
1026
|
+
"sales.documents.items.shippedLineLockPending": "이 주문의 선적 정보를 불러올 때까지 가격 정보가 잠깁니다. 주문을 다시 열어 재시도하세요 — 이름 수정과 수량 증가는 계속 가능합니다.",
|
|
1027
|
+
"sales.documents.items.shippedLineLocked": "이 라인은 이미 선적된 품목이 있어 가격 정보가 잠겨 있습니다. 이름과 수량은 계속 수정할 수 있습니다.",
|
|
1025
1028
|
"sales.documents.items.status": "상태",
|
|
1026
1029
|
"sales.documents.items.statusEmpty": "상태 없음",
|
|
1027
1030
|
"sales.documents.items.statusLoading": "상태 목록을 불러오는 중…",
|