@open-mercato/core 0.10.1-develop.7801.1.52db3dc0dd → 0.10.1-develop.7803.1.e99e862b4e
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.
|
@@ -42,7 +42,7 @@ function matchesContext(row, ctx) {
|
|
|
42
42
|
if (row.minQuantity && quantity < row.minQuantity) return false;
|
|
43
43
|
if (row.maxQuantity && quantity > row.maxQuantity) return false;
|
|
44
44
|
if (row.startsAt && date < row.startsAt) return false;
|
|
45
|
-
if (row.endsAt && date
|
|
45
|
+
if (row.endsAt && date >= row.endsAt) return false;
|
|
46
46
|
if (row.channelId || row.offer && resolvePriceChannelId(row)) {
|
|
47
47
|
const channel = resolvePriceChannelId(row);
|
|
48
48
|
if (channel && ctx.channelId && channel !== ctx.channelId) return false;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/catalog/lib/pricing.ts"],
|
|
4
|
-
"sourcesContent": ["import type { EventBus } from '@open-mercato/events'\nimport type { FilterQuery } from '@mikro-orm/postgresql'\nimport type {\n CatalogOffer,\n CatalogPriceKind,\n CatalogProduct,\n CatalogProductPrice,\n CatalogProductVariant,\n} from '../data/entities'\n\nexport type PricingContext = {\n channelId?: string | null\n offerId?: string | null\n userId?: string | null\n userGroupId?: string | null\n customerId?: string | null\n /** @deprecated use `customerGroupIds` \u2014 kept for backward compatibility, read as a one-element set when `customerGroupIds` is absent. */\n customerGroupId?: string | null\n /** Set membership: a row's `customerGroupId` matches when it appears in this list. Falls back to `customerGroupId` (above) when omitted. */\n customerGroupIds?: string[]\n /** When set, only rows in this currency match. Omitted (the default): no currency filtering, unchanged legacy behavior. */\n currencyCode?: string | null\n /**\n * When set, only rows of this price kind (by `CatalogPriceKind` id) match, plus rows of any promotional\n * kind (`CatalogPriceKind.isPromotion === true`) as an overlay (pricing-engine amendment D2a). Promotion\n * status is read from a populated `priceKind`; a row carrying only a kind id string is admitted solely when\n * that id equals this one. Unset or `null`: kind is not filtered, unchanged legacy behavior.\n */\n priceKindId?: string | null\n /** The buyer's customer entities, person first, then company. A row's `customerId` matches when it appears in this list. Falls back to `customerId` (above) when omitted. */\n customerIds?: string[]\n quantity: number\n date: Date\n}\n\nexport type PriceRow = CatalogProductPrice & {\n product?: CatalogProduct | string | null\n variant?: CatalogProductVariant | string | null\n offer?: CatalogOffer | string | null\n priceKind?: CatalogPriceKind | string | null\n}\n\nexport function resolvePriceVariantId(row: PriceRow): string | null {\n if (!row.variant) return null\n return typeof row.variant === 'string' ? row.variant : row.variant.id\n}\n\n/**\n * Narrows a batch of `CatalogProductPrice` rows down to the ones a single variant's line may\n * actually use: product-level rows (`row.variant === null`) plus rows scoped to THIS variant.\n * Required after any fetch built with a `product OR variant` clause (`{ $or: [{ product },\n * { variant } ] }`) \u2014 every variant's price row also carries its parent `product_id`, so that\n * clause's `{ product: productId }` arm returns every sibling variant's rows too, not just the\n * target variant's. Callers that fetch by product id alone, without this follow-up filter, risk\n * pricing a line off a sibling variant's row.\n */\nexport function filterRowsForVariant(rows: PriceRow[], variantId: string | null): PriceRow[] {\n return rows.filter((row) => {\n const rowVariantId = resolvePriceVariantId(row)\n return rowVariantId === null || rowVariantId === variantId\n })\n}\n\nexport function resolvePriceOfferId(row: PriceRow): string | null {\n if (!row.offer) return null\n return typeof row.offer === 'string' ? row.offer : row.offer.id\n}\n\nexport function resolvePriceChannelId(row: PriceRow): string | null {\n if (!row.offer) return row.channelId ?? null\n if (typeof row.offer === 'string') return row.channelId ?? null\n return row.channelId ?? row.offer.channelId ?? null\n}\n\nexport function resolvePriceKindId(row: PriceRow): string | null {\n if (!row.priceKind) return null\n return typeof row.priceKind === 'string' ? row.priceKind : row.priceKind.id ?? null\n}\n\nfunction resolveContextCustomerIds(ctx: PricingContext): string[] {\n return ctx.customerIds ?? (ctx.customerId ? [ctx.customerId] : [])\n}\n\nfunction isPromotionalPriceKindRow(row: PriceRow): boolean {\n return Boolean(row.priceKind && typeof row.priceKind !== 'string' && row.priceKind.isPromotion === true)\n}\n\nfunction matchesPriceKind(row: PriceRow, priceKindId: string): boolean {\n return resolvePriceKindId(row) === priceKindId || isPromotionalPriceKindRow(row)\n}\n\nexport function resolvePriceKindCode(row: PriceRow): string {\n if (row.priceKind) {\n if (typeof row.priceKind === 'string') return row.priceKind\n return row.priceKind.code ?? row.kind ?? ''\n }\n return row.kind ?? ''\n}\n\nfunction matchesContext(row: PriceRow, ctx: PricingContext): boolean {\n const { quantity, date } = ctx\n if (row.minQuantity && quantity < row.minQuantity) return false\n if (row.maxQuantity && quantity > row.maxQuantity) return false\n if (row.startsAt && date < row.startsAt) return false\n if (row.endsAt && date > row.endsAt) return false\n if (row.channelId || (row.offer && resolvePriceChannelId(row))) {\n const channel = resolvePriceChannelId(row)\n if (channel && ctx.channelId && channel !== ctx.channelId) return false\n if (channel && !ctx.channelId) return false\n }\n if (row.userId && ctx.userId !== row.userId) return false\n if (row.userGroupId && ctx.userGroupId !== row.userGroupId) return false\n if (row.customerId && !resolveContextCustomerIds(ctx).includes(row.customerId)) return false\n if (row.customerGroupId) {\n const candidateGroupIds = ctx.customerGroupIds ?? (ctx.customerGroupId ? [ctx.customerGroupId] : [])\n if (!candidateGroupIds.includes(row.customerGroupId)) return false\n }\n if (ctx.currencyCode && row.currencyCode !== ctx.currencyCode) return false\n if (ctx.priceKindId && !matchesPriceKind(row, ctx.priceKindId)) return false\n if (ctx.offerId && resolvePriceOfferId(row) && resolvePriceOfferId(row) !== ctx.offerId) return false\n return true\n}\n\n/**\n * The narrowing half of `matchesContext` expressed as a MikroORM filter, for\n * callers that fetch `CatalogProductPrice` rows by product/variant today and\n * would otherwise load every contracted customer's rows just to discard\n * almost all of them (per-customer pricing at scale \u2014 see\n * `.ai/specs/2026-08-21-pricing-engine.md` \u00A7 Row narrowing).\n *\n * Covers only the dimensions that are plain column comparisons: customer,\n * customer-group, user, user-group, channel, currency, price kind (the resolved\n * kind or any promotional kind, via a `priceKind.isPromotion` relation clause). Quantity bounds,\n * validity windows, and offer-derived channel resolution stay in\n * `matchesContext` \u2014 they are cheap over an already-narrowed set and are not\n * expressible as one column predicate (offer's own `channelId` lives on a\n * different table).\n *\n * **Invariant (one-directional, this is the whole point of the helper):**\n * `matchesContext(row, ctx)` implies this filter admits `row`. The filter\n * MAY admit rows `matchesContext` later rejects (a slightly wide fetch) \u2014\n * it MUST NEVER exclude a row `matchesContext` would have accepted, because\n * that is a silently wrong price with no error anywhere. Verified by the\n * property-based test in `lib/__tests__/buildPriceRowFilter.property.test.ts`.\n */\nexport function buildPriceRowFilter(ctx: PricingContext): FilterQuery<CatalogProductPrice> {\n const customerGroupIds = ctx.customerGroupIds ?? (ctx.customerGroupId ? [ctx.customerGroupId] : [])\n const customerIds = resolveContextCustomerIds(ctx)\n\n const clauses: FilterQuery<CatalogProductPrice>[] = [\n customerIds.length\n ? { $or: [{ customerId: null }, { customerId: { $in: customerIds } }] }\n : { customerId: null },\n customerGroupIds.length\n ? { $or: [{ customerGroupId: null }, { customerGroupId: { $in: customerGroupIds } }] }\n : { customerGroupId: null },\n ctx.userId\n ? { $or: [{ userId: null }, { userId: ctx.userId }] }\n : { userId: null },\n ctx.userGroupId\n ? { $or: [{ userGroupId: null }, { userGroupId: ctx.userGroupId }] }\n : { userGroupId: null },\n ctx.channelId\n ? { $or: [{ channelId: null }, { channelId: ctx.channelId }] }\n : { channelId: null },\n ]\n\n if (ctx.currencyCode) {\n clauses.push({ currencyCode: ctx.currencyCode })\n }\n\n if (ctx.priceKindId) {\n clauses.push({ $or: [{ priceKind: ctx.priceKindId }, { priceKind: { isPromotion: true } }] })\n }\n\n return { $and: clauses } as FilterQuery<CatalogProductPrice>\n}\n\nfunction scorePrice(row: PriceRow): number {\n const resolvedKind = resolvePriceKindCode(row)\n let score = 0\n if (resolvedKind === 'custom') score += 5\n else if (resolvedKind === 'tier') score += 3\n else if (resolvedKind === 'promotion' || row.priceKind?.isPromotion) score += 4\n else score += 2\n if (row.variant) score += 8\n if (row.offer) score += 6\n if (row.channelId) score += 5\n if (row.userId) score += 5\n if (row.userGroupId) score += 4\n if (row.customerId) score += 4\n if (row.customerGroupId) score += 3\n if (row.minQuantity && row.minQuantity > 1) score += 1\n return score\n}\n\nexport function selectBestPrice(rows: PriceRow[], ctx: PricingContext): PriceRow | null {\n const candidates = rows.filter((row) => matchesContext(row, ctx))\n if (!candidates.length) return null\n const customerIds = resolveContextCustomerIds(ctx)\n const customerRank = (row: PriceRow): number => (row.customerId ? customerIds.indexOf(row.customerId) : -1)\n candidates.sort((a, b) => {\n const scoreDiff = scorePrice(b) - scorePrice(a)\n if (scoreDiff !== 0) return scoreDiff\n const rankA = customerRank(a)\n const rankB = customerRank(b)\n if (rankA >= 0 && rankB >= 0 && rankA !== rankB) return rankA - rankB\n const startA = a.startsAt ? a.startsAt.getTime() : 0\n const startB = b.startsAt ? b.startsAt.getTime() : 0\n if (startA !== startB) return startB - startA\n // minQuantity tie-break is direction-dependent on the resolved kind.\n // Within the same kind we pick the more specific tier (higher minQuantity wins \u2014 issue #1706).\n // Across kinds we keep the pre-#1706 ascending order so a row whose kind has a higher\n // scoreBase still wins when scorePrice's \"+1 for minQuantity > 1\" bonus produces a\n // cross-kind collision (e.g. promotion[minQty=1]=4 vs tier[minQty=3]=4).\n if (resolvePriceKindCode(a) === resolvePriceKindCode(b)) {\n return (b.minQuantity ?? 1) - (a.minQuantity ?? 1)\n }\n return (a.minQuantity ?? 1) - (b.minQuantity ?? 1)\n })\n return candidates[0]\n}\n\nexport type CatalogPricingResolver = (\n rows: PriceRow[],\n ctx: PricingContext\n) => PriceRow | null | undefined | Promise<PriceRow | null | undefined>\n\ntype RegisteredResolver = {\n id?: string\n resolver: CatalogPricingResolver\n priority: number\n}\n\ntype PricingRegistryState = {\n resolvers: RegisteredResolver[]\n}\n\n// `globalThis`-keyed so the registry survives duplicated module instances\n// (a standalone app built from this monorepo, or any dev/build setup that\n// loads `catalog` through more than one chunk) \u2014 the same failure class\n// already fixed once for the ORM entity registry\n// (see `packages/shared/src/modules/integrations/types.ts` for the identical\n// pattern). A module-local array is invisible across instances: a resolver\n// registered from one instance would silently never run for resolution\n// happening in another.\nconst GLOBAL_PRICING_REGISTRY_KEY = '__openMercatoCatalogPricingRegistry__' as const\n\ntype GlobalPricingRegistry = typeof globalThis & {\n [GLOBAL_PRICING_REGISTRY_KEY]?: PricingRegistryState\n}\n\nfunction getPricingRegistryState(): PricingRegistryState {\n const globalRegistry = globalThis as GlobalPricingRegistry\n if (!globalRegistry[GLOBAL_PRICING_REGISTRY_KEY]) {\n globalRegistry[GLOBAL_PRICING_REGISTRY_KEY] = { resolvers: [] }\n }\n return globalRegistry[GLOBAL_PRICING_REGISTRY_KEY]\n}\n\nfunction sortResolvers(state: PricingRegistryState): void {\n // `Array.prototype.sort` is stable (ES2019+): resolvers registered at the\n // same priority keep their registration order. This is the documented,\n // tested same-priority tie-break \u2014 see `catalog/AGENTS.md` \u00A7 Price\n // selection order and the registry test in `lib/__tests__/pricing.test.ts`.\n state.resolvers.sort((a, b) => b.priority - a.priority)\n}\n\nexport function registerCatalogPricingResolver(\n resolver: CatalogPricingResolver,\n options?: { priority?: number; id?: string }\n): void {\n const state = getPricingRegistryState()\n const id = options?.id\n // Dedupe by id so a documented extension point stays HMR-safe for every\n // future registrant, not just one caller: re-registering the same id is a\n // no-op instead of appending a duplicate entry on every module reload.\n if (id && state.resolvers.some((entry) => entry.id === id)) return\n state.resolvers.push({ id, resolver, priority: options?.priority ?? 0 })\n sortResolvers(state)\n}\n\nexport function resetCatalogPricingResolvers(): void {\n getPricingRegistryState().resolvers.splice(0)\n}\n\nexport async function resolveCatalogPrice(\n rows: PriceRow[],\n ctx: PricingContext,\n options?: { eventBus?: EventBus | null }\n): Promise<PriceRow | null> {\n let workingRows = rows\n let workingContext = ctx\n const eventBus = options?.eventBus ?? null\n let resolved: PriceRow | null | undefined\n\n if (eventBus) {\n await eventBus.emitEvent('catalog.pricing.resolve.before', {\n rows: workingRows,\n context: workingContext,\n setRows(next: PriceRow[]) {\n if (Array.isArray(next)) workingRows = next\n },\n setContext(next: PricingContext) {\n if (next) workingContext = next\n },\n setResult(next: PriceRow | null) {\n resolved = next\n },\n })\n if (resolved !== undefined) return resolved\n }\n\n for (const { resolver } of getPricingRegistryState().resolvers) {\n const result = await resolver(workingRows, workingContext)\n if (result !== undefined) {\n resolved = result ?? null\n break\n }\n }\n\n if (resolved === undefined) {\n resolved = selectBestPrice(workingRows, workingContext)\n }\n\n if (eventBus) {\n await eventBus.emitEvent('catalog.pricing.resolve.after', {\n rows: workingRows,\n context: workingContext,\n result: resolved ?? null,\n setResult(next: PriceRow | null) {\n resolved = next\n },\n })\n }\n\n return resolved ?? null\n}\n\nexport async function resolveCatalogPriceBatch(\n entries: Array<{ rows: PriceRow[]; context: PricingContext }>,\n options?: { eventBus?: EventBus | null }\n): Promise<Array<PriceRow | null>> {\n return Promise.all(\n entries.map(({ rows, context }) => resolveCatalogPrice(rows, context, options))\n )\n}\n"],
|
|
5
|
-
"mappings": "AA0CO,SAAS,sBAAsB,KAA8B;AAClE,MAAI,CAAC,IAAI,QAAS,QAAO;AACzB,SAAO,OAAO,IAAI,YAAY,WAAW,IAAI,UAAU,IAAI,QAAQ;AACrE;AAWO,SAAS,qBAAqB,MAAkB,WAAsC;AAC3F,SAAO,KAAK,OAAO,CAAC,QAAQ;AAC1B,UAAM,eAAe,sBAAsB,GAAG;AAC9C,WAAO,iBAAiB,QAAQ,iBAAiB;AAAA,EACnD,CAAC;AACH;AAEO,SAAS,oBAAoB,KAA8B;AAChE,MAAI,CAAC,IAAI,MAAO,QAAO;AACvB,SAAO,OAAO,IAAI,UAAU,WAAW,IAAI,QAAQ,IAAI,MAAM;AAC/D;AAEO,SAAS,sBAAsB,KAA8B;AAClE,MAAI,CAAC,IAAI,MAAO,QAAO,IAAI,aAAa;AACxC,MAAI,OAAO,IAAI,UAAU,SAAU,QAAO,IAAI,aAAa;AAC3D,SAAO,IAAI,aAAa,IAAI,MAAM,aAAa;AACjD;AAEO,SAAS,mBAAmB,KAA8B;AAC/D,MAAI,CAAC,IAAI,UAAW,QAAO;AAC3B,SAAO,OAAO,IAAI,cAAc,WAAW,IAAI,YAAY,IAAI,UAAU,MAAM;AACjF;AAEA,SAAS,0BAA0B,KAA+B;AAChE,SAAO,IAAI,gBAAgB,IAAI,aAAa,CAAC,IAAI,UAAU,IAAI,CAAC;AAClE;AAEA,SAAS,0BAA0B,KAAwB;AACzD,SAAO,QAAQ,IAAI,aAAa,OAAO,IAAI,cAAc,YAAY,IAAI,UAAU,gBAAgB,IAAI;AACzG;AAEA,SAAS,iBAAiB,KAAe,aAA8B;AACrE,SAAO,mBAAmB,GAAG,MAAM,eAAe,0BAA0B,GAAG;AACjF;AAEO,SAAS,qBAAqB,KAAuB;AAC1D,MAAI,IAAI,WAAW;AACjB,QAAI,OAAO,IAAI,cAAc,SAAU,QAAO,IAAI;AAClD,WAAO,IAAI,UAAU,QAAQ,IAAI,QAAQ;AAAA,EAC3C;AACA,SAAO,IAAI,QAAQ;AACrB;AAEA,SAAS,eAAe,KAAe,KAA8B;AACnE,QAAM,EAAE,UAAU,KAAK,IAAI;AAC3B,MAAI,IAAI,eAAe,WAAW,IAAI,YAAa,QAAO;AAC1D,MAAI,IAAI,eAAe,WAAW,IAAI,YAAa,QAAO;AAC1D,MAAI,IAAI,YAAY,OAAO,IAAI,SAAU,QAAO;AAChD,MAAI,IAAI,UAAU,
|
|
4
|
+
"sourcesContent": ["import type { EventBus } from '@open-mercato/events'\nimport type { FilterQuery } from '@mikro-orm/postgresql'\nimport type {\n CatalogOffer,\n CatalogPriceKind,\n CatalogProduct,\n CatalogProductPrice,\n CatalogProductVariant,\n} from '../data/entities'\n\nexport type PricingContext = {\n channelId?: string | null\n offerId?: string | null\n userId?: string | null\n userGroupId?: string | null\n customerId?: string | null\n /** @deprecated use `customerGroupIds` \u2014 kept for backward compatibility, read as a one-element set when `customerGroupIds` is absent. */\n customerGroupId?: string | null\n /** Set membership: a row's `customerGroupId` matches when it appears in this list. Falls back to `customerGroupId` (above) when omitted. */\n customerGroupIds?: string[]\n /** When set, only rows in this currency match. Omitted (the default): no currency filtering, unchanged legacy behavior. */\n currencyCode?: string | null\n /**\n * When set, only rows of this price kind (by `CatalogPriceKind` id) match, plus rows of any promotional\n * kind (`CatalogPriceKind.isPromotion === true`) as an overlay (pricing-engine amendment D2a). Promotion\n * status is read from a populated `priceKind`; a row carrying only a kind id string is admitted solely when\n * that id equals this one. Unset or `null`: kind is not filtered, unchanged legacy behavior.\n */\n priceKindId?: string | null\n /** The buyer's customer entities, person first, then company. A row's `customerId` matches when it appears in this list. Falls back to `customerId` (above) when omitted. */\n customerIds?: string[]\n quantity: number\n date: Date\n}\n\nexport type PriceRow = CatalogProductPrice & {\n product?: CatalogProduct | string | null\n variant?: CatalogProductVariant | string | null\n offer?: CatalogOffer | string | null\n priceKind?: CatalogPriceKind | string | null\n}\n\nexport function resolvePriceVariantId(row: PriceRow): string | null {\n if (!row.variant) return null\n return typeof row.variant === 'string' ? row.variant : row.variant.id\n}\n\n/**\n * Narrows a batch of `CatalogProductPrice` rows down to the ones a single variant's line may\n * actually use: product-level rows (`row.variant === null`) plus rows scoped to THIS variant.\n * Required after any fetch built with a `product OR variant` clause (`{ $or: [{ product },\n * { variant } ] }`) \u2014 every variant's price row also carries its parent `product_id`, so that\n * clause's `{ product: productId }` arm returns every sibling variant's rows too, not just the\n * target variant's. Callers that fetch by product id alone, without this follow-up filter, risk\n * pricing a line off a sibling variant's row.\n */\nexport function filterRowsForVariant(rows: PriceRow[], variantId: string | null): PriceRow[] {\n return rows.filter((row) => {\n const rowVariantId = resolvePriceVariantId(row)\n return rowVariantId === null || rowVariantId === variantId\n })\n}\n\nexport function resolvePriceOfferId(row: PriceRow): string | null {\n if (!row.offer) return null\n return typeof row.offer === 'string' ? row.offer : row.offer.id\n}\n\nexport function resolvePriceChannelId(row: PriceRow): string | null {\n if (!row.offer) return row.channelId ?? null\n if (typeof row.offer === 'string') return row.channelId ?? null\n return row.channelId ?? row.offer.channelId ?? null\n}\n\nexport function resolvePriceKindId(row: PriceRow): string | null {\n if (!row.priceKind) return null\n return typeof row.priceKind === 'string' ? row.priceKind : row.priceKind.id ?? null\n}\n\nfunction resolveContextCustomerIds(ctx: PricingContext): string[] {\n return ctx.customerIds ?? (ctx.customerId ? [ctx.customerId] : [])\n}\n\nfunction isPromotionalPriceKindRow(row: PriceRow): boolean {\n return Boolean(row.priceKind && typeof row.priceKind !== 'string' && row.priceKind.isPromotion === true)\n}\n\nfunction matchesPriceKind(row: PriceRow, priceKindId: string): boolean {\n return resolvePriceKindId(row) === priceKindId || isPromotionalPriceKindRow(row)\n}\n\nexport function resolvePriceKindCode(row: PriceRow): string {\n if (row.priceKind) {\n if (typeof row.priceKind === 'string') return row.priceKind\n return row.priceKind.code ?? row.kind ?? ''\n }\n return row.kind ?? ''\n}\n\nfunction matchesContext(row: PriceRow, ctx: PricingContext): boolean {\n const { quantity, date } = ctx\n if (row.minQuantity && quantity < row.minQuantity) return false\n if (row.maxQuantity && quantity > row.maxQuantity) return false\n if (row.startsAt && date < row.startsAt) return false\n if (row.endsAt && date >= row.endsAt) return false\n if (row.channelId || (row.offer && resolvePriceChannelId(row))) {\n const channel = resolvePriceChannelId(row)\n if (channel && ctx.channelId && channel !== ctx.channelId) return false\n if (channel && !ctx.channelId) return false\n }\n if (row.userId && ctx.userId !== row.userId) return false\n if (row.userGroupId && ctx.userGroupId !== row.userGroupId) return false\n if (row.customerId && !resolveContextCustomerIds(ctx).includes(row.customerId)) return false\n if (row.customerGroupId) {\n const candidateGroupIds = ctx.customerGroupIds ?? (ctx.customerGroupId ? [ctx.customerGroupId] : [])\n if (!candidateGroupIds.includes(row.customerGroupId)) return false\n }\n if (ctx.currencyCode && row.currencyCode !== ctx.currencyCode) return false\n if (ctx.priceKindId && !matchesPriceKind(row, ctx.priceKindId)) return false\n if (ctx.offerId && resolvePriceOfferId(row) && resolvePriceOfferId(row) !== ctx.offerId) return false\n return true\n}\n\n/**\n * The narrowing half of `matchesContext` expressed as a MikroORM filter, for\n * callers that fetch `CatalogProductPrice` rows by product/variant today and\n * would otherwise load every contracted customer's rows just to discard\n * almost all of them (per-customer pricing at scale \u2014 see\n * `.ai/specs/2026-08-21-pricing-engine.md` \u00A7 Row narrowing).\n *\n * Covers only the dimensions that are plain column comparisons: customer,\n * customer-group, user, user-group, channel, currency, price kind (the resolved\n * kind or any promotional kind, via a `priceKind.isPromotion` relation clause). Quantity bounds,\n * validity windows, and offer-derived channel resolution stay in\n * `matchesContext` \u2014 they are cheap over an already-narrowed set and are not\n * expressible as one column predicate (offer's own `channelId` lives on a\n * different table).\n *\n * **Invariant (one-directional, this is the whole point of the helper):**\n * `matchesContext(row, ctx)` implies this filter admits `row`. The filter\n * MAY admit rows `matchesContext` later rejects (a slightly wide fetch) \u2014\n * it MUST NEVER exclude a row `matchesContext` would have accepted, because\n * that is a silently wrong price with no error anywhere. Verified by the\n * property-based test in `lib/__tests__/buildPriceRowFilter.property.test.ts`.\n */\nexport function buildPriceRowFilter(ctx: PricingContext): FilterQuery<CatalogProductPrice> {\n const customerGroupIds = ctx.customerGroupIds ?? (ctx.customerGroupId ? [ctx.customerGroupId] : [])\n const customerIds = resolveContextCustomerIds(ctx)\n\n const clauses: FilterQuery<CatalogProductPrice>[] = [\n customerIds.length\n ? { $or: [{ customerId: null }, { customerId: { $in: customerIds } }] }\n : { customerId: null },\n customerGroupIds.length\n ? { $or: [{ customerGroupId: null }, { customerGroupId: { $in: customerGroupIds } }] }\n : { customerGroupId: null },\n ctx.userId\n ? { $or: [{ userId: null }, { userId: ctx.userId }] }\n : { userId: null },\n ctx.userGroupId\n ? { $or: [{ userGroupId: null }, { userGroupId: ctx.userGroupId }] }\n : { userGroupId: null },\n ctx.channelId\n ? { $or: [{ channelId: null }, { channelId: ctx.channelId }] }\n : { channelId: null },\n ]\n\n if (ctx.currencyCode) {\n clauses.push({ currencyCode: ctx.currencyCode })\n }\n\n if (ctx.priceKindId) {\n clauses.push({ $or: [{ priceKind: ctx.priceKindId }, { priceKind: { isPromotion: true } }] })\n }\n\n return { $and: clauses } as FilterQuery<CatalogProductPrice>\n}\n\nfunction scorePrice(row: PriceRow): number {\n const resolvedKind = resolvePriceKindCode(row)\n let score = 0\n if (resolvedKind === 'custom') score += 5\n else if (resolvedKind === 'tier') score += 3\n else if (resolvedKind === 'promotion' || row.priceKind?.isPromotion) score += 4\n else score += 2\n if (row.variant) score += 8\n if (row.offer) score += 6\n if (row.channelId) score += 5\n if (row.userId) score += 5\n if (row.userGroupId) score += 4\n if (row.customerId) score += 4\n if (row.customerGroupId) score += 3\n if (row.minQuantity && row.minQuantity > 1) score += 1\n return score\n}\n\nexport function selectBestPrice(rows: PriceRow[], ctx: PricingContext): PriceRow | null {\n const candidates = rows.filter((row) => matchesContext(row, ctx))\n if (!candidates.length) return null\n const customerIds = resolveContextCustomerIds(ctx)\n const customerRank = (row: PriceRow): number => (row.customerId ? customerIds.indexOf(row.customerId) : -1)\n candidates.sort((a, b) => {\n const scoreDiff = scorePrice(b) - scorePrice(a)\n if (scoreDiff !== 0) return scoreDiff\n const rankA = customerRank(a)\n const rankB = customerRank(b)\n if (rankA >= 0 && rankB >= 0 && rankA !== rankB) return rankA - rankB\n const startA = a.startsAt ? a.startsAt.getTime() : 0\n const startB = b.startsAt ? b.startsAt.getTime() : 0\n if (startA !== startB) return startB - startA\n // minQuantity tie-break is direction-dependent on the resolved kind.\n // Within the same kind we pick the more specific tier (higher minQuantity wins \u2014 issue #1706).\n // Across kinds we keep the pre-#1706 ascending order so a row whose kind has a higher\n // scoreBase still wins when scorePrice's \"+1 for minQuantity > 1\" bonus produces a\n // cross-kind collision (e.g. promotion[minQty=1]=4 vs tier[minQty=3]=4).\n if (resolvePriceKindCode(a) === resolvePriceKindCode(b)) {\n return (b.minQuantity ?? 1) - (a.minQuantity ?? 1)\n }\n return (a.minQuantity ?? 1) - (b.minQuantity ?? 1)\n })\n return candidates[0]\n}\n\nexport type CatalogPricingResolver = (\n rows: PriceRow[],\n ctx: PricingContext\n) => PriceRow | null | undefined | Promise<PriceRow | null | undefined>\n\ntype RegisteredResolver = {\n id?: string\n resolver: CatalogPricingResolver\n priority: number\n}\n\ntype PricingRegistryState = {\n resolvers: RegisteredResolver[]\n}\n\n// `globalThis`-keyed so the registry survives duplicated module instances\n// (a standalone app built from this monorepo, or any dev/build setup that\n// loads `catalog` through more than one chunk) \u2014 the same failure class\n// already fixed once for the ORM entity registry\n// (see `packages/shared/src/modules/integrations/types.ts` for the identical\n// pattern). A module-local array is invisible across instances: a resolver\n// registered from one instance would silently never run for resolution\n// happening in another.\nconst GLOBAL_PRICING_REGISTRY_KEY = '__openMercatoCatalogPricingRegistry__' as const\n\ntype GlobalPricingRegistry = typeof globalThis & {\n [GLOBAL_PRICING_REGISTRY_KEY]?: PricingRegistryState\n}\n\nfunction getPricingRegistryState(): PricingRegistryState {\n const globalRegistry = globalThis as GlobalPricingRegistry\n if (!globalRegistry[GLOBAL_PRICING_REGISTRY_KEY]) {\n globalRegistry[GLOBAL_PRICING_REGISTRY_KEY] = { resolvers: [] }\n }\n return globalRegistry[GLOBAL_PRICING_REGISTRY_KEY]\n}\n\nfunction sortResolvers(state: PricingRegistryState): void {\n // `Array.prototype.sort` is stable (ES2019+): resolvers registered at the\n // same priority keep their registration order. This is the documented,\n // tested same-priority tie-break \u2014 see `catalog/AGENTS.md` \u00A7 Price\n // selection order and the registry test in `lib/__tests__/pricing.test.ts`.\n state.resolvers.sort((a, b) => b.priority - a.priority)\n}\n\nexport function registerCatalogPricingResolver(\n resolver: CatalogPricingResolver,\n options?: { priority?: number; id?: string }\n): void {\n const state = getPricingRegistryState()\n const id = options?.id\n // Dedupe by id so a documented extension point stays HMR-safe for every\n // future registrant, not just one caller: re-registering the same id is a\n // no-op instead of appending a duplicate entry on every module reload.\n if (id && state.resolvers.some((entry) => entry.id === id)) return\n state.resolvers.push({ id, resolver, priority: options?.priority ?? 0 })\n sortResolvers(state)\n}\n\nexport function resetCatalogPricingResolvers(): void {\n getPricingRegistryState().resolvers.splice(0)\n}\n\nexport async function resolveCatalogPrice(\n rows: PriceRow[],\n ctx: PricingContext,\n options?: { eventBus?: EventBus | null }\n): Promise<PriceRow | null> {\n let workingRows = rows\n let workingContext = ctx\n const eventBus = options?.eventBus ?? null\n let resolved: PriceRow | null | undefined\n\n if (eventBus) {\n await eventBus.emitEvent('catalog.pricing.resolve.before', {\n rows: workingRows,\n context: workingContext,\n setRows(next: PriceRow[]) {\n if (Array.isArray(next)) workingRows = next\n },\n setContext(next: PricingContext) {\n if (next) workingContext = next\n },\n setResult(next: PriceRow | null) {\n resolved = next\n },\n })\n if (resolved !== undefined) return resolved\n }\n\n for (const { resolver } of getPricingRegistryState().resolvers) {\n const result = await resolver(workingRows, workingContext)\n if (result !== undefined) {\n resolved = result ?? null\n break\n }\n }\n\n if (resolved === undefined) {\n resolved = selectBestPrice(workingRows, workingContext)\n }\n\n if (eventBus) {\n await eventBus.emitEvent('catalog.pricing.resolve.after', {\n rows: workingRows,\n context: workingContext,\n result: resolved ?? null,\n setResult(next: PriceRow | null) {\n resolved = next\n },\n })\n }\n\n return resolved ?? null\n}\n\nexport async function resolveCatalogPriceBatch(\n entries: Array<{ rows: PriceRow[]; context: PricingContext }>,\n options?: { eventBus?: EventBus | null }\n): Promise<Array<PriceRow | null>> {\n return Promise.all(\n entries.map(({ rows, context }) => resolveCatalogPrice(rows, context, options))\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.10.1-develop.
|
|
3
|
+
"version": "0.10.1-develop.7803.1.e99e862b4e",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -266,16 +266,16 @@
|
|
|
266
266
|
"zod": "^4.4.3"
|
|
267
267
|
},
|
|
268
268
|
"peerDependencies": {
|
|
269
|
-
"@open-mercato/ai-assistant": "0.10.1-develop.
|
|
270
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
271
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
269
|
+
"@open-mercato/ai-assistant": "0.10.1-develop.7803.1.e99e862b4e",
|
|
270
|
+
"@open-mercato/shared": "0.10.1-develop.7803.1.e99e862b4e",
|
|
271
|
+
"@open-mercato/ui": "0.10.1-develop.7803.1.e99e862b4e",
|
|
272
272
|
"react": "^19.0.0",
|
|
273
273
|
"react-dom": "^19.0.0"
|
|
274
274
|
},
|
|
275
275
|
"devDependencies": {
|
|
276
|
-
"@open-mercato/ai-assistant": "0.10.1-develop.
|
|
277
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
278
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
276
|
+
"@open-mercato/ai-assistant": "0.10.1-develop.7803.1.e99e862b4e",
|
|
277
|
+
"@open-mercato/shared": "0.10.1-develop.7803.1.e99e862b4e",
|
|
278
|
+
"@open-mercato/ui": "0.10.1-develop.7803.1.e99e862b4e",
|
|
279
279
|
"@testing-library/dom": "^10.4.1",
|
|
280
280
|
"@testing-library/jest-dom": "^7.0.1",
|
|
281
281
|
"@testing-library/react": "^16.3.3",
|
|
@@ -102,7 +102,7 @@ function matchesContext(row: PriceRow, ctx: PricingContext): boolean {
|
|
|
102
102
|
if (row.minQuantity && quantity < row.minQuantity) return false
|
|
103
103
|
if (row.maxQuantity && quantity > row.maxQuantity) return false
|
|
104
104
|
if (row.startsAt && date < row.startsAt) return false
|
|
105
|
-
if (row.endsAt && date
|
|
105
|
+
if (row.endsAt && date >= row.endsAt) return false
|
|
106
106
|
if (row.channelId || (row.offer && resolvePriceChannelId(row))) {
|
|
107
107
|
const channel = resolvePriceChannelId(row)
|
|
108
108
|
if (channel && ctx.channelId && channel !== ctx.channelId) return false
|