@open-mercato/core 0.8.1-develop.7228.1.b7ea09d2d3 → 0.8.1-develop.7238.1.3abe669ea5

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 (173) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/generated/entities/availability_policy/index.js +41 -0
  3. package/dist/generated/entities/availability_policy/index.js.map +7 -0
  4. package/dist/generated/entities.ids.generated.js +4 -0
  5. package/dist/generated/entities.ids.generated.js.map +2 -2
  6. package/dist/generated/entity-fields-registry.js +21 -0
  7. package/dist/generated/entity-fields-registry.js.map +2 -2
  8. package/dist/modules/attachments/data/entities.js +3 -3
  9. package/dist/modules/attachments/data/entities.js.map +2 -2
  10. package/dist/modules/availability/acl.js +16 -0
  11. package/dist/modules/availability/acl.js.map +7 -0
  12. package/dist/modules/availability/api/check/route.js +95 -0
  13. package/dist/modules/availability/api/check/route.js.map +7 -0
  14. package/dist/modules/availability/api/openapi.js +27 -0
  15. package/dist/modules/availability/api/openapi.js.map +7 -0
  16. package/dist/modules/availability/api/policies/resolve-preview/route.js +58 -0
  17. package/dist/modules/availability/api/policies/resolve-preview/route.js.map +7 -0
  18. package/dist/modules/availability/api/policies/route.js +188 -0
  19. package/dist/modules/availability/api/policies/route.js.map +7 -0
  20. package/dist/modules/availability/backend/availability/check/page.js +185 -0
  21. package/dist/modules/availability/backend/availability/check/page.js.map +7 -0
  22. package/dist/modules/availability/backend/availability/check/page.meta.js +16 -0
  23. package/dist/modules/availability/backend/availability/check/page.meta.js.map +7 -0
  24. package/dist/modules/availability/backend/availability/policies/ResolutionPreviewPanel.js +79 -0
  25. package/dist/modules/availability/backend/availability/policies/ResolutionPreviewPanel.js.map +7 -0
  26. package/dist/modules/availability/backend/availability/policies/ScopeFields.js +53 -0
  27. package/dist/modules/availability/backend/availability/policies/ScopeFields.js.map +7 -0
  28. package/dist/modules/availability/backend/availability/policies/[id]/page.js +152 -0
  29. package/dist/modules/availability/backend/availability/policies/[id]/page.js.map +7 -0
  30. package/dist/modules/availability/backend/availability/policies/[id]/page.meta.js +12 -0
  31. package/dist/modules/availability/backend/availability/policies/[id]/page.meta.js.map +7 -0
  32. package/dist/modules/availability/backend/availability/policies/create/page.js +94 -0
  33. package/dist/modules/availability/backend/availability/policies/create/page.js.map +7 -0
  34. package/dist/modules/availability/backend/availability/policies/create/page.meta.js +12 -0
  35. package/dist/modules/availability/backend/availability/policies/create/page.meta.js.map +7 -0
  36. package/dist/modules/availability/backend/availability/policies/formGroups.js +75 -0
  37. package/dist/modules/availability/backend/availability/policies/formGroups.js.map +7 -0
  38. package/dist/modules/availability/backend/availability/policies/page.js +176 -0
  39. package/dist/modules/availability/backend/availability/policies/page.js.map +7 -0
  40. package/dist/modules/availability/backend/availability/policies/page.meta.js +16 -0
  41. package/dist/modules/availability/backend/availability/policies/page.meta.js.map +7 -0
  42. package/dist/modules/availability/commands/policies.js +312 -0
  43. package/dist/modules/availability/commands/policies.js.map +7 -0
  44. package/dist/modules/availability/commands/scope.js +19 -0
  45. package/dist/modules/availability/commands/scope.js.map +7 -0
  46. package/dist/modules/availability/data/entities.js +95 -0
  47. package/dist/modules/availability/data/entities.js.map +7 -0
  48. package/dist/modules/availability/data/validators.js +53 -0
  49. package/dist/modules/availability/data/validators.js.map +7 -0
  50. package/dist/modules/availability/di.js +41 -0
  51. package/dist/modules/availability/di.js.map +7 -0
  52. package/dist/modules/availability/events.js +18 -0
  53. package/dist/modules/availability/events.js.map +7 -0
  54. package/dist/modules/availability/index.js +15 -0
  55. package/dist/modules/availability/index.js.map +7 -0
  56. package/dist/modules/availability/lib/policyResolution.js +155 -0
  57. package/dist/modules/availability/lib/policyResolution.js.map +7 -0
  58. package/dist/modules/availability/lib/tryResolve.js +11 -0
  59. package/dist/modules/availability/lib/tryResolve.js.map +7 -0
  60. package/dist/modules/availability/migrations/Migration20260922095844_availability.js +16 -0
  61. package/dist/modules/availability/migrations/Migration20260922095844_availability.js.map +7 -0
  62. package/dist/modules/availability/setup.js +12 -0
  63. package/dist/modules/availability/setup.js.map +7 -0
  64. package/dist/modules/entities/cli.js +33 -12
  65. package/dist/modules/entities/cli.js.map +2 -2
  66. package/dist/modules/inbox_ops/api/settings/route.js +7 -32
  67. package/dist/modules/inbox_ops/api/settings/route.js.map +2 -2
  68. package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.js +2 -2
  69. package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.js.map +2 -2
  70. package/dist/modules/inbox_ops/components/proposals/ActionCard.js +1 -1
  71. package/dist/modules/inbox_ops/components/proposals/ActionCard.js.map +2 -2
  72. package/dist/modules/inbox_ops/lib/ensure-settings.js +47 -0
  73. package/dist/modules/inbox_ops/lib/ensure-settings.js.map +7 -0
  74. package/dist/modules/inbox_ops/lib/executionEngine.js.map +1 -1
  75. package/dist/modules/inbox_ops/lib/executionHelpers.js +0 -23
  76. package/dist/modules/inbox_ops/lib/executionHelpers.js.map +2 -2
  77. package/dist/modules/inbox_ops/lib/payloadEnrichment.js +0 -3
  78. package/dist/modules/inbox_ops/lib/payloadEnrichment.js.map +2 -2
  79. package/dist/modules/inbox_ops/setup.js +2 -21
  80. package/dist/modules/inbox_ops/setup.js.map +2 -2
  81. package/dist/modules/inbox_ops/subscribers/extractionWorker.js.map +1 -1
  82. package/dist/modules/sales/components/documents/SalesDocumentForm.js +7 -0
  83. package/dist/modules/sales/components/documents/SalesDocumentForm.js.map +2 -2
  84. package/dist/modules/sales/data/entities.js +3 -1
  85. package/dist/modules/sales/data/entities.js.map +2 -2
  86. package/dist/modules/sales/inbox-actions.js +0 -12
  87. package/dist/modules/sales/inbox-actions.js.map +2 -2
  88. package/dist/modules/sales/migrations/Migration20260916120000_sales_payment_allocation_order_idx.js +20 -0
  89. package/dist/modules/sales/migrations/Migration20260916120000_sales_payment_allocation_order_idx.js.map +7 -0
  90. package/dist/modules/wms/di.js +3 -0
  91. package/dist/modules/wms/di.js.map +2 -2
  92. package/dist/modules/wms/lib/availabilityCache.js +85 -0
  93. package/dist/modules/wms/lib/availabilityCache.js.map +7 -0
  94. package/dist/modules/wms/lib/availabilityCalculation.js +178 -0
  95. package/dist/modules/wms/lib/availabilityCalculation.js.map +7 -0
  96. package/dist/modules/wms/lib/availabilityProvider.js +14 -0
  97. package/dist/modules/wms/lib/availabilityProvider.js.map +7 -0
  98. package/dist/modules/wms/lib/sqlInClause.js +9 -0
  99. package/dist/modules/wms/lib/sqlInClause.js.map +7 -0
  100. package/dist/modules/wms/lib/tryResolve.js +11 -0
  101. package/dist/modules/wms/lib/tryResolve.js.map +7 -0
  102. package/dist/modules/wms/subscribers/invalidate-availability-cache.js +16 -0
  103. package/dist/modules/wms/subscribers/invalidate-availability-cache.js.map +7 -0
  104. package/generated/entities/availability_policy/index.ts +19 -0
  105. package/generated/entities.ids.generated.ts +4 -0
  106. package/generated/entity-fields-registry.ts +21 -0
  107. package/package.json +7 -7
  108. package/src/modules/attachments/data/entities.ts +5 -3
  109. package/src/modules/auth/i18n/de.json +3 -0
  110. package/src/modules/auth/i18n/en.json +3 -0
  111. package/src/modules/auth/i18n/es.json +3 -0
  112. package/src/modules/auth/i18n/ko.json +3 -0
  113. package/src/modules/auth/i18n/pl.json +3 -0
  114. package/src/modules/availability/acl.ts +12 -0
  115. package/src/modules/availability/api/check/route.ts +114 -0
  116. package/src/modules/availability/api/openapi.ts +28 -0
  117. package/src/modules/availability/api/policies/resolve-preview/route.ts +67 -0
  118. package/src/modules/availability/api/policies/route.ts +224 -0
  119. package/src/modules/availability/backend/availability/check/page.meta.ts +12 -0
  120. package/src/modules/availability/backend/availability/check/page.tsx +223 -0
  121. package/src/modules/availability/backend/availability/policies/ResolutionPreviewPanel.tsx +106 -0
  122. package/src/modules/availability/backend/availability/policies/ScopeFields.tsx +55 -0
  123. package/src/modules/availability/backend/availability/policies/[id]/page.meta.ts +8 -0
  124. package/src/modules/availability/backend/availability/policies/[id]/page.tsx +182 -0
  125. package/src/modules/availability/backend/availability/policies/create/page.meta.ts +8 -0
  126. package/src/modules/availability/backend/availability/policies/create/page.tsx +105 -0
  127. package/src/modules/availability/backend/availability/policies/formGroups.ts +77 -0
  128. package/src/modules/availability/backend/availability/policies/page.meta.ts +12 -0
  129. package/src/modules/availability/backend/availability/policies/page.tsx +196 -0
  130. package/src/modules/availability/commands/policies.ts +358 -0
  131. package/src/modules/availability/commands/scope.ts +26 -0
  132. package/src/modules/availability/data/entities.ts +92 -0
  133. package/src/modules/availability/data/validators.ts +70 -0
  134. package/src/modules/availability/di.ts +54 -0
  135. package/src/modules/availability/events.ts +17 -0
  136. package/src/modules/availability/i18n/de.json +81 -0
  137. package/src/modules/availability/i18n/en.json +81 -0
  138. package/src/modules/availability/i18n/es.json +81 -0
  139. package/src/modules/availability/i18n/ko.json +81 -0
  140. package/src/modules/availability/i18n/pl.json +81 -0
  141. package/src/modules/availability/index.ts +13 -0
  142. package/src/modules/availability/lib/policyResolution.ts +264 -0
  143. package/src/modules/availability/lib/tryResolve.ts +8 -0
  144. package/src/modules/availability/migrations/.snapshot-open-mercato.json +375 -0
  145. package/src/modules/availability/migrations/Migration20260922095844_availability.ts +13 -0
  146. package/src/modules/availability/setup.ts +10 -0
  147. package/src/modules/entities/cli.ts +53 -13
  148. package/src/modules/inbox_ops/api/settings/route.ts +9 -32
  149. package/src/modules/inbox_ops/backend/inbox-ops/settings/page.tsx +2 -2
  150. package/src/modules/inbox_ops/components/proposals/ActionCard.tsx +1 -1
  151. package/src/modules/inbox_ops/i18n/de.json +1 -1
  152. package/src/modules/inbox_ops/i18n/en.json +1 -1
  153. package/src/modules/inbox_ops/i18n/es.json +1 -1
  154. package/src/modules/inbox_ops/i18n/ko.json +1 -1
  155. package/src/modules/inbox_ops/i18n/pl.json +1 -1
  156. package/src/modules/inbox_ops/lib/ensure-settings.ts +77 -0
  157. package/src/modules/inbox_ops/lib/executionEngine.ts +1 -1
  158. package/src/modules/inbox_ops/lib/executionHelpers.ts +0 -27
  159. package/src/modules/inbox_ops/lib/payloadEnrichment.ts +1 -5
  160. package/src/modules/inbox_ops/setup.ts +2 -21
  161. package/src/modules/inbox_ops/subscribers/extractionWorker.ts +1 -1
  162. package/src/modules/sales/components/documents/SalesDocumentForm.tsx +13 -0
  163. package/src/modules/sales/data/entities.ts +2 -0
  164. package/src/modules/sales/inbox-actions.ts +0 -20
  165. package/src/modules/sales/migrations/.snapshot-open-mercato.json +20 -0
  166. package/src/modules/sales/migrations/Migration20260916120000_sales_payment_allocation_order_idx.ts +74 -0
  167. package/src/modules/wms/di.ts +6 -0
  168. package/src/modules/wms/lib/availabilityCache.ts +136 -0
  169. package/src/modules/wms/lib/availabilityCalculation.ts +289 -0
  170. package/src/modules/wms/lib/availabilityProvider.ts +29 -0
  171. package/src/modules/wms/lib/sqlInClause.ts +21 -0
  172. package/src/modules/wms/lib/tryResolve.ts +8 -0
  173. package/src/modules/wms/subscribers/invalidate-availability-cache.ts +30 -0
