@open-mercato/core 0.6.5-develop.4446.1.4c5c71bfc2 → 0.6.5-develop.4458.1.3fcfd7d565

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.
@@ -62,6 +62,9 @@ function selectBestPrice(rows, ctx) {
62
62
  const startA = a.startsAt ? a.startsAt.getTime() : 0;
63
63
  const startB = b.startsAt ? b.startsAt.getTime() : 0;
64
64
  if (startA !== startB) return startB - startA;
65
+ if (resolvePriceKindCode(a) === resolvePriceKindCode(b)) {
66
+ return (b.minQuantity ?? 1) - (a.minQuantity ?? 1);
67
+ }
65
68
  return (a.minQuantity ?? 1) - (b.minQuantity ?? 1);
66
69
  });
67
70
  return candidates[0];
@@ -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 {\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 customerGroupId?: string | null\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\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 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 && ctx.customerId !== row.customerId) return false\n if (row.customerGroupId && ctx.customerGroupId !== row.customerGroupId) return false\n if (ctx.offerId && resolvePriceOfferId(row) && resolvePriceOfferId(row) !== ctx.offerId) return false\n return true\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 candidates.sort((a, b) => {\n const scoreDiff = scorePrice(b) - scorePrice(a)\n if (scoreDiff !== 0) return scoreDiff\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 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 resolver: CatalogPricingResolver\n priority: number\n}\n\nconst pricingResolvers: RegisteredResolver[] = []\n\nfunction sortResolvers(): void {\n pricingResolvers.sort((a, b) => b.priority - a.priority)\n}\n\nexport function registerCatalogPricingResolver(\n resolver: CatalogPricingResolver,\n options?: { priority?: number }\n): void {\n pricingResolvers.push({ resolver, priority: options?.priority ?? 0 })\n sortResolvers()\n}\n\nexport function resetCatalogPricingResolvers(): void {\n pricingResolvers.splice(0, pricingResolvers.length)\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 pricingResolvers) {\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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",
4
+ "sourcesContent": ["import type { EventBus } from '@open-mercato/events'\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 customerGroupId?: string | null\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\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 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 && ctx.customerId !== row.customerId) return false\n if (row.customerGroupId && ctx.customerGroupId !== row.customerGroupId) return false\n if (ctx.offerId && resolvePriceOfferId(row) && resolvePriceOfferId(row) !== ctx.offerId) return false\n return true\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 candidates.sort((a, b) => {\n const scoreDiff = scorePrice(b) - scorePrice(a)\n if (scoreDiff !== 0) return scoreDiff\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 resolver: CatalogPricingResolver\n priority: number\n}\n\nconst pricingResolvers: RegisteredResolver[] = []\n\nfunction sortResolvers(): void {\n pricingResolvers.sort((a, b) => b.priority - a.priority)\n}\n\nexport function registerCatalogPricingResolver(\n resolver: CatalogPricingResolver,\n options?: { priority?: number }\n): void {\n pricingResolvers.push({ resolver, priority: options?.priority ?? 0 })\n sortResolvers()\n}\n\nexport function resetCatalogPricingResolvers(): void {\n pricingResolvers.splice(0, pricingResolvers.length)\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 pricingResolvers) {\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.6.5-develop.4446.1.4c5c71bfc2",
3
+ "version": "0.6.5-develop.4458.1.3fcfd7d565",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -244,16 +244,16 @@
244
244
  "zod": "^4.4.3"
245
245
  },
246
246
  "peerDependencies": {
247
- "@open-mercato/ai-assistant": "0.6.5-develop.4446.1.4c5c71bfc2",
248
- "@open-mercato/shared": "0.6.5-develop.4446.1.4c5c71bfc2",
249
- "@open-mercato/ui": "0.6.5-develop.4446.1.4c5c71bfc2",
247
+ "@open-mercato/ai-assistant": "0.6.5-develop.4458.1.3fcfd7d565",
248
+ "@open-mercato/shared": "0.6.5-develop.4458.1.3fcfd7d565",
249
+ "@open-mercato/ui": "0.6.5-develop.4458.1.3fcfd7d565",
250
250
  "react": "^19.0.0",
251
251
  "react-dom": "^19.0.0"
252
252
  },
253
253
  "devDependencies": {
254
- "@open-mercato/ai-assistant": "0.6.5-develop.4446.1.4c5c71bfc2",
255
- "@open-mercato/shared": "0.6.5-develop.4446.1.4c5c71bfc2",
256
- "@open-mercato/ui": "0.6.5-develop.4446.1.4c5c71bfc2",
254
+ "@open-mercato/ai-assistant": "0.6.5-develop.4458.1.3fcfd7d565",
255
+ "@open-mercato/shared": "0.6.5-develop.4458.1.3fcfd7d565",
256
+ "@open-mercato/ui": "0.6.5-develop.4458.1.3fcfd7d565",
257
257
  "@testing-library/dom": "^10.4.1",
258
258
  "@testing-library/jest-dom": "^6.9.1",
259
259
  "@testing-library/react": "^16.3.1",
@@ -45,6 +45,16 @@ registerCatalogPricingResolver(myResolver, { priority: 10 })
45
45
 
46
46
  The default pipeline emits `catalog.pricing.resolve.before|after` events.
47
47
 
48
+ ### Price selection order
49
+
50
+ When multiple price rows match the same context, `selectBestPrice` resolves ties in this order:
51
+
52
+ 1. **Score** (descending) — `custom` > `promotion` > `tier` > `regular`, plus additional points for variant, offer, channel, user, group, and customer scoping. See `scorePrice` for the full rubric.
53
+ 2. **`startsAt`** (descending) — when scores tie, the row with the more recent `startsAt` wins.
54
+ 3. **`minQuantity`** — direction depends on whether the tied rows share the same resolved kind:
55
+ - **Same kind** (e.g. tier vs tier): **descending** — the row with the larger `minQuantity` wins. This implements the standard volume-discount semantic: for `qty=50` with tiers `minQty=10` ($9) and `minQty=50` ($8), the `minQty=50` row applies (issue #1706).
56
+ - **Different kinds** (e.g. promotion vs tier): **ascending** — the row with the smaller `minQuantity` wins. The `+1 for minQuantity > 1` bonus in `scorePrice` can pull a lower-base kind up to the same total as a higher-base kind (e.g. tier `minQty=3` = 3+1 vs promotion `minQty=1` = 4). Ascending across kinds preserves the kind precedence in those collisions.
57
+
48
58
  ## Data Model Constraints
49
59
 
50
60
  - **Products** — core entities with media and descriptions. MUST have at least a name
@@ -95,6 +95,14 @@ export function selectBestPrice(rows: PriceRow[], ctx: PricingContext): PriceRow
95
95
  const startA = a.startsAt ? a.startsAt.getTime() : 0
96
96
  const startB = b.startsAt ? b.startsAt.getTime() : 0
97
97
  if (startA !== startB) return startB - startA
98
+ // minQuantity tie-break is direction-dependent on the resolved kind.
99
+ // Within the same kind we pick the more specific tier (higher minQuantity wins — issue #1706).
100
+ // Across kinds we keep the pre-#1706 ascending order so a row whose kind has a higher
101
+ // scoreBase still wins when scorePrice's "+1 for minQuantity > 1" bonus produces a
102
+ // cross-kind collision (e.g. promotion[minQty=1]=4 vs tier[minQty=3]=4).
103
+ if (resolvePriceKindCode(a) === resolvePriceKindCode(b)) {
104
+ return (b.minQuantity ?? 1) - (a.minQuantity ?? 1)
105
+ }
98
106
  return (a.minQuantity ?? 1) - (b.minQuantity ?? 1)
99
107
  })
100
108
  return candidates[0]