@open-mercato/core 0.6.7-develop.6842.1.8cd8a8883c → 0.6.7-develop.6859.1.e4727b568a

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 (115) 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/api/definitions.js +2 -0
  32. package/dist/modules/entities/api/definitions.js.map +2 -2
  33. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js +18 -3
  34. package/dist/modules/entities/backend/entities/user/[entityId]/records/page.js.map +2 -2
  35. package/dist/modules/entities/cli.js +193 -19
  36. package/dist/modules/entities/cli.js.map +2 -2
  37. package/dist/modules/entities/components/RecordListRelationCell.js +63 -0
  38. package/dist/modules/entities/components/RecordListRelationCell.js.map +7 -0
  39. package/dist/modules/entities/components/recordListRelationDisplays.js +80 -0
  40. package/dist/modules/entities/components/recordListRelationDisplays.js.map +7 -0
  41. package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js +14 -1
  42. package/dist/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.js.map +2 -2
  43. package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js +22 -17
  44. package/dist/modules/inbox_ops/components/proposals/EditActionDialog.js.map +2 -2
  45. package/dist/modules/messages/api/[id]/route.js +15 -0
  46. package/dist/modules/messages/api/[id]/route.js.map +2 -2
  47. package/dist/modules/query_index/api/reindex.js +10 -0
  48. package/dist/modules/query_index/api/reindex.js.map +2 -2
  49. package/dist/modules/query_index/cli.js +125 -1
  50. package/dist/modules/query_index/cli.js.map +2 -2
  51. package/dist/modules/query_index/lib/coverage.js +19 -7
  52. package/dist/modules/query_index/lib/coverage.js.map +2 -2
  53. package/dist/modules/query_index/lib/reindexer.js +43 -37
  54. package/dist/modules/query_index/lib/reindexer.js.map +2 -2
  55. package/dist/modules/query_index/lib/subscriber-scope.js +2 -3
  56. package/dist/modules/query_index/lib/subscriber-scope.js.map +2 -2
  57. package/dist/modules/sales/commands/documents.js +137 -22
  58. package/dist/modules/sales/commands/documents.js.map +2 -2
  59. package/dist/modules/sales/data/validators.js +2 -0
  60. package/dist/modules/sales/data/validators.js.map +2 -2
  61. package/dist/modules/sales/workflows.js +10 -7
  62. package/dist/modules/sales/workflows.js.map +2 -2
  63. package/dist/modules/workflows/lib/activity-executor.js +27 -0
  64. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  65. package/dist/modules/workflows/widgets/injection/order-approval/context-keys.js +10 -0
  66. package/dist/modules/workflows/widgets/injection/order-approval/context-keys.js.map +7 -0
  67. package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js +10 -6
  68. package/dist/modules/workflows/widgets/injection/order-approval/widget.client.js.map +2 -2
  69. package/generated/entities/attachment_quota_reservation/index.ts +15 -0
  70. package/generated/entities.ids.generated.ts +2 -1
  71. package/generated/entity-fields-registry.ts +17 -0
  72. package/package.json +7 -7
  73. package/src/bootstrap.ts +17 -2
  74. package/src/modules/attachments/api/route.ts +114 -7
  75. package/src/modules/attachments/data/entities.ts +59 -0
  76. package/src/modules/attachments/di.ts +9 -1
  77. package/src/modules/attachments/i18n/de.json +3 -0
  78. package/src/modules/attachments/i18n/en.json +3 -0
  79. package/src/modules/attachments/i18n/es.json +3 -0
  80. package/src/modules/attachments/i18n/pl.json +3 -0
  81. package/src/modules/attachments/lib/drivers/index.ts +1 -1
  82. package/src/modules/attachments/lib/drivers/localDriver.ts +23 -8
  83. package/src/modules/attachments/lib/drivers/types.ts +5 -0
  84. package/src/modules/attachments/lib/quota-recovery-queue.ts +16 -0
  85. package/src/modules/attachments/lib/quota-service.ts +442 -0
  86. package/src/modules/attachments/migrations/.snapshot-open-mercato.json +648 -120
  87. package/src/modules/attachments/migrations/Migration20260709234741_attachments.ts +12 -0
  88. package/src/modules/attachments/workers/quota-recovery.ts +68 -0
  89. package/src/modules/auth/lib/setup-app.ts +20 -2
  90. package/src/modules/dashboards/services/widgetDataService.ts +3 -6
  91. package/src/modules/entities/api/definitions.ts +4 -0
  92. package/src/modules/entities/backend/entities/user/[entityId]/records/page.tsx +94 -77
  93. package/src/modules/entities/cli.ts +238 -21
  94. package/src/modules/entities/components/RecordListRelationCell.tsx +90 -0
  95. package/src/modules/entities/components/recordListRelationDisplays.ts +109 -0
  96. package/src/modules/inbox_ops/api/proposals/[id]/actions/[actionId]/route.ts +17 -1
  97. package/src/modules/inbox_ops/components/proposals/EditActionDialog.tsx +21 -16
  98. package/src/modules/inbox_ops/components/proposals/types.ts +1 -0
  99. package/src/modules/messages/api/[id]/route.ts +21 -0
  100. package/src/modules/query_index/api/reindex.ts +15 -0
  101. package/src/modules/query_index/cli.ts +180 -1
  102. package/src/modules/query_index/lib/coverage.ts +38 -8
  103. package/src/modules/query_index/lib/reindexer.ts +62 -40
  104. package/src/modules/query_index/lib/subscriber-scope.ts +2 -7
  105. package/src/modules/sales/commands/documents.ts +237 -29
  106. package/src/modules/sales/data/validators.ts +2 -0
  107. package/src/modules/sales/workflows.ts +10 -7
  108. package/src/modules/workflows/i18n/de.json +1 -0
  109. package/src/modules/workflows/i18n/en.json +1 -0
  110. package/src/modules/workflows/i18n/es.json +1 -0
  111. package/src/modules/workflows/i18n/ko.json +1 -0
  112. package/src/modules/workflows/i18n/pl.json +1 -0
  113. package/src/modules/workflows/lib/activity-executor.ts +40 -1
  114. package/src/modules/workflows/widgets/injection/order-approval/context-keys.ts +18 -0
  115. package/src/modules/workflows/widgets/injection/order-approval/widget.client.tsx +15 -6
