@open-mercato/core 0.7.1-develop.7154.1.981330c924 → 0.7.1-develop.7171.1.9b31dbba45

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 (155) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -2
  3. package/dist/modules/auth/commands/users.js +14 -1
  4. package/dist/modules/auth/commands/users.js.map +2 -2
  5. package/dist/modules/auth/data/entities.js +2 -2
  6. package/dist/modules/auth/data/entities.js.map +2 -2
  7. package/dist/modules/auth/lib/backendChrome.js +26 -3
  8. package/dist/modules/auth/lib/backendChrome.js.map +2 -2
  9. package/dist/modules/auth/lib/profile-sections.js +1 -1
  10. package/dist/modules/auth/lib/profile-sections.js.map +2 -2
  11. package/dist/modules/auth/migrations/Migration20260907120000_auth.js +19 -0
  12. package/dist/modules/auth/migrations/Migration20260907120000_auth.js.map +7 -0
  13. package/dist/modules/communication_channels/backend/profile/communication-channels/page.meta.js +2 -2
  14. package/dist/modules/communication_channels/backend/profile/communication-channels/page.meta.js.map +1 -1
  15. package/dist/modules/customer_accounts/api/admin/demo-accounts.js +118 -0
  16. package/dist/modules/customer_accounts/api/admin/demo-accounts.js.map +7 -0
  17. package/dist/modules/customer_accounts/api/invitations/accept.js +23 -6
  18. package/dist/modules/customer_accounts/api/invitations/accept.js.map +2 -2
  19. package/dist/modules/customer_accounts/backend/customer_accounts/settings/CustomerAccountsSettingsPageClient.js +10 -13
  20. package/dist/modules/customer_accounts/backend/customer_accounts/settings/CustomerAccountsSettingsPageClient.js.map +2 -2
  21. package/dist/modules/customer_accounts/backend/customer_accounts/useDemoPortalAccounts.js +28 -0
  22. package/dist/modules/customer_accounts/backend/customer_accounts/useDemoPortalAccounts.js.map +7 -0
  23. package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js +6 -1
  24. package/dist/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.js.map +2 -2
  25. package/dist/modules/customer_accounts/data/entities.js +4 -1
  26. package/dist/modules/customer_accounts/data/entities.js.map +2 -2
  27. package/dist/modules/customer_accounts/lib/exampleAccounts.js +9 -0
  28. package/dist/modules/customer_accounts/lib/exampleAccounts.js.map +7 -0
  29. package/dist/modules/customer_accounts/migrations/Migration20260827160000_customer_accounts.js +16 -0
  30. package/dist/modules/customer_accounts/migrations/Migration20260827160000_customer_accounts.js.map +7 -0
  31. package/dist/modules/customer_accounts/services/customerInvitationService.js +53 -6
  32. package/dist/modules/customer_accounts/services/customerInvitationService.js.map +2 -2
  33. package/dist/modules/customer_accounts/setup.js +2 -6
  34. package/dist/modules/customer_accounts/setup.js.map +2 -2
  35. package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js +55 -40
  36. package/dist/modules/customer_accounts/widgets/injection/account-status/widget.client.js.map +2 -2
  37. package/dist/modules/customers/commands/people.js +16 -1
  38. package/dist/modules/customers/commands/people.js.map +2 -2
  39. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js +18 -36
  40. package/dist/modules/data_sync/backend/data-sync/runs/[id]/page.js.map +2 -2
  41. package/dist/modules/entities/api/records.js +18 -15
  42. package/dist/modules/entities/api/records.js.map +3 -3
  43. package/dist/modules/entities/lib/scoped-field-defs.js +52 -0
  44. package/dist/modules/entities/lib/scoped-field-defs.js.map +7 -0
  45. package/dist/modules/entities/lib/validation.js +4 -35
  46. package/dist/modules/entities/lib/validation.js.map +2 -2
  47. package/dist/modules/integrations/backend/integrations/[id]/page.js +32 -54
  48. package/dist/modules/integrations/backend/integrations/[id]/page.js.map +2 -2
  49. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js +12 -29
  50. package/dist/modules/integrations/backend/integrations/bundle/[id]/page.js.map +2 -2
  51. package/dist/modules/notifications/backend/profile/notification-preferences/page.meta.js +2 -2
  52. package/dist/modules/notifications/backend/profile/notification-preferences/page.meta.js.map +1 -1
  53. package/dist/modules/progress/lib/progressServiceImpl.js +14 -3
  54. package/dist/modules/progress/lib/progressServiceImpl.js.map +2 -2
  55. package/dist/modules/sales/backend/sales/documents/[id]/page.js +9 -9
  56. package/dist/modules/sales/backend/sales/documents/[id]/page.js.map +2 -2
  57. package/dist/modules/sales/components/documents/LineItemDialog.js +50 -10
  58. package/dist/modules/sales/components/documents/LineItemDialog.js.map +2 -2
  59. package/dist/modules/sales/components/documents/PaymentsSection.js +8 -3
  60. package/dist/modules/sales/components/documents/PaymentsSection.js.map +2 -2
  61. package/dist/modules/sales/components/documents/ReturnEditDialog.js +2 -7
  62. package/dist/modules/sales/components/documents/ReturnEditDialog.js.map +2 -2
  63. package/dist/modules/sales/components/documents/ReturnsSection.js +2 -7
  64. package/dist/modules/sales/components/documents/ReturnsSection.js.map +2 -2
  65. package/dist/modules/sales/components/documents/ShipmentsSection.js +3 -8
  66. package/dist/modules/sales/components/documents/ShipmentsSection.js.map +2 -2
  67. package/dist/modules/wms/commands/sales-order-assignment.js +12 -0
  68. package/dist/modules/wms/commands/sales-order-assignment.js.map +2 -2
  69. package/dist/modules/wms/data/enrichers.js +25 -0
  70. package/dist/modules/wms/data/enrichers.js.map +2 -2
  71. package/dist/modules/wms/lib/enricherCacheTags.js +9 -0
  72. package/dist/modules/wms/lib/enricherCacheTags.js.map +7 -0
  73. package/dist/modules/wms/lib/invalidateInventoryEnricherCache.js +37 -0
  74. package/dist/modules/wms/lib/invalidateInventoryEnricherCache.js.map +7 -0
  75. package/dist/modules/wms/lib/inventoryEnricherCacheSubscriber.js +14 -0
  76. package/dist/modules/wms/lib/inventoryEnricherCacheSubscriber.js.map +7 -0
  77. package/dist/modules/wms/subscribers/invalidate-enricher-cache-balance.js +12 -0
  78. package/dist/modules/wms/subscribers/invalidate-enricher-cache-balance.js.map +7 -0
  79. package/dist/modules/wms/subscribers/invalidate-enricher-cache-inventory.js +12 -0
  80. package/dist/modules/wms/subscribers/invalidate-enricher-cache-inventory.js.map +7 -0
  81. package/dist/modules/wms/subscribers/invalidate-enricher-cache-movement.js +12 -0
  82. package/dist/modules/wms/subscribers/invalidate-enricher-cache-movement.js.map +7 -0
  83. package/dist/modules/wms/subscribers/invalidate-enricher-cache-profile.js +12 -0
  84. package/dist/modules/wms/subscribers/invalidate-enricher-cache-profile.js.map +7 -0
  85. package/dist/modules/wms/subscribers/invalidate-enricher-cache-reservation.js +12 -0
  86. package/dist/modules/wms/subscribers/invalidate-enricher-cache-reservation.js.map +7 -0
  87. package/dist/modules/wms/subscribers/invalidate-enricher-cache-sales-line.js +12 -0
  88. package/dist/modules/wms/subscribers/invalidate-enricher-cache-sales-line.js.map +7 -0
  89. package/dist/modules/wms/subscribers/invalidate-enricher-cache-sales-order.js +12 -0
  90. package/dist/modules/wms/subscribers/invalidate-enricher-cache-sales-order.js.map +7 -0
  91. package/dist/modules/wms/subscribers/invalidate-enricher-cache-variant.js +12 -0
  92. package/dist/modules/wms/subscribers/invalidate-enricher-cache-variant.js.map +7 -0
  93. package/dist/modules/wms/subscribers/invalidate-enricher-cache-warehouse.js +12 -0
  94. package/dist/modules/wms/subscribers/invalidate-enricher-cache-warehouse.js.map +7 -0
  95. package/package.json +7 -7
  96. package/src/modules/auth/commands/users.ts +14 -1
  97. package/src/modules/auth/data/entities.ts +2 -2
  98. package/src/modules/auth/lib/backendChrome.tsx +46 -2
  99. package/src/modules/auth/lib/profile-sections.tsx +14 -3
  100. package/src/modules/auth/migrations/.snapshot-open-mercato.json +4 -2
  101. package/src/modules/auth/migrations/Migration20260907120000_auth.ts +21 -0
  102. package/src/modules/communication_channels/backend/profile/communication-channels/page.meta.ts +2 -2
  103. package/src/modules/customer_accounts/AGENTS.md +3 -1
  104. package/src/modules/customer_accounts/api/admin/demo-accounts.ts +149 -0
  105. package/src/modules/customer_accounts/api/invitations/accept.ts +23 -6
  106. package/src/modules/customer_accounts/backend/customer_accounts/settings/CustomerAccountsSettingsPageClient.tsx +43 -41
  107. package/src/modules/customer_accounts/backend/customer_accounts/useDemoPortalAccounts.ts +42 -0
  108. package/src/modules/customer_accounts/backend/customer_accounts/users/PortalUsersPageClient.tsx +10 -3
  109. package/src/modules/customer_accounts/data/entities.ts +5 -1
  110. package/src/modules/customer_accounts/i18n/de.json +4 -2
  111. package/src/modules/customer_accounts/i18n/en.json +4 -2
  112. package/src/modules/customer_accounts/i18n/es.json +4 -2
  113. package/src/modules/customer_accounts/i18n/ko.json +4 -2
  114. package/src/modules/customer_accounts/i18n/pl.json +4 -2
  115. package/src/modules/customer_accounts/lib/exampleAccounts.ts +18 -0
  116. package/src/modules/customer_accounts/migrations/.snapshot-open-mercato.json +5 -7
  117. package/src/modules/customer_accounts/migrations/Migration20260827160000_customer_accounts.ts +23 -0
  118. package/src/modules/customer_accounts/services/customerInvitationService.ts +89 -5
  119. package/src/modules/customer_accounts/setup.ts +2 -6
  120. package/src/modules/customer_accounts/widgets/injection/account-status/widget.client.tsx +74 -41
  121. package/src/modules/customers/commands/people.ts +15 -0
  122. package/src/modules/data_sync/backend/data-sync/runs/[id]/page.tsx +19 -44
  123. package/src/modules/entities/api/records.ts +24 -15
  124. package/src/modules/entities/lib/scoped-field-defs.ts +76 -0
  125. package/src/modules/entities/lib/validation.ts +5 -45
  126. package/src/modules/integrations/backend/integrations/[id]/page.tsx +33 -62
  127. package/src/modules/integrations/backend/integrations/bundle/[id]/page.tsx +13 -37
  128. package/src/modules/notifications/backend/profile/notification-preferences/page.meta.ts +2 -2
  129. package/src/modules/progress/AGENTS.md +1 -1
  130. package/src/modules/progress/lib/progressServiceImpl.ts +26 -3
  131. package/src/modules/sales/backend/sales/documents/[id]/page.tsx +19 -25
  132. package/src/modules/sales/components/documents/LineItemDialog.tsx +53 -9
  133. package/src/modules/sales/components/documents/PaymentsSection.tsx +8 -8
  134. package/src/modules/sales/components/documents/ReturnEditDialog.tsx +2 -8
  135. package/src/modules/sales/components/documents/ReturnsSection.tsx +8 -7
  136. package/src/modules/sales/components/documents/ShipmentsSection.tsx +9 -8
  137. package/src/modules/sales/i18n/de.json +2 -0
  138. package/src/modules/sales/i18n/en.json +2 -0
  139. package/src/modules/sales/i18n/es.json +2 -0
  140. package/src/modules/sales/i18n/ko.json +2 -0
  141. package/src/modules/sales/i18n/pl.json +2 -0
  142. package/src/modules/wms/commands/sales-order-assignment.ts +20 -0
  143. package/src/modules/wms/data/enrichers.ts +25 -0
  144. package/src/modules/wms/lib/enricherCacheTags.ts +29 -0
  145. package/src/modules/wms/lib/invalidateInventoryEnricherCache.ts +71 -0
  146. package/src/modules/wms/lib/inventoryEnricherCacheSubscriber.ts +30 -0
  147. package/src/modules/wms/subscribers/invalidate-enricher-cache-balance.ts +9 -0
  148. package/src/modules/wms/subscribers/invalidate-enricher-cache-inventory.ts +9 -0
  149. package/src/modules/wms/subscribers/invalidate-enricher-cache-movement.ts +9 -0
  150. package/src/modules/wms/subscribers/invalidate-enricher-cache-profile.ts +9 -0
  151. package/src/modules/wms/subscribers/invalidate-enricher-cache-reservation.ts +9 -0
  152. package/src/modules/wms/subscribers/invalidate-enricher-cache-sales-line.ts +14 -0
  153. package/src/modules/wms/subscribers/invalidate-enricher-cache-sales-order.ts +18 -0
  154. package/src/modules/wms/subscribers/invalidate-enricher-cache-variant.ts +9 -0
  155. package/src/modules/wms/subscribers/invalidate-enricher-cache-warehouse.ts +9 -0