@@ -0,0 +1,74 @@
1
+ import { Migration } from '@mikro-orm/migrations';
2
+
3
+ // sales_payment_allocations carried a single non-PK index — (payment_id,
4
+ // organization_id, tenant_id) — which serves the reads that start from a payment.
5
+ // Every such read in commands/payments.ts is `{ payment }`, so that index has
6
+ // always matched them, and its leading column also serves the payment_id foreign
7
+ // key's referential check. Nothing here replaces it.
8
+ //
9
+ // The table is also read from the ORDER side, and that direction was unindexed.
10
+ // Recomputing an order's payment totals does:
11
+ //
12
+ // findWithDecryption(em, SalesPaymentAllocation, { ...scope, order: orderId },
13
+ // { populate: ['payment'] }, scope)
14
+ //
15
+ // which the ORM emits filtered on (organization_id, tenant_id, order_id), inner
16
+ // joined to sales_payments — `payment` is a required to-one, and the default
17
+ // BALANCED load strategy joins those. That recompute runs on every payment create,
18
+ // update and delete. commands/documents.ts reads the same way in four more places:
19
+ // one em.find in the sales.orders.delete snapshot, and three nativeDelete({ order })
20
+ // calls that take row locks while they scan.
21
+ //
22
+ // Measured on a table of 1.4M rows: a parallel sequential scan removing 478,376 rows
23
+ // per worker, 28.6 ms per read to return one row. The cost is CPU filtering across
24
+ // 23,757 buffer accesses, so a warm cache does not reduce it — the plan that
25
+ // measured this reported every one of those buffers as `shared hit`.
26
+ //
27
+ // order_id and invoice_id are indexed separately rather than folded into the index
28
+ // above, and the scope columns are deliberately omitted, for the reason given in
29
+ // Migration20260821120000: sales_orders.id is gen_random_uuid() from its own table,
30
+ // so an order_id already determines its tenant and organization. Four of the five
31
+ // order-side reads supply no scope at all, so a composite would serve none of them
32
+ // better; the seek returns the rows of one order and any remaining terms are
33
+ // rechecked over those.
34
+ //
35
+ // invoice_id gets the same treatment on the foreign key's account alone. Both
36
+ // columns are the child side of `on delete set null` constraints, which PostgreSQL
37
+ // does not index automatically, and the constraint check probes the referencing
38
+ // column — so without these, every delete of a parent order or invoice scans this
39
+ // table in full. Both parents really are hard-deleted (commands/documents.ts). This
40
+ // is the same pairing Migration20260821120000 made for order_id and quote_id.
41
+ //
42
+ // Built CONCURRENTLY — the table takes a write per payment allocation and is large
43
+ // on any install that has been running a while. CREATE INDEX CONCURRENTLY cannot run
44
+ // inside a transaction, hence isTransactional() => false; the runner applies
45
+ // migrations one-by-one, so the opt-out is safe. Drop first so retrying a failed
46
+ // concurrent build removes PostgreSQL's INVALID index stub instead of letting IF NOT
47
+ // EXISTS report success over a table that stays unindexed. Same shape as
48
+ // Migration20260806120000 and Migration20260821120000.
49
+ //
50
+ // On a database that does not have these indexes yet — every fresh install — the
51
+ // drops are no-ops and there is no window without them. An operator who already
52
+ // created one out of band should expect it to be rebuilt here, and the reads that
53
+ // depend on it to fall back to their pre-index cost until the concurrent build
54
+ // finishes; on a large table, run this while the writers that depend on it are
55
+ // paused.
56
+ export class Migration20260916120000_sales_payment_allocation_order_idx extends Migration {
57
+
58
+ override isTransactional(): boolean {
59
+ return false;
60
+ }
61
+
62
+ override up(): void | Promise<void> {
63
+ this.addSql(`drop index concurrently if exists "sales_payment_allocations_order_idx";`);
64
+ this.addSql(`create index concurrently "sales_payment_allocations_order_idx" on "sales_payment_allocations" ("order_id");`);
65
+ this.addSql(`drop index concurrently if exists "sales_payment_allocations_invoice_idx";`);
66
+ this.addSql(`create index concurrently "sales_payment_allocations_invoice_idx" on "sales_payment_allocations" ("invoice_id");`);
67
+ }
68
+
69
+ override down(): void | Promise<void> {
70
+ this.addSql(`drop index concurrently if exists "sales_payment_allocations_invoice_idx";`);
71
+ this.addSql(`drop index concurrently if exists "sales_payment_allocations_order_idx";`);
72
+ }
73
+
74
+ }
@@ -1,5 +1,6 @@
1
1
  import { asValue } from 'awilix'
