@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.
- package/.turbo/turbo-build.log +2 -2
- package/dist/generated/entities/availability_policy/index.js +41 -0
- package/dist/generated/entities/availability_policy/index.js.map +7 -0
- package/dist/generated/entities.ids.generated.js +4 -0
- package/dist/generated/entities.ids.generated.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +21 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/attachments/data/entities.js +3 -3
- package/dist/modules/attachments/data/entities.js.map +2 -2
- package/dist/modules/availability/acl.js +16 -0
- package/dist/modules/availability/acl.js.map +7 -0
- package/dist/modules/availability/api/check/route.js +95 -0
- package/dist/modules/availability/api/check/route.js.map +7 -0
- package/dist/modules/availability/api/openapi.js +27 -0
- package/dist/modules/availability/api/openapi.js.map +7 -0
- package/dist/modules/availability/api/policies/resolve-preview/route.js +58 -0
- package/dist/modules/availability/api/policies/resolve-preview/route.js.map +7 -0
- package/dist/modules/availability/api/policies/route.js +188 -0
- package/dist/modules/availability/api/policies/route.js.map +7 -0
- package/dist/modules/availability/backend/availability/check/page.js +185 -0
- package/dist/modules/availability/backend/availability/check/page.js.map +7 -0
- package/dist/modules/availability/backend/availability/check/page.meta.js +16 -0
- package/dist/modules/availability/backend/availability/check/page.meta.js.map +7 -0
- package/dist/modules/availability/backend/availability/policies/ResolutionPreviewPanel.js +79 -0
- package/dist/modules/availability/backend/availability/policies/ResolutionPreviewPanel.js.map +7 -0
- package/dist/modules/availability/backend/availability/policies/ScopeFields.js +53 -0
- package/dist/modules/availability/backend/availability/policies/ScopeFields.js.map +7 -0
- package/dist/modules/availability/backend/availability/policies/[id]/page.js +152 -0
- package/dist/modules/availability/backend/availability/policies/[id]/page.js.map +7 -0
- package/dist/modules/availability/backend/availability/policies/[id]/page.meta.js +12 -0
- package/dist/modules/availability/backend/availability/policies/[id]/page.meta.js.map +7 -0
- package/dist/modules/availability/backend/availability/policies/create/page.js +94 -0
- package/dist/modules/availability/backend/availability/policies/create/page.js.map +7 -0
- package/dist/modules/availability/backend/availability/policies/create/page.meta.js +12 -0
- package/dist/modules/availability/backend/availability/policies/create/page.meta.js.map +7 -0
- package/dist/modules/availability/backend/availability/policies/formGroups.js +75 -0
- package/dist/modules/availability/backend/availability/policies/formGroups.js.map +7 -0
- package/dist/modules/availability/backend/availability/policies/page.js +176 -0
- package/dist/modules/availability/backend/availability/policies/page.js.map +7 -0
- package/dist/modules/availability/backend/availability/policies/page.meta.js +16 -0
- package/dist/modules/availability/backend/availability/policies/page.meta.js.map +7 -0
- package/dist/modules/availability/commands/policies.js +312 -0
- package/dist/modules/availability/commands/policies.js.map +7 -0
- package/dist/modules/availability/commands/scope.js +19 -0
- package/dist/modules/availability/commands/scope.js.map +7 -0
- package/dist/modules/availability/data/entities.js +95 -0
- package/dist/modules/availability/data/entities.js.map +7 -0
- package/dist/modules/availability/data/validators.js +53 -0
- package/dist/modules/availability/data/validators.js.map +7 -0
- package/dist/modules/availability/di.js +41 -0
- package/dist/modules/availability/di.js.map +7 -0
- package/dist/modules/availability/events.js +18 -0
- package/dist/modules/availability/events.js.map +7 -0
- package/dist/modules/availability/index.js +15 -0
- package/dist/modules/availability/index.js.map +7 -0
- package/dist/modules/availability/lib/policyResolution.js +155 -0
- package/dist/modules/availability/lib/policyResolution.js.map +7 -0
- package/dist/modules/availability/lib/tryResolve.js +11 -0
- package/dist/modules/availability/lib/tryResolve.js.map +7 -0
- package/dist/modules/availability/migrations/Migration20260922095844_availability.js +16 -0
- package/dist/modules/availability/migrations/Migration20260922095844_availability.js.map +7 -0
- package/dist/modules/availability/setup.js +12 -0
- package/dist/modules/availability/setup.js.map +7 -0
- package/dist/modules/entities/cli.js +33 -12
- package/dist/modules/entities/cli.js.map +2 -2
- package/dist/modules/inbox_ops/api/settings/route.js +7 -32
- package/dist/modules/inbox_ops/api/settings/route.js.map +2 -2
- package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.js +2 -2
- package/dist/modules/inbox_ops/backend/inbox-ops/settings/page.js.map +2 -2
- package/dist/modules/inbox_ops/components/proposals/ActionCard.js +1 -1
- package/dist/modules/inbox_ops/components/proposals/ActionCard.js.map +2 -2
- package/dist/modules/inbox_ops/lib/ensure-settings.js +47 -0
- package/dist/modules/inbox_ops/lib/ensure-settings.js.map +7 -0
- package/dist/modules/inbox_ops/lib/executionEngine.js.map +1 -1
- package/dist/modules/inbox_ops/lib/executionHelpers.js +0 -23
- package/dist/modules/inbox_ops/lib/executionHelpers.js.map +2 -2
- package/dist/modules/inbox_ops/lib/payloadEnrichment.js +0 -3
- package/dist/modules/inbox_ops/lib/payloadEnrichment.js.map +2 -2
- package/dist/modules/inbox_ops/setup.js +2 -21
- package/dist/modules/inbox_ops/setup.js.map +2 -2
- package/dist/modules/inbox_ops/subscribers/extractionWorker.js.map +1 -1
- package/dist/modules/sales/components/documents/SalesDocumentForm.js +7 -0
- package/dist/modules/sales/components/documents/SalesDocumentForm.js.map +2 -2
- package/dist/modules/sales/data/entities.js +3 -1
- package/dist/modules/sales/data/entities.js.map +2 -2
- package/dist/modules/sales/inbox-actions.js +0 -12
- package/dist/modules/sales/inbox-actions.js.map +2 -2
- package/dist/modules/sales/migrations/Migration20260916120000_sales_payment_allocation_order_idx.js +20 -0
- package/dist/modules/sales/migrations/Migration20260916120000_sales_payment_allocation_order_idx.js.map +7 -0
- package/dist/modules/wms/di.js +3 -0
- package/dist/modules/wms/di.js.map +2 -2
- package/dist/modules/wms/lib/availabilityCache.js +85 -0
- package/dist/modules/wms/lib/availabilityCache.js.map +7 -0
- package/dist/modules/wms/lib/availabilityCalculation.js +178 -0
- package/dist/modules/wms/lib/availabilityCalculation.js.map +7 -0
- package/dist/modules/wms/lib/availabilityProvider.js +14 -0
- package/dist/modules/wms/lib/availabilityProvider.js.map +7 -0
- package/dist/modules/wms/lib/sqlInClause.js +9 -0
- package/dist/modules/wms/lib/sqlInClause.js.map +7 -0
- package/dist/modules/wms/lib/tryResolve.js +11 -0
- package/dist/modules/wms/lib/tryResolve.js.map +7 -0
- package/dist/modules/wms/subscribers/invalidate-availability-cache.js +16 -0
- package/dist/modules/wms/subscribers/invalidate-availability-cache.js.map +7 -0
- package/generated/entities/availability_policy/index.ts +19 -0
- package/generated/entities.ids.generated.ts +4 -0
- package/generated/entity-fields-registry.ts +21 -0
- package/package.json +7 -7
- package/src/modules/attachments/data/entities.ts +5 -3
- package/src/modules/auth/i18n/de.json +3 -0
- package/src/modules/auth/i18n/en.json +3 -0
- package/src/modules/auth/i18n/es.json +3 -0
- package/src/modules/auth/i18n/ko.json +3 -0
- package/src/modules/auth/i18n/pl.json +3 -0
- package/src/modules/availability/acl.ts +12 -0
- package/src/modules/availability/api/check/route.ts +114 -0
- package/src/modules/availability/api/openapi.ts +28 -0
- package/src/modules/availability/api/policies/resolve-preview/route.ts +67 -0
- package/src/modules/availability/api/policies/route.ts +224 -0
- package/src/modules/availability/backend/availability/check/page.meta.ts +12 -0
- package/src/modules/availability/backend/availability/check/page.tsx +223 -0
- package/src/modules/availability/backend/availability/policies/ResolutionPreviewPanel.tsx +106 -0
- package/src/modules/availability/backend/availability/policies/ScopeFields.tsx +55 -0
- package/src/modules/availability/backend/availability/policies/[id]/page.meta.ts +8 -0
- package/src/modules/availability/backend/availability/policies/[id]/page.tsx +182 -0
- package/src/modules/availability/backend/availability/policies/create/page.meta.ts +8 -0
- package/src/modules/availability/backend/availability/policies/create/page.tsx +105 -0
- package/src/modules/availability/backend/availability/policies/formGroups.ts +77 -0
- package/src/modules/availability/backend/availability/policies/page.meta.ts +12 -0
- package/src/modules/availability/backend/availability/policies/page.tsx +196 -0
- package/src/modules/availability/commands/policies.ts +358 -0
- package/src/modules/availability/commands/scope.ts +26 -0
- package/src/modules/availability/data/entities.ts +92 -0
- package/src/modules/availability/data/validators.ts +70 -0
- package/src/modules/availability/di.ts +54 -0
- package/src/modules/availability/events.ts +17 -0
- package/src/modules/availability/i18n/de.json +81 -0
- package/src/modules/availability/i18n/en.json +81 -0
- package/src/modules/availability/i18n/es.json +81 -0
- package/src/modules/availability/i18n/ko.json +81 -0
- package/src/modules/availability/i18n/pl.json +81 -0
- package/src/modules/availability/index.ts +13 -0
- package/src/modules/availability/lib/policyResolution.ts +264 -0
- package/src/modules/availability/lib/tryResolve.ts +8 -0
- package/src/modules/availability/migrations/.snapshot-open-mercato.json +375 -0
- package/src/modules/availability/migrations/Migration20260922095844_availability.ts +13 -0
- package/src/modules/availability/setup.ts +10 -0
- package/src/modules/entities/cli.ts +53 -13
- package/src/modules/inbox_ops/api/settings/route.ts +9 -32
- package/src/modules/inbox_ops/backend/inbox-ops/settings/page.tsx +2 -2
- package/src/modules/inbox_ops/components/proposals/ActionCard.tsx +1 -1
- package/src/modules/inbox_ops/i18n/de.json +1 -1
- package/src/modules/inbox_ops/i18n/en.json +1 -1
- package/src/modules/inbox_ops/i18n/es.json +1 -1
- package/src/modules/inbox_ops/i18n/ko.json +1 -1
- package/src/modules/inbox_ops/i18n/pl.json +1 -1
- package/src/modules/inbox_ops/lib/ensure-settings.ts +77 -0
- package/src/modules/inbox_ops/lib/executionEngine.ts +1 -1
- package/src/modules/inbox_ops/lib/executionHelpers.ts +0 -27
- package/src/modules/inbox_ops/lib/payloadEnrichment.ts +1 -5
- package/src/modules/inbox_ops/setup.ts +2 -21
- package/src/modules/inbox_ops/subscribers/extractionWorker.ts +1 -1
- package/src/modules/sales/components/documents/SalesDocumentForm.tsx +13 -0
- package/src/modules/sales/data/entities.ts +2 -0
- package/src/modules/sales/inbox-actions.ts +0 -20
- package/src/modules/sales/migrations/.snapshot-open-mercato.json +20 -0
- package/src/modules/sales/migrations/Migration20260916120000_sales_payment_allocation_order_idx.ts +74 -0
- package/src/modules/wms/di.ts +6 -0
- package/src/modules/wms/lib/availabilityCache.ts +136 -0
- package/src/modules/wms/lib/availabilityCalculation.ts +289 -0
- package/src/modules/wms/lib/availabilityProvider.ts +29 -0
- package/src/modules/wms/lib/sqlInClause.ts +21 -0
- package/src/modules/wms/lib/tryResolve.ts +8 -0
- package/src/modules/wms/subscribers/invalidate-availability-cache.ts +30 -0
package/src/modules/sales/migrations/Migration20260916120000_sales_payment_allocation_order_idx.ts
ADDED
|
@@ -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
|
+
}
|
package/src/modules/wms/di.ts
CHANGED
|
@@ -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
|
+
}
|