@endora-commerce/mod-price-lists 0.100.0
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/LICENSE +21 -0
- package/README.md +59 -0
- package/dist/admin/components/ApplicationRuleBuilder.d.ts +24 -0
- package/dist/admin/components/ApplicationRuleBuilder.d.ts.map +1 -0
- package/dist/admin/components/ApplicationRuleBuilder.js +249 -0
- package/dist/admin/components/ApplicationRuleBuilder.js.map +1 -0
- package/dist/admin/components/BracketGrid.d.ts +29 -0
- package/dist/admin/components/BracketGrid.d.ts.map +1 -0
- package/dist/admin/components/BracketGrid.js +252 -0
- package/dist/admin/components/BracketGrid.js.map +1 -0
- package/dist/admin/components/DisplayModeOverrideRow.d.ts +46 -0
- package/dist/admin/components/DisplayModeOverrideRow.d.ts.map +1 -0
- package/dist/admin/components/DisplayModeOverrideRow.js +109 -0
- package/dist/admin/components/DisplayModeOverrideRow.js.map +1 -0
- package/dist/admin/components/LinkedPriceListsPanel.d.ts +55 -0
- package/dist/admin/components/LinkedPriceListsPanel.d.ts.map +1 -0
- package/dist/admin/components/LinkedPriceListsPanel.js +131 -0
- package/dist/admin/components/LinkedPriceListsPanel.js.map +1 -0
- package/dist/admin/index.d.ts +34 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +119 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/pages/DisplayModeOverridesPage.d.ts +25 -0
- package/dist/admin/pages/DisplayModeOverridesPage.d.ts.map +1 -0
- package/dist/admin/pages/DisplayModeOverridesPage.js +266 -0
- package/dist/admin/pages/DisplayModeOverridesPage.js.map +1 -0
- package/dist/admin/pages/PriceListDetailPage.d.ts +23 -0
- package/dist/admin/pages/PriceListDetailPage.d.ts.map +1 -0
- package/dist/admin/pages/PriceListDetailPage.js +421 -0
- package/dist/admin/pages/PriceListDetailPage.js.map +1 -0
- package/dist/admin/pages/PriceListsPage.d.ts +17 -0
- package/dist/admin/pages/PriceListsPage.d.ts.map +1 -0
- package/dist/admin/pages/PriceListsPage.js +198 -0
- package/dist/admin/pages/PriceListsPage.js.map +1 -0
- package/dist/admin/zones/CategoryDisplayMode.d.ts +27 -0
- package/dist/admin/zones/CategoryDisplayMode.d.ts.map +1 -0
- package/dist/admin/zones/CategoryDisplayMode.js +11 -0
- package/dist/admin/zones/CategoryDisplayMode.js.map +1 -0
- package/dist/admin/zones/OrganizationDisplayMode.d.ts +36 -0
- package/dist/admin/zones/OrganizationDisplayMode.d.ts.map +1 -0
- package/dist/admin/zones/OrganizationDisplayMode.js +12 -0
- package/dist/admin/zones/OrganizationDisplayMode.js.map +1 -0
- package/dist/admin/zones/ProductLinkedPriceLists.d.ts +16 -0
- package/dist/admin/zones/ProductLinkedPriceLists.d.ts.map +1 -0
- package/dist/admin/zones/ProductLinkedPriceLists.js +7 -0
- package/dist/admin/zones/ProductLinkedPriceLists.js.map +1 -0
- package/dist/backend/entities/price-display-mode-override.entity.d.ts +23 -0
- package/dist/backend/entities/price-display-mode-override.entity.d.ts.map +1 -0
- package/dist/backend/entities/price-display-mode-override.entity.js +58 -0
- package/dist/backend/entities/price-display-mode-override.entity.js.map +1 -0
- package/dist/backend/entities/price-list-price-bracket.entity.d.ts +26 -0
- package/dist/backend/entities/price-list-price-bracket.entity.d.ts.map +1 -0
- package/dist/backend/entities/price-list-price-bracket.entity.js +73 -0
- package/dist/backend/entities/price-list-price-bracket.entity.js.map +1 -0
- package/dist/backend/entities/price-list-product.entity.d.ts +15 -0
- package/dist/backend/entities/price-list-product.entity.d.ts.map +1 -0
- package/dist/backend/entities/price-list-product.entity.js +43 -0
- package/dist/backend/entities/price-list-product.entity.js.map +1 -0
- package/dist/backend/entities/price-list.entity.d.ts +34 -0
- package/dist/backend/entities/price-list.entity.d.ts.map +1 -0
- package/dist/backend/entities/price-list.entity.js +115 -0
- package/dist/backend/entities/price-list.entity.js.map +1 -0
- package/dist/backend/index.d.ts +125 -0
- package/dist/backend/index.d.ts.map +1 -0
- package/dist/backend/index.js +179 -0
- package/dist/backend/index.js.map +1 -0
- package/dist/backend/plugin.d.ts +68 -0
- package/dist/backend/plugin.d.ts.map +1 -0
- package/dist/backend/plugin.js +88 -0
- package/dist/backend/plugin.js.map +1 -0
- package/dist/backend/routes.d.ts +40 -0
- package/dist/backend/routes.d.ts.map +1 -0
- package/dist/backend/routes.js +447 -0
- package/dist/backend/routes.js.map +1 -0
- package/dist/backend/routes.storefront.d.ts +47 -0
- package/dist/backend/routes.storefront.d.ts.map +1 -0
- package/dist/backend/routes.storefront.js +131 -0
- package/dist/backend/routes.storefront.js.map +1 -0
- package/dist/backend/services/application-rule-evaluator.d.ts +25 -0
- package/dist/backend/services/application-rule-evaluator.d.ts.map +1 -0
- package/dist/backend/services/application-rule-evaluator.js +2 -0
- package/dist/backend/services/application-rule-evaluator.js.map +1 -0
- package/dist/backend/services/audit-references.d.ts +11 -0
- package/dist/backend/services/audit-references.d.ts.map +1 -0
- package/dist/backend/services/audit-references.js +23 -0
- package/dist/backend/services/audit-references.js.map +1 -0
- package/dist/backend/services/default-price-list-migration.d.ts +70 -0
- package/dist/backend/services/default-price-list-migration.d.ts.map +1 -0
- package/dist/backend/services/default-price-list-migration.js +177 -0
- package/dist/backend/services/default-price-list-migration.js.map +1 -0
- package/dist/backend/services/display-mode-resolver.d.ts +78 -0
- package/dist/backend/services/display-mode-resolver.d.ts.map +1 -0
- package/dist/backend/services/display-mode-resolver.js +71 -0
- package/dist/backend/services/display-mode-resolver.js.map +1 -0
- package/dist/backend/services/listing-price-chain.d.ts +26 -0
- package/dist/backend/services/listing-price-chain.d.ts.map +1 -0
- package/dist/backend/services/listing-price-chain.js +55 -0
- package/dist/backend/services/listing-price-chain.js.map +1 -0
- package/dist/backend/services/price-bracket-resolver.d.ts +24 -0
- package/dist/backend/services/price-bracket-resolver.d.ts.map +1 -0
- package/dist/backend/services/price-bracket-resolver.js +16 -0
- package/dist/backend/services/price-bracket-resolver.js.map +1 -0
- package/dist/backend/services/price-list-candidate-vector.d.ts +116 -0
- package/dist/backend/services/price-list-candidate-vector.d.ts.map +1 -0
- package/dist/backend/services/price-list-candidate-vector.js +133 -0
- package/dist/backend/services/price-list-candidate-vector.js.map +1 -0
- package/dist/backend/services/price-list-currency-reference.d.ts +12 -0
- package/dist/backend/services/price-list-currency-reference.d.ts.map +1 -0
- package/dist/backend/services/price-list-currency-reference.js +24 -0
- package/dist/backend/services/price-list-currency-reference.js.map +1 -0
- package/dist/backend/services/price-list-read-port.d.ts +29 -0
- package/dist/backend/services/price-list-read-port.d.ts.map +1 -0
- package/dist/backend/services/price-list-read-port.js +64 -0
- package/dist/backend/services/price-list-read-port.js.map +1 -0
- package/dist/backend/services/price-list-resolver.d.ts +26 -0
- package/dist/backend/services/price-list-resolver.d.ts.map +1 -0
- package/dist/backend/services/price-list-resolver.js +37 -0
- package/dist/backend/services/price-list-resolver.js.map +1 -0
- package/dist/backend/services/price-list-service.d.ts +431 -0
- package/dist/backend/services/price-list-service.d.ts.map +1 -0
- package/dist/backend/services/price-list-service.js +1238 -0
- package/dist/backend/services/price-list-service.js.map +1 -0
- package/dist/backend/services/price-list-status-worker.d.ts +28 -0
- package/dist/backend/services/price-list-status-worker.d.ts.map +1 -0
- package/dist/backend/services/price-list-status-worker.js +38 -0
- package/dist/backend/services/price-list-status-worker.js.map +1 -0
- package/dist/backend/services/pricing-cache.d.ts +53 -0
- package/dist/backend/services/pricing-cache.d.ts.map +1 -0
- package/dist/backend/services/pricing-cache.js +88 -0
- package/dist/backend/services/pricing-cache.js.map +1 -0
- package/dist/backend/services/pricing-service.d.ts +304 -0
- package/dist/backend/services/pricing-service.d.ts.map +1 -0
- package/dist/backend/services/pricing-service.interface.d.ts +151 -0
- package/dist/backend/services/pricing-service.interface.d.ts.map +1 -0
- package/dist/backend/services/pricing-service.interface.js +10 -0
- package/dist/backend/services/pricing-service.interface.js.map +1 -0
- package/dist/backend/services/pricing-service.js +803 -0
- package/dist/backend/services/pricing-service.js.map +1 -0
- package/dist/backend/services/unit-price-ordering.d.ts +121 -0
- package/dist/backend/services/unit-price-ordering.d.ts.map +1 -0
- package/dist/backend/services/unit-price-ordering.js +164 -0
- package/dist/backend/services/unit-price-ordering.js.map +1 -0
- package/dist/manifest.d.ts +218 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +228 -0
- package/dist/manifest.js.map +1 -0
- package/dist/migrations/20260426T075235_price_lists_pricing_init.d.ts +24 -0
- package/dist/migrations/20260426T075235_price_lists_pricing_init.d.ts.map +1 -0
- package/dist/migrations/20260426T075235_price_lists_pricing_init.js +89 -0
- package/dist/migrations/20260426T075235_price_lists_pricing_init.js.map +1 -0
- package/dist/migrations/20260504T125655_price_lists_engine.d.ts +6 -0
- package/dist/migrations/20260504T125655_price_lists_engine.d.ts.map +1 -0
- package/dist/migrations/20260504T125655_price_lists_engine.js +319 -0
- package/dist/migrations/20260504T125655_price_lists_engine.js.map +1 -0
- package/dist/migrations/20260817T055457_price_lists_single_system_price_list.d.ts +15 -0
- package/dist/migrations/20260817T055457_price_lists_single_system_price_list.d.ts.map +1 -0
- package/dist/migrations/20260817T055457_price_lists_single_system_price_list.js +58 -0
- package/dist/migrations/20260817T055457_price_lists_single_system_price_list.js.map +1 -0
- package/dist/migrations/20260821T135907_price_lists_unit_price_amount_index.d.ts +40 -0
- package/dist/migrations/20260821T135907_price_lists_unit_price_amount_index.d.ts.map +1 -0
- package/dist/migrations/20260821T135907_price_lists_unit_price_amount_index.js +46 -0
- package/dist/migrations/20260821T135907_price_lists_unit_price_amount_index.js.map +1 -0
- package/dist/migrations/index.d.ts +30 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/migrations/index.js +35 -0
- package/dist/migrations/index.js.map +1 -0
- package/docs/price_lists.md +278 -0
- package/i18n/en.json +14 -0
- package/i18n/pl.json +14 -0
- package/package.json +99 -0
- package/tailwind.css +14 -0
|
@@ -0,0 +1,803 @@
|
|
|
1
|
+
import { PriceList } from '../entities/price-list.entity.js';
|
|
2
|
+
import { PriceListPriceBracket } from '../entities/price-list-price-bracket.entity.js';
|
|
3
|
+
import { evaluateApplicationRule, } from './application-rule-evaluator.js';
|
|
4
|
+
import { pickPriorityChain, } from './price-list-resolver.js';
|
|
5
|
+
import { resolvePriceBracket, } from './price-bracket-resolver.js';
|
|
6
|
+
import { listingPriceFrom } from './listing-price-chain.js';
|
|
7
|
+
import { buildCandidateVector, } from './price-list-candidate-vector.js';
|
|
8
|
+
import { buildUnitPriceMergeQuery, compareOrderRows, uuidArrayLiteral, } from './unit-price-ordering.js';
|
|
9
|
+
/**
|
|
10
|
+
* The engine's answer, as a charged line (FR-034): Sale when present, Base
|
|
11
|
+
* otherwise, `null` when nothing on any matching list prices the product.
|
|
12
|
+
*
|
|
13
|
+
* Extracted from `resolveLinePrice` so the batched listing path applies exactly
|
|
14
|
+
* the same rule to the engine results it assembles — the sale-wins step is one
|
|
15
|
+
* of the places a second implementation could have quietly disagreed.
|
|
16
|
+
*/
|
|
17
|
+
export function lineFromEngine(out) {
|
|
18
|
+
if (out.sale) {
|
|
19
|
+
return {
|
|
20
|
+
amount: out.sale.bracket.amount,
|
|
21
|
+
currency: out.currencyCode,
|
|
22
|
+
priceListId: out.sale.listId,
|
|
23
|
+
isSale: true,
|
|
24
|
+
bracketStartQuantity: out.sale.bracket.minQuantity,
|
|
25
|
+
displayMode: out.displayMode,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
if (!out.base.bracket)
|
|
29
|
+
return null;
|
|
30
|
+
return {
|
|
31
|
+
amount: out.base.bracket.amount,
|
|
32
|
+
currency: out.currencyCode,
|
|
33
|
+
priceListId: out.base.listId,
|
|
34
|
+
isSale: false,
|
|
35
|
+
bracketStartQuantity: out.base.bracket.minQuantity,
|
|
36
|
+
displayMode: out.displayMode,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export class PricingService {
|
|
40
|
+
emFactory;
|
|
41
|
+
cache;
|
|
42
|
+
resolveOrgChain;
|
|
43
|
+
targetReads;
|
|
44
|
+
constructor(emFactory, cache,
|
|
45
|
+
/**
|
|
46
|
+
* Feature 056 — resolves the acting org's inheritance chain (nearest-first,
|
|
47
|
+
* `[orgId, ...ancestorIds]`) so a descendant inherits an ancestor's org-named
|
|
48
|
+
* price list. When absent, the chain is `[orgId]` (flat behavior, byte-for-byte).
|
|
49
|
+
*/
|
|
50
|
+
resolveOrgChain,
|
|
51
|
+
/**
|
|
52
|
+
* Feature 075 Phase C — forwarded verbatim to the `PriceListService` this
|
|
53
|
+
* engine builds for the display-mode chain, whose category step reaches
|
|
54
|
+
* `catalog` over its read port. Absent, that step refuses rather than
|
|
55
|
+
* treating every category as unknown.
|
|
56
|
+
*/
|
|
57
|
+
targetReads) {
|
|
58
|
+
this.emFactory = emFactory;
|
|
59
|
+
this.cache = cache;
|
|
60
|
+
this.resolveOrgChain = resolveOrgChain;
|
|
61
|
+
this.targetReads = targetReads;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* `catalog`'s category read port, or a refusal — the same refusal
|
|
65
|
+
* `PriceListService.targets()` makes, for the same reason.
|
|
66
|
+
*
|
|
67
|
+
* Every category question this engine asks is asked here: which categories a
|
|
68
|
+
* product is in, and which products a category holds. Skipping one because
|
|
69
|
+
* nothing was wired would turn "this composition cannot reach `catalog`" into
|
|
70
|
+
* an answer — "this product is in no category", "this category rule applies
|
|
71
|
+
* to everything" — and both of those price a line the rule does not cover.
|
|
72
|
+
*/
|
|
73
|
+
#categoryRead() {
|
|
74
|
+
const categoryRead = this.targetReads?.catalogCategoryRead;
|
|
75
|
+
if (!categoryRead) {
|
|
76
|
+
throw new Error('PricingService: this resolution asks `catalog` about category membership and this ' +
|
|
77
|
+
"composition constructed the engine without `catalog`'s category read port.");
|
|
78
|
+
}
|
|
79
|
+
return categoryRead;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* `(product, category)` memberships for a set of products, read through
|
|
83
|
+
* `catalog`'s port (feature 075 / D-87) — the shape both resolution paths
|
|
84
|
+
* take, one call for the whole set.
|
|
85
|
+
*
|
|
86
|
+
* **Structural, deliberately** — no `activeOnly`. `evaluateApplicationRule`'s
|
|
87
|
+
* `category` criterion tests the raw membership, so narrowing to live
|
|
88
|
+
* categories here would price a product against a rule its memberships no
|
|
89
|
+
* longer satisfy the moment an operator deactivates a category.
|
|
90
|
+
*
|
|
91
|
+
* Every product asked about gets an entry — an uncategorised one answers an
|
|
92
|
+
* empty set rather than a missing key, which is what `resolveEngine`'s
|
|
93
|
+
* single-product read means by "no rows".
|
|
94
|
+
*/
|
|
95
|
+
async #categoryMemberships(productIds) {
|
|
96
|
+
const out = new Map();
|
|
97
|
+
for (const id of productIds)
|
|
98
|
+
out.set(id, new Set());
|
|
99
|
+
if (productIds.length === 0)
|
|
100
|
+
return out;
|
|
101
|
+
for (const assignment of await this.#categoryRead().listAssignmentsForProducts(productIds)) {
|
|
102
|
+
out.get(assignment.productId)?.add(assignment.categoryId);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
// ---- Engine resolver (US5 / FR-026..FR-032) ------------------------
|
|
107
|
+
/**
|
|
108
|
+
* New resolver pipeline composing the three pure helpers:
|
|
109
|
+
* - evaluateApplicationRule (US4)
|
|
110
|
+
* - pickPriorityChain (US5)
|
|
111
|
+
* - resolvePriceBracket (US3)
|
|
112
|
+
*
|
|
113
|
+
* Returns Base + (optional) Sale resolutions on the given context.
|
|
114
|
+
*
|
|
115
|
+
* Bracket-gap fall-through (FR-031): when the picked list has no
|
|
116
|
+
* bracket for the requested (product, currency, qty), the resolver
|
|
117
|
+
* advances to the next-priority list in the same partition and
|
|
118
|
+
* repeats. Default is the terminal fallback for the Base partition;
|
|
119
|
+
* the Sale partition simply yields null when nothing matches with a
|
|
120
|
+
* usable bracket.
|
|
121
|
+
*
|
|
122
|
+
* Display mode resolution is stubbed at this layer until US7 wires
|
|
123
|
+
* the override chain. The default mode `gross_only` is returned so
|
|
124
|
+
* the response shape is complete.
|
|
125
|
+
*/
|
|
126
|
+
async resolveEngine(input) {
|
|
127
|
+
const em = this.emFactory();
|
|
128
|
+
const { product, context } = input;
|
|
129
|
+
const currencyCode = (context.currencyCode ?? context.salesChannel.defaultCurrency).toUpperCase();
|
|
130
|
+
const cacheKey = {
|
|
131
|
+
productId: product.id,
|
|
132
|
+
variantId: input.variantId ?? null,
|
|
133
|
+
quantity: context.quantity,
|
|
134
|
+
currencyCode,
|
|
135
|
+
salesChannelId: context.salesChannel.id,
|
|
136
|
+
organizationId: context.organization?.id ?? null,
|
|
137
|
+
customerGroupId: context.customerGroupId ?? context.organization?.customerGroupId ?? null,
|
|
138
|
+
};
|
|
139
|
+
const cached = this.cache?.get(cacheKey);
|
|
140
|
+
if (cached)
|
|
141
|
+
return cached;
|
|
142
|
+
// Build the resolution context.
|
|
143
|
+
const productCategoryIds = (await this.#categoryMemberships([product.id])).get(product.id) ?? new Set();
|
|
144
|
+
const orgId = context.organization?.id ?? null;
|
|
145
|
+
// Feature 056 — build the org inheritance chain (nearest-first). Flat when
|
|
146
|
+
// no resolver is wired or the org is a root.
|
|
147
|
+
const organizationChain = orgId !== null && this.resolveOrgChain ? await this.resolveOrgChain(orgId) : orgId !== null ? [orgId] : [];
|
|
148
|
+
const ctx = {
|
|
149
|
+
organizationId: orgId,
|
|
150
|
+
organizationChain,
|
|
151
|
+
customerGroupId: context.customerGroupId ?? context.organization?.customerGroupId ?? null,
|
|
152
|
+
salesChannelId: context.salesChannel.id,
|
|
153
|
+
currencyCode,
|
|
154
|
+
productCategoryIds,
|
|
155
|
+
};
|
|
156
|
+
// Load every active list and evaluate.
|
|
157
|
+
const activeLists = await em.find(PriceList, { status: 'active' });
|
|
158
|
+
const baseMatches = [];
|
|
159
|
+
const saleMatches = [];
|
|
160
|
+
for (const list of activeLists) {
|
|
161
|
+
const evaluation = evaluateApplicationRule(list.applicationRule, ctx);
|
|
162
|
+
if (!evaluation.matched)
|
|
163
|
+
continue;
|
|
164
|
+
const candidate = {
|
|
165
|
+
list,
|
|
166
|
+
evaluation,
|
|
167
|
+
modifiedAt: list.modifiedAt,
|
|
168
|
+
name: list.name,
|
|
169
|
+
isSystem: list.isSystem,
|
|
170
|
+
};
|
|
171
|
+
if (list.type === 'sale')
|
|
172
|
+
saleMatches.push({ list, candidate });
|
|
173
|
+
else
|
|
174
|
+
baseMatches.push({ list, candidate });
|
|
175
|
+
}
|
|
176
|
+
// For each partition: walk the priority chain, do bracket lookup, and
|
|
177
|
+
// fall through if the picked list has no usable bracket (FR-031).
|
|
178
|
+
const baseResult = await this.pickAndResolveBracket(em, baseMatches, product.id, currencyCode, context.quantity);
|
|
179
|
+
const saleResult = await this.pickAndResolveBracket(em, saleMatches, product.id, currencyCode, context.quantity);
|
|
180
|
+
// Display mode (US7): walk the override chain via the price-list service
|
|
181
|
+
// helper. Lazy-construct the helper here so PricingService does not pull
|
|
182
|
+
// PriceListService into its constructor signature (keeps the existing
|
|
183
|
+
// composition.ts wiring intact).
|
|
184
|
+
const { PriceListService } = await import('./price-list-service.js');
|
|
185
|
+
const priceListService = new PriceListService(this.emFactory, undefined, undefined, undefined, this.targetReads);
|
|
186
|
+
const displayMode = await priceListService.resolveDisplayMode({
|
|
187
|
+
productId: product.id,
|
|
188
|
+
organizationId: context.organization?.id ?? null,
|
|
189
|
+
salesChannelId: context.salesChannel.id,
|
|
190
|
+
customerKind: context.organization ? 'signed_in' : 'guest',
|
|
191
|
+
});
|
|
192
|
+
const result = {
|
|
193
|
+
base: {
|
|
194
|
+
listId: baseResult.list?.id ?? '',
|
|
195
|
+
listName: baseResult.list?.name ?? '',
|
|
196
|
+
bracket: baseResult.bracket,
|
|
197
|
+
},
|
|
198
|
+
sale: saleResult.list && saleResult.bracket
|
|
199
|
+
? {
|
|
200
|
+
listId: saleResult.list.id,
|
|
201
|
+
listName: saleResult.list.name,
|
|
202
|
+
bracket: saleResult.bracket,
|
|
203
|
+
}
|
|
204
|
+
: null,
|
|
205
|
+
displayMode,
|
|
206
|
+
currencyCode,
|
|
207
|
+
};
|
|
208
|
+
this.cache?.set(cacheKey, result);
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Convenience wrapper for cart/checkout/order callers (US6 / FR-034).
|
|
213
|
+
*
|
|
214
|
+
* Returns the line's effective unit price as a `{ amount, currency }`
|
|
215
|
+
* pair plus the source price-list ID and a flag indicating whether the
|
|
216
|
+
* Sale partition won. Callers persist the amount on the cart line and
|
|
217
|
+
* the source list ID for audit; if the response is `null`, the product
|
|
218
|
+
* has no bracket on any matching list and the caller should refuse the
|
|
219
|
+
* line (or, in the storefront's case, route to the Quote Request flow).
|
|
220
|
+
*
|
|
221
|
+
* Always charges the Sale price when present (FR-034). Falls back to
|
|
222
|
+
* the Base price; both come from `resolveEngine`.
|
|
223
|
+
*/
|
|
224
|
+
async resolveLinePrice(input) {
|
|
225
|
+
return lineFromEngine(await this.resolveEngine(input));
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Issue #132 — the price a catalogue listing may render, per product.
|
|
229
|
+
*
|
|
230
|
+
* The chain is `listingPriceFrom`; this method feeds it, so the listing paths
|
|
231
|
+
* make one call and get an answer whose type states which step of the chain
|
|
232
|
+
* produced it. Every requested product gets an entry.
|
|
233
|
+
*
|
|
234
|
+
* ## Why this is a batch and not a loop
|
|
235
|
+
*
|
|
236
|
+
* It used to be a loop over `resolveLinePrice`, which is **7 queries per
|
|
237
|
+
* product, sequentially**: a 50-item catalogue page — the platform's default
|
|
238
|
+
* page size and its busiest storefront read — cost 350 round trips and 315 ms
|
|
239
|
+
* of resolution on a warm local Postgres. Six of those seven asked a question
|
|
240
|
+
* whose answer does not vary across the page (the active price lists, the
|
|
241
|
+
* settings pair, the organization's override) or asked it one id at a time
|
|
242
|
+
* (the category memberships, the brackets, the product-scope overrides).
|
|
243
|
+
*
|
|
244
|
+
* So the set is resolved once and applied per product: the memberships in one
|
|
245
|
+
* `in (…)`, the active lists once, the organization's inheritance chain once,
|
|
246
|
+
* the brackets in **priority waves** (one query per fall-through level the
|
|
247
|
+
* page actually needs, not per product), and the display-mode chain through
|
|
248
|
+
* `resolveDisplayModes`, reusing the memberships already read.
|
|
249
|
+
*
|
|
250
|
+
* ## What is *not* batched, and why
|
|
251
|
+
*
|
|
252
|
+
* The application-rule evaluation and the priority walk stay per product, and
|
|
253
|
+
* they have to: `evaluateApplicationRule` reads the product's own category
|
|
254
|
+
* memberships, so a category rule matches some cards on a page and not
|
|
255
|
+
* others, and the winning list therefore differs per product. Both are pure
|
|
256
|
+
* in-memory functions over rows already loaded — the same
|
|
257
|
+
* `evaluateApplicationRule` / `pickPriorityChain` / `resolvePriceBracket`
|
|
258
|
+
* helpers `resolveEngine` composes — so the per-product part costs no I/O.
|
|
259
|
+
*
|
|
260
|
+
* The bracket wave is what keeps that honest. A single `in (…)` over every
|
|
261
|
+
* matched list would fetch brackets for lists most cards never reach; asking
|
|
262
|
+
* one wave at a time fetches exactly the (list, product) pairs the per-product
|
|
263
|
+
* walk would have asked for, in one query per level instead of one per card.
|
|
264
|
+
*
|
|
265
|
+
* ## Identity with the per-product path
|
|
266
|
+
*
|
|
267
|
+
* `resolveEngine` and `resolveLinePrice` are untouched: cart, checkout and the
|
|
268
|
+
* admin resolved-price probe take the same path they always did, and
|
|
269
|
+
* `test/integration/price_lists/listing-prices-batch.test.ts` asserts, product
|
|
270
|
+
* by product over a fixture with a bracket gap, a sale partition, a category
|
|
271
|
+
* rule and both unpriced arms, that this method answers exactly what looping
|
|
272
|
+
* that path answers. The LRU is keyed and populated identically, so a page
|
|
273
|
+
* whose products are already cached still issues no query at all.
|
|
274
|
+
*/
|
|
275
|
+
async resolveListingPrices(input) {
|
|
276
|
+
const currencyCode = (input.context.currencyCode ?? input.context.salesChannel.defaultCurrency).toUpperCase();
|
|
277
|
+
const out = new Map();
|
|
278
|
+
if (input.products.length === 0)
|
|
279
|
+
return out;
|
|
280
|
+
const organization = input.context.organization ?? null;
|
|
281
|
+
const customerGroupId = input.context.customerGroupId ?? organization?.customerGroupId ?? null;
|
|
282
|
+
const salesChannel = input.context.salesChannel;
|
|
283
|
+
const cacheKeyFor = (productId) => ({
|
|
284
|
+
productId,
|
|
285
|
+
variantId: null,
|
|
286
|
+
quantity: 1,
|
|
287
|
+
currencyCode,
|
|
288
|
+
salesChannelId: salesChannel.id,
|
|
289
|
+
organizationId: organization?.id ?? null,
|
|
290
|
+
customerGroupId,
|
|
291
|
+
});
|
|
292
|
+
// A page may name the same product twice; the answer is the same, and the
|
|
293
|
+
// loop it replaces resolved it twice.
|
|
294
|
+
const requested = new Map();
|
|
295
|
+
for (const product of input.products) {
|
|
296
|
+
if (!requested.has(product.id))
|
|
297
|
+
requested.set(product.id, product);
|
|
298
|
+
}
|
|
299
|
+
const pending = [];
|
|
300
|
+
for (const product of requested.values()) {
|
|
301
|
+
const cached = this.cache?.get(cacheKeyFor(product.id));
|
|
302
|
+
if (cached)
|
|
303
|
+
out.set(product.id, listingPriceFrom(lineFromEngine(cached), product, currencyCode));
|
|
304
|
+
else
|
|
305
|
+
pending.push(product);
|
|
306
|
+
}
|
|
307
|
+
if (pending.length === 0)
|
|
308
|
+
return out;
|
|
309
|
+
const em = this.emFactory();
|
|
310
|
+
const productIds = pending.map((p) => p.id);
|
|
311
|
+
const categoryIdsByProduct = await this.#categoryMemberships(productIds);
|
|
312
|
+
const orgId = organization?.id ?? null;
|
|
313
|
+
// Feature 056 — one chain for the page: it depends on the acting org, which
|
|
314
|
+
// the listing context fixes for every product on it.
|
|
315
|
+
const organizationChain = orgId !== null && this.resolveOrgChain
|
|
316
|
+
? await this.resolveOrgChain(orgId)
|
|
317
|
+
: orgId !== null
|
|
318
|
+
? [orgId]
|
|
319
|
+
: [];
|
|
320
|
+
const activeLists = await em.find(PriceList, { status: 'active' });
|
|
321
|
+
const baseMatches = new Map();
|
|
322
|
+
const saleMatches = new Map();
|
|
323
|
+
for (const product of pending) {
|
|
324
|
+
const ctx = {
|
|
325
|
+
organizationId: orgId,
|
|
326
|
+
organizationChain,
|
|
327
|
+
customerGroupId,
|
|
328
|
+
salesChannelId: salesChannel.id,
|
|
329
|
+
currencyCode,
|
|
330
|
+
productCategoryIds: categoryIdsByProduct.get(product.id) ?? new Set(),
|
|
331
|
+
};
|
|
332
|
+
const base = [];
|
|
333
|
+
const sale = [];
|
|
334
|
+
for (const list of activeLists) {
|
|
335
|
+
const evaluation = evaluateApplicationRule(list.applicationRule, ctx);
|
|
336
|
+
if (!evaluation.matched)
|
|
337
|
+
continue;
|
|
338
|
+
const candidate = {
|
|
339
|
+
list,
|
|
340
|
+
evaluation,
|
|
341
|
+
modifiedAt: list.modifiedAt,
|
|
342
|
+
name: list.name,
|
|
343
|
+
isSystem: list.isSystem,
|
|
344
|
+
};
|
|
345
|
+
if (list.type === 'sale')
|
|
346
|
+
sale.push({ list, candidate });
|
|
347
|
+
else
|
|
348
|
+
base.push({ list, candidate });
|
|
349
|
+
}
|
|
350
|
+
baseMatches.set(product.id, base);
|
|
351
|
+
saleMatches.set(product.id, sale);
|
|
352
|
+
}
|
|
353
|
+
const baseResults = await this.pickAndResolveBracketsForSet(em, baseMatches, currencyCode, 1);
|
|
354
|
+
const saleResults = await this.pickAndResolveBracketsForSet(em, saleMatches, currencyCode, 1);
|
|
355
|
+
// Same lazy construction as `resolveEngine`, for the same reason: it keeps
|
|
356
|
+
// `PriceListService` out of this class's constructor signature.
|
|
357
|
+
const { PriceListService } = await import('./price-list-service.js');
|
|
358
|
+
const priceListService = new PriceListService(this.emFactory, undefined, undefined, undefined, this.targetReads);
|
|
359
|
+
const displayModes = await priceListService.resolveDisplayModes({
|
|
360
|
+
productIds,
|
|
361
|
+
organizationId: orgId,
|
|
362
|
+
salesChannelId: salesChannel.id,
|
|
363
|
+
customerKind: organization ? 'signed_in' : 'guest',
|
|
364
|
+
categoryIdsByProduct,
|
|
365
|
+
});
|
|
366
|
+
for (const product of pending) {
|
|
367
|
+
const base = baseResults.get(product.id) ?? { list: null, bracket: null };
|
|
368
|
+
const sale = saleResults.get(product.id) ?? { list: null, bracket: null };
|
|
369
|
+
const engine = {
|
|
370
|
+
base: {
|
|
371
|
+
listId: base.list?.id ?? '',
|
|
372
|
+
listName: base.list?.name ?? '',
|
|
373
|
+
bracket: base.bracket,
|
|
374
|
+
},
|
|
375
|
+
sale: sale.list && sale.bracket
|
|
376
|
+
? { listId: sale.list.id, listName: sale.list.name, bracket: sale.bracket }
|
|
377
|
+
: null,
|
|
378
|
+
displayMode: displayModes.get(product.id) ?? 'gross_only',
|
|
379
|
+
currencyCode,
|
|
380
|
+
};
|
|
381
|
+
this.cache?.set(cacheKeyFor(product.id), engine);
|
|
382
|
+
out.set(product.id, listingPriceFrom(lineFromEngine(engine), product, currencyCode));
|
|
383
|
+
}
|
|
384
|
+
return out;
|
|
385
|
+
}
|
|
386
|
+
// ---- Feature 086 — the listing ordering slice ----------------------------
|
|
387
|
+
/**
|
|
388
|
+
* The catalogue in resolved-unit-price order, for one viewer, one chunk at a
|
|
389
|
+
* time — {@link ListingPriceOrderPort}.
|
|
390
|
+
*
|
|
391
|
+
* ## Identity with the card, which is the whole point
|
|
392
|
+
*
|
|
393
|
+
* The rows this answers are the rows `resolveListingPrices` would answer for
|
|
394
|
+
* the same viewer, sorted. Not "approximately": the candidates come from
|
|
395
|
+
* {@link buildCandidateVector}, which produces its order by running
|
|
396
|
+
* `pickPriorityChain` to exhaustion, and the sale partition leads the base one
|
|
397
|
+
* because `lineFromEngine` charges the sale price whenever the sale partition
|
|
398
|
+
* resolves anything. `test/integration/price_lists/unit-price-ordering.test.ts`
|
|
399
|
+
* asserts it product by product against the batch, the way
|
|
400
|
+
* `listing-prices-batch.test.ts` asserts the batch against the per-product
|
|
401
|
+
* path.
|
|
402
|
+
*
|
|
403
|
+
* ## What it does not know
|
|
404
|
+
*
|
|
405
|
+
* Visibility, channel membership, product status, archival, categories the
|
|
406
|
+
* *buyer* filtered by. Those are `catalog`'s facts about `catalog`'s rows, and
|
|
407
|
+
* a chunk from here may name products the viewer must never see — the caller
|
|
408
|
+
* intersects before anything reaches a page, a cursor or a count.
|
|
409
|
+
*
|
|
410
|
+
* ## Two statements, whatever the page size
|
|
411
|
+
*
|
|
412
|
+
* One probe of the leading stream, which fixes the watermark, and one merge of
|
|
413
|
+
* the rest. A single-candidate viewer costs one. The leading stream's rows are
|
|
414
|
+
* reused rather than re-read.
|
|
415
|
+
*/
|
|
416
|
+
async orderByUnitPrice(input) {
|
|
417
|
+
const empty = { rows: [], exhausted: true, sourceRowsRead: 0 };
|
|
418
|
+
if (input.limit <= 0)
|
|
419
|
+
return { rows: [], exhausted: false, sourceRowsRead: 0 };
|
|
420
|
+
// An empty restriction means "nothing", which is a different answer from
|
|
421
|
+
// `undefined` meaning "the whole catalogue".
|
|
422
|
+
if (input.restrictToProductIds !== undefined && input.restrictToProductIds.length === 0) {
|
|
423
|
+
return empty;
|
|
424
|
+
}
|
|
425
|
+
const em = this.emFactory();
|
|
426
|
+
const currencyCode = this.#listingCurrency(input.context);
|
|
427
|
+
const streams = await this.#candidateStreams(em, input.context, currencyCode, input.restrictToProductIds);
|
|
428
|
+
if (streams.length === 0)
|
|
429
|
+
return empty;
|
|
430
|
+
const limit = input.limit;
|
|
431
|
+
const direction = input.direction;
|
|
432
|
+
const after = input.after;
|
|
433
|
+
const range = input.amountRange;
|
|
434
|
+
// Pass 1 — the leading stream alone. It carries no exclusion (nothing
|
|
435
|
+
// outranks it), so its window is exactly the cheapest `limit` rows any
|
|
436
|
+
// candidate can contribute below the watermark it defines.
|
|
437
|
+
const lead = buildUnitPriceMergeQuery({
|
|
438
|
+
streams: [streams[0]],
|
|
439
|
+
currencyCode,
|
|
440
|
+
direction,
|
|
441
|
+
after: after ?? null,
|
|
442
|
+
...(range ? { amountRange: range } : {}),
|
|
443
|
+
perStreamLimit: limit,
|
|
444
|
+
watermark: null,
|
|
445
|
+
});
|
|
446
|
+
const leadRows = await em.execute(lead.sql, lead.params);
|
|
447
|
+
let sourceRowsRead = leadRows.length;
|
|
448
|
+
// The bound, and the one line that makes it exact: the leading stream has
|
|
449
|
+
// no exclusion, so a full window of `limit` rows at or below this amount
|
|
450
|
+
// means every row above it sorts after all of them and cannot be on this
|
|
451
|
+
// page. A short window is no bound at all.
|
|
452
|
+
const watermark = leadRows.length >= limit ? leadRows[leadRows.length - 1].amount : null;
|
|
453
|
+
const merged = leadRows.map((row) => ({
|
|
454
|
+
productId: row.product_id,
|
|
455
|
+
amount: row.amount,
|
|
456
|
+
streamIndex: 0,
|
|
457
|
+
}));
|
|
458
|
+
let everyOtherStreamShort = true;
|
|
459
|
+
if (streams.length > 1) {
|
|
460
|
+
const rest = buildUnitPriceMergeQuery({
|
|
461
|
+
streams,
|
|
462
|
+
currencyCode,
|
|
463
|
+
direction,
|
|
464
|
+
after: after ?? null,
|
|
465
|
+
...(range ? { amountRange: range } : {}),
|
|
466
|
+
perStreamLimit: limit,
|
|
467
|
+
watermark,
|
|
468
|
+
skipStreams: 1,
|
|
469
|
+
});
|
|
470
|
+
const restRows = await em.execute(rest.sql, rest.params);
|
|
471
|
+
sourceRowsRead += restRows.length;
|
|
472
|
+
const perStream = new Map();
|
|
473
|
+
for (const row of restRows) {
|
|
474
|
+
perStream.set(row.stream_index, (perStream.get(row.stream_index) ?? 0) + 1);
|
|
475
|
+
merged.push({
|
|
476
|
+
productId: row.product_id,
|
|
477
|
+
amount: row.amount,
|
|
478
|
+
streamIndex: Number(row.stream_index),
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
for (const count of perStream.values())
|
|
482
|
+
if (count >= limit)
|
|
483
|
+
everyOtherStreamShort = false;
|
|
484
|
+
}
|
|
485
|
+
merged.sort((a, b) => compareOrderRows(a, b, direction));
|
|
486
|
+
const page = merged.slice(0, limit);
|
|
487
|
+
const rows = page.map((row) => ({
|
|
488
|
+
productId: row.productId,
|
|
489
|
+
amount: row.amount,
|
|
490
|
+
currency: currencyCode,
|
|
491
|
+
priceListId: streams[row.streamIndex].priceListId,
|
|
492
|
+
isSale: streams[row.streamIndex].isSale,
|
|
493
|
+
}));
|
|
494
|
+
// `exhausted` is not "a short chunk". It is "there is provably nothing
|
|
495
|
+
// left": no watermark cut anything off, no stream filled its own window,
|
|
496
|
+
// and the merge fitted inside the page.
|
|
497
|
+
const exhausted = watermark === null && everyOtherStreamShort && merged.length <= limit;
|
|
498
|
+
return { rows, exhausted, sourceRowsRead };
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Which of `productIds` the viewer's candidate lists price at quantity 1.
|
|
502
|
+
*
|
|
503
|
+
* `catalog` composes the unpriced tail out of the complement, so this is a set
|
|
504
|
+
* membership answer and deliberately not a price: it says nothing about which
|
|
505
|
+
* list won or what it charges.
|
|
506
|
+
*/
|
|
507
|
+
async pricedProductIds(input) {
|
|
508
|
+
const out = new Set();
|
|
509
|
+
if (input.productIds.length === 0)
|
|
510
|
+
return out;
|
|
511
|
+
const em = this.emFactory();
|
|
512
|
+
const currencyCode = this.#listingCurrency(input.context);
|
|
513
|
+
const streams = await this.#candidateStreams(em, input.context, currencyCode, input.productIds);
|
|
514
|
+
if (streams.length === 0)
|
|
515
|
+
return out;
|
|
516
|
+
const params = [currencyCode, uuidArrayLiteral([...new Set(input.productIds)])];
|
|
517
|
+
const perStream = streams.map((stream) => {
|
|
518
|
+
if (stream.productIds === null) {
|
|
519
|
+
params.push(stream.priceListId);
|
|
520
|
+
return `b."price_list_id" = ?`;
|
|
521
|
+
}
|
|
522
|
+
params.push(stream.priceListId, uuidArrayLiteral(stream.productIds));
|
|
523
|
+
return `(b."price_list_id" = ? and b."product_id" = any(?::uuid[]))`;
|
|
524
|
+
});
|
|
525
|
+
const rows = await em.execute(`select distinct b."product_id"::text as product_id
|
|
526
|
+
from "price_list_price_brackets" b
|
|
527
|
+
where b."currency_code" = ?
|
|
528
|
+
and b."min_quantity" = 1
|
|
529
|
+
and b."product_id" = any(?::uuid[])
|
|
530
|
+
and (${perStream.join(' or ')})`, params);
|
|
531
|
+
for (const row of rows)
|
|
532
|
+
out.add(row.product_id);
|
|
533
|
+
return out;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* The display mode a *page* resolves to for this viewer — feature 086 /
|
|
537
|
+
* FR-016. See `PriceListService.resolvePageDisplayMode` for why the
|
|
538
|
+
* per-product and per-category steps are absent.
|
|
539
|
+
*/
|
|
540
|
+
async pageDisplayMode(input) {
|
|
541
|
+
const { PriceListService } = await import('./price-list-service.js');
|
|
542
|
+
const priceListService = new PriceListService(this.emFactory, undefined, undefined, undefined, this.targetReads);
|
|
543
|
+
return priceListService.resolvePageDisplayMode({
|
|
544
|
+
organizationId: input.context.organization?.id ?? null,
|
|
545
|
+
salesChannelId: input.context.salesChannel.id,
|
|
546
|
+
customerKind: input.context.organization ? 'signed_in' : 'guest',
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
#listingCurrency(context) {
|
|
550
|
+
return (context.currencyCode ?? context.salesChannel.defaultCurrency).toUpperCase();
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* The viewer's candidate vector, resolved to streams: a price list, and the
|
|
554
|
+
* product ids the stream is restricted to.
|
|
555
|
+
*
|
|
556
|
+
* Two things restrict a stream, and they are intersected rather than chosen
|
|
557
|
+
* between: the caller's opaque id set, and the categories a candidate's own
|
|
558
|
+
* rule requires. The second is read through `catalog`'s category port — this
|
|
559
|
+
* module names `product_categories` in no statement of its own, and could not
|
|
560
|
+
* express the membership as a join into the merge anyway without putting
|
|
561
|
+
* another module's table in this module's SQL (D-87).
|
|
562
|
+
*/
|
|
563
|
+
async #candidateStreams(em, context, currencyCode, restrictToProductIds) {
|
|
564
|
+
const organization = context.organization ?? null;
|
|
565
|
+
const orgId = organization?.id ?? null;
|
|
566
|
+
const organizationChain = orgId !== null && this.resolveOrgChain
|
|
567
|
+
? await this.resolveOrgChain(orgId)
|
|
568
|
+
: orgId !== null
|
|
569
|
+
? [orgId]
|
|
570
|
+
: [];
|
|
571
|
+
const viewer = {
|
|
572
|
+
organizationId: orgId,
|
|
573
|
+
organizationChain,
|
|
574
|
+
customerGroupId: context.customerGroupId ?? organization?.customerGroupId ?? null,
|
|
575
|
+
salesChannelId: context.salesChannel.id,
|
|
576
|
+
currencyCode,
|
|
577
|
+
};
|
|
578
|
+
const activeLists = await em.find(PriceList, { status: 'active' });
|
|
579
|
+
const candidates = buildCandidateVector(activeLists.map((list) => ({
|
|
580
|
+
id: list.id,
|
|
581
|
+
type: list.type,
|
|
582
|
+
applicationRule: list.applicationRule,
|
|
583
|
+
modifiedAt: list.modifiedAt,
|
|
584
|
+
name: list.name,
|
|
585
|
+
isSystem: list.isSystem,
|
|
586
|
+
})), viewer);
|
|
587
|
+
const restriction = restrictToProductIds ? new Set(restrictToProductIds) : null;
|
|
588
|
+
const categoryProducts = await this.#categoryProductIds(candidates);
|
|
589
|
+
const streams = [];
|
|
590
|
+
for (const candidate of candidates) {
|
|
591
|
+
let ids = restriction;
|
|
592
|
+
for (const group of candidate.requireAnyOf) {
|
|
593
|
+
const inGroup = new Set();
|
|
594
|
+
for (const categoryId of group) {
|
|
595
|
+
for (const productId of categoryProducts.get(categoryId) ?? [])
|
|
596
|
+
inGroup.add(productId);
|
|
597
|
+
}
|
|
598
|
+
ids = ids === null ? inGroup : new Set([...ids].filter((id) => inGroup.has(id)));
|
|
599
|
+
}
|
|
600
|
+
// A candidate whose categories hold nothing prices nothing: dropping it
|
|
601
|
+
// is not an optimisation, it is the empty stream written down.
|
|
602
|
+
if (ids !== null && ids.size === 0)
|
|
603
|
+
continue;
|
|
604
|
+
streams.push({
|
|
605
|
+
priceListId: candidate.priceListId,
|
|
606
|
+
isSale: candidate.isSale,
|
|
607
|
+
productIds: ids === null ? null : [...ids],
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
return streams;
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Product ids per category id, for every category any candidate's rule names.
|
|
614
|
+
*
|
|
615
|
+
* Read through `catalog`'s port, structurally — `listProductIdsInCategory`
|
|
616
|
+
* filters neither `isActive` nor `deletedAt`, and neither does
|
|
617
|
+
* `evaluateApplicationRule`'s `category` criterion, which tests the raw
|
|
618
|
+
* membership. The two have to agree, so the read that agrees is the one to
|
|
619
|
+
* make.
|
|
620
|
+
*
|
|
621
|
+
* Nothing is read at all in the ordinary case: a rule that names no category
|
|
622
|
+
* produces no group, and the reference catalogue's rules name none.
|
|
623
|
+
*/
|
|
624
|
+
async #categoryProductIds(candidates) {
|
|
625
|
+
const wanted = new Set();
|
|
626
|
+
for (const candidate of candidates) {
|
|
627
|
+
for (const group of candidate.requireAnyOf)
|
|
628
|
+
for (const id of group)
|
|
629
|
+
wanted.add(id);
|
|
630
|
+
}
|
|
631
|
+
const out = new Map();
|
|
632
|
+
if (wanted.size === 0)
|
|
633
|
+
return out;
|
|
634
|
+
// Refuses when nothing was wired — see `#categoryRead`. Skipping the
|
|
635
|
+
// membership would make this category rule apply to everything, which
|
|
636
|
+
// prices the products it excludes.
|
|
637
|
+
const categoryRead = this.#categoryRead();
|
|
638
|
+
for (const categoryId of wanted) {
|
|
639
|
+
out.set(categoryId, await categoryRead.listProductIdsInCategory(categoryId));
|
|
640
|
+
}
|
|
641
|
+
return out;
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Issue #132 — the lowest-quantity bracket amount on one named price list.
|
|
645
|
+
*
|
|
646
|
+
* `product_feeds` used to run this `select` itself, which put another
|
|
647
|
+
* module's table in its SQL and let a feed publish prices while this module
|
|
648
|
+
* was refusing to serve them. The read is the same; owning it here is what
|
|
649
|
+
* makes the port gate apply.
|
|
650
|
+
*/
|
|
651
|
+
async namedListPrices(input) {
|
|
652
|
+
const out = new Map();
|
|
653
|
+
if (input.productIds.length === 0)
|
|
654
|
+
return out;
|
|
655
|
+
const em = this.emFactory();
|
|
656
|
+
const placeholders = input.productIds.map(() => '?').join(',');
|
|
657
|
+
const rows = await em.getConnection().execute(`select distinct on ("product_id") "product_id", "amount"
|
|
658
|
+
from "price_list_price_brackets"
|
|
659
|
+
where "price_list_id" = ? and "currency_code" = ?
|
|
660
|
+
and "product_id" in (${placeholders})
|
|
661
|
+
order by "product_id" asc, "min_quantity" asc`, [input.priceListId, input.currencyCode.toUpperCase(), ...input.productIds], 'all', em.getTransactionContext());
|
|
662
|
+
for (const row of rows)
|
|
663
|
+
out.set(row.product_id, row.amount);
|
|
664
|
+
return out;
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Feature 062 — distinct bracket start quantities (ascending) across every
|
|
668
|
+
* ACTIVE list for a (product, currency). Consumers probe `resolveLinePrice`
|
|
669
|
+
* at each quantity to derive the acting org's effective tier ladder; a
|
|
670
|
+
* quantity contributed by a non-matching list simply resolves to the same
|
|
671
|
+
* amount as its predecessor and is de-duplicated by the caller — no
|
|
672
|
+
* cross-org data leaks through this read.
|
|
673
|
+
*/
|
|
674
|
+
async listBracketMinQuantities(productId, currencyCode) {
|
|
675
|
+
const em = this.emFactory();
|
|
676
|
+
const rows = await em.execute(`select distinct b.min_quantity
|
|
677
|
+
from price_list_price_brackets b
|
|
678
|
+
join price_lists l on l.id = b.price_list_id
|
|
679
|
+
where b.product_id = ? and b.currency_code = ? and l.status = 'active'
|
|
680
|
+
order by b.min_quantity asc`, [productId, currencyCode.toUpperCase()]);
|
|
681
|
+
return rows.map((r) => Number(r.min_quantity));
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Walks the priority chain until a list yields a bracket for the
|
|
685
|
+
* requested (product, currency, quantity). Excluded candidates are
|
|
686
|
+
* ones that already failed the bracket lookup. Returns the first
|
|
687
|
+
* list+bracket pair, or {list: null, bracket: null} if every match
|
|
688
|
+
* has no usable bracket.
|
|
689
|
+
*/
|
|
690
|
+
/**
|
|
691
|
+
* `pickAndResolveBracket` for a whole page, in **priority waves**.
|
|
692
|
+
*
|
|
693
|
+
* Each wave picks the top-priority remaining list per product — the same
|
|
694
|
+
* `pickPriorityChain` call the single-product walk makes — and reads the
|
|
695
|
+
* brackets for that wave's (list, product) pairs in one query. A product
|
|
696
|
+
* whose picked list has no usable bracket drops that list and enters the next
|
|
697
|
+
* wave, exactly as FR-031's fall-through does; a product with no candidate
|
|
698
|
+
* left answers `{ list: null, bracket: null }`, exactly as the single walk's
|
|
699
|
+
* exhausted `remaining` does.
|
|
700
|
+
*
|
|
701
|
+
* The number of queries is therefore the number of fall-through levels the
|
|
702
|
+
* page actually needs — one, normally — instead of one per product, and the
|
|
703
|
+
* rows fetched are the ones the per-product walk would have fetched rather
|
|
704
|
+
* than every matched list's brackets.
|
|
705
|
+
*/
|
|
706
|
+
async pickAndResolveBracketsForSet(em, matchesByProduct, currencyCode, quantity) {
|
|
707
|
+
const out = new Map();
|
|
708
|
+
const remaining = new Map();
|
|
709
|
+
for (const [productId, matches] of matchesByProduct) {
|
|
710
|
+
if (matches.length === 0)
|
|
711
|
+
out.set(productId, { list: null, bracket: null });
|
|
712
|
+
else
|
|
713
|
+
remaining.set(productId, [...matches]);
|
|
714
|
+
}
|
|
715
|
+
while (remaining.size > 0) {
|
|
716
|
+
const picks = new Map();
|
|
717
|
+
for (const [productId, matches] of [...remaining]) {
|
|
718
|
+
const picked = pickPriorityChain(matches.map((m) => m.candidate));
|
|
719
|
+
if (!picked) {
|
|
720
|
+
out.set(productId, { list: null, bracket: null });
|
|
721
|
+
remaining.delete(productId);
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
picks.set(productId, picked.list);
|
|
725
|
+
}
|
|
726
|
+
if (picks.size === 0)
|
|
727
|
+
break;
|
|
728
|
+
const listIds = [...new Set([...picks.values()].map((list) => list.id))];
|
|
729
|
+
const brackets = await em.find(PriceListPriceBracket, {
|
|
730
|
+
priceListId: { $in: listIds },
|
|
731
|
+
productId: { $in: [...picks.keys()] },
|
|
732
|
+
});
|
|
733
|
+
const rowsByPair = new Map();
|
|
734
|
+
for (const b of brackets) {
|
|
735
|
+
const key = `${b.priceListId}|${b.productId}`;
|
|
736
|
+
const rows = rowsByPair.get(key);
|
|
737
|
+
const row = {
|
|
738
|
+
priceListId: b.priceListId,
|
|
739
|
+
productId: b.productId,
|
|
740
|
+
currencyCode: b.currencyCode,
|
|
741
|
+
minQuantity: b.minQuantity,
|
|
742
|
+
maxQuantity: b.maxQuantity ?? null,
|
|
743
|
+
amount: b.amount,
|
|
744
|
+
};
|
|
745
|
+
if (rows)
|
|
746
|
+
rows.push(row);
|
|
747
|
+
else
|
|
748
|
+
rowsByPair.set(key, [row]);
|
|
749
|
+
}
|
|
750
|
+
for (const [productId, list] of picks) {
|
|
751
|
+
const bracket = resolvePriceBracket(rowsByPair.get(`${list.id}|${productId}`) ?? [], currencyCode, quantity);
|
|
752
|
+
if (bracket) {
|
|
753
|
+
out.set(productId, { list, bracket });
|
|
754
|
+
remaining.delete(productId);
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
const matches = remaining.get(productId);
|
|
758
|
+
if (!matches)
|
|
759
|
+
continue;
|
|
760
|
+
const idx = matches.findIndex((m) => m.list.id === list.id);
|
|
761
|
+
if (idx >= 0)
|
|
762
|
+
matches.splice(idx, 1);
|
|
763
|
+
if (matches.length === 0) {
|
|
764
|
+
out.set(productId, { list: null, bracket: null });
|
|
765
|
+
remaining.delete(productId);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
return out;
|
|
770
|
+
}
|
|
771
|
+
async pickAndResolveBracket(em, matches, productId, currencyCode, quantity) {
|
|
772
|
+
if (matches.length === 0)
|
|
773
|
+
return { list: null, bracket: null };
|
|
774
|
+
const remaining = [...matches];
|
|
775
|
+
while (remaining.length > 0) {
|
|
776
|
+
const picked = pickPriorityChain(remaining.map((m) => m.candidate));
|
|
777
|
+
if (!picked)
|
|
778
|
+
return { list: null, bracket: null };
|
|
779
|
+
const list = picked.list;
|
|
780
|
+
const brackets = await em.find(PriceListPriceBracket, {
|
|
781
|
+
priceListId: list.id,
|
|
782
|
+
productId,
|
|
783
|
+
});
|
|
784
|
+
const rows = brackets.map((b) => ({
|
|
785
|
+
priceListId: b.priceListId,
|
|
786
|
+
productId: b.productId,
|
|
787
|
+
currencyCode: b.currencyCode,
|
|
788
|
+
minQuantity: b.minQuantity,
|
|
789
|
+
maxQuantity: b.maxQuantity ?? null,
|
|
790
|
+
amount: b.amount,
|
|
791
|
+
}));
|
|
792
|
+
const bracket = resolvePriceBracket(rows, currencyCode, quantity);
|
|
793
|
+
if (bracket)
|
|
794
|
+
return { list, bracket };
|
|
795
|
+
// Fall through: drop the picked list from `remaining` and retry.
|
|
796
|
+
const idx = remaining.findIndex((m) => m.list.id === list.id);
|
|
797
|
+
if (idx >= 0)
|
|
798
|
+
remaining.splice(idx, 1);
|
|
799
|
+
}
|
|
800
|
+
return { list: null, bracket: null };
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
//# sourceMappingURL=pricing-service.js.map
|