@open-mercato/core 0.6.6-develop.6525.1.e9a4b56963 → 0.6.6-develop.6529.1.e1de4b3f27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/customer_accounts/widgets/injection-table.js +0 -2
  3. package/dist/modules/customer_accounts/widgets/injection-table.js.map +2 -2
  4. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +7 -1
  5. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
  6. package/dist/modules/customers/components/detail/CompanyDetailTabs.js +11 -7
  7. package/dist/modules/customers/components/detail/CompanyDetailTabs.js.map +2 -2
  8. package/dist/modules/customers/components/detail/useDealsAccess.js +16 -0
  9. package/dist/modules/customers/components/detail/useDealsAccess.js.map +7 -0
  10. package/dist/modules/sales/commands/documents.js +44 -0
  11. package/dist/modules/sales/commands/documents.js.map +2 -2
  12. package/dist/modules/sales/commands/returns.js +2 -27
  13. package/dist/modules/sales/commands/returns.js.map +2 -2
  14. package/dist/modules/sales/components/documents/ItemsSection.js +1 -0
  15. package/dist/modules/sales/components/documents/ItemsSection.js.map +2 -2
  16. package/dist/modules/sales/components/documents/LineItemDialog.js +9 -0
  17. package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
  18. package/dist/modules/sales/lib/shipments/snapshots.js +27 -1
  19. package/dist/modules/sales/lib/shipments/snapshots.js.map +2 -2
  20. package/package.json +7 -7
  21. package/src/modules/customer_accounts/AGENTS.md +8 -6
  22. package/src/modules/customer_accounts/widgets/injection-table.ts +0 -2
  23. package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +11 -1
  24. package/src/modules/customers/components/detail/CompanyDetailTabs.tsx +13 -7
  25. package/src/modules/customers/components/detail/useDealsAccess.ts +19 -0
  26. package/src/modules/sales/commands/documents.ts +65 -0
  27. package/src/modules/sales/commands/returns.ts +2 -32
  28. package/src/modules/sales/components/documents/ItemsSection.tsx +5 -0
  29. package/src/modules/sales/components/documents/LineItemDialog.tsx +10 -0
  30. package/src/modules/sales/i18n/de.json +4 -0
  31. package/src/modules/sales/i18n/en.json +4 -0
  32. package/src/modules/sales/i18n/es.json +4 -0
  33. package/src/modules/sales/i18n/pl.json +4 -0
  34. package/src/modules/sales/lib/shipments/snapshots.ts +30 -0
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "crypto";
2
- import { SalesOrderLine, SalesShipmentItem } from "../../data/entities.js";
2
+ import { SalesOrderLine, SalesShipment, SalesShipmentItem } from "../../data/entities.js";
3
3
  import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