@@ -665,6 +665,41 @@ function cloneJson<T>(value: T): T {
665
665
  return JSON.parse(JSON.stringify(value)) as T;
666
666
  }
667
667
 
668
+ /**
669
+ * Persists document-level custom fields supplied on a create input.
670
+ *
671
+ * Order and quote creates used to accept `customFields` at the HTTP boundary
672
+ * (`splitCustomFieldPayload` folds `cf_*` keys into it) and then drop the values
673
+ * on the floor, because the create schemas never declared the key and the
674
+ * handlers never wrote it — only updates did. Callers therefore had to create,
675
+ * then immediately update, to get custom fields to stick.
676
+ */
677
+ async function setDocumentCustomFieldsIfSupplied(
678
+ em: EntityManager,
679
+ params: {
680
+ entityId: string;
681
+ recordId: string;
682
+ organizationId: string;
683
+ tenantId: string;
684
+ customFields: unknown;
685
+ },
686
+ ): Promise<void> {
687
+ const { customFields } = params;
688
+ if (customFields === undefined || customFields === null) return;
689
+ const values =
690
+ typeof customFields === "object" && !Array.isArray(customFields)
691
+ ? (customFields as Record<string, unknown>)
692
+ : {};
693
+ if (!Object.keys(values).length) return;
694
+ await setRecordCustomFields(em, {
695
+ entityId: params.entityId,
696
+ recordId: params.recordId,
697
+ organizationId: params.organizationId,
698
+ tenantId: params.tenantId,
699
+ values: normalizeCustomFieldValues(values),
700
+ });
701
+ }
702
+
668
703
  async function resolveCustomerSnapshot(
669
704
  em: EntityManager,
670
705
  organizationId: string,
@@ -4186,6 +4221,20 @@ async function restoreOrderGraph(
4186
4221
  organizationId: snapshot.order.organizationId,
4187
4222
  },
4188
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
+ }
4189
4238
  if (!order) {
4190
4239
  order = em.create(SalesOrder, {
4191
4240
  id: snapshot.order.id,
@@ -4270,11 +4319,6 @@ async function restoreOrderGraph(
4270
4319
  }
4271
4320
  applyOrderSnapshot(order, snapshot.order);
4272
4321
  await em.flush();
4273
- const existingLines = await em.find(
4274
- SalesOrderLine,
4275
- { order: order.id },
4276
- { fields: ["id"] },
4277
- );
4278
4322
  const existingAdjustments = await em.find(
4279
4323
  SalesOrderAdjustment,
4280
4324
  { order: order.id },
@@ -4782,6 +4826,13 @@ const createQuoteCommand: CommandHandler<
4782
4826
  tenantId: quote.tenantId,
4783
4827
  tagIds: parsed.tags,
4784
4828
  });
4829
+ await setDocumentCustomFieldsIfSupplied(em, {
4830
+ entityId: E.sales.sales_quote,
4831
+ recordId: quote.id,
4832
+ organizationId: quote.organizationId,
4833
+ tenantId: quote.tenantId,
4834
+ customFields: parsed.customFields,
4835
+ });
4785
4836
  },
4786
4837
  ],
