@open-mercato/core 0.6.7-develop.6851.1.c1680b00d8 → 0.6.7-develop.6860.1.f796b22e80

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 (82) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/bootstrap.js +16 -1
  3. package/dist/bootstrap.js.map +2 -2
  4. package/dist/generated/entities/attachment_quota_reservation/index.js +33 -0
  5. package/dist/generated/entities/attachment_quota_reservation/index.js.map +7 -0
  6. package/dist/generated/entities.ids.generated.js +2 -1
  7. package/dist/generated/entities.ids.generated.js.map +2 -2
  8. package/dist/generated/entity-fields-registry.js +17 -0
  9. package/dist/generated/entity-fields-registry.js.map +2 -2
  10. package/dist/modules/attachments/api/route.js +104 -8
  11. package/dist/modules/attachments/api/route.js.map +2 -2
  12. package/dist/modules/attachments/data/entities.js +67 -1
  13. package/dist/modules/attachments/data/entities.js.map +2 -2
  14. package/dist/modules/attachments/di.js +7 -1
  15. package/dist/modules/attachments/di.js.map +2 -2
  16. package/dist/modules/attachments/lib/drivers/index.js.map +1 -1
  17. package/dist/modules/attachments/lib/drivers/localDriver.js +19 -6
  18. package/dist/modules/attachments/lib/drivers/localDriver.js.map +2 -2
  19. package/dist/modules/attachments/lib/quota-recovery-queue.js +9 -0
  20. package/dist/modules/attachments/lib/quota-recovery-queue.js.map +7 -0
  21. package/dist/modules/attachments/lib/quota-service.js +224 -0
  22. package/dist/modules/attachments/lib/quota-service.js.map +7 -0
  23. package/dist/modules/attachments/migrations/Migration20260709234741_attachments.js +13 -0
  24. package/dist/modules/attachments/migrations/Migration20260709234741_attachments.js.map +7 -0
  25. package/dist/modules/attachments/workers/quota-recovery.js +45 -0
  26. package/dist/modules/attachments/workers/quota-recovery.js.map +7 -0
  27. package/dist/modules/auth/lib/setup-app.js +16 -2
  28. package/dist/modules/auth/lib/setup-app.js.map +2 -2
  29. package/dist/modules/dashboards/services/widgetDataService.js +3 -4
  30. package/dist/modules/dashboards/services/widgetDataService.js.map +2 -2
  31. package/dist/modules/entities/cli.js +193 -19
  32. package/dist/modules/entities/cli.js.map +2 -2
  33. package/dist/modules/messages/api/[id]/route.js +15 -0
  34. package/dist/modules/messages/api/[id]/route.js.map +2 -2
  35. package/dist/modules/query_index/lib/subscriber-scope.js +2 -3
  36. package/dist/modules/query_index/lib/subscriber-scope.js.map +2 -2
  37. package/dist/modules/sales/commands/documents.js +110 -22
  38. package/dist/modules/sales/commands/documents.js.map +2 -2
  39. package/dist/modules/sales/workflows.js +10 -7
  40. package/dist/modules/sales/workflows.js.map +2 -2
  41. package/dist/modules/workflows/lib/activity-executor.js +27 -0
  42. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  43. package/dist/modules/workflows/widgets/injection/order-approval/context-keys.js +10 -0
  44. package/dist/modules/workflows/widgets/injection/order-approval/context-keys.js.map +7 -0
  45. package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js +10 -6
  46. package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js.map +2 -2
  47. package/generated/entities/attachment_quota_reservation/index.ts +15 -0
  48. package/generated/entities.ids.generated.ts +2 -1
  49. package/generated/entity-fields-registry.ts +17 -0
  50. package/package.json +7 -7
  51. package/src/bootstrap.ts +17 -2
  52. package/src/modules/attachments/api/route.ts +114 -7
  53. package/src/modules/attachments/data/entities.ts +59 -0
  54. package/src/modules/attachments/di.ts +9 -1
  55. package/src/modules/attachments/i18n/de.json +3 -0
  56. package/src/modules/attachments/i18n/en.json +3 -0
  57. package/src/modules/attachments/i18n/es.json +3 -0
  58. package/src/modules/attachments/i18n/ko.json +3 -0
  59. package/src/modules/attachments/i18n/pl.json +3 -0
  60. package/src/modules/attachments/lib/drivers/index.ts +1 -1
  61. package/src/modules/attachments/lib/drivers/localDriver.ts +23 -8
  62. package/src/modules/attachments/lib/drivers/types.ts +5 -0
  63. package/src/modules/attachments/lib/quota-recovery-queue.ts +16 -0
  64. package/src/modules/attachments/lib/quota-service.ts +442 -0
  65. package/src/modules/attachments/migrations/.snapshot-open-mercato.json +648 -120
  66. package/src/modules/attachments/migrations/Migration20260709234741_attachments.ts +12 -0
  67. package/src/modules/attachments/workers/quota-recovery.ts +68 -0
  68. package/src/modules/auth/lib/setup-app.ts +20 -2
  69. package/src/modules/dashboards/services/widgetDataService.ts +3 -6
  70. package/src/modules/entities/cli.ts +238 -21
  71. package/src/modules/messages/api/[id]/route.ts +21 -0
  72. package/src/modules/query_index/lib/subscriber-scope.ts +2 -7
  73. package/src/modules/sales/commands/documents.ts +188 -29
  74. package/src/modules/sales/workflows.ts +10 -7
  75. package/src/modules/workflows/i18n/de.json +1 -0
  76. package/src/modules/workflows/i18n/en.json +1 -0
  77. package/src/modules/workflows/i18n/es.json +1 -0
  78. package/src/modules/workflows/i18n/ko.json +1 -0
  79. package/src/modules/workflows/i18n/pl.json +1 -0
  80. package/src/modules/workflows/lib/activity-executor.ts +40 -1
  81. package/src/modules/workflows/widgets/injection/order-approval/context-keys.ts +18 -0
  82. package/src/modules/workflows/widgets/injection/order-approval/widget.client.tsx +15 -6
