@open-mercato/core 0.8.1-develop.7263.1.bbc6db3437 → 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.
- package/.turbo/turbo-build.log +2 -2
- package/dist/generated/entities.ids.generated.js +0 -4
- package/dist/generated/entities.ids.generated.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +0 -21
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/communication_channels/commands/deliver-outbound-message.js +5 -0
- package/dist/modules/communication_channels/commands/deliver-outbound-message.js.map +2 -2
- package/dist/modules/communication_channels/commands/ingest-inbound-message.js +13 -0
- package/dist/modules/communication_channels/commands/ingest-inbound-message.js.map +2 -2
- package/dist/modules/communication_channels/lib/email-mime.js +19 -1
- package/dist/modules/communication_channels/lib/email-mime.js.map +2 -2
- package/dist/modules/customers/lib/link-channel-message-handler.js +17 -7
- package/dist/modules/customers/lib/link-channel-message-handler.js.map +3 -3
- package/dist/modules/messages/api/route.js +1 -0
- package/dist/modules/messages/api/route.js.map +2 -2
- package/dist/modules/messages/commands/messages.js +3 -1
- package/dist/modules/messages/commands/messages.js.map +2 -2
- package/dist/modules/messages/data/validators.js +10 -2
- package/dist/modules/messages/data/validators.js.map +2 -2
- package/dist/modules/wms/di.js +0 -3
- package/dist/modules/wms/di.js.map +2 -2
- package/generated/entities.ids.generated.ts +0 -4
- package/generated/entity-fields-registry.ts +0 -21
- package/package.json +7 -7
- package/src/modules/auth/i18n/de.json +0 -3
- package/src/modules/auth/i18n/en.json +0 -3
- package/src/modules/auth/i18n/es.json +0 -3
- package/src/modules/auth/i18n/ko.json +0 -3
- package/src/modules/auth/i18n/pl.json +0 -3
- package/src/modules/communication_channels/commands/deliver-outbound-message.ts +8 -0
- package/src/modules/communication_channels/commands/ingest-inbound-message.ts +16 -0
- package/src/modules/communication_channels/lib/email-mime.ts +52 -2
- package/src/modules/customers/lib/link-channel-message-handler.ts +48 -5
- package/src/modules/messages/api/route.ts +6 -3
- package/src/modules/messages/commands/messages.ts +3 -1
- package/src/modules/messages/data/validators.ts +21 -12
- package/src/modules/wms/di.ts +0 -6
- package/dist/generated/entities/availability_policy/index.js +0 -41
- package/dist/generated/entities/availability_policy/index.js.map +0 -7
- package/dist/modules/availability/acl.js +0 -16
- package/dist/modules/availability/acl.js.map +0 -7
- package/dist/modules/availability/api/check/route.js +0 -95
- package/dist/modules/availability/api/check/route.js.map +0 -7
- package/dist/modules/availability/api/openapi.js +0 -27
- package/dist/modules/availability/api/openapi.js.map +0 -7
- package/dist/modules/availability/api/policies/resolve-preview/route.js +0 -58
- package/dist/modules/availability/api/policies/resolve-preview/route.js.map +0 -7
- package/dist/modules/availability/api/policies/route.js +0 -188
- package/dist/modules/availability/api/policies/route.js.map +0 -7
- package/dist/modules/availability/backend/availability/check/page.js +0 -185
- package/dist/modules/availability/backend/availability/check/page.js.map +0 -7
- package/dist/modules/availability/backend/availability/check/page.meta.js +0 -16
- package/dist/modules/availability/backend/availability/check/page.meta.js.map +0 -7
- package/dist/modules/availability/backend/availability/policies/ResolutionPreviewPanel.js +0 -79
- package/dist/modules/availability/backend/availability/policies/ResolutionPreviewPanel.js.map +0 -7
- package/dist/modules/availability/backend/availability/policies/ScopeFields.js +0 -53
- package/dist/modules/availability/backend/availability/policies/ScopeFields.js.map +0 -7
- package/dist/modules/availability/backend/availability/policies/[id]/page.js +0 -152
- package/dist/modules/availability/backend/availability/policies/[id]/page.js.map +0 -7
- package/dist/modules/availability/backend/availability/policies/[id]/page.meta.js +0 -12
- package/dist/modules/availability/backend/availability/policies/[id]/page.meta.js.map +0 -7
- package/dist/modules/availability/backend/availability/policies/create/page.js +0 -94
- package/dist/modules/availability/backend/availability/policies/create/page.js.map +0 -7
- package/dist/modules/availability/backend/availability/policies/create/page.meta.js +0 -12
- package/dist/modules/availability/backend/availability/policies/create/page.meta.js.map +0 -7
- package/dist/modules/availability/backend/availability/policies/formGroups.js +0 -75
- package/dist/modules/availability/backend/availability/policies/formGroups.js.map +0 -7
- package/dist/modules/availability/backend/availability/policies/page.js +0 -176
- package/dist/modules/availability/backend/availability/policies/page.js.map +0 -7
- package/dist/modules/availability/backend/availability/policies/page.meta.js +0 -16
- package/dist/modules/availability/backend/availability/policies/page.meta.js.map +0 -7
- package/dist/modules/availability/commands/policies.js +0 -312
- package/dist/modules/availability/commands/policies.js.map +0 -7
- package/dist/modules/availability/commands/scope.js +0 -19
- package/dist/modules/availability/commands/scope.js.map +0 -7
- package/dist/modules/availability/data/entities.js +0 -95
- package/dist/modules/availability/data/entities.js.map +0 -7
- package/dist/modules/availability/data/validators.js +0 -53
- package/dist/modules/availability/data/validators.js.map +0 -7
- package/dist/modules/availability/di.js +0 -41
- package/dist/modules/availability/di.js.map +0 -7
- package/dist/modules/availability/events.js +0 -18
- package/dist/modules/availability/events.js.map +0 -7
- package/dist/modules/availability/index.js +0 -15
- package/dist/modules/availability/index.js.map +0 -7
- package/dist/modules/availability/lib/policyResolution.js +0 -155
- package/dist/modules/availability/lib/policyResolution.js.map +0 -7
- package/dist/modules/availability/lib/tryResolve.js +0 -11
- package/dist/modules/availability/lib/tryResolve.js.map +0 -7
- package/dist/modules/availability/migrations/Migration20260922095844_availability.js +0 -16
- package/dist/modules/availability/migrations/Migration20260922095844_availability.js.map +0 -7
- package/dist/modules/availability/setup.js +0 -12
- package/dist/modules/availability/setup.js.map +0 -7
- package/dist/modules/wms/lib/availabilityCache.js +0 -85
- package/dist/modules/wms/lib/availabilityCache.js.map +0 -7
- package/dist/modules/wms/lib/availabilityCalculation.js +0 -178
- package/dist/modules/wms/lib/availabilityCalculation.js.map +0 -7
- package/dist/modules/wms/lib/availabilityProvider.js +0 -14
- package/dist/modules/wms/lib/availabilityProvider.js.map +0 -7
- package/dist/modules/wms/lib/sqlInClause.js +0 -9
- package/dist/modules/wms/lib/sqlInClause.js.map +0 -7
- package/dist/modules/wms/lib/tryResolve.js +0 -11
- package/dist/modules/wms/lib/tryResolve.js.map +0 -7
- package/dist/modules/wms/subscribers/invalidate-availability-cache.js +0 -16
- package/dist/modules/wms/subscribers/invalidate-availability-cache.js.map +0 -7
- package/generated/entities/availability_policy/index.ts +0 -19
- package/src/modules/availability/acl.ts +0 -12
- package/src/modules/availability/api/check/route.ts +0 -114
- package/src/modules/availability/api/openapi.ts +0 -28
- package/src/modules/availability/api/policies/resolve-preview/route.ts +0 -67
- package/src/modules/availability/api/policies/route.ts +0 -224
- package/src/modules/availability/backend/availability/check/page.meta.ts +0 -12
- package/src/modules/availability/backend/availability/check/page.tsx +0 -223
- package/src/modules/availability/backend/availability/policies/ResolutionPreviewPanel.tsx +0 -106
- package/src/modules/availability/backend/availability/policies/ScopeFields.tsx +0 -55
- package/src/modules/availability/backend/availability/policies/[id]/page.meta.ts +0 -8
- package/src/modules/availability/backend/availability/policies/[id]/page.tsx +0 -182
- package/src/modules/availability/backend/availability/policies/create/page.meta.ts +0 -8
- package/src/modules/availability/backend/availability/policies/create/page.tsx +0 -105
- package/src/modules/availability/backend/availability/policies/formGroups.ts +0 -77
- package/src/modules/availability/backend/availability/policies/page.meta.ts +0 -12
- package/src/modules/availability/backend/availability/policies/page.tsx +0 -196
- package/src/modules/availability/commands/policies.ts +0 -358
- package/src/modules/availability/commands/scope.ts +0 -26
- package/src/modules/availability/data/entities.ts +0 -92
- package/src/modules/availability/data/validators.ts +0 -70
- package/src/modules/availability/di.ts +0 -54
- package/src/modules/availability/events.ts +0 -17
- package/src/modules/availability/i18n/de.json +0 -81
- package/src/modules/availability/i18n/en.json +0 -81
- package/src/modules/availability/i18n/es.json +0 -81
- package/src/modules/availability/i18n/ko.json +0 -81
- package/src/modules/availability/i18n/pl.json +0 -81
- package/src/modules/availability/index.ts +0 -13
- package/src/modules/availability/lib/policyResolution.ts +0 -264
- package/src/modules/availability/lib/tryResolve.ts +0 -8
- package/src/modules/availability/migrations/.snapshot-open-mercato.json +0 -375
- package/src/modules/availability/migrations/Migration20260922095844_availability.ts +0 -13
- package/src/modules/availability/setup.ts +0 -10
- package/src/modules/wms/lib/availabilityCache.ts +0 -136
- package/src/modules/wms/lib/availabilityCalculation.ts +0 -289
- package/src/modules/wms/lib/availabilityProvider.ts +0 -29
- package/src/modules/wms/lib/sqlInClause.ts +0 -21
- package/src/modules/wms/lib/tryResolve.ts +0 -8
- 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
|
-
}
|