4787
4838
  { transaction: true },
@@ -5804,6 +5855,13 @@ const createOrderCommand: CommandHandler<
5804
5855
  tenantId: order.tenantId,
5805
5856
  tagIds: parsed.tags,
5806
5857
  });
5858
+ await setDocumentCustomFieldsIfSupplied(em, {
5859
+ entityId: E.sales.sales_order,
5860
+ recordId: order.id,
5861
+ organizationId: order.organizationId,
5862
+ tenantId: order.tenantId,
5863
+ customFields: parsed.customFields,
5864
+ });
5807
5865
  },
5808
5866
  ],
5809
5867
  { transaction: true },
@@ -6625,6 +6683,34 @@ const quoteAdjustmentDeleteSchema = z.object({
6625
6683
 
6626
6684
  const SHIPPED_QUANTITY_TOLERANCE = 1e-6;
6627
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
+
6628
6714
  const hasNumericChange = (
6629
6715
  next: number | null | undefined,
6630
6716
  previous: number | null | undefined,
@@ -6634,6 +6720,17 @@ const hasNumericChange = (
6634
6720
  return Math.abs(next - previous) > SHIPPED_QUANTITY_TOLERANCE;
6635
6721
  };
6636
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
+
6637
6734
  const hasUnitChange = (
6638
6735
  next: string | null | undefined,
6639
6736
  previous: string | null | undefined,
@@ -6644,6 +6741,111 @@ const hasUnitChange = (
6644
6741
  return next.trim().toLowerCase() !== normalizedPrevious;
6645
6742
  };
6646
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
+
6647
6849
  async function assertShippedOrderLineEditable(
6648
6850
  em: EntityManager,
6649
6851
  order: SalesOrder,
@@ -6657,32 +6859,38 @@ async function assertShippedOrderLineEditable(
6657
6859
  organizationId: order.organizationId,
6658
6860
  });
6659
6861
  const shippedQuantity = shippedByLine.get(lineId) ?? 0;
6660
- if (shippedQuantity <= 0) return;
6862
+ await assertShippedOrderLineChangeAllowed(
6863
+ existingSnapshot,
6864
+ parsed,
6865
+ shippedQuantity,
6866
+ false,
6867
+ );
6868
+ }
6661
6869
 
6662
- const { translate } = await resolveTranslations();
6663
- const nextQuantity = parsed.quantity ?? existingSnapshot.quantity;
6664
- if (nextQuantity < shippedQuantity - SHIPPED_QUANTITY_TOLERANCE) {
6665
- throw new CrudHttpError(409, {
6666
- error: translate(
6667
- "sales.documents.items.errorQuantityBelowShipped",
6668
- "You cannot lower the quantity below the {{shipped}} already shipped.",
6669
- { shipped: shippedQuantity },
6670
- ),
6671
- });
6672
- }
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;
6673
6881
 
6674
- const pricingChanged =
6675
- hasNumericChange(parsed.unitPriceNet, existingSnapshot.unitPriceNet) ||
6676
- hasNumericChange(parsed.unitPriceGross, existingSnapshot.unitPriceGross) ||
6677
- hasNumericChange(parsed.taxRate, existingSnapshot.taxRate) ||
6678
- hasUnitChange(parsed.quantityUnit, existingSnapshot.quantityUnit);
6679
- if (pricingChanged) {
6680
- throw new CrudHttpError(409, {
6681
- error: translate(
6682
- "sales.documents.items.errorPriceShipped",
6683
- "You cannot change the price or unit of a line that has shipped items.",
6684
- ),
6685
- });
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
+ );
6686
6894
  }
6687
6895
  }
6688
6896
 
@@ -717,6 +717,7 @@ export const orderCreateSchema = scoped.extend({
717
717
  paymentMethodSnapshot: jsonRecord.optional(),
718
718
  metadata,
719
719
  customFieldSetId: uuid().optional(),
720
+ customFields: z.record(z.string(), z.unknown()).optional(),
720
721
  lines: z
721
722
  .array(orderLineCreateSchema.omit({ organizationId: true, tenantId: true, orderId: true }), {
722
723
  error: SALES_ORDER_LINES_REQUIRED_MESSAGE_KEY,
@@ -761,6 +762,7 @@ export const quoteCreateSchema = scoped.extend({
761
762
  paymentMethodSnapshot: jsonRecord.optional(),
762
763
  metadata,
763
764
  customFieldSetId: uuid().optional(),
765
+ customFields: z.record(z.string(), z.unknown()).optional(),
764
766
  lines: z
765
767
  .array(quoteLineCreateSchema.omit({ organizationId: true, tenantId: true, quoteId: true }))
766
768
  .optional(),
@@ -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,