@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.
@@ -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.7057.1.61440fc3bc",
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.7057.1.61440fc3bc",
256
- "@open-mercato/shared": "0.6.8-develop.7057.1.61440fc3bc",
257
- "@open-mercato/ui": "0.6.8-develop.7057.1.61440fc3bc",
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.7057.1.61440fc3bc",
263
- "@open-mercato/shared": "0.6.8-develop.7057.1.61440fc3bc",
264
- "@open-mercato/ui": "0.6.8-develop.7057.1.61440fc3bc",
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 params = new URLSearchParams({
386
- page: "1",
387
- pageSize: "100",
388
- orderId: documentId,
389
- });
390
- const response = await apiCall<{
391
- items?: Array<Record<string, unknown>>;
392
- }>(`/api/sales/shipments?${params.toString()}`, undefined, {
393
- fallback: { items: [] },
394
- });
395
- if (response.ok && Array.isArray(response.result?.items)) {
396
- const totals = new Map<string, number>();
397
- response.result.items.forEach((shipment) => {
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
- setShippedTotals(totals);
416
- } else {
417
- setShippedTotals(new Map());
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 (shippedQuantity > 0 && qtyNumber < shippedQuantity) {
1334
- const message = t(
1335
- "sales.documents.items.errorQuantityBelowShipped",
1336
- "You cannot lower the quantity below the {{shipped}} already shipped.",
1337
- { shipped: shippedQuantity },
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(payload, editingId);
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 ? { id: editingId, ...payload } : payload,
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={typeof value === "string" ? value : null}
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": "상태 목록을 불러오는 중…",