4
4
  const cloneJson = (value) => {
5
5
  if (value === null || value === void 0) return value;
@@ -84,9 +84,35 @@ const refreshShipmentItemsSnapshot = async (em, shipment, options) => {
84
84
  shipment.itemsSnapshot = snapshot.length ? snapshot : null;
85
85
  return snapshot;
86
86
  };
87
+ const loadShippedQuantityByLine = async (em, orderId, scope) => {
88
+ const shipments = await findWithDecryption(
89
+ em,
90
+ SalesShipment,
91
+ { order: orderId, deletedAt: null },
92
+ {},
93
+ scope
94
+ );
95
+ const shippedByLine = /* @__PURE__ */ new Map();
96
+ if (!shipments.length) return shippedByLine;
97
+ const items = await findWithDecryption(
98
+ em,
99
+ SalesShipmentItem,
100
+ { shipment: { $in: shipments.map((shipment) => shipment.id) } },
101
+ {},
102
+ scope
103
+ );
104
+ items.forEach((item) => {
105
+ const orderLineId = typeof item.orderLine === "string" ? item.orderLine : item.orderLine?.id ?? null;
106
+ if (!orderLineId) return;
107
+ const next = (shippedByLine.get(orderLineId) ?? 0) + coerceShipmentQuantity(item.quantity);
108
+ shippedByLine.set(orderLineId, next);
109
+ });
110
+ return shippedByLine;
111
+ };
87
112
  export {
88
113
  buildShipmentItemSnapshots,
89
114
  coerceShipmentQuantity,
115
+ loadShippedQuantityByLine,
90
116
  readShipmentItemsSnapshot,
91
117
  refreshShipmentItemsSnapshot
92
118
  };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/sales/lib/shipments/snapshots.ts"],
4
- "sourcesContent": ["import { randomUUID } from 'crypto'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { SalesOrderLine, SalesShipment, SalesShipmentItem } from '../../data/entities'\nimport type { ShipmentItemSnapshot } from './types'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\n\nconst cloneJson = <T>(value: T): T => {\n if (value === null || value === undefined) return value\n return JSON.parse(JSON.stringify(value)) as T\n}\n\nexport const coerceShipmentQuantity = (value: unknown): number => {\n if (typeof value === 'number' && Number.isFinite(value)) return value\n if (typeof value === 'string' && value.trim().length) {\n const parsed = Number(value)\n if (!Number.isNaN(parsed)) return parsed\n }\n return 0\n}\n\nconst extractOrderLineId = (entry: SalesShipmentItem): string | null => {\n const raw =\n typeof entry.orderLine === 'string'\n ? entry.orderLine\n : entry.orderLine?.id ?? (entry as any).orderLineId ?? null\n return typeof raw === 'string' ? raw : null\n}\n\nconst ensureLineMap = async ( // NOSONAR \u2014 returns same Map ref but populated from DB between early return and final return\n em: EntityManager,\n items: SalesShipmentItem[],\n lineMap?: Map<string, SalesOrderLine>\n): Promise<Map<string, SalesOrderLine>> => {\n const map = lineMap ?? new Map<string, SalesOrderLine>()\n const missing: string[] = []\n items.forEach((item) => {\n const lineId = extractOrderLineId(item)\n if (!lineId || map.has(lineId)) return\n missing.push(lineId)\n })\n if (!missing.length) return map\n const lines = await em.find(SalesOrderLine, { id: { $in: missing } })\n lines.forEach((line) => map.set(line.id, line))\n return map\n}\n\nexport const buildShipmentItemSnapshots = (\n items: SalesShipmentItem[],\n options?: { lineMap?: Map<string, SalesOrderLine> }\n): ShipmentItemSnapshot[] => {\n const map = options?.lineMap ?? new Map<string, SalesOrderLine>()\n return items\n .map((item) => {\n const orderLineId = extractOrderLineId(item)\n if (!orderLineId) return null\n const line = map.get(orderLineId) ?? null\n const id = typeof item.id === 'string' ? item.id : randomUUID()\n const quantity = coerceShipmentQuantity(item.quantity)\n const metadata =\n item.metadata && typeof item.metadata === 'object' ? cloneJson(item.metadata) : null\n return {\n id,\n orderLineId,\n orderLineName: line?.name ?? null,\n orderLineNumber: line?.lineNumber ?? null,\n quantity,\n metadata,\n } satisfies ShipmentItemSnapshot\n })\n .filter((entry): entry is ShipmentItemSnapshot => Boolean(entry))\n}\n\nexport const readShipmentItemsSnapshot = (raw: unknown): ShipmentItemSnapshot[] => {\n if (!Array.isArray(raw)) return []\n return raw\n .map((entry) => {\n if (!entry || typeof entry !== 'object') return null\n const orderLineId =\n typeof (entry as any).orderLineId === 'string'\n ? (entry as any).orderLineId\n : typeof (entry as any).order_line_id === 'string'\n ? (entry as any).order_line_id\n : null\n if (!orderLineId) return null\n const quantity = coerceShipmentQuantity((entry as any).quantity)\n const orderLineNumberRaw = (entry as any).orderLineNumber ?? (entry as any).order_line_number\n const orderLineNumber =\n typeof orderLineNumberRaw === 'number' && Number.isFinite(orderLineNumberRaw)\n ? orderLineNumberRaw\n : typeof orderLineNumberRaw === 'string' && orderLineNumberRaw.trim().length\n ? Number(orderLineNumberRaw)\n : null\n const metadata =\n (entry as any).metadata && typeof (entry as any).metadata === 'object'\n ? cloneJson((entry as any).metadata as Record<string, unknown>)\n : null\n const orderLineName =\n typeof (entry as any).orderLineName === 'string'\n ? (entry as any).orderLineName\n : typeof (entry as any).order_line_name === 'string'\n ? (entry as any).order_line_name\n : null\n const id =\n typeof (entry as any).id === 'string' && (entry as any).id.length\n ? (entry as any).id\n : randomUUID()\n return {\n id,\n orderLineId,\n orderLineName,\n orderLineNumber: Number.isFinite(orderLineNumber) ? Number(orderLineNumber) : null,\n quantity,\n metadata,\n } satisfies ShipmentItemSnapshot\n })\n .filter((entry): entry is ShipmentItemSnapshot => Boolean(entry))\n}\n\nexport const refreshShipmentItemsSnapshot = async (\n em: EntityManager,\n shipment: SalesShipment,\n options?: { items?: SalesShipmentItem[]; lineMap?: Map<string, SalesOrderLine> }\n): Promise<ShipmentItemSnapshot[]> => {\n const items =\n options?.items ??\n (await findWithDecryption(\n em,\n SalesShipmentItem,\n { shipment },\n { populate: ['orderLine'] },\n { tenantId: shipment.tenantId ?? null, organizationId: shipment.organizationId ?? null },\n ))\n const map = await ensureLineMap(em, items, options?.lineMap)\n const snapshot = buildShipmentItemSnapshots(items, { lineMap: map })\n shipment.itemsSnapshot = snapshot.length ? snapshot : null\n return snapshot\n}\n\nexport type { ShipmentItemSnapshot } from './types'\n"],
5
- "mappings": "AAAA,SAAS,kBAAkB;AAE3B,SAAS,gBAA+B,yBAAyB;AAEjE,SAAS,0BAA0B;AAEnC,MAAM,YAAY,CAAI,UAAgB;AACpC,MAAI,UAAU,QAAQ,UAAU,OAAW,QAAO;AAClD,SAAO,KAAK,MAAM,KAAK,UAAU,KAAK,CAAC;AACzC;AAEO,MAAM,yBAAyB,CAAC,UAA2B;AAChE,MAAI,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,EAAG,QAAO;AAChE,MAAI,OAAO,UAAU,YAAY,MAAM,KAAK,EAAE,QAAQ;AACpD,UAAM,SAAS,OAAO,KAAK;AAC3B,QAAI,CAAC,OAAO,MAAM,MAAM,EAAG,QAAO;AAAA,EACpC;AACA,SAAO;AACT;AAEA,MAAM,qBAAqB,CAAC,UAA4C;AACtE,QAAM,MACJ,OAAO,MAAM,cAAc,WACvB,MAAM,YACN,MAAM,WAAW,MAAO,MAAc,eAAe;AAC3D,SAAO,OAAO,QAAQ,WAAW,MAAM;AACzC;AAEA,MAAM,gBAAgB,OACpB,IACA,OACA,YACyC;AACzC,QAAM,MAAM,WAAW,oBAAI,IAA4B;AACvD,QAAM,UAAoB,CAAC;AAC3B,QAAM,QAAQ,CAAC,SAAS;AACtB,UAAM,SAAS,mBAAmB,IAAI;AACtC,QAAI,CAAC,UAAU,IAAI,IAAI,MAAM,EAAG;AAChC,YAAQ,KAAK,MAAM;AAAA,EACrB,CAAC;AACD,MAAI,CAAC,QAAQ,OAAQ,QAAO;AAC5B,QAAM,QAAQ,MAAM,GAAG,KAAK,gBAAgB,EAAE,IAAI,EAAE,KAAK,QAAQ,EAAE,CAAC;AACpE,QAAM,QAAQ,CAAC,SAAS,IAAI,IAAI,KAAK,IAAI,IAAI,CAAC;AAC9C,SAAO;AACT;AAEO,MAAM,6BAA6B,CACxC,OACA,YAC2B;AAC3B,QAAM,MAAM,SAAS,WAAW,oBAAI,IAA4B;AAChE,SAAO,MACJ,IAAI,CAAC,SAAS;AACb,UAAM,cAAc,mBAAmB,IAAI;AAC3C,QAAI,CAAC,YAAa,QAAO;AACzB,UAAM,OAAO,IAAI,IAAI,WAAW,KAAK;AACrC,UAAM,KAAK,OAAO,KAAK,OAAO,WAAW,KAAK,KAAK,WAAW;AAC9D,UAAM,WAAW,uBAAuB,KAAK,QAAQ;AACrD,UAAM,WACJ,KAAK,YAAY,OAAO,KAAK,aAAa,WAAW,UAAU,KAAK,QAAQ,IAAI;AAClF,WAAO;AAAA,MACL;AAAA,MACA;AAAA,MACA,eAAe,MAAM,QAAQ;AAAA,MAC7B,iBAAiB,MAAM,cAAc;AAAA,MACrC;AAAA,MACA;AAAA,IACF;AAAA,EACF,CAAC,EACA,OAAO,CAAC,UAAyC,QAAQ,KAAK,CAAC;AACpE;AAEO,MAAM,4BAA4B,CAAC,QAAyC;AACjF,MAAI,CAAC,MAAM,QAAQ,GAAG,EAAG,QAAO,CAAC;AACjC,SAAO,IACJ,IAAI,CAAC,UAAU;AACd,QAAI,CAAC,SAAS,OAAO,UAAU,SAAU,QAAO;AAChD,UAAM,cACJ,OAAQ,MAAc,gBAAgB,WACjC,MAAc,cACf,OAAQ,MAAc,kBAAkB,WACrC,MAAc,gBACf;AACR,QAAI,CAAC,YAAa,QAAO;AACzB,UAAM,WAAW,uBAAwB,MAAc,QAAQ;AAC/D,UAAM,qBAAsB,MAAc,mBAAoB,MAAc;AAC5E,UAAM,kBACJ,OAAO,uBAAuB,YAAY,OAAO,SAAS,kBAAkB,IACxE,qBACA,OAAO,uBAAuB,YAAY,mBAAmB,KAAK,EAAE,SAClE,OAAO,kBAAkB,IACzB;AACR,UAAM,WACH,MAAc,YAAY,OAAQ,MAAc,aAAa,WAC1D,UAAW,MAAc,QAAmC,IAC5D;AACN,UAAM,gBACJ,OAAQ,MAAc,kBAAkB,WACnC,MAAc,gBACf,OAAQ,MAAc,oBAAoB,WACvC,MAAc,kBACf;AACR,UAAM,KACJ,OAAQ,MAAc,OAAO,YAAa,MAAc,GAAG,SACtD,MAAc,KACf,WAAW;AACjB,WAAO;AAAA,MACL;AAAA,MACA;AAAA,MACA;AAAA,MACA,iBAAiB,OAAO,SAAS,eAAe,IAAI,OAAO,eAAe,IAAI;AAAA,MAC9E;AAAA,MACA;AAAA,IACF;AAAA,EACF,CAAC,EACA,OAAO,CAAC,UAAyC,QAAQ,KAAK,CAAC;AACpE;AAEO,MAAM,+BAA+B,OAC1C,IACA,UACA,YACoC;AACpC,QAAM,QACJ,SAAS,SACR,MAAM;AAAA,IACL;AAAA,IACA;AAAA,IACA,EAAE,SAAS;AAAA,IACX,EAAE,UAAU,CAAC,WAAW,EAAE;AAAA,IAC1B,EAAE,UAAU,SAAS,YAAY,MAAM,gBAAgB,SAAS,kBAAkB,KAAK;AAAA,EACzF;AACF,QAAM,MAAM,MAAM,cAAc,IAAI,OAAO,SAAS,OAAO;AAC3D,QAAM,WAAW,2BAA2B,OAAO,EAAE,SAAS,IAAI,CAAC;AACnE,WAAS,gBAAgB,SAAS,SAAS,WAAW;AACtD,SAAO;AACT;",
4
+ "sourcesContent": ["import { randomUUID } from 'crypto'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { SalesOrderLine, SalesShipment, SalesShipmentItem } from '../../data/entities'\nimport type { ShipmentItemSnapshot } from './types'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\n\nconst cloneJson = <T>(value: T): T => {\n if (value === null || value === undefined) return value\n return JSON.parse(JSON.stringify(value)) as T\n}\n\nexport const coerceShipmentQuantity = (value: unknown): number => {\n if (typeof value === 'number' && Number.isFinite(value)) return value\n if (typeof value === 'string' && value.trim().length) {\n const parsed = Number(value)\n if (!Number.isNaN(parsed)) return parsed\n }\n return 0\n}\n\nconst extractOrderLineId = (entry: SalesShipmentItem): string | null => {\n const raw =\n typeof entry.orderLine === 'string'\n ? entry.orderLine\n : entry.orderLine?.id ?? (entry as any).orderLineId ?? null\n return typeof raw === 'string' ? raw : null\n}\n\nconst ensureLineMap = async ( // NOSONAR \u2014 returns same Map ref but populated from DB between early return and final return\n em: EntityManager,\n items: SalesShipmentItem[],\n lineMap?: Map<string, SalesOrderLine>\n): Promise<Map<string, SalesOrderLine>> => {\n const map = lineMap ?? new Map<string, SalesOrderLine>()\n const missing: string[] = []\n items.forEach((item) => {\n const lineId = extractOrderLineId(item)\n if (!lineId || map.has(lineId)) return\n missing.push(lineId)\n })\n if (!missing.length) return map\n const lines = await em.find(SalesOrderLine, { id: { $in: missing } })\n lines.forEach((line) => map.set(line.id, line))\n return map\n}\n\nexport const buildShipmentItemSnapshots = (\n items: SalesShipmentItem[],\n options?: { lineMap?: Map<string, SalesOrderLine> }\n): ShipmentItemSnapshot[] => {\n const map = options?.lineMap ?? new Map<string, SalesOrderLine>()\n return items\n .map((item) => {\n const orderLineId = extractOrderLineId(item)\n if (!orderLineId) return null\n const line = map.get(orderLineId) ?? null\n const id = typeof item.id === 'string' ? item.id : randomUUID()\n const quantity = coerceShipmentQuantity(item.quantity)\n const metadata =\n item.metadata && typeof item.metadata === 'object' ? cloneJson(item.metadata) : null\n return {\n id,\n orderLineId,\n orderLineName: line?.name ?? null,\n orderLineNumber: line?.lineNumber ?? null,\n quantity,\n metadata,\n } satisfies ShipmentItemSnapshot\n })\n .filter((entry): entry is ShipmentItemSnapshot => Boolean(entry))\n}\n\nexport const readShipmentItemsSnapshot = (raw: unknown): ShipmentItemSnapshot[] => {\n if (!Array.isArray(raw)) return []\n return raw\n .map((entry) => {\n if (!entry || typeof entry !== 'object') return null\n const orderLineId =\n typeof (entry as any).orderLineId === 'string'\n ? (entry as any).orderLineId\n : typeof (entry as any).order_line_id === 'string'\n ? (entry as any).order_line_id\n : null\n if (!orderLineId) return null\n const quantity = coerceShipmentQuantity((entry as any).quantity)\n const orderLineNumberRaw = (entry as any).orderLineNumber ?? (entry as any).order_line_number\n const orderLineNumber =\n typeof orderLineNumberRaw === 'number' && Number.isFinite(orderLineNumberRaw)\n ? orderLineNumberRaw\n : typeof orderLineNumberRaw === 'string' && orderLineNumberRaw.trim().length\n ? Number(orderLineNumberRaw)\n : null\n const metadata =\n (entry as any).metadata && typeof (entry as any).metadata === 'object'\n ? cloneJson((entry as any).metadata as Record<string, unknown>)\n : null\n const orderLineName =\n typeof (entry as any).orderLineName === 'string'\n ? (entry as any).orderLineName\n : typeof (entry as any).order_line_name === 'string'\n ? (entry as any).order_line_name\n : null\n const id =\n typeof (entry as any).id === 'string' && (entry as any).id.length\n ? (entry as any).id\n : randomUUID()\n return {\n id,\n orderLineId,\n orderLineName,\n orderLineNumber: Number.isFinite(orderLineNumber) ? Number(orderLineNumber) : null,\n quantity,\n metadata,\n } satisfies ShipmentItemSnapshot\n })\n .filter((entry): entry is ShipmentItemSnapshot => Boolean(entry))\n}\n\nexport const refreshShipmentItemsSnapshot = async (\n em: EntityManager,\n shipment: SalesShipment,\n options?: { items?: SalesShipmentItem[]; lineMap?: Map<string, SalesOrderLine> }\n): Promise<ShipmentItemSnapshot[]> => {\n const items =\n options?.items ??\n (await findWithDecryption(\n em,\n SalesShipmentItem,\n { shipment },\n { populate: ['orderLine'] },\n { tenantId: shipment.tenantId ?? null, organizationId: shipment.organizationId ?? null },\n ))\n const map = await ensureLineMap(em, items, options?.lineMap)\n const snapshot = buildShipmentItemSnapshots(items, { lineMap: map })\n shipment.itemsSnapshot = snapshot.length ? snapshot : null\n return snapshot\n}\n\nexport const loadShippedQuantityByLine = async (\n em: EntityManager,\n orderId: string,\n scope: { tenantId: string; organizationId: string },\n): Promise<Map<string, number>> => {\n const shipments = await findWithDecryption(\n em,\n SalesShipment,\n { order: orderId, deletedAt: null },\n {},\n scope,\n )\n const shippedByLine = new Map<string, number>()\n if (!shipments.length) return shippedByLine\n const items = await findWithDecryption(\n em,\n SalesShipmentItem,\n { shipment: { $in: shipments.map((shipment) => shipment.id) } },\n {},\n scope,\n )\n items.forEach((item) => {\n const orderLineId = typeof item.orderLine === 'string' ? item.orderLine : (item.orderLine?.id ?? null)\n if (!orderLineId) return\n const next = (shippedByLine.get(orderLineId) ?? 0) + coerceShipmentQuantity(item.quantity)\n shippedByLine.set(orderLineId, next)\n })\n return shippedByLine\n}\n\nexport type { ShipmentItemSnapshot } from './types'\n"],
5
+ "mappings": "AAAA,SAAS,kBAAkB;AAE3B,SAAS,gBAAgB,eAAe,yBAAyB;AAEjE,SAAS,0BAA0B;AAEnC,MAAM,YAAY,CAAI,UAAgB;AACpC,MAAI,UAAU,QAAQ,UAAU,OAAW,QAAO;AAClD,SAAO,KAAK,MAAM,KAAK,UAAU,KAAK,CAAC;AACzC;AAEO,MAAM,yBAAyB,CAAC,UAA2B;AAChE,MAAI,OAAO,UAAU,YAAY,OAAO,SAAS,KAAK,EAAG,QAAO;AAChE,MAAI,OAAO,UAAU,YAAY,MAAM,KAAK,EAAE,QAAQ;AACpD,UAAM,SAAS,OAAO,KAAK;AAC3B,QAAI,CAAC,OAAO,MAAM,MAAM,EAAG,QAAO;AAAA,EACpC;AACA,SAAO;AACT;AAEA,MAAM,qBAAqB,CAAC,UAA4C;AACtE,QAAM,MACJ,OAAO,MAAM,cAAc,WACvB,MAAM,YACN,MAAM,WAAW,MAAO,MAAc,eAAe;AAC3D,SAAO,OAAO,QAAQ,WAAW,MAAM;AACzC;AAEA,MAAM,gBAAgB,OACpB,IACA,OACA,YACyC;AACzC,QAAM,MAAM,WAAW,oBAAI,IAA4B;AACvD,QAAM,UAAoB,CAAC;AAC3B,QAAM,QAAQ,CAAC,SAAS;AACtB,UAAM,SAAS,mBAAmB,IAAI;AACtC,QAAI,CAAC,UAAU,IAAI,IAAI,MAAM,EAAG;AAChC,YAAQ,KAAK,MAAM;AAAA,EACrB,CAAC;AACD,MAAI,CAAC,QAAQ,OAAQ,QAAO;AAC5B,QAAM,QAAQ,MAAM,GAAG,KAAK,gBAAgB,EAAE,IAAI,EAAE,KAAK,QAAQ,EAAE,CAAC;AACpE,QAAM,QAAQ,CAAC,SAAS,IAAI,IAAI,KAAK,IAAI,IAAI,CAAC;AAC9C,SAAO;AACT;AAEO,MAAM,6BAA6B,CACxC,OACA,YAC2B;AAC3B,QAAM,MAAM,SAAS,WAAW,oBAAI,IAA4B;AAChE,SAAO,MACJ,IAAI,CAAC,SAAS;AACb,UAAM,cAAc,mBAAmB,IAAI;AAC3C,QAAI,CAAC,YAAa,QAAO;AACzB,UAAM,OAAO,IAAI,IAAI,WAAW,KAAK;AACrC,UAAM,KAAK,OAAO,KAAK,OAAO,WAAW,KAAK,KAAK,WAAW;AAC9D,UAAM,WAAW,uBAAuB,KAAK,QAAQ;AACrD,UAAM,WACJ,KAAK,YAAY,OAAO,KAAK,aAAa,WAAW,UAAU,KAAK,QAAQ,IAAI;AAClF,WAAO;AAAA,MACL;AAAA,MACA;AAAA,MACA,eAAe,MAAM,QAAQ;AAAA,MAC7B,iBAAiB,MAAM,cAAc;AAAA,MACrC;AAAA,MACA;AAAA,IACF;AAAA,EACF,CAAC,EACA,OAAO,CAAC,UAAyC,QAAQ,KAAK,CAAC;AACpE;AAEO,MAAM,4BAA4B,CAAC,QAAyC;AACjF,MAAI,CAAC,MAAM,QAAQ,GAAG,EAAG,QAAO,CAAC;AACjC,SAAO,IACJ,IAAI,CAAC,UAAU;AACd,QAAI,CAAC,SAAS,OAAO,UAAU,SAAU,QAAO;AAChD,UAAM,cACJ,OAAQ,MAAc,gBAAgB,WACjC,MAAc,cACf,OAAQ,MAAc,kBAAkB,WACrC,MAAc,gBACf;AACR,QAAI,CAAC,YAAa,QAAO;AACzB,UAAM,WAAW,uBAAwB,MAAc,QAAQ;AAC/D,UAAM,qBAAsB,MAAc,mBAAoB,MAAc;AAC5E,UAAM,kBACJ,OAAO,uBAAuB,YAAY,OAAO,SAAS,kBAAkB,IACxE,qBACA,OAAO,uBAAuB,YAAY,mBAAmB,KAAK,EAAE,SAClE,OAAO,kBAAkB,IACzB;AACR,UAAM,WACH,MAAc,YAAY,OAAQ,MAAc,aAAa,WAC1D,UAAW,MAAc,QAAmC,IAC5D;AACN,UAAM,gBACJ,OAAQ,MAAc,kBAAkB,WACnC,MAAc,gBACf,OAAQ,MAAc,oBAAoB,WACvC,MAAc,kBACf;AACR,UAAM,KACJ,OAAQ,MAAc,OAAO,YAAa,MAAc,GAAG,SACtD,MAAc,KACf,WAAW;AACjB,WAAO;AAAA,MACL;AAAA,MACA;AAAA,MACA;AAAA,MACA,iBAAiB,OAAO,SAAS,eAAe,IAAI,OAAO,eAAe,IAAI;AAAA,MAC9E;AAAA,MACA;AAAA,IACF;AAAA,EACF,CAAC,EACA,OAAO,CAAC,UAAyC,QAAQ,KAAK,CAAC;AACpE;AAEO,MAAM,+BAA+B,OAC1C,IACA,UACA,YACoC;AACpC,QAAM,QACJ,SAAS,SACR,MAAM;AAAA,IACL;AAAA,IACA;AAAA,IACA,EAAE,SAAS;AAAA,IACX,EAAE,UAAU,CAAC,WAAW,EAAE;AAAA,IAC1B,EAAE,UAAU,SAAS,YAAY,MAAM,gBAAgB,SAAS,kBAAkB,KAAK;AAAA,EACzF;AACF,QAAM,MAAM,MAAM,cAAc,IAAI,OAAO,SAAS,OAAO;AAC3D,QAAM,WAAW,2BAA2B,OAAO,EAAE,SAAS,IAAI,CAAC;AACnE,WAAS,gBAAgB,SAAS,SAAS,WAAW;AACtD,SAAO;AACT;AAEO,MAAM,4BAA4B,OACvC,IACA,SACA,UACiC;AACjC,QAAM,YAAY,MAAM;AAAA,IACtB;AAAA,IACA;AAAA,IACA,EAAE,OAAO,SAAS,WAAW,KAAK;AAAA,IAClC,CAAC;AAAA,IACD;AAAA,EACF;AACA,QAAM,gBAAgB,oBAAI,IAAoB;AAC9C,MAAI,CAAC,UAAU,OAAQ,QAAO;AAC9B,QAAM,QAAQ,MAAM;AAAA,IAClB;AAAA,IACA;AAAA,IACA,EAAE,UAAU,EAAE,KAAK,UAAU,IAAI,CAAC,aAAa,SAAS,EAAE,EAAE,EAAE;AAAA,IAC9D,CAAC;AAAA,IACD;AAAA,EACF;AACA,QAAM,QAAQ,CAAC,SAAS;AACtB,UAAM,cAAc,OAAO,KAAK,cAAc,WAAW,KAAK,YAAa,KAAK,WAAW,MAAM;AACjG,QAAI,CAAC,YAAa;AAClB,UAAM,QAAQ,cAAc,IAAI,WAAW,KAAK,KAAK,uBAAuB,KAAK,QAAQ;AACzF,kBAAc,IAAI,aAAa,IAAI;AAAA,EACrC,CAAC;AACD,SAAO;AACT;",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.6.6-develop.6525.1.e9a4b56963",
3
+ "version": "0.6.6-develop.6529.1.e1de4b3f27",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -253,16 +253,16 @@
253
253
  "zod": "^4.4.3"
254
254
  },