2
2
  import type { AppContainer } from '@open-mercato/shared/lib/di/container'
3
+ import { availabilityProviderRegistry } from '@open-mercato/shared/lib/availability'
3
4
  import {
4
5
  InventoryBalance,
5
6
  InventoryLot,
@@ -10,6 +11,7 @@ import {
10
11
  WarehouseLocation,
11
12
  WarehouseZone,
12
13
  } from './data/entities'
14
+ import { createWmsAvailabilityProvider } from './lib/availabilityProvider'
13
15
 
14
16
  export function register(container: AppContainer) {
15
17
  container.register({
@@ -22,4 +24,8 @@ export function register(container: AppContainer) {
22
24
  InventoryReservation: asValue(InventoryReservation),
23
25
  InventoryMovement: asValue(InventoryMovement),
24
26
  })
27
+
28
+ // Explicit, idempotent (replace-by-id) registration into the shared,
29
+ // dependency-free registry — never "same DI key, load order wins" (§3.1).
30
+ availabilityProviderRegistry.register(createWmsAvailabilityProvider(container))
25
31
  }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Read-through cache for `wms`'s `AvailabilityProvider` — §6 staleness
3
+ * budget (60s TTL for a browse-time `check`, never cached for
4
+ * `query.bypassCache`), tag-based invalidation on `wms` balance changes.
5
+ *
6
+ * Tag granularity is a deliberate deviation from §6's literal
7
+ * `availability:{tenantId}:{variantId}` naming: this module already has a
8
+ * coarse, tenant-scoped tag for the same purpose
9
+ * (`enricherCacheTags.ts`'s `WMS_INVENTORY_CACHE_TAG`), justified there as
10
+ * "collection tags over-invalidate slightly; a per-warehouse scheme the
11
+ * write side could miss would under-invalidate, which is the failure that
12
+ * actually shows wrong stock to a user." The same reasoning applies here, so
13
+ * `WMS_AVAILABILITY_CACHE_TAG` follows that established precedent rather
14
+ * than a literal per-variant tag. `runWithCacheTenant` already scopes both
15
+ * keys and tags to the current tenant (`packages/cache/src/service.ts`), so
16
+ * neither needs `tenantId` embedded manually.
17
+ */
18
+
19
+ import { runWithCacheTenant } from '@open-mercato/cache'
20
+ import { createLogger } from '@open-mercato/shared/lib/logger'
21
+ import { availabilityItemKey } from '@open-mercato/shared/lib/availability'
22
+ import type { AvailabilityItemResult, AvailabilityQuery, AvailabilityResult } from '@open-mercato/shared/lib/availability'
23
+ import type { EntityManager } from '@mikro-orm/postgresql'
24
+ import { computeAvailability } from './availabilityCalculation'
25
+
26
+ export const WMS_AVAILABILITY_CACHE_TAG = 'wms:availability'
27
+ /** §6 staleness budget: a browse-time `check` may be up to 60s stale. */
28
+ export const WMS_AVAILABILITY_CACHE_TTL_MS = 60_000
29
+
30
+ const logger = createLogger('wms').child({ component: 'availability-cache' })
31
+
32
+ type CacheService = {
33
+ get(key: string, options?: { returnExpired?: boolean }): Promise<unknown>
34
+ set(key: string, value: unknown, options?: { ttl?: number; tags?: string[] }): Promise<void>
35
+ deleteByTags(tags: string[]): Promise<number>
36
+ }
37
+
38
+ type Resolver = { resolve: <T = unknown>(name: string) => T }
39
+
40
+ function resolveCache(container: Resolver | null | undefined): CacheService | null {
41
+ if (!container?.resolve) return null
42
+ for (const name of ['cache', 'cacheService']) {
43
+ try {
44
+ const candidate = container.resolve<CacheService>(name)
45
+ if (candidate && typeof candidate.deleteByTags === 'function') return candidate
46
+ } catch {
47
+ // try the next registration name
48
+ }
49
+ }
50
+ return null
51
+ }
52
+
53
+ function cacheKeyForItem(query: AvailabilityQuery, item: AvailabilityQuery['items'][number]): string {
54
+ return [
55
+ 'wms:availability',
56
+ query.organizationId,
57
+ query.storeId ?? '',
58
+ item.catalogProductId,
59
+ item.catalogVariantId ?? '',
60
+ item.quantity,
61
+ ].join(':')
62
+ }
63
+
64
+ /**
65
+ * Wraps `computeAvailability` with the read-through cache. Falls back to a
66
+ * live computation when no cache service is registered or `bypassCache` is
67
+ * set — the same code path either way, just without the read/write.
68
+ */
69
+ export async function computeAvailabilityCached(
70
+ em: EntityManager,
71
+ container: Resolver,
72
+ query: AvailabilityQuery,
73
+ ): Promise<AvailabilityResult> {
74
+ const cache = resolveCache(container)
75
+ if (!cache || query.bypassCache) {
76
+ return computeAvailability(em, container, query)
77
+ }
78
+
79
+ return runWithCacheTenant(query.tenantId, async () => {
80
+ const byItem: AvailabilityResult['byItem'] = {}
81
+ const missed: AvailabilityQuery['items'] = []
82
+
83
+ for (const item of query.items) {
84
+ const key = availabilityItemKey(item)
85
+ try {
86
+ const cached = await cache.get(cacheKeyForItem(query, item))
87
+ if (cached) {
88
+ byItem[key] = { ...(cached as AvailabilityItemResult), isAuthoritative: false }
89
+ continue
90
+ }
91
+ } catch (err) {
92
+ logger.warn('Availability cache read failed; computing live', { key, err })
93
+ }
94
+ missed.push(item)
95
+ }
96
+
97
+ if (missed.length > 0) {
98
+ const live = await computeAvailability(em, container, { ...query, items: missed })
99
+ for (const item of missed) {
100
+ const key = availabilityItemKey(item)
101
+ const result = live.byItem[key]
102
+ byItem[key] = result
103
+ try {
104
+ await cache.set(cacheKeyForItem(query, item), result, {
105
+ ttl: WMS_AVAILABILITY_CACHE_TTL_MS,
106
+ tags: [WMS_AVAILABILITY_CACHE_TAG],
107
+ })
108
+ } catch (err) {
109
+ logger.warn('Availability cache write failed', { key, err })
110
+ }
111
+ }
112
+ }
113
+
114
+ return { byItem }
115
+ })
116
+ }
117
+
118
+ /**
119
+ * Best-effort tag invalidation — mirrors
120
+ * `invalidateInventoryEnricherCache.ts`. A failure is logged and swallowed:
121
+ * invalidation must never fail the write that triggered it, and the TTL
122
+ * bounds how long a missed drop can serve stale data.
123
+ */
124
+ export async function invalidateWmsAvailabilityCache(
125
+ container: Resolver | null | undefined,
126
+ tenantId: string | null | undefined,
127
+ ): Promise<void> {
128
+ if (!tenantId) return
129
+ const cache = resolveCache(container)
130
+ if (!cache) return
131
+ try {
132
+ await runWithCacheTenant(tenantId, () => cache.deleteByTags([WMS_AVAILABILITY_CACHE_TAG]))
133
+ } catch (err) {
134
+ logger.warn('Failed to invalidate wms availability cache', { tenantId, err })
135
+ }
136
+ }
@@ -0,0 +1,289 @@
1
+ /**
2
+ * `wms`'s `AvailabilityProvider` computation — batched sellable-quantity
3
+ * aggregation, safety-stock handling, low-stock thresholds, and product
4
+ * rollup.
5
+ *
6
+ * @see .ai/specs/2026-08-14-availability-contract.md §4.2
7
+ *
8
+ * Batching contract (R4): exactly one balance aggregation, one profile
9
+ * lookup, one policy lookup, and (only when the batch has product-level
10
+ * items) one variant-rollup lookup — never per item.
11
+ *
12
+ * Safety-stock contract (R1): `sellable = max(0, aggregate_available -
13
+ * safety_stock)`, with `safety_stock` subtracted ONCE per variant, after
14
+ * aggregating every in-scope location — never once per balance row.
15
+ */
16
+
17
+ import type { EntityManager } from '@mikro-orm/postgresql'
18
+ import { availabilityItemKey } from '@open-mercato/shared/lib/availability'
19
+ import type { AvailabilityItemResult, AvailabilityQuery, AvailabilityResult, AvailabilityState } from '@open-mercato/shared/lib/availability'
20
+ import { tryResolve } from './tryResolve'
21
+ import { buildSqlInClause } from './sqlInClause'
22
+
23
+ type Resolver = { resolve: <T = unknown>(name: string) => T }
24
+
25
+ /**
26
+ * Narrow, locally-declared duck type for `availability`'s `PolicyResolutionService`
27
+ * (soft-resolved, never statically imported — `wms` never hard-requires `availability`,
28
+ * §3.1). Structurally identical to the real service's relevant surface.
29
+ */
30
+ type PolicyField<T> = { value: T; policySourceId: string | null }
31
+ type ResolvedPolicyOverlay = {
32
+ isStockManaged: PolicyField<boolean>
33
+ allowBackorder: PolicyField<boolean>
34
+ backorderLeadTimeDays: PolicyField<number | null>
35
+ preorderReleaseAt: PolicyField<Date | null>
36
+ lowStockThreshold: PolicyField<number | null>
37
+ isActive: PolicyField<boolean>
38
+ }
39
+ type PolicyResolutionScopeLike = {
40
+ tenantId: string
41
+ organizationId: string
42
+ storeId?: string | null
43
+ productId: string
44
+ variantId?: string | null
45
+ }
46
+ type PolicyResolutionServiceLike = {
47
+ resolveMany(em: EntityManager, scopes: PolicyResolutionScopeLike[]): Promise<ResolvedPolicyOverlay[]>
48
+ }
49
+
50
+ const OPEN_POLICY_DEFAULT: ResolvedPolicyOverlay = {
51
+ isStockManaged: { value: true, policySourceId: null },
52
+ allowBackorder: { value: false, policySourceId: null },
53
+ backorderLeadTimeDays: { value: null, policySourceId: null },
54
+ preorderReleaseAt: { value: null, policySourceId: null },
55
+ lowStockThreshold: { value: null, policySourceId: null },
56
+ isActive: { value: true, policySourceId: null },
57
+ }
58
+
59
+ type BalanceAggregateRow = { catalog_variant_id: string; aggregate_available: string | number | null }
60
+ type ProfileRow = {
61
+ catalog_product_id: string
62
+ catalog_variant_id: string | null
63
+ safety_stock: string | number | null
64
+ reorder_point: string | number | null
65
+ }
66
+ type VariantRow = { id: string; product_id: string }
67
+
68
+ async function loadBalanceAggregates(
69
+ em: EntityManager,
70
+ tenantId: string,
71
+ organizationId: string,
72
+ variantIds: string[],
73
+ locationIds?: string[] | null,
74
+ ): Promise<Map<string, number>> {
75
+ const result = new Map<string, number>()
76
+ if (variantIds.length === 0) return result
77
+
78
+ const variantClause = buildSqlInClause('b.catalog_variant_id', variantIds)
79
+ const params: unknown[] = [organizationId, tenantId, ...variantClause.params]
80
+ let locationClause = ''
81
+ if (locationIds && locationIds.length > 0) {
82
+ const locationIn = buildSqlInClause('b.location_id', locationIds)
83
+ locationClause = ` and ${locationIn.sql}`
84
+ params.push(...locationIn.params)
85
+ }
86
+
87
+ const sql = `
88
+ select b.catalog_variant_id,
89
+ sum(coalesce(b.quantity_on_hand, 0) - coalesce(b.quantity_reserved, 0) - coalesce(b.quantity_allocated, 0)) as aggregate_available
90
+ from wms_inventory_balances b
91
+ where b.organization_id = ? and b.tenant_id = ? and ${variantClause.sql} and b.deleted_at is null
92
+ ${locationClause}
93
+ group by b.catalog_variant_id
94
+ `
95
+ const rows = await em.getConnection().execute<BalanceAggregateRow[]>(sql, params)
96
+ for (const row of rows) {
97
+ result.set(row.catalog_variant_id, Number(row.aggregate_available ?? 0))
98
+ }
99
+ return result
100
+ }
101
+
102
+ async function loadProfiles(
103
+ em: EntityManager,
104
+ tenantId: string,
105
+ organizationId: string,
106
+ variantIds: string[],
107
+ productIds: string[],
108
+ ): Promise<ProfileRow[]> {
109
+ if (variantIds.length === 0 && productIds.length === 0) return []
110
+ const variantClause = buildSqlInClause('catalog_variant_id', variantIds)
111
+ const productClause = buildSqlInClause('catalog_product_id', productIds)
112
+ const sql = `
113
+ select catalog_product_id, catalog_variant_id, safety_stock, reorder_point
114
+ from wms_product_inventory_profiles
115
+ where organization_id = ? and tenant_id = ? and deleted_at is null
116
+ and (${variantClause.sql} or (${productClause.sql} and catalog_variant_id is null))
117
+ `
118
+ return em.getConnection().execute<ProfileRow[]>(sql, [
119
+ organizationId,
120
+ tenantId,
121
+ ...variantClause.params,
122
+ ...productClause.params,
123
+ ])
124
+ }
125
+
126
+ async function loadActiveVariantsForProducts(
127
+ em: EntityManager,
128
+ tenantId: string,
129
+ organizationId: string,
130
+ productIds: string[],
131
+ ): Promise<VariantRow[]> {
132
+ if (productIds.length === 0) return []
133
+ const productClause = buildSqlInClause('product_id', productIds)
134
+ const sql = `
135
+ select id, product_id
136
+ from catalog_product_variants
137
+ where organization_id = ? and tenant_id = ? and ${productClause.sql} and is_active = true and deleted_at is null
138
+ `
139
+ return em.getConnection().execute<VariantRow[]>(sql, [organizationId, tenantId, ...productClause.params])
140
+ }
141
+
142
+ function findProfile(profiles: ProfileRow[], productId: string, variantId: string | null): ProfileRow | null {
143
+ if (variantId) {
144
+ const variantRow = profiles.find((p) => p.catalog_variant_id === variantId)
145
+ if (variantRow) return variantRow
146
+ }
147
+ return profiles.find((p) => p.catalog_product_id === productId && !p.catalog_variant_id) ?? null
148
+ }
149
+
150
+ function computeState(params: {
151
+ sellable: number
152
+ requested: number
153
+ lowStockThreshold: number | null
154
+ allowBackorder: boolean
155
+ backorderLeadTimeDays: number | null
156
+ preorderReleaseAt: Date | null
157
+ isActive: boolean
158
+ }): { state: AvailabilityState; canFulfil: boolean; leadTimeDays: number | null; releaseAt: string | null } {
159
+ if (!params.isActive) {
160
+ return { state: 'out_of_stock', canFulfil: false, leadTimeDays: null, releaseAt: null }
161
+ }
162
+ if (params.preorderReleaseAt && params.preorderReleaseAt.getTime() > Date.now()) {
163
+ return { state: 'preorder', canFulfil: true, leadTimeDays: null, releaseAt: params.preorderReleaseAt.toISOString() }
164
+ }
165
+ if (params.sellable >= params.requested) {
166
+ const state: AvailabilityState =
167
+ params.lowStockThreshold != null && params.sellable <= params.lowStockThreshold ? 'low_stock' : 'in_stock'
168
+ return { state, canFulfil: true, leadTimeDays: null, releaseAt: null }
169
+ }
170
+ if (params.allowBackorder) {
171
+ return { state: 'backorder', canFulfil: true, leadTimeDays: params.backorderLeadTimeDays, releaseAt: null }
172
+ }
173
+ return { state: 'out_of_stock', canFulfil: false, leadTimeDays: null, releaseAt: null }
174
+ }
175
+
176
+ /**
177
+ * Computes availability for a batch of items — always live, never cached
178
+ * (the cache wrapper lives in `availabilityCache.ts`, Phase 2 Step 3.2).
179
+ */
180
+ export async function computeAvailability(
181
+ em: EntityManager,
182
+ container: Resolver,
183
+ query: AvailabilityQuery,
184
+ ): Promise<AvailabilityResult> {
185
+ const byItem: AvailabilityResult['byItem'] = {}
186
+ if (query.items.length === 0) return { byItem }
187
+
188
+ const variantItems = query.items.filter((item) => !!item.catalogVariantId)
189
+ const productItems = query.items.filter((item) => !item.catalogVariantId)
190
+ const productItemIds = Array.from(new Set(productItems.map((item) => item.catalogProductId)))
191
+
192
+ // One batched lookup of active variants for every product-level item (R4).
193
+ const rollupVariantRows = await loadActiveVariantsForProducts(em, query.tenantId, query.organizationId, productItemIds)
194
+ const variantsByProduct = new Map<string, string[]>()
195
+ for (const row of rollupVariantRows) {
196
+ const list = variantsByProduct.get(row.product_id) ?? []
197
+ list.push(row.id)
198
+ variantsByProduct.set(row.product_id, list)
199
+ }
200
+
201
+ const allVariantIds = Array.from(
202
+ new Set([
203
+ ...variantItems.map((item) => item.catalogVariantId as string),
204
+ ...rollupVariantRows.map((row) => row.id),
205
+ ]),
206
+ )
207
+ const allProductIds = Array.from(new Set(query.items.map((item) => item.catalogProductId)))
208
+
209
+ // One balance aggregation and one profile lookup for the whole batch (R4).
210
+ const [balances, profiles] = await Promise.all([
211
+ loadBalanceAggregates(em, query.tenantId, query.organizationId, allVariantIds, query.locationIds),
212
+ loadProfiles(em, query.tenantId, query.organizationId, allVariantIds, allProductIds),
213
+ ])
214
+
215
+ // One batched policy resolution for the whole batch (R4).
216
+ const policyService = tryResolve<PolicyResolutionServiceLike>(container, 'policyResolutionService')
217
+ const policyScopes: PolicyResolutionScopeLike[] = query.items.map((item) => ({
218
+ tenantId: query.tenantId,
219
+ organizationId: query.organizationId,
220
+ storeId: query.storeId ?? null,
221
+ productId: item.catalogProductId,
222
+ variantId: item.catalogVariantId ?? null,
223
+ }))
224
+ const resolvedPolicies = policyService
225
+ ? await policyService.resolveMany(em, policyScopes)
226
+ : policyScopes.map(() => OPEN_POLICY_DEFAULT)
227
+
228
+ function sellableFor(variantId: string, productId: string): number {
229
+ const aggregate = balances.get(variantId) ?? 0
230
+ const profile = findProfile(profiles, productId, variantId)
231
+ const safetyStock = profile ? Number(profile.safety_stock ?? 0) : 0
232
+ return Math.max(0, aggregate - safetyStock)
233
+ }
234
+
235
+ query.items.forEach((item, index) => {
236
+ const policy = resolvedPolicies[index] ?? OPEN_POLICY_DEFAULT
237
+ const key = availabilityItemKey(item)
238
+
239
+ if (!policy.isStockManaged.value) {
240
+ byItem[key] = {
241
+ state: 'not_tracked',
242
+ availableQuantity: null,
243
+ canFulfil: true,
244
+ leadTimeDays: null,
245
+ releaseAt: null,
246
+ isAuthoritative: true,
247
+ policySourceId: policy.isStockManaged.policySourceId,
248
+ }
249
+ return
250
+ }
251
+
252
+ let sellable: number
253
+ let reorderPoint: number | null = null
254
+ if (item.catalogVariantId) {
255
+ sellable = sellableFor(item.catalogVariantId, item.catalogProductId)
256
+ const profile = findProfile(profiles, item.catalogProductId, item.catalogVariantId)
257
+ reorderPoint = profile && profile.reorder_point != null ? Number(profile.reorder_point) : null
258
+ } else {
259
+ const variantIds = variantsByProduct.get(item.catalogProductId) ?? []
260
+ sellable = variantIds.reduce((sum, variantId) => sum + sellableFor(variantId, item.catalogProductId), 0)
261
+ const profile = findProfile(profiles, item.catalogProductId, null)
262
+ reorderPoint = profile && profile.reorder_point != null ? Number(profile.reorder_point) : null
263
+ }
264
+
265
+ const effectiveLowStockThreshold = policy.lowStockThreshold.value ?? reorderPoint
266
+
267
+ const computed = computeState({
268
+ sellable,
269
+ requested: item.quantity,
270
+ lowStockThreshold: effectiveLowStockThreshold,
271
+ allowBackorder: policy.allowBackorder.value,
272
+ backorderLeadTimeDays: policy.backorderLeadTimeDays.value,
273
+ preorderReleaseAt: policy.preorderReleaseAt.value,
274
+ isActive: policy.isActive.value,
275
+ })
276
+
277
+ byItem[key] = {
278
+ state: computed.state,
279
+ availableQuantity: sellable,
280
+ canFulfil: computed.canFulfil,
281
+ leadTimeDays: computed.leadTimeDays,
282
+ releaseAt: computed.releaseAt,
283
+ isAuthoritative: true,
284
+ policySourceId: policy.isStockManaged.policySourceId,
285
+ }
286
+ })
287
+
288
+ return { byItem }
289
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * `wms`'s `AvailabilityProvider` registration (`id: 'wms'`).
3
+ *
4
+ * @see .ai/specs/2026-08-14-availability-contract.md §3.1, §4.2
5
+ *
6
+ * Registered from `wms/di.ts`'s `register(container)`, which — like every
7
+ * module DI registrar — runs once per `createRequestContainer()` call
8
+ * (`packages/shared/src/lib/di/container.ts`), not once at process boot.
9
+ * `availabilityProviderRegistry.register()` is idempotent replace-by-id, so
10
+ * each request's registration simply re-points the shared registry entry at
11
+ * a closure over that request's own container — the same pattern this
12
+ * module already uses for its plain entity-class registrations in `di.ts`.
13
+ */
14
+
15
+ import type { EntityManager } from '@mikro-orm/postgresql'
16
+ import type { AvailabilityProvider, AvailabilityQuery, AvailabilityResult } from '@open-mercato/shared/lib/availability'
17
+ import { computeAvailabilityCached } from './availabilityCache'
18
+
19
+ type Resolver = { resolve: <T = unknown>(name: string) => T }
20
+
21
+ export function createWmsAvailabilityProvider(container: Resolver): AvailabilityProvider {
22
+ return {
23
+ id: 'wms',
24
+ async getAvailability(query: AvailabilityQuery): Promise<AvailabilityResult> {
25
+ const em = (container.resolve('em') as EntityManager).fork()
26
+ return computeAvailabilityCached(em, container, query)
27
+ },
28
+ }
29
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Renders an id list into a raw-SQL `IN` clause with one placeholder per member.
3
+ *
4
+ * MikroORM does not bind parameters at the driver level: `AbstractSqlConnection.execute`
5
+ * calls `platform.formatQuery`, which interpolates each value through
6
+ * `BasePostgreSqlPlatform.escape`, and that renders a JavaScript array as a bare
7
+ * comma-separated list. `column = ANY(?)` therefore reaches PostgreSQL as
8
+ * `= ANY('a', 'b')` — or, for a single-element array, as the bare scalar, which fails
9
+ * with `malformed array literal`. Same fix as the dashboards aggregation builder (#4669)
10
+ * and the staff time-tracking raw queries: one placeholder per member.
11
+ *
12
+ * An empty list yields `IN (NULL)` rather than a dropped clause: a caller that narrowed
13
+ * to nothing must select nothing, never silently widen to every row.
14
+ */
15
+ export type SqlInClause = { sql: string; params: unknown[] }
16
+
17
+ export function buildSqlInClause(column: string, values: readonly string[]): SqlInClause {
18
+ if (values.length === 0) return { sql: `${column} IN (NULL)`, params: [] }
19
+ const placeholders = values.map(() => '?').join(', ')
20
+ return { sql: `${column} IN (${placeholders})`, params: [...values] }
21
+ }
@@ -0,0 +1,8 @@
1
+ /** Per-module local soft-resolve helper — see `packages/core/AGENTS.md` § Cross-Module Coupling. */
2
+ export function tryResolve<T>(resolver: { resolve: <R = unknown>(name: string) => R }, name: string): T | undefined {
3
+ try {
4
+ return resolver.resolve<T>(name)
5
+ } catch {
6
+ return undefined
7
+ }
8
+ }
@@ -0,0 +1,30 @@
1
+ // Invalidates the wms AvailabilityProvider's read-through cache
2
+ // (`availabilityCache.ts`) whenever a balance changes. Ephemeral
3
+ // (`persistent: false`) — cache invalidation is a real-time housekeeping
4
+ // concern, not something that needs retry/audit semantics; the cache's own
5
+ // 60s TTL is the backstop if an event is ever lost.
6
+ //
7
+ // `wms.*` does NOT match `wms.inventory_balance.created` (single-segment
8
+ // matcher) — `wms.inventory_balance.*` matches all three CRUD actions
9
+ // (created/updated/deleted) in one subscriber, mirroring
10
+ // `invalidate-enricher-cache-balance.ts`.
11
+
12
+ import { invalidateWmsAvailabilityCache } from '../lib/availabilityCache'
13
+
14
+ export const metadata = {
15
+ event: 'wms.inventory_balance.*',
16
+ persistent: false,
17
+ id: 'wms:invalidate-availability-cache',
18
+ }
19
+
20
+ type SubscriberContext = {
21
+ resolve: <T = unknown>(name: string) => T
22
+ tenantId?: string | null
23
+ }
24
+
25
+ export default async function handle(payload: unknown, ctx: SubscriberContext): Promise<void> {
26
+ const data = (payload ?? {}) as Record<string, unknown>
27
+ const tenantId =
28
+ typeof data.tenantId === 'string' && data.tenantId.length > 0 ? data.tenantId : ctx.tenantId ?? null
29
+ await invalidateWmsAvailabilityCache(ctx, tenantId)
30
+ }