@@ -24,6 +24,7 @@ import { Badge } from '@open-mercato/ui/primitives/badge'
24
24
  import { Spinner } from '@open-mercato/ui/primitives/spinner'
25
25
  import { Input } from '@open-mercato/ui/primitives/input'
26
26
  import { EmailInput } from '@open-mercato/ui/primitives/email-input'
27
+ import { formatDisplayDate, formatDisplayDateTime, toDateInputValue, toUtcDateInputValue } from '@open-mercato/ui/primitives/date-format'
27
28
  import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@open-mercato/ui/primitives/dialog'
28
29
  import { ArrowRightLeft, Building2, CreditCard, Mail, Pencil, Plus, Send, Store, Truck, UserRound, Wand2, X } from 'lucide-react'
29
30
  import { FormHeader, type ActionItem } from '@open-mercato/ui/backend/forms'
@@ -3864,17 +3865,16 @@ export default function SalesDocumentDetailPage({
3864
3865
  label: t('sales.documents.detail.expectedDeliveryAt.label', 'Expected delivery'),
3865
3866
  emptyLabel: t('sales.documents.detail.empty', 'Not set'),
3866
3867
  placeholder: t('sales.documents.detail.expectedDeliveryAt.placeholder', 'Add expected delivery date'),
3867
- value: record?.expectedDeliveryAt
3868
- ? new Date(record.expectedDeliveryAt).toISOString().slice(0, 10)
3869
- : null,
3868
+ // UTC, not local: this editor submits a bare `yyyy-MM-dd`, which `z.coerce.date()`
3869
+ // stores as UTC midnight. Reading it back locally names the previous day west of UTC.
3870
+ value: toUtcDateInputValue(record?.expectedDeliveryAt),
3870
3871
  onSave: handleUpdateExpectedDeliveryAt,
3871
3872
  inputType: 'date',
3872
3873
  renderDisplay: (params) => {
3873
3874
  const { value, emptyLabel } = params
3874
- if (value && value.length) {
3875
- return <span className="text-sm text-muted-foreground">{new Date(value).toLocaleDateString(locale)}</span>
3876
- }
3877
- return <span className="text-sm text-muted-foreground">{emptyLabel}</span>
3875
+ return (
3876
+ <span className="text-sm text-muted-foreground">{formatDisplayDate(value, locale) ?? emptyLabel}</span>
3877
+ )
3878
3878
  },
3879
3879
  })