255
255
  "peerDependencies": {
256
- "@open-mercato/ai-assistant": "0.6.6-develop.6525.1.e9a4b56963",
257
- "@open-mercato/shared": "0.6.6-develop.6525.1.e9a4b56963",
258
- "@open-mercato/ui": "0.6.6-develop.6525.1.e9a4b56963",
256
+ "@open-mercato/ai-assistant": "0.6.6-develop.6529.1.e1de4b3f27",
257
+ "@open-mercato/shared": "0.6.6-develop.6529.1.e1de4b3f27",
258
+ "@open-mercato/ui": "0.6.6-develop.6529.1.e1de4b3f27",
259
259
  "react": "^19.0.0",
260
260
  "react-dom": "^19.0.0"
261
261
  },
262
262
  "devDependencies": {
263
- "@open-mercato/ai-assistant": "0.6.6-develop.6525.1.e9a4b56963",
264
- "@open-mercato/shared": "0.6.6-develop.6525.1.e9a4b56963",
265
- "@open-mercato/ui": "0.6.6-develop.6525.1.e9a4b56963",
263
+ "@open-mercato/ai-assistant": "0.6.6-develop.6529.1.e1de4b3f27",
264
+ "@open-mercato/shared": "0.6.6-develop.6529.1.e1de4b3f27",
265
+ "@open-mercato/ui": "0.6.6-develop.6529.1.e1de4b3f27",
266
266
  "@testing-library/dom": "^10.4.1",
267
267
  "@testing-library/jest-dom": "^6.9.1",
268
268
  "@testing-library/react": "^16.3.1",
@@ -306,15 +306,17 @@ Both link to `/backend/customer_accounts/{sourceEntityId}` for staff review.
306
306
 
307
307
  | Spot ID | Widget | Purpose |
308
308
  |---------|--------|---------|
309
- | `customers.person` | `account-status` | Shows portal account status on Customers v2 person detail forms |
310
- | `crud-form:customers.person` | `account-status` | Alias for CrudForm hosts deriving the spot from `customers.person` |
311
- | `crud-form:customers:customer_person_profile:fields` | `account-status` | Shows portal account status on CRM person detail page |
312
- | `customers.company` | `company-users` | Shows portal users on Customers v2 company detail forms |
313
- | `crud-form:customers.company` | `company-users` | Alias for CrudForm hosts deriving the spot from `customers.company` |
314
- | `crud-form:customers:customer_company_profile:fields` | `company-users` | Shows portal users linked to a CRM company |
309
+ | `customers.person` | `account-status` | Alias for hosts requesting the bare `customers.person` entity spot |
310
+ | `crud-form:customers.person` | `account-status` | Shows portal account status on the person v2 detail page (`people-v2/[id]`) |
311
+ | `customers.company` | `company-users` | Alias for hosts requesting the bare `customers.company` entity spot |
312
+ | `crud-form:customers.company` | `company-users` | Shows portal users on the company v2 detail page (`companies-v2/[id]`) |
315
313
 
316
314
  Both inject as column 2 groups with priority 200, gated by `customer_accounts.view` feature.
317
315
 
316
+ CrudForm derives its spot id as `crud-form:${entityId}` with every `:` normalized to `.`, so a
317
+ registered `crud-form:` key whose entity segment still contains `:` can never be requested by any
318
+ host. Register against the normalized (dot) form only — `injection-table.test.ts` enforces this.
319
+
318
320
  ## Backend Pages
319
321
 
320
322
  | Path | Purpose |
@@ -19,10 +19,8 @@ const companyUsersWidget = {
19
19
  export const injectionTable: ModuleInjectionTable = {
20
20
  'customers.person': [accountStatusWidget],
21
21
  'crud-form:customers.person': [accountStatusWidget],
22
- 'crud-form:customers:customer_person_profile:fields': [accountStatusWidget],
23
22
  'customers.company': [companyUsersWidget],
24
23
  'crud-form:customers.company': [companyUsersWidget],
25
- 'crud-form:customers:customer_company_profile:fields': [companyUsersWidget],
26
24
  // Step 4.10 — Portal AiChat injection example.
27
25
  // Mapped to the portal profile page's `pageAfter('profile')` spot;
28
26
  // third-party modules targeting other portal pages can copy this entry.
@@ -31,6 +31,7 @@ import type { TagsSectionController } from '@open-mercato/ui/backend/detail'
31
31
  import { coerceDisplayName } from '../../../../lib/displayName'
32
32
  import { CompanyDetailHeader } from '../../../../components/detail/CompanyDetailHeader'
33
33
  import { CompanyDetailTabs, resolveLegacyTab, type CompanyTabId } from '../../../../components/detail/CompanyDetailTabs'
34
+ import { useDealsAccess } from '../../../../components/detail/useDealsAccess'
34
35
  import { CompanyKpiBar } from '../../../../components/detail/CompanyKpiBar'
35
36
  import { ScheduleActivityDialog, type ScheduleActivityEditData } from '../../../../components/detail/ScheduleActivityDialog'
36
37
  import { ChangelogTab } from '../../../../components/detail/ChangelogTab'
@@ -69,6 +70,7 @@ export default function CompanyDetailV2Page({ params }: { params?: { id?: string
69
70
  }, [searchParams])
70
71
  const [activeTab, setActiveTab] = React.useState<CompanyTabId>(initialTab)
71
72
  const [sectionAction, setSectionAction] = React.useState<SectionAction | null>(null)
73
+ const { canViewDeals, isReady: isDealsAccessReady } = useDealsAccess()
72
74
 
73
75
  // Form state
74
76
  const [isDirty, setIsDirty] = React.useState(false)
@@ -328,6 +330,14 @@ export default function CompanyDetailV2Page({ params }: { params?: { id?: string
328
330
  setSectionAction(null)
329
331
  }, [activeTab])
330
332
 
333
+ // A `?tab=deals` deep link must not strand users without `customers.deals.view`
334
+ // on a tab that no longer exists for them. Wait for the granted features to load
335
+ // so a permitted user is never bounced off the tab mid-fetch.
336
+ React.useEffect(() => {
337
+ if (!isDealsAccessReady || canViewDeals) return
338
+ setActiveTab((current) => (current === 'deals' ? 'people' : current))
339
+ }, [isDealsAccessReady, canViewDeals])
340
+
331
341
  // Deals scope
332
342
  const dealsScope = React.useMemo(
333
343
  () => (currentCompanyId ? ({ kind: 'company', entityId: currentCompanyId } as const) : null),
@@ -544,7 +554,7 @@ export default function CompanyDetailV2Page({ params }: { params?: { id?: string
544
554
  />
545
555
  )}
546
556
 
547
- {activeTab === 'deals' && (
557
+ {activeTab === 'deals' && canViewDeals && (
548
558
  <DealsSection
549
559
  scope={dealsScope}
550
560
  emptyLabel={t('customers.companies.detail.empty.deals', 'No deals linked to this company.')}
@@ -14,6 +14,7 @@ import {
14
14
  Plus,
15
15
  } from 'lucide-react'
16
16
  import type { SectionAction } from '@open-mercato/ui/backend/detail'
17
+ import { useDealsAccess } from './useDealsAccess'
17
18
 
18
19
  export type CompanyTabId =
19
20
  | 'people'
@@ -87,6 +88,7 @@ export function CompanyDetailTabs({
87
88
  children,
88
89
  }: CompanyDetailTabsProps) {
89
90
  const t = useT()
91
+ const { canViewDeals } = useDealsAccess()
90
92
 
91
93
  const builtInTabs: TabDef[] = React.useMemo(
92
94
  () => [
@@ -96,12 +98,16 @@ export function CompanyDetailTabs({
96
98
  icon: <Users className="size-4" />,
97
99
  badge: <CountBadge count={peopleCount} />,
98
100
  },
99
- {
100
- id: 'deals',
101
- label: t('customers.companies.detail.tabs.deals', 'Deals'),
102
- icon: <Handshake className="size-4" />,
103
- badge: <CountBadge count={dealsCount} />,
104
- },
101
+ ...(canViewDeals
102
+ ? [
103
+ {
104
+ id: 'deals' as CompanyTabId,
105
+ label: t('customers.companies.detail.tabs.deals', 'Deals'),
106
+ icon: <Handshake className="size-4" />,
107
+ badge: <CountBadge count={dealsCount} />,
108
+ },
109
+ ]
110
+ : []),
105
111
  {
106
112
  id: 'activity-log',
107
113
  label: t('customers.companies.detail.tabs.activityLog', 'Activity log'),
@@ -121,7 +127,7 @@ export function CompanyDetailTabs({
121
127
  badge: <CountBadge count={filesCount} />,
122
128
  },
123
129
  ],
124
- [t, peopleCount, dealsCount, activitiesCount, filesCount],
130
+ [t, canViewDeals, peopleCount, dealsCount, activitiesCount, filesCount],
125
131
  )
126
132
 
127
133
  const allTabs: TabDef[] = React.useMemo(
@@ -0,0 +1,19 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { useBackendChrome } from '@open-mercato/ui/backend/BackendChromeProvider'
5
+ import { hasFeature } from '@open-mercato/shared/security/features'
6
+
7
+ export type DealsAccess = {
8
+ canViewDeals: boolean
9
+ isReady: boolean
10
+ }
11
+
12
+ export function useDealsAccess(): DealsAccess {
13
+ const { payload, isReady } = useBackendChrome()
14
+ const canViewDeals = React.useMemo(
15
+ () => hasFeature(payload?.grantedFeatures ?? [], 'customers.deals.view'),
16
+ [payload?.grantedFeatures],
17
+ )
18
+ return { canViewDeals, isReady }
19
+ }
@@ -125,6 +125,7 @@ import {
125
125
  type SalesLineCalculationResult,
126
126
  type SalesDocumentCalculationResult,
127
127
  } from "../lib/types";
128
+ import { loadShippedQuantityByLine } from "../lib/shipments/snapshots";
128
129
  import { resolveDictionaryEntryValue } from "../lib/dictionaries";
129
130
  import { resolveStatusEntryIdByValue } from "../lib/statusHelpers";
130
131
  import { SalesDocumentNumberGenerator } from "../services/salesDocumentNumberGenerator";
@@ -6521,6 +6522,69 @@ const quoteAdjustmentDeleteSchema = z.object({
6521
6522
  quoteId: z.string().uuid(),
6522
6523
  });
6523
6524
 
6525
+ const SHIPPED_QUANTITY_TOLERANCE = 1e-6;
6526
+
6527
+ const hasNumericChange = (
6528
+ next: number | null | undefined,
6529
+ previous: number | null | undefined,
6530
+ ): boolean => {
6531
+ if (next === undefined || next === null) return false;
6532
+ if (previous === undefined || previous === null) return true;
6533
+ return Math.abs(next - previous) > SHIPPED_QUANTITY_TOLERANCE;
6534
+ };
6535
+
6536
+ const hasUnitChange = (
6537
+ next: string | null | undefined,
6538
+ previous: string | null | undefined,
6539
+ ): boolean => {
6540
+ if (next === undefined || next === null) return false;
6541
+ const normalizedPrevious = (previous ?? "").trim().toLowerCase();
6542
+ if (!normalizedPrevious) return false;
6543
+ return next.trim().toLowerCase() !== normalizedPrevious;
6544
+ };
6545
+
6546
+ async function assertShippedOrderLineEditable(
6547
+ em: EntityManager,
6548
+ order: SalesOrder,
6549
+ existingSnapshot: SalesLineSnapshot | null,
6550
+ parsed: z.infer<typeof orderLineUpsertSchema>,
6551
+ ): Promise<void> {
6552
+ const lineId = existingSnapshot?.id;
6553
+ if (!existingSnapshot || !lineId) return;
6554
+ const shippedByLine = await loadShippedQuantityByLine(em, order.id, {
6555
+ tenantId: order.tenantId,
6556
+ organizationId: order.organizationId,
6557
+ });
6558
+ const shippedQuantity = shippedByLine.get(lineId) ?? 0;
6559
+ if (shippedQuantity <= 0) return;
6560
+
6561
+ const { translate } = await resolveTranslations();
6562
+ const nextQuantity = parsed.quantity ?? existingSnapshot.quantity;
6563
+ if (nextQuantity < shippedQuantity - SHIPPED_QUANTITY_TOLERANCE) {
6564
+ throw new CrudHttpError(409, {
6565
+ error: translate(
6566
+ "sales.documents.items.errorQuantityBelowShipped",
6567
+ "You cannot lower the quantity below the {{shipped}} already shipped.",
6568
+ { shipped: shippedQuantity },
6569
+ ),
6570
+ });
6571
+ }
6572
+
6573
+ const pricingChanged =
6574
+ hasNumericChange(parsed.unitPriceNet, existingSnapshot.unitPriceNet) ||
6575
+ hasNumericChange(parsed.unitPriceGross, existingSnapshot.unitPriceGross) ||
6576
+ hasNumericChange(parsed.taxRate, existingSnapshot.taxRate) ||
6577
+ hasUnitChange(parsed.quantityUnit, existingSnapshot.quantityUnit);
6578
+ if (pricingChanged) {
6579
+ throw new CrudHttpError(409, {
6580
+ error: translate(
6581
+ "sales.documents.items.errorPriceShipped",
6582
+ "You cannot change the price or unit of a line that has shipped items.",
6583
+ ),
6584
+ });
6585
+ }
6586
+ }
6587
+
6524
6588
  const orderLineUpsertCommand: CommandHandler<
6525
6589
  { body?: Record<string, unknown>; query?: Record<string, unknown> },
6526
6590
  { orderId: string; lineId: string }
@@ -6565,6 +6629,7 @@ const orderLineUpsertCommand: CommandHandler<
6565
6629
  const existingSnapshot = parsed.id
6566
6630
  ? (lineSnapshots.find((line) => line.id === parsed.id) ?? null)
6567
6631
  : null;
6632
+ await assertShippedOrderLineEditable(em, order, existingSnapshot, parsed);
6568
6633
  const priceMode =
6569
6634
  parsed.priceMode === "gross"
6570
6635
  ? "gross"
@@ -15,8 +15,8 @@ import type { SalesCalculationService } from '../services/salesCalculationServic
15
15
  import type { SalesAdjustmentDraft, SalesLineSnapshot, SalesDocumentCalculationResult } from '../lib/types'
16
16
  import { cloneJson, deriveLineNetFromGross, ensureOrganizationScope, ensureSameScope, ensureTenantScope, extractUndoPayload, toNumericString, enforceSalesDocumentOptimisticLock, SALES_RESOURCE_KIND_ORDER, SALES_RESOURCE_KIND_RETURN } from './shared'
17
17
  import { resolveRedoSnapshot } from '@open-mercato/shared/lib/commands/redo'
18
- import { SalesOrder, SalesOrderAdjustment, SalesOrderLine, SalesReturn, SalesReturnLine, SalesShipment, SalesShipmentItem } from '../data/entities'
19
- import { coerceShipmentQuantity } from '../lib/shipments/snapshots'
18
+ import { SalesOrder, SalesOrderAdjustment, SalesOrderLine, SalesReturn, SalesReturnLine } from '../data/entities'
19
+ import { loadShippedQuantityByLine } from '../lib/shipments/snapshots'
20
20
  import { computeAvailableReturnQuantity } from '../lib/returnQuantity'
21
21
  import {
22
22
  returnCreateSchema,
@@ -581,36 +581,6 @@ async function restoreReturnEffects(
581
581
  return createdLines
582
582
  }
583
583
 
584
- async function loadShippedQuantityByLine(
585
- em: EntityManager,
586
- orderId: string,
587
- scope: { tenantId: string; organizationId: string },
588
- ): Promise<Map<string, number>> {
589
- const shipments = await findWithDecryption(
590
- em,
591
- SalesShipment,
592
- { order: orderId, deletedAt: null },
593
- {},
594
- scope,
595
- )
596
- const shippedByLine = new Map<string, number>()
597
- if (!shipments.length) return shippedByLine
598
- const items = await findWithDecryption(
599
- em,
600
- SalesShipmentItem,
601
- { shipment: { $in: shipments.map((shipment) => shipment.id) } },
602
- {},
603
- scope,
604
- )
605
- items.forEach((item) => {
606
- const orderLineId = typeof item.orderLine === 'string' ? item.orderLine : item.orderLine?.id ?? null
607
- if (!orderLineId) return
608
- const next = (shippedByLine.get(orderLineId) ?? 0) + coerceShipmentQuantity(item.quantity)
609
- shippedByLine.set(orderLineId, next)
610
- })
611
- return shippedByLine
612
- }
613
-
614
584
  function normalizeLinesInput(lines: ReturnCreateInput['lines']): ReturnLineInput[] {
615
585
  const seen = new Set<string>()
616
586
  const result: ReturnLineInput[] = []
@@ -817,6 +817,11 @@ export function SalesDocumentItemsSection({
817
817
  organizationId={resolvedOrganizationId}
818
818
  tenantId={resolvedTenantId}
819
819
  initialLine={lineForEdit}
820
+ shippedQuantity={
821
+ lineForEdit
822
+ ? Math.max(0, shippedTotals.get(lineForEdit.id) ?? 0)
823
+ : 0
824
+ }
820
825
  onSaved={async () => {
821
826
  await loadItems();
822
827
  emitSalesDocumentTotalsRefresh({ documentId, kind });
@@ -278,6 +278,7 @@ type SalesLineDialogProps = {
278
278
  organizationId: string | null;
279
279
  tenantId: string | null;
280
280
  initialLine?: SalesLineRecord | null;
281
+ shippedQuantity?: number;
281
282
  onOpenChange: (open: boolean) => void;
282
283
  onSaved?: () => Promise<void> | void;
283
284
  };
@@ -472,6 +473,7 @@ export function LineItemDialog({
472
473
  organizationId,
473
474
  tenantId,
474
475
  initialLine,
476
+ shippedQuantity = 0,
475
477
  onOpenChange,
476
478
  onSaved,
477
479
  }: SalesLineDialogProps) {
@@ -1326,6 +1328,14 @@ export function LineItemDialog({
1326
1328
  },
1327
1329
  );
1328
1330
  }
1331
+ if (shippedQuantity > 0 && qtyNumber < shippedQuantity) {
1332
+ const message = t(
1333
+ "sales.documents.items.errorQuantityBelowShipped",
1334
+ "You cannot lower the quantity below the {{shipped}} already shipped.",
1335
+ { shipped: shippedQuantity },
1336
+ );
1337
+ throw createCrudFormError(message, { quantity: message });
1338
+ }
1329
1339
  const resolvedQuantityUnit = (() => {
1330
1340
  const entered = normalizeUnitCode(values.quantityUnit);
1331
1341
  if (isCustomLine) return entered;
@@ -638,8 +638,10 @@
638
638
  "sales.documents.detail.items.errorDelete": "Position konnte nicht gelöscht werden.",
639
639
  "sales.documents.detail.items.errorDeleteShipped": "Positionen mit versandten Artikeln können nicht gelöscht werden.",
640
640
  "sales.documents.detail.items.errorLoad": "Positionen konnten nicht geladen werden.",
641
+ "sales.documents.detail.items.errorPriceShipped": "Preis oder Einheit einer Position mit versandten Artikeln können nicht geändert werden.",
641
642
  "sales.documents.detail.items.errorProductRequired": "Wähle ein Produkt aus, um fortzufahren.",
642
643
  "sales.documents.detail.items.errorQuantity": "Die Menge muss größer als 0 sein.",
644
+ "sales.documents.detail.items.errorQuantityBelowShipped": "Die Menge kann nicht unter die bereits versandten {{shipped}} gesenkt werden.",
643
645
  "sales.documents.detail.items.errorSave": "Position konnte nicht gespeichert werden.",
644
646
  "sales.documents.detail.items.errorUnitPrice": "Der Stückpreis muss größer als 0 sein.",
645
647
  "sales.documents.detail.items.errorVariantRequired": "Wähle eine Variante aus, um fortzufahren.",
@@ -950,8 +952,10 @@
950
952
  "sales.documents.items.errorDeleteShipped": "Positionen mit versandten Artikeln können nicht gelöscht werden.",
951
953
  "sales.documents.items.errorLoad": "Positionen konnten nicht geladen werden.",
952
954
  "sales.documents.items.errorNameRequired": "Name ist für benutzerdefinierte Positionen erforderlich.",
955
+ "sales.documents.items.errorPriceShipped": "Preis oder Einheit einer Position mit versandten Artikeln können nicht geändert werden.",
953
956
  "sales.documents.items.errorProductRequired": "Wähle ein Produkt aus, um fortzufahren.",
954
957
  "sales.documents.items.errorQuantity": "Die Menge muss größer als 0 sein.",
958
+ "sales.documents.items.errorQuantityBelowShipped": "Die Menge kann nicht unter die bereits versandten {{shipped}} gesenkt werden.",
955
959
  "sales.documents.items.errorSave": "Position konnte nicht gespeichert werden.",
956
960
  "sales.documents.items.errorScope": "Organisation und Mandant sind erforderlich.",
957
961
  "sales.documents.items.errorUnitPrice": "Der Stückpreis muss größer als 0 sein.",
@@ -638,8 +638,10 @@
638
638
  "sales.documents.detail.items.errorDelete": "Failed to delete line.",
639
639
  "sales.documents.detail.items.errorDeleteShipped": "You cannot delete a line that has shipped items.",
640
640
  "sales.documents.detail.items.errorLoad": "Failed to load items.",
641
+ "sales.documents.detail.items.errorPriceShipped": "You cannot change the price or unit of a line that has shipped items.",
641
642
  "sales.documents.detail.items.errorProductRequired": "Select a product to continue.",
642
643
  "sales.documents.detail.items.errorQuantity": "Quantity must be greater than 0.",
644
+ "sales.documents.detail.items.errorQuantityBelowShipped": "You cannot lower the quantity below the {{shipped}} already shipped.",
643
645
  "sales.documents.detail.items.errorSave": "Failed to save line.",
644
646
  "sales.documents.detail.items.errorUnitPrice": "Unit price must be greater than 0.",
645
647
  "sales.documents.detail.items.errorVariantRequired": "Select a variant to continue.",
@@ -950,8 +952,10 @@
950
952
  "sales.documents.items.errorDeleteShipped": "You cannot delete a line that has shipped items.",
951
953
  "sales.documents.items.errorLoad": "Failed to load items.",
952
954
  "sales.documents.items.errorNameRequired": "Name is required for custom lines.",
955
+ "sales.documents.items.errorPriceShipped": "You cannot change the price or unit of a line that has shipped items.",
953
956
  "sales.documents.items.errorProductRequired": "Select a product to continue.",
954
957
  "sales.documents.items.errorQuantity": "Quantity must be greater than 0.",
958
+ "sales.documents.items.errorQuantityBelowShipped": "You cannot lower the quantity below the {{shipped}} already shipped.",
955
959
  "sales.documents.items.errorSave": "Failed to save line.",
956
960
  "sales.documents.items.errorScope": "Organization and tenant are required.",
957
961
  "sales.documents.items.errorUnitPrice": "Unit price must be greater than 0.",
@@ -638,8 +638,10 @@
638
638
  "sales.documents.detail.items.errorDelete": "No se pudo eliminar la línea.",
639
639
  "sales.documents.detail.items.errorDeleteShipped": "No puedes eliminar una línea que ya tiene envíos.",
640
640
  "sales.documents.detail.items.errorLoad": "No se pudieron cargar los artículos.",
641
+ "sales.documents.detail.items.errorPriceShipped": "No puedes cambiar el precio ni la unidad de una línea que ya tiene envíos.",
641
642
  "sales.documents.detail.items.errorProductRequired": "Selecciona un producto para continuar.",
642
643
  "sales.documents.detail.items.errorQuantity": "La cantidad debe ser mayor que 0.",
644
+ "sales.documents.detail.items.errorQuantityBelowShipped": "No puedes reducir la cantidad por debajo de las {{shipped}} ya enviadas.",
643
645
  "sales.documents.detail.items.errorSave": "No se pudo guardar la línea.",
644
646
  "sales.documents.detail.items.errorUnitPrice": "El precio unitario debe ser mayor que 0.",
645
647
  "sales.documents.detail.items.errorVariantRequired": "Selecciona una variante para continuar.",
@@ -950,8 +952,10 @@
950
952
  "sales.documents.items.errorDeleteShipped": "No puedes eliminar una línea que ya tiene envíos.",
951
953
  "sales.documents.items.errorLoad": "No se pudieron cargar los artículos.",
952
954
  "sales.documents.items.errorNameRequired": "El nombre es obligatorio para líneas personalizadas.",
955
+ "sales.documents.items.errorPriceShipped": "No puedes cambiar el precio ni la unidad de una línea que ya tiene envíos.",
953
956
  "sales.documents.items.errorProductRequired": "Selecciona un producto para continuar.",
954
957
  "sales.documents.items.errorQuantity": "La cantidad debe ser mayor que 0.",
958
+ "sales.documents.items.errorQuantityBelowShipped": "No puedes reducir la cantidad por debajo de las {{shipped}} ya enviadas.",
955
959
  "sales.documents.items.errorSave": "No se pudo guardar la línea.",
956
960
  "sales.documents.items.errorScope": "Se requieren organización e inquilino.",
957
961
  "sales.documents.items.errorUnitPrice": "El precio unitario debe ser mayor que 0.",
@@ -638,8 +638,10 @@
638
638
  "sales.documents.detail.items.errorDelete": "Nie udało się usunąć pozycji.",
639
639
  "sales.documents.detail.items.errorDeleteShipped": "Nie można usunąć pozycji, która ma wysłane sztuki.",
640
640
  "sales.documents.detail.items.errorLoad": "Nie udało się wczytać pozycji.",
641
+ "sales.documents.detail.items.errorPriceShipped": "Nie można zmienić ceny ani jednostki pozycji, która ma wysłane sztuki.",
641
642
  "sales.documents.detail.items.errorProductRequired": "Wybierz produkt, aby kontynuować.",
642
643
  "sales.documents.detail.items.errorQuantity": "Ilość musi być większa niż 0.",
644
+ "sales.documents.detail.items.errorQuantityBelowShipped": "Nie można obniżyć ilości poniżej {{shipped}} już wysłanych.",
643
645
  "sales.documents.detail.items.errorSave": "Nie udało się zapisać pozycji.",
644
646
  "sales.documents.detail.items.errorUnitPrice": "Cena jednostkowa musi być większa niż 0.",
645
647
  "sales.documents.detail.items.errorVariantRequired": "Wybierz wariant, aby kontynuować.",
@@ -950,8 +952,10 @@
950
952
  "sales.documents.items.errorDeleteShipped": "Nie można usunąć pozycji, która ma wysłane sztuki.",
951
953
  "sales.documents.items.errorLoad": "Nie udało się załadować pozycji.",
952
954
  "sales.documents.items.errorNameRequired": "Nazwa jest wymagana dla niestandardowych pozycji.",
955
+ "sales.documents.items.errorPriceShipped": "Nie można zmienić ceny ani jednostki pozycji, która ma wysłane sztuki.",
953
956
  "sales.documents.items.errorProductRequired": "Wybierz produkt, aby kontynuować.",
954
957
  "sales.documents.items.errorQuantity": "Ilość musi być większa niż 0.",
958
+ "sales.documents.items.errorQuantityBelowShipped": "Nie można obniżyć ilości poniżej {{shipped}} już wysłanych.",
955
959
  "sales.documents.items.errorSave": "Nie udało się zapisać pozycji.",
956
960
  "sales.documents.items.errorScope": "Wymagane są organizacja i najemca.",
957
961
  "sales.documents.items.errorUnitPrice": "Cena jednostkowa musi być większa niż 0.",
@@ -136,4 +136,34 @@ export const refreshShipmentItemsSnapshot = async (
136
136
  return snapshot
137
137
  }
138
138
 
139
+ export const loadShippedQuantityByLine = async (
140
+ em: EntityManager,
141
+ orderId: string,
142
+ scope: { tenantId: string; organizationId: string },
143
+ ): Promise<Map<string, number>> => {
144
+ const shipments = await findWithDecryption(
145
+ em,
146
+ SalesShipment,
147
+ { order: orderId, deletedAt: null },
148
+ {},
149
+ scope,
150
+ )
151
+ const shippedByLine = new Map<string, number>()
152
+ if (!shipments.length) return shippedByLine
153
+ const items = await findWithDecryption(
154
+ em,
155
+ SalesShipmentItem,
156
+ { shipment: { $in: shipments.map((shipment) => shipment.id) } },
157
+ {},
158
+ scope,
159
+ )
160
+ items.forEach((item) => {
161
+ const orderLineId = typeof item.orderLine === 'string' ? item.orderLine : (item.orderLine?.id ?? null)
162
+ if (!orderLineId) return
163
+ const next = (shippedByLine.get(orderLineId) ?? 0) + coerceShipmentQuantity(item.quantity)
164
+ shippedByLine.set(orderLineId, next)
165
+ })
166
+ return shippedByLine
167
+ }
168
+
139
169
  export type { ShipmentItemSnapshot } from './types'