@@ -4221,6 +4221,20 @@ async function restoreOrderGraph(
4221
4221
  organizationId: snapshot.order.organizationId,
4222
4222
  },
4223
4223
  );
4224
+ let existingLines: SalesOrderLine[] = [];
4225
+ if (order) {
4226
+ existingLines = await em.find(
4227
+ SalesOrderLine,
4228
+ { order },
4229
+ { orderBy: { lineNumber: "asc" } },
4230
+ );
4231
+ await assertShippedOrderGraphRestorable(
4232
+ em,
4233
+ order,
4234
+ existingLines,
4235
+ snapshot,
4236
+ );
4237
+ }
4224
4238
  if (!order) {
4225
4239
  order = em.create(SalesOrder, {
4226
4240
  id: snapshot.order.id,
@@ -4305,11 +4319,6 @@ async function restoreOrderGraph(
4305
4319
  }
4306
4320
  applyOrderSnapshot(order, snapshot.order);
4307
4321
  await em.flush();
4308
- const existingLines = await em.find(
4309
- SalesOrderLine,
4310
- { order: order.id },
4311
- { fields: ["id"] },
4312
- );
4313
4322
  const existingAdjustments = await em.find(
4314
4323
  SalesOrderAdjustment,
4315
4324
  { order: order.id },
@@ -6674,6 +6683,34 @@ const quoteAdjustmentDeleteSchema = z.object({
6674
6683
 
6675
6684
  const SHIPPED_QUANTITY_TOLERANCE = 1e-6;
6676
6685
 
6686
+ // Locked outright on a shipped line: none of these may move at all.
6687
+ const SHIPPED_LINE_NUMERIC_PRICING_FIELDS = [
6688
+ "unitPriceNet",
6689
+ "unitPriceGross",
6690
+ "discountAmount",
6691
+ "discountPercent",
6692
+ "taxRate",
6693
+ ] as const;
6694
+
6695
+ // Line totals are derived, not authored: clients recompute and echo them on every
6696
+ // submit, so raising the quantity of a shipped line legitimately changes them. They
6697
+ // are still writable straight through, so they cannot simply be ignored either.
6698
+ // The invariant that separates a quantity edit from moving money is the per-unit
6699
+ // economics — total per unit must stay put, whatever the quantity does. That also
6700
+ // keeps discounted lines working, since a proportional total scales with quantity.
6701
+ const SHIPPED_LINE_DERIVED_TOTAL_FIELDS = [
6702
+ "totalNetAmount",
6703
+ "totalGrossAmount",
6704
+ ] as const;
6705
+
6706
+ type ShippedLineComparable = Pick<
6707
+ SalesLineSnapshot,
6708
+ | "quantity"
6709
+ | "quantityUnit"
6710
+ | (typeof SHIPPED_LINE_NUMERIC_PRICING_FIELDS)[number]
6711
+ | (typeof SHIPPED_LINE_DERIVED_TOTAL_FIELDS)[number]
6712
+ >;
6713
+
6677
6714
  const hasNumericChange = (
6678
6715
  next: number | null | undefined,
6679
6716
  previous: number | null | undefined,
@@ -6683,6 +6720,17 @@ const hasNumericChange = (
6683
6720
  return Math.abs(next - previous) > SHIPPED_QUANTITY_TOLERANCE;
6684
6721
  };
6685
6722
 
6723
+ const hasExactNumericChange = (
6724
+ next: number | null | undefined,
6725
+ previous: number | null | undefined,
6726
+ ): boolean => {
6727
+ if (next === undefined || next === null) {
6728
+ return previous !== undefined && previous !== null;
6729
+ }
6730
+ if (previous === undefined || previous === null) return true;
6731
+ return Math.abs(next - previous) > SHIPPED_QUANTITY_TOLERANCE;
6732
+ };
6733
+
6686
6734
  const hasUnitChange = (
6687
6735
  next: string | null | undefined,
6688
6736
  previous: string | null | undefined,
@@ -6693,6 +6741,111 @@ const hasUnitChange = (
6693
6741
  return next.trim().toLowerCase() !== normalizedPrevious;
6694
6742
  };
6695
6743
 
6744
+ const hasExactUnitChange = (
6745
+ next: string | null | undefined,
6746
+ previous: string | null | undefined,
6747
+ ): boolean =>
6748
+ (next ?? "").trim().toLowerCase() !==
6749
+ (previous ?? "").trim().toLowerCase();
6750
+
6751
+ function hasShippedLineTotalChange(
6752
+ next: Partial<ShippedLineComparable>,
6753
+ previous: ShippedLineComparable,
6754
+ nextQuantity: number | null | undefined,
6755
+ exact: boolean,
6756
+ ): boolean {
6757
+ const numericChange = exact ? hasExactNumericChange : hasNumericChange;
6758
+ const previousQuantity = previous.quantity;
6759
+ const canScale =
6760
+ typeof previousQuantity === "number" &&
6761
+ Math.abs(previousQuantity) > SHIPPED_QUANTITY_TOLERANCE &&
6762
+ typeof nextQuantity === "number";
6763
+
6764
+ return SHIPPED_LINE_DERIVED_TOTAL_FIELDS.some((field) => {
6765
+ const nextTotal = next[field];
6766
+ const previousTotal = previous[field];
6767
+ // Without a scalable quantity pair there is nothing to derive the expected
6768
+ // total from, so fall back to locking the value outright.
6769
+ if (!canScale || typeof previousTotal !== "number") {
6770
+ return numericChange(nextTotal, previousTotal);
6771
+ }
6772
+ if (nextTotal === undefined || nextTotal === null) {
6773
+ return exact ? numericChange(nextTotal, previousTotal) : false;
6774
+ }
6775
+ const expectedTotal =
6776
+ (previousTotal / (previousQuantity as number)) * (nextQuantity as number);
6777
+ // Scale the tolerance with magnitude: a fixed 1e-6 is below the float noise
6778
+ // floor once totals reach six figures.
6779
+ const tolerance = Math.max(
6780
+ SHIPPED_QUANTITY_TOLERANCE,
6781
+ Math.abs(expectedTotal) * 1e-9,
6782
+ );
6783
+ return Math.abs(nextTotal - expectedTotal) > tolerance;
6784
+ });
6785
+ }
6786
+
6787
+ function hasShippedLinePricingChange(
6788
+ next: Partial<ShippedLineComparable>,
6789
+ previous: ShippedLineComparable,
6790
+ nextQuantity: number | null | undefined,
6791
+ exact: boolean,
6792
+ ): boolean {
6793
+ const numericChange = exact ? hasExactNumericChange : hasNumericChange;
6794
+ return (
6795
+ SHIPPED_LINE_NUMERIC_PRICING_FIELDS.some((field) =>
6796
+ numericChange(next[field], previous[field]),
6797
+ ) ||
6798
+ hasShippedLineTotalChange(next, previous, nextQuantity, exact) ||
6799
+ (exact
6800
+ ? hasExactUnitChange(next.quantityUnit, previous.quantityUnit)
6801
+ : hasUnitChange(next.quantityUnit, previous.quantityUnit))
6802
+ );
6803
+ }
6804
+
6805
+ async function assertShippedOrderLineChangeAllowed(
6806
+ existingSnapshot: SalesLineSnapshot,
6807
+ nextSnapshot: Partial<ShippedLineComparable> | null,
6808
+ shippedQuantity: number,
6809
+ exact: boolean,
6810
+ ): Promise<void> {
6811
+ if (shippedQuantity <= 0) return;
6812
+
6813
+ const nextQuantity = nextSnapshot
6814
+ ? exact
6815
+ ? nextSnapshot.quantity
6816
+ : (nextSnapshot.quantity ?? existingSnapshot.quantity)
6817
+ : 0;
6818
+ const quantityInvalid =
6819
+ nextQuantity === undefined ||
6820
+ nextQuantity < shippedQuantity - SHIPPED_QUANTITY_TOLERANCE;
6821
+ const pricingChanged =
6822
+ nextSnapshot !== null &&
6823
+ hasShippedLinePricingChange(
6824
+ nextSnapshot,
6825
+ existingSnapshot,
6826
+ nextQuantity,
6827
+ exact,
6828
+ );
6829
+ if (!quantityInvalid && !pricingChanged) return;
6830
+
6831
+ const { translate } = await resolveTranslations();
6832
+ if (quantityInvalid) {
6833
+ throw new CrudHttpError(409, {
6834
+ error: translate(
6835
+ "sales.documents.items.errorQuantityBelowShipped",
6836
+ "You cannot lower the quantity below the {{shipped}} already shipped.",
6837
+ { shipped: shippedQuantity },
6838
+ ),
6839
+ });
6840
+ }
6841
+ throw new CrudHttpError(409, {
6842
+ error: translate(
6843
+ "sales.documents.items.errorPriceShipped",
6844
+ "You cannot change the price or unit of a line that has shipped items.",
6845
+ ),
6846
+ });
6847
+ }
6848
+
6696
6849
  async function assertShippedOrderLineEditable(
6697
6850
  em: EntityManager,
6698
6851
  order: SalesOrder,
@@ -6706,32 +6859,38 @@ async function assertShippedOrderLineEditable(
6706
6859
  organizationId: order.organizationId,
6707
6860
  });
6708
6861
  const shippedQuantity = shippedByLine.get(lineId) ?? 0;
6709
- if (shippedQuantity <= 0) return;
6862
+ await assertShippedOrderLineChangeAllowed(
6863
+ existingSnapshot,
6864
+ parsed,
6865
+ shippedQuantity,
6866
+ false,
6867
+ );
6868
+ }
6710
6869
 
6711
- const { translate } = await resolveTranslations();
6712
- const nextQuantity = parsed.quantity ?? existingSnapshot.quantity;
6713
- if (nextQuantity < shippedQuantity - SHIPPED_QUANTITY_TOLERANCE) {
6714
- throw new CrudHttpError(409, {
6715
- error: translate(
6716
- "sales.documents.items.errorQuantityBelowShipped",
6717
- "You cannot lower the quantity below the {{shipped}} already shipped.",
6718
- { shipped: shippedQuantity },
6719
- ),
6720
- });
6721
- }
6870
+ async function assertShippedOrderGraphRestorable(
6871
+ em: EntityManager,
6872
+ order: SalesOrder,
6873
+ existingLines: SalesOrderLine[],
6874
+ snapshot: OrderGraphSnapshot,
6875
+ ): Promise<void> {
6876
+ const shippedByLine = await loadShippedQuantityByLine(em, order.id, {
6877
+ tenantId: order.tenantId,
6878
+ organizationId: order.organizationId,
6879
+ });
6880
+ if (shippedByLine.size === 0) return;
6722
6881
 
6723
- const pricingChanged =
6724
- hasNumericChange(parsed.unitPriceNet, existingSnapshot.unitPriceNet) ||
6725
- hasNumericChange(parsed.unitPriceGross, existingSnapshot.unitPriceGross) ||
6726
- hasNumericChange(parsed.taxRate, existingSnapshot.taxRate) ||
6727
- hasUnitChange(parsed.quantityUnit, existingSnapshot.quantityUnit);
6728
- if (pricingChanged) {
6729
- throw new CrudHttpError(409, {
6730
- error: translate(
6731
- "sales.documents.items.errorPriceShipped",
6732
- "You cannot change the price or unit of a line that has shipped items.",
6733
- ),
6734
- });
6882
+ const snapshotLinesById = new Map(
6883
+ snapshot.lines.flatMap((line) => (line.id ? [[line.id, line] as const] : [])),
6884
+ );
6885
+ for (const line of existingLines) {
6886
+ const shippedQuantity = shippedByLine.get(line.id) ?? 0;
6887
+ if (shippedQuantity <= 0) continue;
6888
+ await assertShippedOrderLineChangeAllowed(
6889
+ mapOrderLineEntityToSnapshot(line),
6890
+ snapshotLinesById.get(line.id) ?? null,
6891
+ shippedQuantity,
6892
+ true,
6893
+ );
6735
6894
  }
6736
6895
  }
6737
6896
 
@@ -57,7 +57,7 @@ const orderApproval = defineWorkflow({
57
57
  config: {
58
58
  commandId: 'sales.orders.update',
59
59
  statusDictionary: 'sales.order_status',
60
- input: { id: '{{context.id}}', statusValue: 'pending_approval' },
60
+ input: { id: '{{context.orderId}}', statusValue: 'pending_approval' },
61
61
  },
62
62
  retryPolicy: { maxAttempts: 3, initialIntervalMs: 1000, backoffCoefficient: 2, maxIntervalMs: 10000 },
63
63
  },
@@ -68,7 +68,7 @@ const orderApproval = defineWorkflow({
68
68
  async: true,
69
69
  config: {
70
70
  eventName: 'sales.order.approval.requested',
71
- payload: { orderId: '{{context.id}}', workflowInstanceId: '{{workflow.instanceId}}' },
71
+ payload: { orderId: '{{context.orderId}}', workflowInstanceId: '{{workflow.instanceId}}' },
72
72
  },
73
73
  },
74
74
  ],
@@ -91,7 +91,7 @@ const orderApproval = defineWorkflow({
91
91
  config: {
92
92
  commandId: 'sales.orders.update',
93
93
  statusDictionary: 'sales.order_status',
94
- input: { id: '{{context.id}}', statusValue: 'approved' },
94
+ input: { id: '{{context.orderId}}', statusValue: 'approved' },
95
95
  },
96
96
  retryPolicy: { maxAttempts: 3, initialIntervalMs: 1000, backoffCoefficient: 2, maxIntervalMs: 10000 },
97
97
  },
@@ -103,7 +103,7 @@ const orderApproval = defineWorkflow({
103
103
  config: {
104
104
  eventName: 'sales.order.approval.approved',
105
105
  payload: {
106
- orderId: '{{context.id}}',
106
+ orderId: '{{context.orderId}}',
107
107
  workflowInstanceId: '{{workflow.instanceId}}',
108
108
  approvedBy: '{{context.completedBy}}',
109
109
  comments: '{{context.comments}}',
@@ -130,7 +130,7 @@ const orderApproval = defineWorkflow({
130
130
  config: {
131
131
  commandId: 'sales.orders.update',
132
132
  statusDictionary: 'sales.order_status',
133
- input: { id: '{{context.id}}', statusValue: 'rejected' },
133
+ input: { id: '{{context.orderId}}', statusValue: 'rejected' },
134
134
  },
135
135
  retryPolicy: { maxAttempts: 3, initialIntervalMs: 1000, backoffCoefficient: 2, maxIntervalMs: 10000 },
136
136
  },
@@ -142,7 +142,7 @@ const orderApproval = defineWorkflow({
142
142
  config: {
143
143
  eventName: 'sales.order.approval.rejected',
144
144
  payload: {
145
- orderId: '{{context.id}}',
145
+ orderId: '{{context.orderId}}',
146
146
  workflowInstanceId: '{{workflow.instanceId}}',
147
147
  rejectedBy: '{{context.completedBy}}',
148
148
  comments: '{{context.comments}}',
@@ -175,7 +175,10 @@ const orderApproval = defineWorkflow({
175
175
  name: 'Order Approval Trigger',
176
176
  description: 'Triggers when a new sales order is created',
177
177
  eventPattern: 'sales.order.created',
178
- config: { entityType: 'SalesOrder' },
178
+ config: {
179
+ entityType: 'SalesOrder',
180
+ contextMapping: [{ targetKey: 'orderId', sourceExpression: 'id' }],
181
+ },
179
182
  enabled: true,
180
183
  priority: 0,
181
184
  }],
@@ -935,6 +935,7 @@
935
935
  "workflows.orderApproval.decisionLabel": "Entscheidung",
936
936
  "workflows.orderApproval.groupDescription": "Bestellung prüfen und genehmigen oder ablehnen.",
937
937
  "workflows.orderApproval.groupLabel": "Bestellgenehmigung",
938
+ "workflows.orderApproval.missingOrderId": "Der Genehmigungsworkflow kann erst gestartet werden, wenn die Bestellung geladen ist.",
938
939
  "workflows.orderApproval.missingStatuses": "Fehlende Bestellstatus. Stellen Sie sicher, dass pending_approval, approved und rejected vorhanden sind.",
939
940
  "workflows.orderApproval.noWorkflowActive": "Kein Genehmigungsworkflow für diese Bestellung aktiv.",
940
941
  "workflows.orderApproval.pendingDescription": "Diese Bestellung erfordert eine Genehmigung.",
@@ -935,6 +935,7 @@
935
935
  "workflows.orderApproval.decisionLabel": "Decision",
936
936
  "workflows.orderApproval.groupDescription": "Review and approve or reject this order.",
937
937
  "workflows.orderApproval.groupLabel": "Order Approval",
938
+ "workflows.orderApproval.missingOrderId": "Cannot start the approval workflow before the order has loaded.",
938
939
  "workflows.orderApproval.missingStatuses": "Missing order status entries. Please ensure pending_approval, approved, and rejected statuses exist.",
939
940
  "workflows.orderApproval.noWorkflowActive": "No approval workflow is active for this order.",
940
941
  "workflows.orderApproval.pendingDescription": "This order requires approval before processing.",
@@ -935,6 +935,7 @@
935
935
  "workflows.orderApproval.decisionLabel": "Decisión",
936
936
  "workflows.orderApproval.groupDescription": "Revise y apruebe o rechace este pedido.",
937
937
  "workflows.orderApproval.groupLabel": "Aprobación de pedido",
938
+ "workflows.orderApproval.missingOrderId": "No se puede iniciar el flujo de aprobación antes de que el pedido se haya cargado.",
938
939
  "workflows.orderApproval.missingStatuses": "Faltan entradas de estado del pedido. Asegúrese de que existan los estados pending_approval, approved y rejected.",
939
940
  "workflows.orderApproval.noWorkflowActive": "No hay un flujo de aprobación activo para este pedido.",
940
941
  "workflows.orderApproval.pendingDescription": "Este pedido requiere aprobación antes de procesarse.",
@@ -935,6 +935,7 @@
935
935
  "workflows.orderApproval.decisionLabel": "결정",
936
936
  "workflows.orderApproval.groupDescription": "이 주문을 검토하여 승인하거나 반려하세요.",
937
937
  "workflows.orderApproval.groupLabel": "주문 승인",
938
+ "workflows.orderApproval.missingOrderId": "주문이 로드되기 전에는 승인 워크플로를 시작할 수 없습니다.",
938
939
  "workflows.orderApproval.missingStatuses": "주문 상태 항목이 누락되었습니다. pending_approval, approved, rejected 상태가 존재하는지 확인하세요.",
939
940
  "workflows.orderApproval.noWorkflowActive": "이 주문에 대해 활성화된 승인 워크플로우가 없습니다.",
940
941
  "workflows.orderApproval.pendingDescription": "이 주문은 처리 전에 승인이 필요합니다.",
@@ -935,6 +935,7 @@
935
935
  "workflows.orderApproval.decisionLabel": "Decyzja",
936
936
  "workflows.orderApproval.groupDescription": "Przejrzyj i zatwierdź lub odrzuć to zamówienie.",
937
937
  "workflows.orderApproval.groupLabel": "Zatwierdzanie zamówienia",
938
+ "workflows.orderApproval.missingOrderId": "Nie można rozpocząć procesu zatwierdzania, zanim zamówienie się nie wczyta.",
938
939
  "workflows.orderApproval.missingStatuses": "Brakuje wpisów statusów zamówienia. Upewnij się, że istnieją statusy pending_approval, approved i rejected.",
939
940
  "workflows.orderApproval.noWorkflowActive": "Brak aktywnego przepływu zatwierdzania dla tego zamówienia.",
940
941
  "workflows.orderApproval.pendingDescription": "To zamówienie wymaga zatwierdzenia przed realizacją.",
@@ -603,6 +603,17 @@ export async function executeEmitEvent(
603
603
  throw new Error('EMIT_EVENT requires "eventName" field')
604
604
  }
605
605
 
606
+ // Emissions are fire-and-forget and no subscriber validates the payload shape,
607
+ // so an unresolved `{{context.x}}` here is even quieter than on the command
608
+ // path — it ships the literal template to every consumer. Fail loudly instead.
609
+ const unresolvedPayloadKeys = findUnresolvedTemplateKeys(payload)
610
+ if (unresolvedPayloadKeys.length > 0) {
611
+ throw new Error(
612
+ `EMIT_EVENT payload contains unresolved template variables for: ${unresolvedPayloadKeys.join(', ')}. ` +
613
+ `Check that the workflow context provides these keys.`
614
+ )
615
+ }
616
+
606
617
  // Get event bus from container
607
618
  const eventBus = container.resolve<{ emitEvent: (event: string, payload: unknown, options?: unknown) => Promise<unknown> | unknown }>('eventBus')
608
619
 
@@ -629,6 +640,23 @@ export async function executeEmitEvent(
629
640
  return { emitted: true, eventName, payload: enrichedPayload }
630
641
  }
631
642
 
643
+ const UNRESOLVED_TEMPLATE_PATTERN = /\{\{[^}]+\}\}/
644
+
645
+ function findUnresolvedTemplateKeys(value: unknown, path = ''): string[] {
646
+ if (typeof value === 'string') {
647
+ return UNRESOLVED_TEMPLATE_PATTERN.test(value) ? [path] : []
648
+ }
649
+ if (Array.isArray(value)) {
650
+ return value.flatMap((item, index) => findUnresolvedTemplateKeys(item, `${path}[${index}]`))
651
+ }
652
+ if (value && typeof value === 'object') {
653
+ return Object.entries(value).flatMap(([key, nested]) =>
654
+ findUnresolvedTemplateKeys(nested, path ? `${path}.${key}` : key)
655
+ )
656
+ }
657
+ return []
658
+ }
659
+
632
660
  /**
633
661
  * UPDATE_ENTITY activity handler
634
662
  *
@@ -651,11 +679,14 @@ export async function executeEmitEvent(
651
679
  * "commandId": "sales.orders.update",
652
680
  * "statusDictionary": "sales.order_status",
653
681
  * "input": {
654
- * "id": "{{context.id}}",
682
+ * "id": "{{context.orderId}}",
655
683
  * "statusValue": "pending_approval"
656
684
  * }
657
685
  * }
658
686
  * ```
687
+ *
688
+ * Every `{{...}}` reference in `input` must resolve against the workflow context —
689
+ * an unresolved reference is rejected rather than forwarded to the command bus.
659
690
  */
660
691
  export async function executeUpdateEntity(
661
692
  em: EntityManager,
@@ -701,6 +732,14 @@ export async function executeUpdateEntity(
701
732
  throw new Error('CommandBus not available in container')
702
733
  }
703
734
 
735
+ const unresolvedKeys = findUnresolvedTemplateKeys(input)
736
+ if (unresolvedKeys.length > 0) {
737
+ throw new Error(
738
+ `UPDATE_ENTITY input contains unresolved template variables for: ${unresolvedKeys.join(', ')}. ` +
739
+ `Check that the workflow context provides these keys.`
740
+ )
741
+ }
742
+
704
743
  // Prepare final input, resolving statusValue if provided
705
744
  let finalInput = { ...input }
706
745
 
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The context keys the order-approval widget seeds when it starts the
3
+ * `sales.order-approval` workflow. The workflow definition interpolates
4
+ * `{{context.<key>}}` against exactly this set, and `interpolateVariables()`
5
+ * silently returns the original string when a key is missing — so the two must
6
+ * not drift. Both the widget and the contract test import this list, which is
7
+ * what makes the drift a type error rather than a runtime surprise (issue #4334).
8
+ */
9
+ export const ORDER_APPROVAL_CONTEXT_KEYS = [
10
+ 'orderId',
11
+ 'pendingApprovalStatusId',
12
+ 'approvedStatusId',
13
+ 'rejectedStatusId',
14
+ ] as const
15
+
16
+ export type OrderApprovalContextKey = (typeof ORDER_APPROVAL_CONTEXT_KEYS)[number]
17
+
18
+ export type OrderApprovalInitialContext = Record<OrderApprovalContextKey, string>
@@ -11,6 +11,7 @@ import { Textarea } from '@open-mercato/ui/primitives/textarea'
11
11
  import { Badge } from '@open-mercato/ui/primitives/badge'
12
12
  import { emitSalesDocumentDataRefresh } from '@open-mercato/core/modules/sales/lib/frontend/documentDataEvents'
13
13
  import { fetchAllDictionaryEntries } from '@open-mercato/core/modules/dictionaries/lib/fetchAllEntries'
14
+ import type { OrderApprovalInitialContext } from './context-keys'
14
15
 
15
16
  type OrderRecord = {
16
17
  id: string
@@ -199,20 +200,28 @@ export default function OrderApprovalWidget({ data }: InjectionWidgetComponentPr
199
200
  const approvedStatusId = findStatusId('approved')
200
201
  const rejectedStatusId = findStatusId('rejected')
201
202
 
203
+ if (!orderId) {
204
+ throw new Error(t('workflows.orderApproval.missingOrderId', 'Cannot start the approval workflow before the order has loaded.'))
205
+ }
206
+
202
207
  if (!pendingApprovalStatusId || !approvedStatusId || !rejectedStatusId) {
203
208
  throw new Error(t('workflows.orderApproval.missingStatuses', 'Missing order status entries. Please ensure pending_approval, approved, and rejected statuses exist in the sales.order_status dictionary.'))
204
209
  }
205
210
 
211
+ // Typed against the shared key list so a definition that references a new
212
+ // {{context.*}} key cannot compile until this seed provides it.
213
+ const initialContext: OrderApprovalInitialContext = {
214
+ orderId,
215
+ pendingApprovalStatusId,
216
+ approvedStatusId,
217
+ rejectedStatusId,
218
+ }
219
+
206
220
  const result = await apiCall<{ data: WorkflowInstance }>('/api/workflows/instances', {
207
221
  method: 'POST',
208
222
  body: JSON.stringify({
209
223
  workflowId: WORKFLOW_ID,
210
- initialContext: {
211
- orderId,
212
- pendingApprovalStatusId,
213
- approvedStatusId,
214
- rejectedStatusId,
215
- },
224
+ initialContext,
216
225
  metadata: {
217
226
  entityType: 'SalesOrder',
218
227
  entityId: orderId,