3880
3880
  }
@@ -3915,10 +3915,10 @@ export default function SalesDocumentDetailPage({
3915
3915
  render: () => (
3916
3916
  <SectionCard title={t('sales.documents.detail.timestamps', 'Timestamps')} muted>
3917
3917
  <p className="text-sm text-muted-foreground">
3918
- {t('sales.documents.detail.created', 'Created')}: {record?.createdAt ?? '—'}
3918
+ {t('sales.documents.detail.created', 'Created')}: {formatDisplayDateTime(record?.createdAt, locale) ?? '—'}
3919
3919
  </p>
3920
3920
  <p className="text-sm text-muted-foreground">
3921
- {t('sales.documents.detail.updated', 'Updated')}: {record?.updatedAt ?? '—'}
3921
+ {t('sales.documents.detail.updated', 'Updated')}: {formatDisplayDateTime(record?.updatedAt, locale) ?? '—'}
3922
3922
  </p>
3923
3923
  </SectionCard>
3924
3924
  ),
@@ -4707,28 +4707,22 @@ export default function SalesDocumentDetailPage({
4707
4707
  <InlineTextEditor
4708
4708
  key="date"
4709
4709
  label={t('sales.documents.detail.date', 'Date')}
4710
- value={
4711
- record?.placedAt
4712
- ? new Date(record.placedAt).toISOString().slice(0, 10)
4713
- : record?.createdAt
4714
- ? new Date(record.createdAt).toISOString().slice(0, 10)
4715
- : null
4716
- }
4710
+ // Two readings on purpose. `placedAt` is settable from this date input, so it is
4711
+ // stored as UTC midnight and must be read back in UTC or the field flips a day west
4712
+ // of UTC. `createdAt` is only ever stamped from a clock — a real instant, whose local
4713
+ // day is the one to show.
4714
+ value={toUtcDateInputValue(record?.placedAt) ?? toDateInputValue(record?.createdAt)}
4717
4715
  emptyLabel={t('sales.documents.detail.empty', 'Not set')}
4718
4716
  onSave={handleUpdatePlacedAt}
4719
4717
  inputType="date"
4720
4718
  activateOnClick
4721
4719
  containerClassName="h-full"
4722
4720
  saveLabel={t('customers.people.detail.inline.saveShortcut')}
4723
- renderDisplay={({ value, emptyLabel }) =>
4724
- value && value.length ? (
4725
- <span className="text-sm text-muted-foreground">
4726
- {new Date(value).toLocaleDateString(locale)}
4727
- </span>
4728
- ) : (
4729
- <span className="text-sm text-muted-foreground">{emptyLabel}</span>
4730
- )
4731
- }
4721
+ renderDisplay={({ value, emptyLabel }) => (
4722
+ <span className="text-sm text-muted-foreground">
4723
+ {formatDisplayDate(value, locale) ?? emptyLabel}
4724
+ </span>
4725
+ )}
4732
4726
  />
4733
4727
  </div>
4734
4728
 
@@ -56,6 +56,7 @@ import {
56
56
  extractCustomFieldValues,
57
57
  } from "./customFieldHelpers";
58
58
  import { canonicalizeUnitCode } from "@open-mercato/shared/lib/units/unitCodes";
59
+ import { parseLocaleNumber } from "@open-mercato/shared/lib/number";
59
60
  import { createLogger } from '@open-mercato/shared/lib/logger'
60
61
 
61
62
  const logger = createLogger('sales')
@@ -490,6 +491,30 @@ export function LineItemDialog({
490
491
  }: SalesLineDialogProps) {
491
492
  const t = useT();
492
493
  const locale = useLocale();
494
+ // These fields are hand-rolled text inputs, so the raw string a user typed reaches the
495
+ // submit handler. It carries the separator the surrounding UI displays, which follows the
496
+ // application locale — `110,70` under Polish (issue #5552). A blank field keeps its old
497
+ // "must be greater than 0" message; only genuinely unparseable input returns null.
498
+ const parseUserNumber = React.useCallback(
499
+ (value: unknown): number | null => {
500
+ if (value == null) return 0;
501
+ if (typeof value === "number") return Number.isFinite(value) ? value : null;
502
+ if (typeof value !== "string") return null;
503
+ if (!value.trim()) return 0;
504
+ return parseLocaleNumber(value, locale);
505
+ },
506
+ [locale],
507
+ );
508
+ const numberExample = React.useMemo(() => {
509
+ try {
510
+ return new Intl.NumberFormat(locale, {
511
+ minimumFractionDigits: 2,
512
+ maximumFractionDigits: 2,
513
+ }).format(110.7);
514
+ } catch {
515
+ return "110.70";
516
+ }
517
+ }, [locale]);
493
518
  const scope = useOrganizationScopeDetail();
494
519
  const resolvedOrganizationId = organizationId ?? scope.organizationId ?? null;
495
520
  const resolvedTenantId = tenantId ?? scope.tenantId ?? null;
@@ -952,7 +977,7 @@ export function LineItemDialog({
952
977
  try {
953
978
  const params = new URLSearchParams({ productId, pageSize: "20" });
954
979
  if (variantId) params.set("variantId", variantId);
955
- const quantityValue = normalizeNumber(quantity, Number.NaN);
980
+ const quantityValue = parseUserNumber(quantity) ?? Number.NaN;
956
981
  if (Number.isFinite(quantityValue) && quantityValue > 0) {
957
982
  params.set("quantity", String(quantityValue));
958
983
  }
@@ -1093,7 +1118,7 @@ export function LineItemDialog({
1093
1118
  setPriceLoading(false);
1094
1119
  }
1095
1120
  },
1096
- [t],
1121
+ [parseUserNumber, t],
1097
1122
  );
1098
1123
 
1099
1124
  const selectPriceAfterRefresh = React.useCallback(
@@ -1136,7 +1161,7 @@ export function LineItemDialog({
1136
1161
  fromUnit: string | null | undefined,
1137
1162
  toUnit: string | null | undefined,
1138
1163
  ): string | null => {
1139
- const amount = normalizeNumber(rawUnitPrice, Number.NaN);
1164
+ const amount = parseUserNumber(rawUnitPrice) ?? Number.NaN;
1140
1165
  if (!Number.isFinite(amount) || amount <= 0) return null;
1141
1166
  const fromCode = normalizeUnitCode(fromUnit);
1142
1167
  const toCode = normalizeUnitCode(toUnit);
@@ -1156,7 +1181,7 @@ export function LineItemDialog({
1156
1181
  if (!Number.isFinite(convertedAmount) || convertedAmount <= 0) return null;
1157
1182
  return normalizeUnitPriceInputValue(convertedAmount);
1158
1183
  },
1159
- [resolveUnitPriceFactor],
1184
+ [parseUserNumber, resolveUnitPriceFactor],
1160
1185
  );
1161
1186
 
1162
1187
  const applyPriceSelection = React.useCallback(
@@ -1342,8 +1367,16 @@ export function LineItemDialog({
1342
1367
  );
1343
1368
  }
1344
1369
 
1345
- const qtyNumber = Number(values.quantity ?? 0);
1346
- if (!Number.isFinite(qtyNumber) || qtyNumber <= 0) {
1370
+ const qtyNumber = parseUserNumber(values.quantity);
1371
+ if (qtyNumber === null) {
1372
+ const message = t(
1373
+ "sales.documents.items.errorQuantityInvalid",
1374
+ "Enter the quantity as a number, for example {{example}}.",
1375
+ { example: numberExample },
1376
+ );
1377
+ throw createCrudFormError(message, { quantity: message });
1378
+ }
1379
+ if (qtyNumber <= 0) {
1347
1380
  throw createCrudFormError(
1348
1381
  t(
1349
1382
  "sales.documents.items.errorQuantity",
@@ -1380,8 +1413,16 @@ export function LineItemDialog({
1380
1413
  );
1381
1414
  })();
1382
1415
 
1383
- const unitPriceNumber = Number(values.unitPrice ?? 0);
1384
- if (!Number.isFinite(unitPriceNumber) || unitPriceNumber <= 0) {
1416
+ const unitPriceNumber = parseUserNumber(values.unitPrice);
1417
+ if (unitPriceNumber === null) {
1418
+ const message = t(
1419
+ "sales.documents.items.errorUnitPriceInvalid",
1420
+ "Enter the unit price as a number, for example {{example}}.",
1421
+ { example: numberExample },
1422
+ );
1423
+ throw createCrudFormError(message, { unitPrice: message });
1424
+ }
1425
+ if (unitPriceNumber <= 0) {
1385
1426
  throw createCrudFormError(
1386
1427
  t(
1387
1428
  "sales.documents.items.errorUnitPrice",
@@ -1610,6 +1651,8 @@ export function LineItemDialog({
1610
1651
  variantOption,
1611
1652
  onSaved,
1612
1653
  closeDialog,
1654
+ numberExample,
1655
+ parseUserNumber,
1613
1656
  resolvedOrganizationId,
1614
1657
  resolvedTenantId,
1615
1658
  ],
@@ -2541,7 +2584,7 @@ export function LineItemDialog({
2541
2584
  layout: "full",
2542
2585
  component: ({ values }: FieldRenderProps) => {
2543
2586
  if (isCustomLine) return null;
2544
- const quantity = normalizeNumber(values?.quantity, Number.NaN);
2587
+ const quantity = parseUserNumber(values?.quantity) ?? Number.NaN;
2545
2588
  const enteredUnit = normalizeUnitCode(values?.quantityUnit);
2546
2589
  if (!Number.isFinite(quantity) || quantity <= 0 || !enteredUnit) {
2547
2590
  return (
@@ -2651,6 +2694,7 @@ export function LineItemDialog({
2651
2694
  selectPriceAfterRefresh,
2652
2695
  hasTaxMetadata,
2653
2696
  isShippedOrderLine,
2697
+ parseUserNumber,
2654
2698
  ]);
2655
2699
 
2656
2700
  const groups = React.useMemo<CrudFormGroup[]>(() => {
@@ -11,6 +11,7 @@ import { handleSectionMutationError, readRowUpdatedAt } from './optimisticLock'
11
11
  import type { SectionAction } from '@open-mercato/ui/backend/detail'
12
12
  import { RowActions } from '@open-mercato/ui/backend/RowActions'
13
13
  import { Button } from '@open-mercato/ui/primitives/button'
14
+ import { formatDisplayDate, formatDisplayDateTime, toUtcDateInputValue } from '@open-mercato/ui/primitives/date-format'
14
15
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
15
16
  import { useOrganizationScopeDetail } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
16
17
  import { useT, useLocale } from '@open-mercato/shared/lib/i18n/context'
@@ -293,19 +294,18 @@ export function SalesDocumentPaymentsSection({
293
294
  {
294
295
  accessorKey: 'receivedAt',
295
296
  header: t('sales.documents.payments.receivedAt', 'Received'),
296
- cell: ({ row }) =>
297
- row.original.receivedAt
298
- ? new Date(row.original.receivedAt).toLocaleDateString(locale)
299
- : '—',
297
+ // `receivedAt` is written from a date input (`PaymentDialog`), coerced with
298
+ // `z.coerce.date()` and stored as UTC midnight, so its day must be read in UTC.
299
+ // Reading it locally names the previous day west of UTC — and disagrees with the
300
+ // Edit dialog, which seeds from `receivedAt.slice(0, 10)`, i.e. the UTC day.
301
+ cell: ({ row }) => formatDisplayDate(toUtcDateInputValue(row.original.receivedAt), locale) ?? '—',
300
302
  },
301
303
  {
302
304
  accessorKey: 'createdAt',
303
305
  header: t('sales.documents.payments.createdAt', 'Created'),
304
- cell: ({ row }) =>
305
- row.original.createdAt ? new Date(row.original.createdAt).toLocaleString(locale) : '—',
306
+ cell: ({ row }) => formatDisplayDateTime(row.original.createdAt, locale) ?? '—',
306
307
  meta: {
307
- tooltipContent: (row: PaymentRow) =>
308
- row.createdAt ? new Date(row.createdAt).toLocaleString(locale) : undefined,
308
+ tooltipContent: (row: PaymentRow) => formatDisplayDateTime(row.createdAt, locale) ?? undefined,
309
309
  },
310
310
  },
311
311
  {
@@ -1,6 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from 'react'
4
+ import { toUtcDateInputValue } from '@open-mercato/ui/primitives/date-format'
4
5
  import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@open-mercato/ui/primitives/dialog'
5
6
  import { useDialogKeyHandler } from '@open-mercato/ui/hooks/useDialogKeyHandler'
6
7
  import { CrudForm, type CrudField } from '@open-mercato/ui/backend/CrudForm'
@@ -36,13 +37,6 @@ type ReturnEditDialogProps = {
36
37
  onSaved: () => Promise<void>
37
38
  }
38
39
 
39
- function toDateInputValue(value: string | null | undefined): string {
40
- if (!value) return ''
41
- const date = new Date(value)
42
- if (Number.isNaN(date.getTime())) return ''
43
- return date.toISOString().slice(0, 10)
44
- }
45
-
46
40
  export function ReturnEditDialog({
47
41
  open,
48
42
  returnRecord,
@@ -62,7 +56,7 @@ export function ReturnEditDialog({
62
56
  updatedAt: returnRecord?.updatedAt ?? null,
63
57
  reason: returnRecord?.reason ?? '',
64
58
  notes: returnRecord?.notes ?? '',
65
- returnedAt: toDateInputValue(returnRecord?.returnedAt),
59
+ returnedAt: toUtcDateInputValue(returnRecord?.returnedAt) ?? '',
66
60
  }),
67
61
  [returnRecord],
68
62
  )
@@ -1,6 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from 'react'
4
+ import { formatDisplayDate, toUtcDateInputValue } from '@open-mercato/ui/primitives/date-format'
4
5
  import { Undo2, Plus } from 'lucide-react'
5
6
  import { Button } from '@open-mercato/ui/primitives/button'
6
7
  import { Badge } from '@open-mercato/ui/primitives/badge'
@@ -41,12 +42,8 @@ type SalesReturnsSectionProps = {
41
42
  documentUpdatedAt?: string | null
42
43
  }
43
44
 
44
- export function formatDisplayDate(value: string | null | undefined, locale?: string): string | null {
45
- if (!value) return null
46
- const date = new Date(value)
47
- if (Number.isNaN(date.getTime())) return null
48
- return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(date)
49
- }
45
+ // One date formatter for this page rather than three with incompatible semantics.
46
+ export { formatDisplayDate }
50
47
 
51
48
  export function SalesReturnsSection({ orderId, currencyCode, documentUpdatedAt }: SalesReturnsSectionProps) {
52
49
  const t = useT()
@@ -329,7 +326,11 @@ export function SalesReturnsSection({ orderId, currencyCode, documentUpdatedAt }
329
326
  </div>
330
327
  </div>
331
328
  <div className="whitespace-nowrap text-right text-sm text-muted-foreground">
332
- {formatDisplayDate(ret.returnedAt, locale) ?? t('sales.returns.notSet', 'Not set')}
329
+ {/* `returnedAt` is submitted as a bare `yyyy-MM-dd` by `ReturnEditDialog`, coerced by
330
+ `z.coerce.date()` and returned via `.toISOString()`, and that dialog seeds itself
331
+ back from the UTC day — unconditionally, however the row was written. Matching the
332
+ dialog is the invariant, so the row cannot contradict itself west of UTC. */}
333
+ {formatDisplayDate(toUtcDateInputValue(ret.returnedAt), locale) ?? t('sales.returns.notSet', 'Not set')}
333
334
  </div>
334
335
  <div className="whitespace-nowrap text-right text-sm font-medium">
335
336
  {formatMoney(ret.total, currencyCode ?? null, locale)}
@@ -1,6 +1,7 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from 'react'
4
+ import { formatDisplayDate, toUtcDateInputValue } from '@open-mercato/ui/primitives/date-format'
4
5
  import { Pencil, Plus, Trash2, Truck } from 'lucide-react'
5
6
  import { Button } from '@open-mercato/ui/primitives/button'
6
7
  import { Badge } from '@open-mercato/ui/primitives/badge'
@@ -41,12 +42,8 @@ type SalesShipmentsSectionProps = {
41
42
  onAddComment?: (body: string) => Promise<void>
42
43
  }
43
44
 
44
- export function formatDisplayDate(value: string | null | undefined, locale?: string): string | null {
45
- if (!value) return null
46
- const date = new Date(value)
47
- if (Number.isNaN(date.getTime())) return null
48
- return new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }).format(date)
49
- }
45
+ // One date formatter for this page rather than three with incompatible semantics.
46
+ export { formatDisplayDate }
50
47
 
51
48
  const formatShipmentAddress = (metadata?: Record<string, unknown> | null): string | null => {
52
49
  if (!metadata || typeof metadata !== 'object') return null
@@ -487,8 +484,12 @@ export function SalesShipmentsSection({
487
484
  ) : (
488
485
  <div className="grid gap-3 md:grid-cols-2">
489
486
  {shipments.map((shipment) => {
490
- const shippedAt = formatDisplayDate(shipment.shippedAt, locale)
491
- const deliveredAt = formatDisplayDate(shipment.deliveredAt, locale)
487
+ // `shippedAt` / `deliveredAt` are written from date inputs (`ShipmentDialog`), coerced
488
+ // by `z.coerce.date()` and stored as UTC midnight, and the dialog seeds itself back from
489
+ // `.slice(0, 10)` — the UTC day. Reading them locally names the previous day west of UTC
490
+ // and disagrees with that dialog on the same row.
491
+ const shippedAt = formatDisplayDate(toUtcDateInputValue(shipment.shippedAt), locale)
492
+ const deliveredAt = formatDisplayDate(toUtcDateInputValue(shipment.deliveredAt), locale)
492
493
  const addressSummary = formatShipmentAddress(shipment.metadata)
493
494
  const statusLabel =
494
495
  shipment.statusLabel ??
@@ -1016,10 +1016,12 @@
1016
1016
  "sales.documents.items.errorProductRequired": "Wähle ein Produkt aus, um fortzufahren.",
1017
1017
  "sales.documents.items.errorQuantity": "Die Menge muss größer als 0 sein.",
1018
1018
  "sales.documents.items.errorQuantityBelowShipped": "Die Menge kann nicht unter die bereits versandten {{shipped}} gesenkt werden.",
1019
+ "sales.documents.items.errorQuantityInvalid": "Geben Sie die Menge als Zahl ein, zum Beispiel {{example}}.",
1019
1020
  "sales.documents.items.errorQuantityShipmentsUnknown": "Die Menge kann erst gesenkt werden, wenn die Sendungen dieses Auftrags geladen wurden. Öffnen Sie den Auftrag erneut, um es noch einmal zu versuchen.",
1020
1021
  "sales.documents.items.errorSave": "Position konnte nicht gespeichert werden.",
1021
1022
  "sales.documents.items.errorScope": "Organisation und Mandant sind erforderlich.",
1022
1023
  "sales.documents.items.errorUnitPrice": "Der Stückpreis muss größer als 0 sein.",
1024
+ "sales.documents.items.errorUnitPriceInvalid": "Geben Sie den Stückpreis als Zahl ein, zum Beispiel {{example}}.",
1023
1025
  "sales.documents.items.errorVariantRequired": "Wähle eine Variante aus, um fortzufahren.",
1024
1026
  "sales.documents.items.errors.uom.conversionNotFound": "Keine Einheitenumrechnung für dieses Produkt gefunden.",
1025
1027
  "sales.documents.items.errors.uom.defaultUnitMissing": "Dem Produkt fehlt eine Standard-Basiseinheit.",
@@ -1016,10 +1016,12 @@
1016
1016
  "sales.documents.items.errorProductRequired": "Select a product to continue.",
1017
1017
  "sales.documents.items.errorQuantity": "Quantity must be greater than 0.",
1018
1018
  "sales.documents.items.errorQuantityBelowShipped": "You cannot lower the quantity below the {{shipped}} already shipped.",
1019
+ "sales.documents.items.errorQuantityInvalid": "Enter the quantity as a number, for example {{example}}.",
1019
1020
  "sales.documents.items.errorQuantityShipmentsUnknown": "The quantity cannot be lowered until this order's shipments have been read. Reopen the order to try again.",
1020
1021
  "sales.documents.items.errorSave": "Failed to save line.",
1021
1022
  "sales.documents.items.errorScope": "Organization and tenant are required.",
1022
1023
  "sales.documents.items.errorUnitPrice": "Unit price must be greater than 0.",
1024
+ "sales.documents.items.errorUnitPriceInvalid": "Enter the unit price as a number, for example {{example}}.",
1023
1025
  "sales.documents.items.errorVariantRequired": "Select a variant to continue.",
1024
1026
  "sales.documents.items.errors.uom.conversionNotFound": "No unit conversion found for this product.",
1025
1027
  "sales.documents.items.errors.uom.defaultUnitMissing": "Product is missing a default base unit.",
@@ -1016,10 +1016,12 @@
1016
1016
  "sales.documents.items.errorProductRequired": "Selecciona un producto para continuar.",
1017
1017
  "sales.documents.items.errorQuantity": "La cantidad debe ser mayor que 0.",
1018
1018
  "sales.documents.items.errorQuantityBelowShipped": "No puedes reducir la cantidad por debajo de las {{shipped}} ya enviadas.",
1019
+ "sales.documents.items.errorQuantityInvalid": "Introduce la cantidad como un número, por ejemplo {{example}}.",
1019
1020
  "sales.documents.items.errorQuantityShipmentsUnknown": "La cantidad no se puede reducir hasta que se carguen los envíos de este pedido. Vuelve a abrir el pedido para intentarlo de nuevo.",
1020
1021
  "sales.documents.items.errorSave": "No se pudo guardar la línea.",
1021
1022
  "sales.documents.items.errorScope": "Se requieren organización e inquilino.",
1022
1023
  "sales.documents.items.errorUnitPrice": "El precio unitario debe ser mayor que 0.",
1024
+ "sales.documents.items.errorUnitPriceInvalid": "Introduce el precio unitario como un número, por ejemplo {{example}}.",
1023
1025
  "sales.documents.items.errorVariantRequired": "Selecciona una variante para continuar.",
1024
1026
  "sales.documents.items.errors.uom.conversionNotFound": "No se encontró conversión de unidad para este producto.",
1025
1027
  "sales.documents.items.errors.uom.defaultUnitMissing": "El producto no tiene una unidad base predeterminada.",
@@ -1016,10 +1016,12 @@
1016
1016
  "sales.documents.items.errorProductRequired": "계속하려면 상품을 선택하세요.",
1017
1017
  "sales.documents.items.errorQuantity": "수량은 0보다 커야 합니다.",
1018
1018
  "sales.documents.items.errorQuantityBelowShipped": "이미 배송된 {{shipped}} 이하로 수량을 줄일 수 없습니다.",
1019
+ "sales.documents.items.errorQuantityInvalid": "수량을 숫자로 입력하세요. 예: {{example}}.",
1019
1020
  "sales.documents.items.errorQuantityShipmentsUnknown": "이 주문의 배송 정보를 불러올 때까지 수량을 줄일 수 없습니다. 주문을 다시 열어 재시도하세요.",
1020
1021
  "sales.documents.items.errorSave": "품목을 저장하지 못했습니다.",
1021
1022
  "sales.documents.items.errorScope": "조직 및 테넌트 정보가 필요합니다.",
1022
1023
  "sales.documents.items.errorUnitPrice": "단가는 0보다 커야 합니다.",
1024
+ "sales.documents.items.errorUnitPriceInvalid": "단가를 숫자로 입력하세요. 예: {{example}}.",
1023
1025
  "sales.documents.items.errorVariantRequired": "계속하려면 옵션(변형)을 선택하세요.",
1024
1026
  "sales.documents.items.errors.uom.conversionNotFound": "이 상품에 대한 단위 변환 정보를 찾을 수 없습니다.",
1025
1027
  "sales.documents.items.errors.uom.defaultUnitMissing": "상품에 기본 단위가 설정되어 있지 않습니다.",
@@ -1016,10 +1016,12 @@
1016
1016
  "sales.documents.items.errorProductRequired": "Wybierz produkt, aby kontynuować.",
1017
1017
  "sales.documents.items.errorQuantity": "Ilość musi być większa niż 0.",
1018
1018
  "sales.documents.items.errorQuantityBelowShipped": "Nie można obniżyć ilości poniżej {{shipped}} już wysłanych.",
1019
+ "sales.documents.items.errorQuantityInvalid": "Podaj ilość jako liczbę, na przykład {{example}}.",
1019
1020
  "sales.documents.items.errorQuantityShipmentsUnknown": "Nie można obniżyć ilości, dopóki wysyłki tego zamówienia nie zostaną wczytane. Otwórz zamówienie ponownie, aby spróbować jeszcze raz.",
1020
1021
  "sales.documents.items.errorSave": "Nie udało się zapisać pozycji.",
1021
1022
  "sales.documents.items.errorScope": "Wymagane są organizacja i najemca.",
1022
1023
  "sales.documents.items.errorUnitPrice": "Cena jednostkowa musi być większa niż 0.",
1024
+ "sales.documents.items.errorUnitPriceInvalid": "Podaj cenę jednostkową jako liczbę, na przykład {{example}}.",
1023
1025
  "sales.documents.items.errorVariantRequired": "Wybierz wariant, aby kontynuować.",
1024
1026
  "sales.documents.items.errors.uom.conversionNotFound": "Nie znaleziono przelicznika jednostek dla tego produktu.",
1025
1027
  "sales.documents.items.errors.uom.defaultUnitMissing": "Produkt nie ma zdefiniowanej domyślnej jednostki bazowej.",
@@ -10,6 +10,7 @@ import { SalesOrderWarehouseAssignment, Warehouse } from '../data/entities'
10
10
  import type { SalesOrderWarehouseAssignInput, SalesOrderWarehouseUnassignInput } from '../data/validators'
11
11
  import { z } from 'zod'
12
12
  import { reserveInventoryForConfirmedOrder } from '../lib/salesOrderInventoryAutomation'
13
+ import { invalidateWmsInventoryEnricherCache } from '../lib/invalidateInventoryEnricherCache'
13
14
  import { ensureOrganizationScope, ensureTenantScope } from './shared'
14
15
 
15
16
  type AssignmentSnapshot = {
@@ -30,6 +31,17 @@ type UnassignWarehouseUndoPayload = {
30
31
  before: AssignmentSnapshot | null
31
32
  }
32
33
 
34
+ // These commands emit no WMS event, so the enricher-cache subscribers never see
35
+ // them. `wms.sales-order-inventory` surfaces the assigned warehouse, so every
36
+ // path here that writes an assignment — including the undo handlers — drops the
37
+ // warehouse tag itself.
38
+ async function invalidateAssignmentEnricherCache(
39
+ ctx: CommandRuntimeContext,
40
+ tenantId: string | null | undefined,
41
+ ): Promise<void> {
42
+ await invalidateWmsInventoryEnricherCache(ctx.container, tenantId, 'warehouse')
43
+ }
44
+
33
45
  function resolveScope(
34
46
  ctx: CommandRuntimeContext,
35
47
  fallback?: { tenantId?: string | null; organizationId?: string | null },
@@ -125,6 +137,7 @@ const assignWarehouseHandler: CommandHandler<
125
137
  existing.assignedBy = ctx.auth?.sub ?? null
126
138
  existing.updatedAt = new Date()
127
139
  await em.flush()
140
+ await invalidateAssignmentEnricherCache(ctx, scope.tenantId)
128
141
  return { assignmentId: existing.id, warehouseId: warehouse.id }
129
142
  }
130
143
 
@@ -138,6 +151,7 @@ const assignWarehouseHandler: CommandHandler<
138
151
  })
139
152
  em.persist(assignment)
140
153
  await em.flush()
154
+ await invalidateAssignmentEnricherCache(ctx, scope.tenantId)
141
155
  return { assignmentId: assignment.id, warehouseId: warehouse.id }
142
156
  },
143
157
 
@@ -187,6 +201,7 @@ const assignWarehouseHandler: CommandHandler<
187
201
  if (!before) {
188
202
  assignment.deletedAt = new Date()
189
203
  await em.flush()
204
+ await invalidateAssignmentEnricherCache(ctx, scope.tenantId)
190
205
  return
191
206
  }
192
207
 
@@ -205,12 +220,14 @@ const assignWarehouseHandler: CommandHandler<
205
220
  if (!previousWarehouse) {
206
221
  assignment.deletedAt = new Date()
207
222
  await em.flush()
223
+ await invalidateAssignmentEnricherCache(ctx, scope.tenantId)
208
224
  return
209
225
  }
210
226
 
211
227
  assignment.warehouse = previousWarehouse
212
228
  assignment.notes = before.notes ?? null
213
229
  await em.flush()
230
+ await invalidateAssignmentEnricherCache(ctx, scope.tenantId)
214
231
  }
215
232
  },
216
233
  }
@@ -239,6 +256,7 @@ const unassignWarehouseHandler: CommandHandler<
239
256
  if (existing) {
240
257
  existing.deletedAt = new Date()
241
258
  await em.flush()
259
+ await invalidateAssignmentEnricherCache(ctx, scope.tenantId)
242
260
  }
243
261
 
244
262
  return { ok: true as const }
@@ -278,6 +296,7 @@ const unassignWarehouseHandler: CommandHandler<
278
296
  if (existingDeleted) {
279
297
  existingDeleted.deletedAt = null
280
298
  await em.flush()
299
+ await invalidateAssignmentEnricherCache(ctx, scope.tenantId)
281
300
  return
282
301
  }
283
302
 
@@ -304,6 +323,7 @@ const unassignWarehouseHandler: CommandHandler<
304
323
  })
305
324
  em.persist(restored)
306
325
  await em.flush()
326
+ await invalidateAssignmentEnricherCache(ctx, scope.tenantId)
307
327
  },
308
328
  }
309
329
 
@@ -6,6 +6,11 @@ import type { QueryEngine } from '@open-mercato/shared/lib/query/types'
6
6
  import { E } from '#generated/entities.ids.generated'
7
7
  import { InventoryBalance, InventoryReservation, ProductInventoryProfile, SalesOrderWarehouseAssignment, Warehouse } from './entities'
8
8
  import { resolvePrimaryWarehouseId } from '../lib/primaryWarehousePolicy'
9
+ import {
10
+ WMS_ENRICHER_CACHE_TTL_MS,
11
+ WMS_INVENTORY_CACHE_TAG,
12
+ WMS_WAREHOUSE_CACHE_TAG,
13
+ } from '../lib/enricherCacheTags'
9
14
 
10
15
  type SalesOrderRecord = Record<string, unknown> & { id?: string }
11
16
  type CatalogProductRecord = Record<string, unknown> & { id?: string }
@@ -419,6 +424,13 @@ const salesOrderInventoryEnricher: ResponseEnricher<SalesOrderRecord, SalesOrder
419
424
  priority: 40,
420
425
  timeout: 2000,
421
426
  fallback: EMPTY_ENRICHMENT,
427
+ // Reads reservations, balances, the primary warehouse and the explicit
428
+ // assignment, so it depends on both the inventory and the warehouse tags.
429
+ cache: {
430
+ strategy: 'read-through',
431
+ ttl: WMS_ENRICHER_CACHE_TTL_MS,
432
+ tags: [WMS_INVENTORY_CACHE_TAG, WMS_WAREHOUSE_CACHE_TAG],
433
+ },
422
434
 
423
435
  async enrichOne(record, context: EnricherScope) {
424
436
  return (await this.enrichMany!([record], context))[0]
@@ -583,6 +595,13 @@ const catalogProductInventoryEnricher: ResponseEnricher<CatalogProductRecord, Ca
583
595
  priority: 40,
584
596
  timeout: 2000,
585
597
  fallback: EMPTY_CATALOG_ENRICHMENT,
598
+ // Reads inventory profiles and balances; also reads catalog variants, so the
599
+ // catalog variant events invalidate the inventory tag too.
600
+ cache: {
601
+ strategy: 'read-through',
602
+ ttl: WMS_ENRICHER_CACHE_TTL_MS,
603
+ tags: [WMS_INVENTORY_CACHE_TAG],
604
+ },
586
605
 
587
606
  async enrichOne(record, context: EnricherScope) {
588
607
  return (await this.enrichMany!([record], context))[0]
@@ -659,6 +678,12 @@ const catalogVariantInventoryEnricher: ResponseEnricher<CatalogVariantRecord, Ca
659
678
  priority: 40,
660
679
  timeout: 2000,
661
680
  fallback: EMPTY_CATALOG_ENRICHMENT,
681
+ // Reads inventory profiles and balances for the variant only.
682
+ cache: {
683
+ strategy: 'read-through',
684
+ ttl: WMS_ENRICHER_CACHE_TTL_MS,
685
+ tags: [WMS_INVENTORY_CACHE_TAG],
686
+ },
662
687
 
663
688
  async enrichOne(record, context: EnricherScope) {
664
689
  return (await this.enrichMany!([record], context))[0]
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Cache tags shared by the WMS inventory enrichers and the write surfaces that
3
+ * invalidate them. Read side and write side MUST use these constants so the two
4
+ * cannot drift — a tag typo on either side is silent, and the only symptom is
5
+ * inventory that stays stale until the TTL expires.
6
+ *
7
+ * The enricher runner adds `tenant:`, `organization:` and `enricher:` tags of
8
+ * its own, and the cache service prefixes every tag with the current cache
9
+ * tenant, so these are deliberately coarse collection tags rather than
10
+ * per-warehouse ones: `ResponseEnricher.cache.tags` is a static array read from
11
+ * the enricher definition and cannot vary per call. Collection tags
12
+ * over-invalidate slightly; a per-warehouse scheme that the write side could
13
+ * miss would under-invalidate, which is the failure that actually shows wrong
14
+ * stock to a user.
15
+ */
16
+
17
+ /** Inventory quantities: balances, reservations, movements and profiles. */
18
+ export const WMS_INVENTORY_CACHE_TAG = 'wms:inventory'
19
+
20
+ /** Warehouse identity and sales-order warehouse assignment. */
21
+ export const WMS_WAREHOUSE_CACHE_TAG = 'wms:warehouse'
22
+
23
+ /**
24
+ * Short by design. The tag invalidations below are the primary freshness
25
+ * mechanism; this TTL is the unconditional backstop for anything they miss —
26
+ * a write that failed to resolve the cache service, a cross-process race, or a
27
+ * write surface added later without wiring invalidation.
28
+ */
29
+ export const WMS_ENRICHER_CACHE_TTL_MS = 30_000