@open-mercato/core 0.8.1-develop.7266.1.8e520bbe03 → 0.8.1-develop.7267.1.2e95af80a6

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 (124) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/dist/generated/entities.ids.generated.js +0 -4
  3. package/dist/generated/entities.ids.generated.js.map +2 -2
  4. package/dist/generated/entity-fields-registry.js +0 -21
  5. package/dist/generated/entity-fields-registry.js.map +2 -2
  6. package/dist/modules/wms/di.js +0 -3
  7. package/dist/modules/wms/di.js.map +2 -2
  8. package/generated/entities.ids.generated.ts +0 -4
  9. package/generated/entity-fields-registry.ts +0 -21
  10. package/package.json +7 -7
  11. package/src/modules/auth/i18n/de.json +0 -3
  12. package/src/modules/auth/i18n/en.json +0 -3
  13. package/src/modules/auth/i18n/es.json +0 -3
  14. package/src/modules/auth/i18n/ko.json +0 -3
  15. package/src/modules/auth/i18n/pl.json +0 -3
  16. package/src/modules/wms/di.ts +0 -6
  17. package/dist/generated/entities/availability_policy/index.js +0 -41
  18. package/dist/generated/entities/availability_policy/index.js.map +0 -7
  19. package/dist/modules/availability/acl.js +0 -16
  20. package/dist/modules/availability/acl.js.map +0 -7
  21. package/dist/modules/availability/api/check/route.js +0 -95
  22. package/dist/modules/availability/api/check/route.js.map +0 -7
  23. package/dist/modules/availability/api/openapi.js +0 -27
  24. package/dist/modules/availability/api/openapi.js.map +0 -7
  25. package/dist/modules/availability/api/policies/resolve-preview/route.js +0 -58
  26. package/dist/modules/availability/api/policies/resolve-preview/route.js.map +0 -7
  27. package/dist/modules/availability/api/policies/route.js +0 -188
  28. package/dist/modules/availability/api/policies/route.js.map +0 -7
  29. package/dist/modules/availability/backend/availability/check/page.js +0 -185
  30. package/dist/modules/availability/backend/availability/check/page.js.map +0 -7
  31. package/dist/modules/availability/backend/availability/check/page.meta.js +0 -16
  32. package/dist/modules/availability/backend/availability/check/page.meta.js.map +0 -7
  33. package/dist/modules/availability/backend/availability/policies/ResolutionPreviewPanel.js +0 -79
  34. package/dist/modules/availability/backend/availability/policies/ResolutionPreviewPanel.js.map +0 -7
  35. package/dist/modules/availability/backend/availability/policies/ScopeFields.js +0 -53
  36. package/dist/modules/availability/backend/availability/policies/ScopeFields.js.map +0 -7
  37. package/dist/modules/availability/backend/availability/policies/[id]/page.js +0 -152
  38. package/dist/modules/availability/backend/availability/policies/[id]/page.js.map +0 -7
  39. package/dist/modules/availability/backend/availability/policies/[id]/page.meta.js +0 -12
  40. package/dist/modules/availability/backend/availability/policies/[id]/page.meta.js.map +0 -7
  41. package/dist/modules/availability/backend/availability/policies/create/page.js +0 -94
  42. package/dist/modules/availability/backend/availability/policies/create/page.js.map +0 -7
  43. package/dist/modules/availability/backend/availability/policies/create/page.meta.js +0 -12
  44. package/dist/modules/availability/backend/availability/policies/create/page.meta.js.map +0 -7
  45. package/dist/modules/availability/backend/availability/policies/formGroups.js +0 -75
  46. package/dist/modules/availability/backend/availability/policies/formGroups.js.map +0 -7
  47. package/dist/modules/availability/backend/availability/policies/page.js +0 -176
  48. package/dist/modules/availability/backend/availability/policies/page.js.map +0 -7
  49. package/dist/modules/availability/backend/availability/policies/page.meta.js +0 -16
  50. package/dist/modules/availability/backend/availability/policies/page.meta.js.map +0 -7
  51. package/dist/modules/availability/commands/policies.js +0 -312
  52. package/dist/modules/availability/commands/policies.js.map +0 -7
  53. package/dist/modules/availability/commands/scope.js +0 -19
  54. package/dist/modules/availability/commands/scope.js.map +0 -7
  55. package/dist/modules/availability/data/entities.js +0 -95
  56. package/dist/modules/availability/data/entities.js.map +0 -7
  57. package/dist/modules/availability/data/validators.js +0 -53
  58. package/dist/modules/availability/data/validators.js.map +0 -7
  59. package/dist/modules/availability/di.js +0 -41
  60. package/dist/modules/availability/di.js.map +0 -7
  61. package/dist/modules/availability/events.js +0 -18
  62. package/dist/modules/availability/events.js.map +0 -7
  63. package/dist/modules/availability/index.js +0 -15
  64. package/dist/modules/availability/index.js.map +0 -7
  65. package/dist/modules/availability/lib/policyResolution.js +0 -155
  66. package/dist/modules/availability/lib/policyResolution.js.map +0 -7
  67. package/dist/modules/availability/lib/tryResolve.js +0 -11
  68. package/dist/modules/availability/lib/tryResolve.js.map +0 -7
  69. package/dist/modules/availability/migrations/Migration20260922095844_availability.js +0 -16
  70. package/dist/modules/availability/migrations/Migration20260922095844_availability.js.map +0 -7
  71. package/dist/modules/availability/setup.js +0 -12
  72. package/dist/modules/availability/setup.js.map +0 -7
  73. package/dist/modules/wms/lib/availabilityCache.js +0 -85
  74. package/dist/modules/wms/lib/availabilityCache.js.map +0 -7
  75. package/dist/modules/wms/lib/availabilityCalculation.js +0 -178
  76. package/dist/modules/wms/lib/availabilityCalculation.js.map +0 -7
  77. package/dist/modules/wms/lib/availabilityProvider.js +0 -14
  78. package/dist/modules/wms/lib/availabilityProvider.js.map +0 -7
  79. package/dist/modules/wms/lib/sqlInClause.js +0 -9
  80. package/dist/modules/wms/lib/sqlInClause.js.map +0 -7
  81. package/dist/modules/wms/lib/tryResolve.js +0 -11
  82. package/dist/modules/wms/lib/tryResolve.js.map +0 -7
  83. package/dist/modules/wms/subscribers/invalidate-availability-cache.js +0 -16
  84. package/dist/modules/wms/subscribers/invalidate-availability-cache.js.map +0 -7
  85. package/generated/entities/availability_policy/index.ts +0 -19
  86. package/src/modules/availability/acl.ts +0 -12
  87. package/src/modules/availability/api/check/route.ts +0 -114
  88. package/src/modules/availability/api/openapi.ts +0 -28
  89. package/src/modules/availability/api/policies/resolve-preview/route.ts +0 -67
  90. package/src/modules/availability/api/policies/route.ts +0 -224
  91. package/src/modules/availability/backend/availability/check/page.meta.ts +0 -12
  92. package/src/modules/availability/backend/availability/check/page.tsx +0 -223
  93. package/src/modules/availability/backend/availability/policies/ResolutionPreviewPanel.tsx +0 -106
  94. package/src/modules/availability/backend/availability/policies/ScopeFields.tsx +0 -55
  95. package/src/modules/availability/backend/availability/policies/[id]/page.meta.ts +0 -8
  96. package/src/modules/availability/backend/availability/policies/[id]/page.tsx +0 -182
  97. package/src/modules/availability/backend/availability/policies/create/page.meta.ts +0 -8
  98. package/src/modules/availability/backend/availability/policies/create/page.tsx +0 -105
  99. package/src/modules/availability/backend/availability/policies/formGroups.ts +0 -77
  100. package/src/modules/availability/backend/availability/policies/page.meta.ts +0 -12
  101. package/src/modules/availability/backend/availability/policies/page.tsx +0 -196
  102. package/src/modules/availability/commands/policies.ts +0 -358
  103. package/src/modules/availability/commands/scope.ts +0 -26
  104. package/src/modules/availability/data/entities.ts +0 -92
  105. package/src/modules/availability/data/validators.ts +0 -70
  106. package/src/modules/availability/di.ts +0 -54
  107. package/src/modules/availability/events.ts +0 -17
  108. package/src/modules/availability/i18n/de.json +0 -81
  109. package/src/modules/availability/i18n/en.json +0 -81
  110. package/src/modules/availability/i18n/es.json +0 -81
  111. package/src/modules/availability/i18n/ko.json +0 -81
  112. package/src/modules/availability/i18n/pl.json +0 -81
  113. package/src/modules/availability/index.ts +0 -13
  114. package/src/modules/availability/lib/policyResolution.ts +0 -264
  115. package/src/modules/availability/lib/tryResolve.ts +0 -8
  116. package/src/modules/availability/migrations/.snapshot-open-mercato.json +0 -375
  117. package/src/modules/availability/migrations/Migration20260922095844_availability.ts +0 -13
  118. package/src/modules/availability/setup.ts +0 -10
  119. package/src/modules/wms/lib/availabilityCache.ts +0 -136
  120. package/src/modules/wms/lib/availabilityCalculation.ts +0 -289
  121. package/src/modules/wms/lib/availabilityProvider.ts +0 -29
  122. package/src/modules/wms/lib/sqlInClause.ts +0 -21
  123. package/src/modules/wms/lib/tryResolve.ts +0 -8
  124. package/src/modules/wms/subscribers/invalidate-availability-cache.ts +0 -30
@@ -1,136 +0,0 @@
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
- }
@@ -1,289 +0,0 @@
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
- }
@@ -1,29 +0,0 @@
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
- }
@@ -1,21 +0,0 @@
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
- }
@@ -1,8 +0,0 @@
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
- }
@@ -1,30 +0,0 @@
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
- }