@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,1238 @@
|
|
|
1
|
+
import { ERROR_CODES } from '@endora-commerce/contracts';
|
|
2
|
+
import { HttpError } from '@endora-commerce/platform/http';
|
|
3
|
+
import { PriceList } from '../entities/price-list.entity.js';
|
|
4
|
+
import { PriceListProduct } from '../entities/price-list-product.entity.js';
|
|
5
|
+
import { PriceListPriceBracket } from '../entities/price-list-price-bracket.entity.js';
|
|
6
|
+
import { PriceDisplayModeOverride } from '../entities/price-display-mode-override.entity.js';
|
|
7
|
+
import { SalesChannel } from '@endora-commerce/platform/kernel';
|
|
8
|
+
import { Setting } from '@endora-commerce/platform/kernel';
|
|
9
|
+
import { SettingValue } from '@endora-commerce/platform/kernel';
|
|
10
|
+
import { decideDisplayMode, settingsDisplayModeKey, } from './display-mode-resolver.js';
|
|
11
|
+
import { randomUUID } from 'crypto';
|
|
12
|
+
/**
|
|
13
|
+
* PriceListService — admin CRUD over PriceList + the two child collections
|
|
14
|
+
* (items + assignments). Keeping all three operations on the same service
|
|
15
|
+
* keeps the "edit a price list and its rules" admin flow on one transaction
|
|
16
|
+
* boundary when we need it later.
|
|
17
|
+
*/
|
|
18
|
+
export class PriceListService {
|
|
19
|
+
emFactory;
|
|
20
|
+
pricingCache;
|
|
21
|
+
auditLog;
|
|
22
|
+
commandBus;
|
|
23
|
+
targetReads;
|
|
24
|
+
constructor(emFactory,
|
|
25
|
+
/**
|
|
26
|
+
* Optional pricing cache. Every write path on this service calls
|
|
27
|
+
* `invalidateAll()` so the next read repopulates from the DB.
|
|
28
|
+
* Coarse but correct — fan-out makes per-tuple invalidation
|
|
29
|
+
* unprofitable until profiling shows otherwise.
|
|
30
|
+
*/
|
|
31
|
+
pricingCache,
|
|
32
|
+
/** Feature 024 — optional audit log writer. When omitted, no audit
|
|
33
|
+
* rows are emitted (tests that don't care about audit pass nothing). */
|
|
34
|
+
auditLog,
|
|
35
|
+
/**
|
|
36
|
+
* Feature 054 — when injected, `patch` runs through the Command Bus so the
|
|
37
|
+
* update is audited co-transactionally (Principle XIII). Optional: bus-less
|
|
38
|
+
* construction keeps the legacy audit path, byte-identical.
|
|
39
|
+
*/
|
|
40
|
+
commandBus,
|
|
41
|
+
/**
|
|
42
|
+
* Feature 075 Phase C — the ports behind every rule-target and
|
|
43
|
+
* override-target existence check. Optional in the signature because a
|
|
44
|
+
* price list with no organisation, category or product target never reaches
|
|
45
|
+
* them; **never optional in effect**, because the paths that need them
|
|
46
|
+
* refuse rather than skip when they are absent (see {@link targets}).
|
|
47
|
+
*/
|
|
48
|
+
targetReads) {
|
|
49
|
+
this.emFactory = emFactory;
|
|
50
|
+
this.pricingCache = pricingCache;
|
|
51
|
+
this.auditLog = auditLog;
|
|
52
|
+
this.commandBus = commandBus;
|
|
53
|
+
this.targetReads = targetReads;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The neighbour ports, or a refusal. Skipping the validation when nothing was
|
|
57
|
+
* wired would turn "this composition cannot reach `catalog`" into "every
|
|
58
|
+
* category id is valid", which is the fail-open shape feature 075 exists to
|
|
59
|
+
* remove.
|
|
60
|
+
*/
|
|
61
|
+
targets() {
|
|
62
|
+
if (!this.targetReads) {
|
|
63
|
+
throw new HttpError(500, ERROR_CODES.INTERNAL, 'This price-list operation validates a target owned by `catalog` or `organizations`, ' +
|
|
64
|
+
'and this composition constructed PriceListService without their read ports.');
|
|
65
|
+
}
|
|
66
|
+
return this.targetReads;
|
|
67
|
+
}
|
|
68
|
+
invalidatePricingCache() {
|
|
69
|
+
this.pricingCache?.invalidateAll();
|
|
70
|
+
}
|
|
71
|
+
async audit(action, row, stateBefore, stateAfter, auditCtx) {
|
|
72
|
+
if (!this.auditLog || !auditCtx)
|
|
73
|
+
return;
|
|
74
|
+
await this.auditLog.record({
|
|
75
|
+
actorAdminUserId: auditCtx.actorAdminUserId,
|
|
76
|
+
...(auditCtx.impersonatedCustomerAccountId !== undefined
|
|
77
|
+
? { impersonatedCustomerAccountId: auditCtx.impersonatedCustomerAccountId }
|
|
78
|
+
: {}),
|
|
79
|
+
action,
|
|
80
|
+
objectType: 'price_list',
|
|
81
|
+
objectId: row.id,
|
|
82
|
+
...(stateBefore !== null ? { stateBefore } : {}),
|
|
83
|
+
...(stateAfter !== null ? { stateAfter } : {}),
|
|
84
|
+
...(auditCtx.ipAddress !== undefined ? { ipAddress: auditCtx.ipAddress } : {}),
|
|
85
|
+
...(auditCtx.userAgent !== undefined ? { userAgent: auditCtx.userAgent } : {}),
|
|
86
|
+
...(auditCtx.requestId !== undefined ? { requestId: auditCtx.requestId } : {}),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Feature 054 — run a price-list write through the Command Bus so the audit is
|
|
91
|
+
* co-transactional (Principle XIII), or fall back to a self-forked em + the
|
|
92
|
+
* legacy `this.audit()` helper when no bus is wired (bus-less test
|
|
93
|
+
* constructions). `write` performs the mutation on the given em (no flush) and
|
|
94
|
+
* returns the caller result, the audit row identity, and the before/after
|
|
95
|
+
* snapshot; a `skipAudit` result commits with no audit row and no cache flush.
|
|
96
|
+
*/
|
|
97
|
+
async #runAudited(action, objectId, auditCtx, write) {
|
|
98
|
+
if (this.commandBus) {
|
|
99
|
+
return this.commandBus.run({
|
|
100
|
+
action,
|
|
101
|
+
objectType: 'price_list',
|
|
102
|
+
objectId,
|
|
103
|
+
run: async ({ em }) => {
|
|
104
|
+
const w = await write(em);
|
|
105
|
+
if (w.skipAudit)
|
|
106
|
+
return { result: w.result, skipAudit: true };
|
|
107
|
+
this.invalidatePricingCache();
|
|
108
|
+
return { result: w.result, before: w.before, after: w.after };
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
const em = this.emFactory();
|
|
113
|
+
const w = await write(em);
|
|
114
|
+
await em.flush();
|
|
115
|
+
if (!w.skipAudit) {
|
|
116
|
+
this.invalidatePricingCache();
|
|
117
|
+
await this.audit(action, w.row, w.before, w.after, auditCtx);
|
|
118
|
+
}
|
|
119
|
+
return w.result;
|
|
120
|
+
}
|
|
121
|
+
// ---- PriceList -----------------------------------------------------
|
|
122
|
+
async listEngine(filter = {}) {
|
|
123
|
+
const where = {};
|
|
124
|
+
if (filter.status && filter.status.length > 0)
|
|
125
|
+
where['status'] = { $in: filter.status };
|
|
126
|
+
if (filter.type && filter.type.length > 0)
|
|
127
|
+
where['type'] = { $in: filter.type };
|
|
128
|
+
if (filter.search && filter.search.trim().length > 0) {
|
|
129
|
+
where['name'] = { $ilike: `%${filter.search.trim()}%` };
|
|
130
|
+
}
|
|
131
|
+
return this.emFactory().find(PriceList, where, {
|
|
132
|
+
orderBy: { isSystem: 'desc', modifiedAt: 'desc', name: 'asc' },
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
async getById(id, em) {
|
|
136
|
+
const ent = em ?? this.emFactory();
|
|
137
|
+
const row = await ent.findOne(PriceList, { id });
|
|
138
|
+
if (!row)
|
|
139
|
+
throw new HttpError(404, ERROR_CODES.NOT_FOUND, `Price list ${id} not found.`);
|
|
140
|
+
return row;
|
|
141
|
+
}
|
|
142
|
+
async remove(id) {
|
|
143
|
+
await this.#runAudited('price_list.delete', id, undefined, async (em) => {
|
|
144
|
+
const row = await em.findOne(PriceList, { id });
|
|
145
|
+
if (!row) {
|
|
146
|
+
return { result: undefined, row: { id, name: '' }, before: null, after: null, skipAudit: true };
|
|
147
|
+
}
|
|
148
|
+
if (row.isSystem) {
|
|
149
|
+
throw new HttpError(403, ERROR_CODES.FORBIDDEN, 'The Default price list cannot be deleted; it is the system fallback.');
|
|
150
|
+
}
|
|
151
|
+
const before = { name: row.name, code: row.code, type: row.type, status: row.status };
|
|
152
|
+
em.remove(row);
|
|
153
|
+
return { result: undefined, row, before, after: null };
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Default-list rule-attachment guard (FR-006). Rejects any attempt to attach
|
|
158
|
+
* a non-empty Application Rule to the seeded `Default` row.
|
|
159
|
+
*/
|
|
160
|
+
async assertCanSetApplicationRule(id, rule) {
|
|
161
|
+
const row = await this.getById(id);
|
|
162
|
+
if (row.isSystem && rule.kind !== 'all') {
|
|
163
|
+
throw new HttpError(403, ERROR_CODES.FORBIDDEN, 'The Default price list cannot carry an Application Rule; it always matches as the global fallback.');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Default-list status-change guard (FR-005 / FR-006 / spec.md §State Machines).
|
|
168
|
+
* The system Default row stays `active` for the platform's lifetime. The
|
|
169
|
+
* service rejects any attempt to move it to `draft`, `scheduled`, or `expired`.
|
|
170
|
+
*/
|
|
171
|
+
async assertCanTransitionStatus(id, nextStatus) {
|
|
172
|
+
const row = await this.getById(id);
|
|
173
|
+
if (row.isSystem && nextStatus !== 'active') {
|
|
174
|
+
throw new HttpError(403, ERROR_CODES.FORBIDDEN, 'The Default price list must remain active.');
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// ---- Engine CRUD + lifecycle (feature 011) -------------------------
|
|
178
|
+
/**
|
|
179
|
+
* Create a new price list. Status defaults to `draft`. Default-list
|
|
180
|
+
* protections are out of scope here — system rows are seeded by migration
|
|
181
|
+
* 031, never created via this method.
|
|
182
|
+
*/
|
|
183
|
+
async create(input, auditCtx) {
|
|
184
|
+
this.assertDateSanity(input.startsAt ?? null, input.endsAt ?? null, true);
|
|
185
|
+
const normalisedRule = input.applicationRule !== undefined
|
|
186
|
+
? await this.normaliseAndValidateRule(input.applicationRule)
|
|
187
|
+
: { kind: 'all' };
|
|
188
|
+
const id = randomUUID();
|
|
189
|
+
return this.#runAudited('price_list.create', id, auditCtx, async (em) => {
|
|
190
|
+
const row = em.create(PriceList, {
|
|
191
|
+
id,
|
|
192
|
+
// Legacy columns are required by the foundation schema; populate them
|
|
193
|
+
// with engine-equivalent values so writes don't fail until contract
|
|
194
|
+
// migration retires them.
|
|
195
|
+
code: `pl-${randomUUID()}`,
|
|
196
|
+
name: input.name,
|
|
197
|
+
currency: 'PLN',
|
|
198
|
+
isDefault: false,
|
|
199
|
+
priority: 0,
|
|
200
|
+
type: input.type,
|
|
201
|
+
status: 'draft',
|
|
202
|
+
startsAt: input.startsAt ?? null,
|
|
203
|
+
endsAt: input.endsAt ?? null,
|
|
204
|
+
applicationRule: normalisedRule,
|
|
205
|
+
isSystem: false,
|
|
206
|
+
modifiedAt: new Date(),
|
|
207
|
+
});
|
|
208
|
+
return {
|
|
209
|
+
result: row,
|
|
210
|
+
row,
|
|
211
|
+
before: null,
|
|
212
|
+
after: {
|
|
213
|
+
name: row.name,
|
|
214
|
+
code: row.code,
|
|
215
|
+
type: row.type,
|
|
216
|
+
status: row.status,
|
|
217
|
+
startsAt: row.startsAt ?? null,
|
|
218
|
+
endsAt: row.endsAt ?? null,
|
|
219
|
+
applicationRuleKind: row.applicationRule.kind,
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Partial update. Bumps `modifiedAt` on every material change.
|
|
226
|
+
* Refuses non-empty rule attachment on the seeded `Default` row (FR-006).
|
|
227
|
+
*/
|
|
228
|
+
async patch(id, input, auditCtx) {
|
|
229
|
+
// Feature 054 — audited path: the Command Bus records the update
|
|
230
|
+
// co-transactionally. No-op patches (nothing changed) skip the audit row.
|
|
231
|
+
if (this.commandBus) {
|
|
232
|
+
return this.commandBus.run(this.#patchCommand(id, input));
|
|
233
|
+
}
|
|
234
|
+
// Legacy fallback (bus-less construction): unaudited unless auditCtx given.
|
|
235
|
+
const em = this.emFactory();
|
|
236
|
+
const r = await this.#applyPatch(em, id, input);
|
|
237
|
+
await em.flush();
|
|
238
|
+
if (r.mutated) {
|
|
239
|
+
this.invalidatePricingCache();
|
|
240
|
+
await this.audit('price_list.update', r.row, r.stateBefore, r.stateAfter, auditCtx);
|
|
241
|
+
}
|
|
242
|
+
return r.row;
|
|
243
|
+
}
|
|
244
|
+
/** The `patch` write expressed as a Command (audited via the bus). */
|
|
245
|
+
#patchCommand(id, input) {
|
|
246
|
+
return {
|
|
247
|
+
action: 'price_list.update',
|
|
248
|
+
objectType: 'price_list',
|
|
249
|
+
objectId: id,
|
|
250
|
+
run: async ({ em }) => {
|
|
251
|
+
const r = await this.#applyPatch(em, id, input);
|
|
252
|
+
if (!r.mutated) {
|
|
253
|
+
// Nothing changed → commit without an audit row.
|
|
254
|
+
return { result: r.row, skipAudit: true };
|
|
255
|
+
}
|
|
256
|
+
this.invalidatePricingCache();
|
|
257
|
+
return { result: r.row, before: r.stateBefore, after: r.stateAfter };
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Pure patch write on the given em — no flush, no audit, no cache invalidation.
|
|
263
|
+
* Returns the row, whether it mutated, and the before/after snapshots.
|
|
264
|
+
*/
|
|
265
|
+
async #applyPatch(em, id, input) {
|
|
266
|
+
const row = await this.getById(id, em);
|
|
267
|
+
let normalisedRule;
|
|
268
|
+
if (input.applicationRule !== undefined) {
|
|
269
|
+
await this.assertCanSetApplicationRule(id, input.applicationRule);
|
|
270
|
+
normalisedRule = await this.normaliseAndValidateRule(input.applicationRule);
|
|
271
|
+
}
|
|
272
|
+
const nextStartsAt = input.startsAt !== undefined ? input.startsAt : row.startsAt ?? null;
|
|
273
|
+
const nextEndsAt = input.endsAt !== undefined ? input.endsAt : row.endsAt ?? null;
|
|
274
|
+
if (input.startsAt !== undefined || input.endsAt !== undefined) {
|
|
275
|
+
this.assertDateSanity(nextStartsAt, nextEndsAt, false);
|
|
276
|
+
}
|
|
277
|
+
const stateBefore = {
|
|
278
|
+
name: row.name,
|
|
279
|
+
type: row.type,
|
|
280
|
+
status: row.status,
|
|
281
|
+
startsAt: row.startsAt ?? null,
|
|
282
|
+
endsAt: row.endsAt ?? null,
|
|
283
|
+
};
|
|
284
|
+
let mutated = false;
|
|
285
|
+
const changedFields = [];
|
|
286
|
+
if (input.name !== undefined && input.name !== row.name) {
|
|
287
|
+
row.name = input.name;
|
|
288
|
+
mutated = true;
|
|
289
|
+
changedFields.push('name');
|
|
290
|
+
}
|
|
291
|
+
if (input.type !== undefined && input.type !== row.type) {
|
|
292
|
+
row.type = input.type;
|
|
293
|
+
mutated = true;
|
|
294
|
+
changedFields.push('type');
|
|
295
|
+
}
|
|
296
|
+
if (input.startsAt !== undefined) {
|
|
297
|
+
row.startsAt = input.startsAt;
|
|
298
|
+
mutated = true;
|
|
299
|
+
changedFields.push('startsAt');
|
|
300
|
+
}
|
|
301
|
+
if (input.endsAt !== undefined) {
|
|
302
|
+
row.endsAt = input.endsAt;
|
|
303
|
+
mutated = true;
|
|
304
|
+
changedFields.push('endsAt');
|
|
305
|
+
}
|
|
306
|
+
if (input.applicationRule !== undefined && normalisedRule !== undefined) {
|
|
307
|
+
row.applicationRule = normalisedRule;
|
|
308
|
+
mutated = true;
|
|
309
|
+
changedFields.push('applicationRule');
|
|
310
|
+
}
|
|
311
|
+
if (mutated) {
|
|
312
|
+
row.modifiedAt = new Date();
|
|
313
|
+
}
|
|
314
|
+
const stateAfter = {
|
|
315
|
+
name: row.name,
|
|
316
|
+
type: row.type,
|
|
317
|
+
status: row.status,
|
|
318
|
+
startsAt: row.startsAt ?? null,
|
|
319
|
+
endsAt: row.endsAt ?? null,
|
|
320
|
+
changedFields,
|
|
321
|
+
};
|
|
322
|
+
return { row, mutated, stateBefore, stateAfter };
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Manual transition: draft → active (or scheduled / expired if dates
|
|
326
|
+
* dictate). FR-009 row 1.
|
|
327
|
+
*/
|
|
328
|
+
async activate(id, auditCtx) {
|
|
329
|
+
// Pre-read (no mutation) to resolve the dynamic action token before running
|
|
330
|
+
// the audited write: `price_list.activate` for a live state, or
|
|
331
|
+
// `price_list.expire` when the start/end dates push it straight to expired.
|
|
332
|
+
const preRow = await this.getById(id);
|
|
333
|
+
if (!preRow.isSystem && preRow.applicationRule.kind === 'all') {
|
|
334
|
+
throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, 'A non-Default price list cannot be activated with an empty Application Rule.');
|
|
335
|
+
}
|
|
336
|
+
const now = new Date();
|
|
337
|
+
const next = preRow.startsAt && preRow.startsAt > now
|
|
338
|
+
? 'scheduled'
|
|
339
|
+
: preRow.endsAt && preRow.endsAt < now
|
|
340
|
+
? 'expired'
|
|
341
|
+
: 'active';
|
|
342
|
+
const action = next === 'expired' ? 'price_list.expire' : 'price_list.activate';
|
|
343
|
+
return this.#runAudited(action, id, auditCtx, async (em) => {
|
|
344
|
+
const row = await this.getById(id, em);
|
|
345
|
+
if (row.status === next)
|
|
346
|
+
return { result: row, row, before: null, after: null, skipAudit: true };
|
|
347
|
+
await this.assertCanTransitionStatus(id, next);
|
|
348
|
+
const previousStatus = row.status;
|
|
349
|
+
row.status = next;
|
|
350
|
+
row.modifiedAt = new Date();
|
|
351
|
+
return {
|
|
352
|
+
result: row,
|
|
353
|
+
row,
|
|
354
|
+
before: { status: previousStatus },
|
|
355
|
+
after: { name: row.name, status: row.status, activatedAt: new Date() },
|
|
356
|
+
};
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Manual transition: any state → draft. Freezes the list immediately.
|
|
361
|
+
*/
|
|
362
|
+
async draftify(id, auditCtx) {
|
|
363
|
+
return this.#runAudited('price_list.draftify', id, auditCtx, async (em) => {
|
|
364
|
+
const row = await this.getById(id, em);
|
|
365
|
+
if (row.status === 'draft')
|
|
366
|
+
return { result: row, row, before: null, after: null, skipAudit: true };
|
|
367
|
+
await this.assertCanTransitionStatus(id, 'draft');
|
|
368
|
+
const previousStatus = row.status;
|
|
369
|
+
row.status = 'draft';
|
|
370
|
+
row.modifiedAt = new Date();
|
|
371
|
+
return {
|
|
372
|
+
result: row,
|
|
373
|
+
row,
|
|
374
|
+
before: { status: previousStatus },
|
|
375
|
+
after: { name: row.name, status: row.status },
|
|
376
|
+
};
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Duplicate a price list. Copies the rule, the assigned products, and
|
|
381
|
+
* every bracket row. Resets status to `draft`, clears dates, and derives
|
|
382
|
+
* a unique name (suffix ` (copy)`, ` (copy 2)`, …) — FR-013.
|
|
383
|
+
*/
|
|
384
|
+
async duplicate(id, auditCtx) {
|
|
385
|
+
const dupId = randomUUID();
|
|
386
|
+
return this.#runAudited('price_list.duplicate', dupId, auditCtx, async (em) => {
|
|
387
|
+
const source = await this.getById(id, em);
|
|
388
|
+
const baseName = source.name;
|
|
389
|
+
const candidates = await em.find(PriceList, { name: { $like: `${baseName} (copy%` } }, { fields: ['id', 'name'] });
|
|
390
|
+
let suffix = ' (copy)';
|
|
391
|
+
if (candidates.length > 0) {
|
|
392
|
+
// Find the next available numeric suffix.
|
|
393
|
+
let n = 2;
|
|
394
|
+
while (candidates.some((c) => c.name === `${baseName} (copy ${n})`)) {
|
|
395
|
+
n += 1;
|
|
396
|
+
}
|
|
397
|
+
// If the bare " (copy)" doesn't exist yet, use it.
|
|
398
|
+
if (!candidates.some((c) => c.name === `${baseName} (copy)`)) {
|
|
399
|
+
suffix = ' (copy)';
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
suffix = ` (copy ${n})`;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
const newName = `${baseName}${suffix}`;
|
|
406
|
+
const dup = em.create(PriceList, {
|
|
407
|
+
id: dupId,
|
|
408
|
+
code: `pl-${randomUUID()}`,
|
|
409
|
+
name: newName,
|
|
410
|
+
currency: source.currency,
|
|
411
|
+
isDefault: false,
|
|
412
|
+
priority: 0,
|
|
413
|
+
type: source.type,
|
|
414
|
+
status: 'draft',
|
|
415
|
+
startsAt: null,
|
|
416
|
+
endsAt: null,
|
|
417
|
+
applicationRule: structuredClone(source.applicationRule),
|
|
418
|
+
isSystem: false,
|
|
419
|
+
modifiedAt: new Date(),
|
|
420
|
+
});
|
|
421
|
+
// `priceListId` is a plain column (not a mapped relation), so the UoW does
|
|
422
|
+
// not order the parent insert first — flush the new list before its
|
|
423
|
+
// children, then assignments before brackets (composite-FK ordering).
|
|
424
|
+
await em.flush();
|
|
425
|
+
// Copy assignments first (FK target), then brackets.
|
|
426
|
+
const products = await em.find(PriceListProduct, { priceListId: source.id });
|
|
427
|
+
for (const p of products) {
|
|
428
|
+
em.create(PriceListProduct, { priceListId: dup.id, productId: p.productId });
|
|
429
|
+
}
|
|
430
|
+
await em.flush();
|
|
431
|
+
const brackets = await em.find(PriceListPriceBracket, { priceListId: source.id });
|
|
432
|
+
for (const b of brackets) {
|
|
433
|
+
em.create(PriceListPriceBracket, {
|
|
434
|
+
priceListId: dup.id,
|
|
435
|
+
productId: b.productId,
|
|
436
|
+
currencyCode: b.currencyCode,
|
|
437
|
+
minQuantity: b.minQuantity,
|
|
438
|
+
maxQuantity: b.maxQuantity ?? null,
|
|
439
|
+
amount: b.amount,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
return {
|
|
443
|
+
result: dup,
|
|
444
|
+
row: dup,
|
|
445
|
+
before: null,
|
|
446
|
+
after: {
|
|
447
|
+
name: dup.name,
|
|
448
|
+
sourcePriceListId: source.id,
|
|
449
|
+
sourceName: source.name,
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Normalise + validate an Application Rule (US4 / FR-020..FR-024).
|
|
456
|
+
*
|
|
457
|
+
* Steps in order:
|
|
458
|
+
* 1. Recursively walk the AST.
|
|
459
|
+
* 2. For criteria: dedupe values, uppercase currency codes, validate
|
|
460
|
+
* target IDs against their respective tables. Empty `values` arrays
|
|
461
|
+
* collapse to `{ kind: 'all' }`.
|
|
462
|
+
* 3. For groups: recursively normalise each child, then drop children
|
|
463
|
+
* that collapsed to `{ kind: 'all' }`. If the group becomes empty
|
|
464
|
+
* it collapses to `{ kind: 'all' }` as well; if it's left with a
|
|
465
|
+
* single child, that child takes its place.
|
|
466
|
+
* 4. Reject malformed currencies, depth > 5, and unknown target IDs
|
|
467
|
+
* with `400 VALIDATION_FAILED`.
|
|
468
|
+
*
|
|
469
|
+
* Returns the normalised rule. Does NOT enforce FR-023 (non-Default
|
|
470
|
+
* rules must be non-empty); that gate fires at activation time.
|
|
471
|
+
*/
|
|
472
|
+
async normaliseAndValidateRule(rule) {
|
|
473
|
+
return this.normaliseRuleNode(rule, 0);
|
|
474
|
+
}
|
|
475
|
+
async normaliseRuleNode(node, depth) {
|
|
476
|
+
if (depth > 5) {
|
|
477
|
+
throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, 'Application rule exceeds the maximum nesting depth of 5.');
|
|
478
|
+
}
|
|
479
|
+
if (node.kind === 'all')
|
|
480
|
+
return node;
|
|
481
|
+
if (node.kind === 'criterion') {
|
|
482
|
+
// Currency: uppercase, validate format.
|
|
483
|
+
if (node.type === 'currency') {
|
|
484
|
+
const upper = node.values.map((v) => v.toUpperCase());
|
|
485
|
+
for (const v of upper) {
|
|
486
|
+
if (!/^[A-Z]{3}$/.test(v)) {
|
|
487
|
+
throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, `Invalid currency code in rule: ${v}`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
const dedup = Array.from(new Set(upper));
|
|
491
|
+
if (dedup.length === 0)
|
|
492
|
+
return { kind: 'all' };
|
|
493
|
+
return { kind: 'criterion', type: 'currency', values: dedup };
|
|
494
|
+
}
|
|
495
|
+
// ID-based criterion types.
|
|
496
|
+
const dedup = Array.from(new Set(node.values));
|
|
497
|
+
if (dedup.length === 0)
|
|
498
|
+
return { kind: 'all' };
|
|
499
|
+
await this.assertTargetsExist(node.type, dedup);
|
|
500
|
+
return { kind: 'criterion', type: node.type, values: dedup };
|
|
501
|
+
}
|
|
502
|
+
// Group node.
|
|
503
|
+
const childResults = [];
|
|
504
|
+
for (const child of node.children) {
|
|
505
|
+
const normalised = await this.normaliseRuleNode(child, depth + 1);
|
|
506
|
+
childResults.push(normalised);
|
|
507
|
+
}
|
|
508
|
+
// Drop "all" children — they don't constrain the group.
|
|
509
|
+
const meaningful = childResults.filter((c) => c.kind !== 'all');
|
|
510
|
+
if (meaningful.length === 0) {
|
|
511
|
+
return { kind: 'all' };
|
|
512
|
+
}
|
|
513
|
+
if (meaningful.length === 1) {
|
|
514
|
+
return meaningful[0];
|
|
515
|
+
}
|
|
516
|
+
return { kind: 'group', op: node.op, children: meaningful };
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Validate that every value in an ID-based criterion is a real row in the
|
|
520
|
+
* appropriate table. Throws `400 VALIDATION_FAILED` for any unknown ID.
|
|
521
|
+
*/
|
|
522
|
+
async assertTargetsExist(type, ids) {
|
|
523
|
+
const em = this.emFactory();
|
|
524
|
+
let found;
|
|
525
|
+
switch (type) {
|
|
526
|
+
case 'salesChannel':
|
|
527
|
+
found = await em.count(SalesChannel, { id: { $in: ids } });
|
|
528
|
+
break;
|
|
529
|
+
case 'customerGroup':
|
|
530
|
+
found = (await this.targets().customerGroupRead.findByIds(ids)).length;
|
|
531
|
+
break;
|
|
532
|
+
case 'organization':
|
|
533
|
+
found = await this.targets().organizationDetails.countByIds(ids);
|
|
534
|
+
break;
|
|
535
|
+
case 'category':
|
|
536
|
+
found = await this.targets().catalogCategoryRead.countByIds(ids);
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
if (found !== ids.length) {
|
|
540
|
+
throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, `One or more ${type} IDs in the rule do not exist.`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Date sanity per FR-010: `endsAt` must be greater than `startsAt`; at
|
|
545
|
+
* creation time `endsAt` must be in the future.
|
|
546
|
+
*/
|
|
547
|
+
assertDateSanity(startsAt, endsAt, onCreate) {
|
|
548
|
+
if (endsAt && startsAt && endsAt <= startsAt) {
|
|
549
|
+
throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, 'endsAt must be greater than startsAt.');
|
|
550
|
+
}
|
|
551
|
+
if (onCreate && endsAt && endsAt < new Date()) {
|
|
552
|
+
throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, 'endsAt must be in the future at creation time.');
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
// ---- Engine: linked price-lists panel (US8) ------------------------
|
|
556
|
+
/**
|
|
557
|
+
* For a given product, return every price list the product is assigned
|
|
558
|
+
* to with a per-currency bracket summary and a deep-link path. Powers
|
|
559
|
+
* the admin Catalog product editor's Pricing tab (FR-044/045/046).
|
|
560
|
+
*/
|
|
561
|
+
async summarizeBracketsForProduct(productId) {
|
|
562
|
+
const em = this.emFactory();
|
|
563
|
+
const assignments = await em.find(PriceListProduct, { productId });
|
|
564
|
+
if (assignments.length === 0)
|
|
565
|
+
return [];
|
|
566
|
+
const listIds = assignments.map((a) => a.priceListId);
|
|
567
|
+
const lists = await em.find(PriceList, { id: { $in: listIds } }, { orderBy: { isSystem: 'desc', name: 'asc' } });
|
|
568
|
+
const brackets = await em.find(PriceListPriceBracket, { priceListId: { $in: listIds }, productId }, { orderBy: { currencyCode: 'asc', minQuantity: 'asc' } });
|
|
569
|
+
return lists.map((list) => {
|
|
570
|
+
const listBrackets = brackets.filter((b) => b.priceListId === list.id);
|
|
571
|
+
const byCurrency = listBrackets.reduce((acc, b) => {
|
|
572
|
+
(acc[b.currencyCode] ??= []).push(b);
|
|
573
|
+
return acc;
|
|
574
|
+
}, {});
|
|
575
|
+
const summary = Object.entries(byCurrency)
|
|
576
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
577
|
+
.map(([currencyCode, rows]) => ({
|
|
578
|
+
currencyCode,
|
|
579
|
+
summary: this.summariseBrackets(rows),
|
|
580
|
+
}));
|
|
581
|
+
return {
|
|
582
|
+
list: {
|
|
583
|
+
id: list.id,
|
|
584
|
+
name: list.name,
|
|
585
|
+
type: list.type,
|
|
586
|
+
status: list.status,
|
|
587
|
+
modifiedAt: list.modifiedAt,
|
|
588
|
+
},
|
|
589
|
+
summary,
|
|
590
|
+
deepLinkPath: `/admin/price-lists/${list.id}/products?focus=${productId}`,
|
|
591
|
+
};
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Build a human-readable summary string for one currency's bracket
|
|
596
|
+
* series, e.g.:
|
|
597
|
+
* - "80,0000 across 1 bracket"
|
|
598
|
+
* - "80,0000 – 100,0000 across 3 brackets"
|
|
599
|
+
* The numeric formatting deliberately keeps the storage scale (4
|
|
600
|
+
* fractional digits); the admin UI may re-format per locale.
|
|
601
|
+
*/
|
|
602
|
+
summariseBrackets(rows) {
|
|
603
|
+
if (rows.length === 0)
|
|
604
|
+
return 'no brackets';
|
|
605
|
+
const amounts = rows.map((r) => Number(r.amount));
|
|
606
|
+
const min = Math.min(...amounts);
|
|
607
|
+
const max = Math.max(...amounts);
|
|
608
|
+
const count = rows.length;
|
|
609
|
+
const noun = count === 1 ? 'bracket' : 'brackets';
|
|
610
|
+
if (min === max) {
|
|
611
|
+
return `${this.formatAmount(min)} across ${count} ${noun}`;
|
|
612
|
+
}
|
|
613
|
+
return `${this.formatAmount(min)} – ${this.formatAmount(max)} across ${count} ${noun}`;
|
|
614
|
+
}
|
|
615
|
+
formatAmount(value) {
|
|
616
|
+
return value.toFixed(4);
|
|
617
|
+
}
|
|
618
|
+
// ---- Engine: display mode (US7) ------------------------------------
|
|
619
|
+
/**
|
|
620
|
+
* Upsert a per-Org / per-Category / per-Product display-mode override
|
|
621
|
+
* (FR-038). The mode value `'inherit'` deletes the override row.
|
|
622
|
+
* Validates the target ID against the appropriate table — orphans are
|
|
623
|
+
* refused with 400 (the polymorphic FK is enforced here, not at the DB
|
|
624
|
+
* level, since the target table varies — see data-model.md §1.4).
|
|
625
|
+
*/
|
|
626
|
+
async upsertDisplayModeOverride(scope, targetId, mode) {
|
|
627
|
+
// command-coverage-ignore: a pricing-DISPLAY config toggle
|
|
628
|
+
// (PriceDisplayModeOverride) — a presentation setting for how prices render,
|
|
629
|
+
// not a price/catalog value mutation, so it is not an admin-audit target.
|
|
630
|
+
const em = this.emFactory();
|
|
631
|
+
await this.assertOverrideTargetExists(scope, targetId);
|
|
632
|
+
const existing = await em.findOne(PriceDisplayModeOverride, { scope, targetId });
|
|
633
|
+
if (mode === 'inherit') {
|
|
634
|
+
if (existing) {
|
|
635
|
+
await em.removeAndFlush(existing);
|
|
636
|
+
this.invalidatePricingCache();
|
|
637
|
+
}
|
|
638
|
+
return null;
|
|
639
|
+
}
|
|
640
|
+
if (existing) {
|
|
641
|
+
existing.mode = mode;
|
|
642
|
+
existing.updatedAt = new Date();
|
|
643
|
+
await em.flush();
|
|
644
|
+
this.invalidatePricingCache();
|
|
645
|
+
return existing;
|
|
646
|
+
}
|
|
647
|
+
const row = em.create(PriceDisplayModeOverride, { scope, targetId, mode });
|
|
648
|
+
await em.persistAndFlush(row);
|
|
649
|
+
this.invalidatePricingCache();
|
|
650
|
+
return row;
|
|
651
|
+
}
|
|
652
|
+
async listDisplayModeOverrides(scope) {
|
|
653
|
+
const em = this.emFactory();
|
|
654
|
+
return em.find(PriceDisplayModeOverride, scope ? { scope } : {}, { orderBy: { scope: 'asc', targetId: 'asc' } });
|
|
655
|
+
}
|
|
656
|
+
async getDisplayModeOverride(scope, targetId) {
|
|
657
|
+
return this.emFactory().findOne(PriceDisplayModeOverride, { scope, targetId });
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Update a `pricing.*` settings-group display-mode key. Settings are
|
|
661
|
+
* per-sales-channel under the foundation settings module — when no
|
|
662
|
+
* channel is supplied, this method writes the value across every
|
|
663
|
+
* sales channel (treating it as a tenant-wide override). For
|
|
664
|
+
* channel-specific overrides, callers may pass a single channel.
|
|
665
|
+
*
|
|
666
|
+
* `key` is the bare suffix (e.g. `default_display_mode` or
|
|
667
|
+
* `unauthenticated_display_mode`) — the full code is derived as
|
|
668
|
+
* `pricing.<key>`.
|
|
669
|
+
*/
|
|
670
|
+
async setSettingsDisplayMode(key, mode, salesChannelId) {
|
|
671
|
+
// command-coverage-ignore: writes SettingValue rows owned by the settings
|
|
672
|
+
// module (a cross-module config write); settings changes are the settings
|
|
673
|
+
// module's audit concern, not the price-list admin audit log.
|
|
674
|
+
const em = this.emFactory();
|
|
675
|
+
const code = `pricing.${key}`;
|
|
676
|
+
const setting = await em.findOneOrFail(Setting, { code });
|
|
677
|
+
const channels = salesChannelId
|
|
678
|
+
? [await em.findOneOrFail(SalesChannel, { id: salesChannelId })]
|
|
679
|
+
: await em.find(SalesChannel, {});
|
|
680
|
+
for (const channel of channels) {
|
|
681
|
+
const existing = await em.findOne(SettingValue, {
|
|
682
|
+
setting: setting.id,
|
|
683
|
+
salesChannel: channel.id,
|
|
684
|
+
});
|
|
685
|
+
if (existing) {
|
|
686
|
+
existing.value = mode;
|
|
687
|
+
existing.updatedAt = new Date();
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
em.create(SettingValue, { setting, salesChannel: channel, value: mode });
|
|
691
|
+
}
|
|
692
|
+
await em.flush();
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Read the value of a `pricing.*` display-mode setting for a sales
|
|
696
|
+
* channel. Resolves along the settings tier chain
|
|
697
|
+
* per-channel override → global value → manifest `defaultValue`
|
|
698
|
+
* (mirroring `SettingsService.get`). The global tier lives on
|
|
699
|
+
* `settings.global_value` and is what the admin "All channels" editor
|
|
700
|
+
* writes; without it a globally-set mode (e.g. `both`) was ignored and
|
|
701
|
+
* the resolver silently fell back to the manifest default `gross_only`.
|
|
702
|
+
*/
|
|
703
|
+
async readSettingsDisplayMode(key, salesChannelId) {
|
|
704
|
+
const em = this.emFactory();
|
|
705
|
+
const code = `pricing.${key}`;
|
|
706
|
+
const setting = await em.findOne(Setting, { code });
|
|
707
|
+
if (!setting)
|
|
708
|
+
return 'gross_only';
|
|
709
|
+
const value = await em.findOne(SettingValue, {
|
|
710
|
+
setting: setting.id,
|
|
711
|
+
salesChannel: salesChannelId,
|
|
712
|
+
});
|
|
713
|
+
const raw = (value?.value ?? setting.globalValue ?? setting.defaultValue);
|
|
714
|
+
if (raw === 'gross_only' || raw === 'net_only' || raw === 'both' || raw === 'none') {
|
|
715
|
+
return raw;
|
|
716
|
+
}
|
|
717
|
+
return 'gross_only';
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Resolve the effective display mode for a (product, organization?,
|
|
721
|
+
* salesChannel) tuple along the FR-039 chain
|
|
722
|
+
* Product → Category → Organization → Settings.
|
|
723
|
+
*
|
|
724
|
+
* Walks the product's category memberships to find the most specific
|
|
725
|
+
* category override (deepest in the tree, with `(sort_order ASC, id ASC)`
|
|
726
|
+
* as the deterministic tie-break). Reads override rows from
|
|
727
|
+
* `price_display_mode_overrides`; falls back to the per-channel
|
|
728
|
+
* settings value (`default_display_mode` for signed-in customers,
|
|
729
|
+
* `unauthenticated_display_mode` for guests).
|
|
730
|
+
*/
|
|
731
|
+
async resolveDisplayMode(input) {
|
|
732
|
+
const em = this.emFactory();
|
|
733
|
+
// 1. Product-level override.
|
|
734
|
+
const productOverride = await em.findOne(PriceDisplayModeOverride, {
|
|
735
|
+
scope: 'product',
|
|
736
|
+
targetId: input.productId,
|
|
737
|
+
});
|
|
738
|
+
// 2. Category-level override — the candidates the chain ranks.
|
|
739
|
+
let categoryCandidates = [];
|
|
740
|
+
if (!productOverride) {
|
|
741
|
+
const assignments = await this.targets().catalogCategoryRead.listAssignmentsForProducts([
|
|
742
|
+
input.productId,
|
|
743
|
+
]);
|
|
744
|
+
if (assignments.length > 0) {
|
|
745
|
+
const categoryIds = assignments.map((a) => a.categoryId);
|
|
746
|
+
const overrides = await em.find(PriceDisplayModeOverride, {
|
|
747
|
+
scope: 'category',
|
|
748
|
+
targetId: { $in: categoryIds },
|
|
749
|
+
});
|
|
750
|
+
categoryCandidates = await this.rankCategoryOverrides(overrides);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
// 3. Organization-level override (only signed-in customers).
|
|
754
|
+
const needsOrganization = !productOverride &&
|
|
755
|
+
categoryCandidates.length === 0 &&
|
|
756
|
+
input.customerKind === 'signed_in' &&
|
|
757
|
+
input.organizationId !== null;
|
|
758
|
+
const organizationOverride = needsOrganization
|
|
759
|
+
? await em.findOne(PriceDisplayModeOverride, {
|
|
760
|
+
scope: 'organization',
|
|
761
|
+
targetId: input.organizationId,
|
|
762
|
+
})
|
|
763
|
+
: null;
|
|
764
|
+
const decision = decideDisplayMode({
|
|
765
|
+
productOverride: productOverride?.mode ?? null,
|
|
766
|
+
categoryCandidates,
|
|
767
|
+
organizationOverride: organizationOverride?.mode ?? null,
|
|
768
|
+
});
|
|
769
|
+
if (decision.source !== 'settings')
|
|
770
|
+
return decision.mode;
|
|
771
|
+
// 4. Settings fallback.
|
|
772
|
+
return this.readSettingsDisplayMode(settingsDisplayModeKey(input.customerKind), input.salesChannelId);
|
|
773
|
+
}
|
|
774
|
+
/**
|
|
775
|
+
* The chain's answer with **no product in hand** — feature 086 / FR-016.
|
|
776
|
+
*
|
|
777
|
+
* A price ordering and a price range are refused where the page may not show
|
|
778
|
+
* prices at all, and `pricing.unauthenticated_display_mode = none` is the
|
|
779
|
+
* supported "hide prices until login" configuration. Deciding that needs the
|
|
780
|
+
* mode a *page* resolves to, which is the Organization → Settings tail of the
|
|
781
|
+
* FR-039 chain with the product and category steps deliberately absent.
|
|
782
|
+
*
|
|
783
|
+
* **The absence is the ruling, not a shortcut** (spec 086, clarification 2).
|
|
784
|
+
* A single product overridden to `none` keeps its position in a price
|
|
785
|
+
* ordering: a per-product override reads as "ask us for a quote" rather than
|
|
786
|
+
* "this price is secret", and withdrawing the whole control because one
|
|
787
|
+
* product opts out would make the sort appear and disappear as a buyer walks
|
|
788
|
+
* the catalogue. Moving those products to the tail instead is a change to
|
|
789
|
+
* this method's two omitted steps and to nothing else.
|
|
790
|
+
*
|
|
791
|
+
* The decision itself still goes through `decideDisplayMode`, so a page and a
|
|
792
|
+
* card cannot rank the organisation tier differently.
|
|
793
|
+
*/
|
|
794
|
+
async resolvePageDisplayMode(input) {
|
|
795
|
+
const em = this.emFactory();
|
|
796
|
+
const organizationOverride = input.customerKind === 'signed_in' && input.organizationId !== null
|
|
797
|
+
? await em.findOne(PriceDisplayModeOverride, {
|
|
798
|
+
scope: 'organization',
|
|
799
|
+
targetId: input.organizationId,
|
|
800
|
+
})
|
|
801
|
+
: null;
|
|
802
|
+
const decision = decideDisplayMode({
|
|
803
|
+
productOverride: null,
|
|
804
|
+
categoryCandidates: [],
|
|
805
|
+
organizationOverride: organizationOverride?.mode ?? null,
|
|
806
|
+
});
|
|
807
|
+
if (decision.source !== 'settings')
|
|
808
|
+
return decision.mode;
|
|
809
|
+
return this.readSettingsDisplayMode(settingsDisplayModeKey(input.customerKind), input.salesChannelId);
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* The same chain for a **set** of products (issue #132 follow-up).
|
|
813
|
+
*
|
|
814
|
+
* A catalogue listing asked this question once per product, which cost four
|
|
815
|
+
* to six queries per card on the page's hottest read. Every step is a set
|
|
816
|
+
* lookup — the product-scope overrides, the category memberships, the
|
|
817
|
+
* category-scope overrides, the one organization row and the one settings
|
|
818
|
+
* pair — so the whole page costs what a single product used to.
|
|
819
|
+
*
|
|
820
|
+
* The *decision* is not duplicated: both this method and `resolveDisplayMode`
|
|
821
|
+
* hand their four inputs to `decideDisplayMode`, so a batched page cannot
|
|
822
|
+
* rank an override differently from a product page. Only the loading differs.
|
|
823
|
+
*
|
|
824
|
+
* `categoryIdsByProduct` is an optional prefetch for a caller that has
|
|
825
|
+
* already read the memberships — `PricingService.resolveListingPrices` needs
|
|
826
|
+
* them for the application rules and would otherwise read the same table
|
|
827
|
+
* twice per page.
|
|
828
|
+
*/
|
|
829
|
+
async resolveDisplayModes(input) {
|
|
830
|
+
const out = new Map();
|
|
831
|
+
const productIds = [...new Set(input.productIds)];
|
|
832
|
+
if (productIds.length === 0)
|
|
833
|
+
return out;
|
|
834
|
+
const em = this.emFactory();
|
|
835
|
+
// 1. Product-scope overrides for the whole set.
|
|
836
|
+
const productOverrides = new Map();
|
|
837
|
+
for (const row of await em.find(PriceDisplayModeOverride, {
|
|
838
|
+
scope: 'product',
|
|
839
|
+
targetId: { $in: productIds },
|
|
840
|
+
})) {
|
|
841
|
+
productOverrides.set(row.targetId, row.mode);
|
|
842
|
+
}
|
|
843
|
+
// 2. Category memberships, then the category-scope overrides over their
|
|
844
|
+
// union — one lookup for the page rather than one per card.
|
|
845
|
+
const undecided = productIds.filter((id) => !productOverrides.has(id));
|
|
846
|
+
const categoryIdsByProduct = input.categoryIdsByProduct ?? (await this.loadCategoryMemberships(undecided));
|
|
847
|
+
const categoryUnion = new Set();
|
|
848
|
+
for (const productId of undecided) {
|
|
849
|
+
for (const categoryId of categoryIdsByProduct.get(productId) ?? []) {
|
|
850
|
+
categoryUnion.add(categoryId);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
const rankedByCategory = new Map();
|
|
854
|
+
if (categoryUnion.size > 0) {
|
|
855
|
+
const overrides = await em.find(PriceDisplayModeOverride, {
|
|
856
|
+
scope: 'category',
|
|
857
|
+
targetId: { $in: [...categoryUnion] },
|
|
858
|
+
});
|
|
859
|
+
for (const candidate of await this.rankCategoryOverrides(overrides)) {
|
|
860
|
+
rankedByCategory.set(candidate.categoryId, candidate);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
// 3. The organization row is one row for the whole set.
|
|
864
|
+
let organizationOverride = null;
|
|
865
|
+
if (input.customerKind === 'signed_in' && input.organizationId) {
|
|
866
|
+
const row = await em.findOne(PriceDisplayModeOverride, {
|
|
867
|
+
scope: 'organization',
|
|
868
|
+
targetId: input.organizationId,
|
|
869
|
+
});
|
|
870
|
+
organizationOverride = row?.mode ?? null;
|
|
871
|
+
}
|
|
872
|
+
// 4. Decide, and read the settings pair once if anything still needs it.
|
|
873
|
+
const needingSettings = [];
|
|
874
|
+
for (const productId of productIds) {
|
|
875
|
+
const candidates = [];
|
|
876
|
+
for (const categoryId of categoryIdsByProduct.get(productId) ?? []) {
|
|
877
|
+
const ranked = rankedByCategory.get(categoryId);
|
|
878
|
+
if (ranked)
|
|
879
|
+
candidates.push(ranked);
|
|
880
|
+
}
|
|
881
|
+
const decision = decideDisplayMode({
|
|
882
|
+
productOverride: productOverrides.get(productId) ?? null,
|
|
883
|
+
categoryCandidates: candidates,
|
|
884
|
+
organizationOverride,
|
|
885
|
+
});
|
|
886
|
+
if (decision.source === 'settings')
|
|
887
|
+
needingSettings.push(productId);
|
|
888
|
+
else
|
|
889
|
+
out.set(productId, decision.mode);
|
|
890
|
+
}
|
|
891
|
+
if (needingSettings.length > 0) {
|
|
892
|
+
const fallback = await this.readSettingsDisplayMode(settingsDisplayModeKey(input.customerKind), input.salesChannelId);
|
|
893
|
+
for (const productId of needingSettings)
|
|
894
|
+
out.set(productId, fallback);
|
|
895
|
+
}
|
|
896
|
+
return out;
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* `(product, category)` memberships for a set of products, read through
|
|
900
|
+
* `catalog`'s port (feature 075 / D-87).
|
|
901
|
+
*
|
|
902
|
+
* One call for the whole set, not one per product: the port takes the array
|
|
903
|
+
* for exactly this reason, and a page turning into a round trip per card is
|
|
904
|
+
* the regression this shape exists to prevent.
|
|
905
|
+
*
|
|
906
|
+
* **Structural, deliberately** — no `activeOnly`. The single-product path
|
|
907
|
+
* ranks the same memberships, and `evaluateApplicationRule`'s `category`
|
|
908
|
+
* criterion tests the raw membership, so narrowing to live categories here
|
|
909
|
+
* would make the display-mode chain and the rule that prices the product
|
|
910
|
+
* disagree about which categories a product is in.
|
|
911
|
+
*
|
|
912
|
+
* Every product asked about gets an entry, so an uncategorised product is an
|
|
913
|
+
* empty set rather than a missing key.
|
|
914
|
+
*/
|
|
915
|
+
async loadCategoryMemberships(productIds) {
|
|
916
|
+
const out = new Map();
|
|
917
|
+
for (const id of productIds)
|
|
918
|
+
out.set(id, new Set());
|
|
919
|
+
if (productIds.length === 0)
|
|
920
|
+
return out;
|
|
921
|
+
for (const assignment of await this.targets().catalogCategoryRead.listAssignmentsForProducts(productIds)) {
|
|
922
|
+
out.get(assignment.productId)?.add(assignment.categoryId);
|
|
923
|
+
}
|
|
924
|
+
return out;
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* Turn category-scope override rows into the chain's ranking inputs.
|
|
928
|
+
*
|
|
929
|
+
* `ancestorsOf` answers the category **and** its ancestors, nearest-first, so
|
|
930
|
+
* one port call replaces the `findOne` plus the parent-pointer loop that
|
|
931
|
+
* walked `catalog`'s table a level at a time. Depth is the chain length minus
|
|
932
|
+
* the category itself; an unknown id answers an empty chain and is dropped,
|
|
933
|
+
* which is what the old `continue` did.
|
|
934
|
+
*/
|
|
935
|
+
async rankCategoryOverrides(overrides) {
|
|
936
|
+
const out = [];
|
|
937
|
+
for (const override of overrides) {
|
|
938
|
+
const chain = await this.targets().catalogCategoryRead.ancestorsOf(override.targetId);
|
|
939
|
+
const category = chain[0];
|
|
940
|
+
if (!category)
|
|
941
|
+
continue;
|
|
942
|
+
out.push({
|
|
943
|
+
mode: override.mode,
|
|
944
|
+
depth: chain.length - 1,
|
|
945
|
+
sortOrder: category.sortOrder,
|
|
946
|
+
categoryId: category.id,
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
return out;
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* Validate that a polymorphic override target exists in the appropriate
|
|
953
|
+
* table. Throws 400 VALIDATION_FAILED on orphan target IDs (FR-038).
|
|
954
|
+
*/
|
|
955
|
+
async assertOverrideTargetExists(scope, targetId) {
|
|
956
|
+
let exists;
|
|
957
|
+
switch (scope) {
|
|
958
|
+
case 'organization':
|
|
959
|
+
exists = await this.targets().organizationDetails.countByIds([targetId]);
|
|
960
|
+
break;
|
|
961
|
+
case 'category':
|
|
962
|
+
exists = await this.targets().catalogCategoryRead.countByIds([targetId]);
|
|
963
|
+
break;
|
|
964
|
+
case 'product':
|
|
965
|
+
exists = await this.targets().catalogProductRead.countByIds([targetId]);
|
|
966
|
+
break;
|
|
967
|
+
}
|
|
968
|
+
if (exists === 0) {
|
|
969
|
+
throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, `No ${scope} found with id ${targetId} for display-mode override.`);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
// ---- Engine: product roster + bracket pricing (US3) ----------------
|
|
973
|
+
/**
|
|
974
|
+
* List the assigned products for a price list, with their per-currency
|
|
975
|
+
* brackets in `minQuantity` ascending order. Powers the admin Products
|
|
976
|
+
* tab and the linked-price-lists panel from US8.
|
|
977
|
+
*/
|
|
978
|
+
async listProducts(priceListId) {
|
|
979
|
+
await this.getById(priceListId);
|
|
980
|
+
const em = this.emFactory();
|
|
981
|
+
const assignments = await em.find(PriceListProduct, { priceListId });
|
|
982
|
+
if (assignments.length === 0)
|
|
983
|
+
return [];
|
|
984
|
+
const productIds = assignments.map((a) => a.productId);
|
|
985
|
+
const brackets = await em.find(PriceListPriceBracket, { priceListId, productId: { $in: productIds } }, { orderBy: { currencyCode: 'asc', minQuantity: 'asc' } });
|
|
986
|
+
const byProduct = new Map();
|
|
987
|
+
for (const id of productIds)
|
|
988
|
+
byProduct.set(id, {});
|
|
989
|
+
for (const b of brackets) {
|
|
990
|
+
const buckets = byProduct.get(b.productId);
|
|
991
|
+
if (!buckets)
|
|
992
|
+
continue;
|
|
993
|
+
(buckets[b.currencyCode] ??= []).push({
|
|
994
|
+
minQuantity: b.minQuantity,
|
|
995
|
+
maxQuantity: b.maxQuantity ?? null,
|
|
996
|
+
amount: b.amount,
|
|
997
|
+
});
|
|
998
|
+
}
|
|
999
|
+
return assignments.map((a) => ({
|
|
1000
|
+
productId: a.productId,
|
|
1001
|
+
bracketsByCurrency: byProduct.get(a.productId) ?? {},
|
|
1002
|
+
}));
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Idempotent append: assigns a single product to a price list. No-op when
|
|
1006
|
+
* the assignment already exists. The list's `modifiedAt` is bumped only
|
|
1007
|
+
* when the assignment is actually new.
|
|
1008
|
+
*/
|
|
1009
|
+
async addProduct(priceListId, productId) {
|
|
1010
|
+
await this.#runAudited('price_list.product_add', priceListId, undefined, async (em) => {
|
|
1011
|
+
const list = await this.getById(priceListId, em);
|
|
1012
|
+
const existing = await em.findOne(PriceListProduct, { priceListId, productId });
|
|
1013
|
+
if (existing)
|
|
1014
|
+
return { result: undefined, row: list, before: null, after: null, skipAudit: true };
|
|
1015
|
+
em.create(PriceListProduct, { priceListId, productId });
|
|
1016
|
+
list.modifiedAt = new Date();
|
|
1017
|
+
return { result: undefined, row: list, before: null, after: { name: list.name, productId } };
|
|
1018
|
+
});
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* Removes a product's assignment + its bracket rows (cascade via FK).
|
|
1022
|
+
* No-op when the assignment does not exist.
|
|
1023
|
+
*/
|
|
1024
|
+
async removeProduct(priceListId, productId) {
|
|
1025
|
+
await this.#runAudited('price_list.product_remove', priceListId, undefined, async (em) => {
|
|
1026
|
+
const list = await this.getById(priceListId, em);
|
|
1027
|
+
const existing = await em.findOne(PriceListProduct, { priceListId, productId });
|
|
1028
|
+
if (!existing)
|
|
1029
|
+
return { result: undefined, row: list, before: null, after: null, skipAudit: true };
|
|
1030
|
+
em.remove(existing);
|
|
1031
|
+
list.modifiedAt = new Date();
|
|
1032
|
+
return { result: undefined, row: list, before: null, after: { name: list.name, productId } };
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Bulk delta replacement of the product roster. Adds new pairs, removes
|
|
1037
|
+
* pairs absent from the request. Returns the {added, removed, unchanged}
|
|
1038
|
+
* counts. Cascade deletes brackets for removed pairs.
|
|
1039
|
+
*/
|
|
1040
|
+
async replaceProducts(priceListId, productIds, auditCtx) {
|
|
1041
|
+
return this.#runAudited('price_list.products_replace', priceListId, auditCtx, async (em) => {
|
|
1042
|
+
const list = await this.getById(priceListId, em);
|
|
1043
|
+
const existing = await em.find(PriceListProduct, { priceListId });
|
|
1044
|
+
const existingSet = new Set(existing.map((e) => e.productId));
|
|
1045
|
+
const wanted = new Set(productIds);
|
|
1046
|
+
const toAdd = [...wanted].filter((id) => !existingSet.has(id));
|
|
1047
|
+
const toRemove = existing.filter((e) => !wanted.has(e.productId));
|
|
1048
|
+
const unchanged = [...wanted].filter((id) => existingSet.has(id));
|
|
1049
|
+
for (const id of toAdd)
|
|
1050
|
+
em.create(PriceListProduct, { priceListId, productId: id });
|
|
1051
|
+
for (const row of toRemove)
|
|
1052
|
+
em.remove(row);
|
|
1053
|
+
const result = { added: toAdd.length, removed: toRemove.length, unchanged: unchanged.length };
|
|
1054
|
+
if (toAdd.length === 0 && toRemove.length === 0) {
|
|
1055
|
+
return { result, row: list, before: null, after: null, skipAudit: true };
|
|
1056
|
+
}
|
|
1057
|
+
list.modifiedAt = new Date();
|
|
1058
|
+
// Feature 024 — a single summary audit row carrying only counts
|
|
1059
|
+
// (never the full product-id list — FR-013).
|
|
1060
|
+
return {
|
|
1061
|
+
result,
|
|
1062
|
+
row: list,
|
|
1063
|
+
before: null,
|
|
1064
|
+
after: {
|
|
1065
|
+
name: list.name,
|
|
1066
|
+
added: toAdd.length,
|
|
1067
|
+
removed: toRemove.length,
|
|
1068
|
+
kept: unchanged.length,
|
|
1069
|
+
},
|
|
1070
|
+
};
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Bulk replacement of bracket rows for one (list, product) pair across
|
|
1075
|
+
* every currency in the request. Currencies absent from the input are
|
|
1076
|
+
* cleared. Brackets are validated for:
|
|
1077
|
+
* - assignment existence (404 when product is not in the list);
|
|
1078
|
+
* - per-row sanity: minQuantity ≥ 1, maxQuantity ≥ minQuantity (when
|
|
1079
|
+
* present), amount ≥ 0;
|
|
1080
|
+
* - per-currency overlap freedom (FR-015).
|
|
1081
|
+
*
|
|
1082
|
+
* The whole save runs in one EM flush so a partial save is impossible.
|
|
1083
|
+
*/
|
|
1084
|
+
async replaceBrackets(priceListId, productId, bracketsByCurrency, auditCtx) {
|
|
1085
|
+
return this.#runAudited('price_list.bracket_update', priceListId, auditCtx, async (em) => {
|
|
1086
|
+
const list = await this.getById(priceListId, em);
|
|
1087
|
+
const assignment = await em.findOne(PriceListProduct, { priceListId, productId });
|
|
1088
|
+
if (!assignment) {
|
|
1089
|
+
throw new HttpError(404, ERROR_CODES.NOT_FOUND, `Product ${productId} is not assigned to price list ${priceListId}.`);
|
|
1090
|
+
}
|
|
1091
|
+
const validated = {};
|
|
1092
|
+
for (const [currencyRaw, rows] of Object.entries(bracketsByCurrency)) {
|
|
1093
|
+
const currency = currencyRaw.toUpperCase();
|
|
1094
|
+
if (!/^[A-Z]{3}$/.test(currency)) {
|
|
1095
|
+
throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, `Invalid currency code: ${currencyRaw}`);
|
|
1096
|
+
}
|
|
1097
|
+
const sorted = [...rows].sort((a, b) => a.minQuantity - b.minQuantity);
|
|
1098
|
+
this.assertBracketSanity(currency, sorted);
|
|
1099
|
+
this.assertNoOverlap(currency, sorted);
|
|
1100
|
+
validated[currency] = sorted;
|
|
1101
|
+
}
|
|
1102
|
+
// Replace: remove every existing bracket for the (list, product) pair
|
|
1103
|
+
// FIRST, flush, then insert the new rows. The two-flush split keeps
|
|
1104
|
+
// MikroORM's UoW from collapsing identical-PK delete+insert pairs.
|
|
1105
|
+
const existing = await em.find(PriceListPriceBracket, { priceListId, productId });
|
|
1106
|
+
for (const row of existing)
|
|
1107
|
+
em.remove(row);
|
|
1108
|
+
if (existing.length > 0) {
|
|
1109
|
+
await em.flush();
|
|
1110
|
+
}
|
|
1111
|
+
const out = {};
|
|
1112
|
+
for (const [currency, rows] of Object.entries(validated)) {
|
|
1113
|
+
const inserted = [];
|
|
1114
|
+
for (const r of rows) {
|
|
1115
|
+
em.create(PriceListPriceBracket, {
|
|
1116
|
+
priceListId,
|
|
1117
|
+
productId,
|
|
1118
|
+
currencyCode: currency,
|
|
1119
|
+
minQuantity: r.minQuantity,
|
|
1120
|
+
maxQuantity: r.maxQuantity ?? null,
|
|
1121
|
+
amount: r.amount,
|
|
1122
|
+
});
|
|
1123
|
+
inserted.push({
|
|
1124
|
+
minQuantity: r.minQuantity,
|
|
1125
|
+
maxQuantity: r.maxQuantity ?? null,
|
|
1126
|
+
amount: r.amount,
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
out[currency] = inserted;
|
|
1130
|
+
}
|
|
1131
|
+
list.modifiedAt = new Date();
|
|
1132
|
+
// Feature 024 — one audit row per (list, product) bracket save, with
|
|
1133
|
+
// per-currency bracket counts in the summary (never the raw amounts).
|
|
1134
|
+
const summary = {};
|
|
1135
|
+
for (const [currency, rows] of Object.entries(out)) {
|
|
1136
|
+
summary[currency] = rows.length;
|
|
1137
|
+
}
|
|
1138
|
+
return {
|
|
1139
|
+
result: out,
|
|
1140
|
+
row: list,
|
|
1141
|
+
before: null,
|
|
1142
|
+
after: { name: list.name, productId, bracketCountByCurrency: summary },
|
|
1143
|
+
};
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Convenience: copy one currency's brackets into other currencies for the
|
|
1148
|
+
* same (list, product) pair. The target currencies' existing brackets (if
|
|
1149
|
+
* any) are NOT cleared — this method appends. Conflicts on
|
|
1150
|
+
* `(currency, minQuantity)` are refused via the PK constraint (caller
|
|
1151
|
+
* should normally use this on currencies with no brackets yet, e.g. via
|
|
1152
|
+
* the admin "Copy currency" affordance).
|
|
1153
|
+
*/
|
|
1154
|
+
async copyCurrencyBrackets(priceListId, productId, fromCurrency, toCurrencies) {
|
|
1155
|
+
return this.#runAudited('price_list.bracket_copy', priceListId, undefined, async (em) => {
|
|
1156
|
+
const list = await this.getById(priceListId, em);
|
|
1157
|
+
const assignment = await em.findOne(PriceListProduct, { priceListId, productId });
|
|
1158
|
+
if (!assignment) {
|
|
1159
|
+
throw new HttpError(404, ERROR_CODES.NOT_FOUND, `Product ${productId} is not assigned to price list ${priceListId}.`);
|
|
1160
|
+
}
|
|
1161
|
+
const source = await em.find(PriceListPriceBracket, {
|
|
1162
|
+
priceListId,
|
|
1163
|
+
productId,
|
|
1164
|
+
currencyCode: fromCurrency.toUpperCase(),
|
|
1165
|
+
});
|
|
1166
|
+
if (source.length === 0) {
|
|
1167
|
+
throw new HttpError(404, ERROR_CODES.NOT_FOUND, `Source currency ${fromCurrency} has no brackets for this product.`);
|
|
1168
|
+
}
|
|
1169
|
+
let added = 0;
|
|
1170
|
+
for (const targetRaw of toCurrencies) {
|
|
1171
|
+
const target = targetRaw.toUpperCase();
|
|
1172
|
+
if (target === fromCurrency.toUpperCase())
|
|
1173
|
+
continue;
|
|
1174
|
+
if (!/^[A-Z]{3}$/.test(target)) {
|
|
1175
|
+
throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, `Invalid currency code: ${targetRaw}`);
|
|
1176
|
+
}
|
|
1177
|
+
for (const b of source) {
|
|
1178
|
+
const conflict = await em.findOne(PriceListPriceBracket, {
|
|
1179
|
+
priceListId,
|
|
1180
|
+
productId,
|
|
1181
|
+
currencyCode: target,
|
|
1182
|
+
minQuantity: b.minQuantity,
|
|
1183
|
+
});
|
|
1184
|
+
if (conflict)
|
|
1185
|
+
continue;
|
|
1186
|
+
em.create(PriceListPriceBracket, {
|
|
1187
|
+
priceListId,
|
|
1188
|
+
productId,
|
|
1189
|
+
currencyCode: target,
|
|
1190
|
+
minQuantity: b.minQuantity,
|
|
1191
|
+
maxQuantity: b.maxQuantity ?? null,
|
|
1192
|
+
amount: b.amount,
|
|
1193
|
+
});
|
|
1194
|
+
added += 1;
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
const result = { added };
|
|
1198
|
+
if (added === 0)
|
|
1199
|
+
return { result, row: list, before: null, after: null, skipAudit: true };
|
|
1200
|
+
list.modifiedAt = new Date();
|
|
1201
|
+
return { result, row: list, before: null, after: { name: list.name, productId, added } };
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* Per-row bracket sanity (FR-014, FR-015).
|
|
1206
|
+
*/
|
|
1207
|
+
assertBracketSanity(currency, rows) {
|
|
1208
|
+
for (const [i, r] of rows.entries()) {
|
|
1209
|
+
if (!Number.isInteger(r.minQuantity) || r.minQuantity < 1) {
|
|
1210
|
+
throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, `Bracket ${currency}[${i}]: minQuantity must be an integer ≥ 1 (got ${r.minQuantity}).`);
|
|
1211
|
+
}
|
|
1212
|
+
if (r.maxQuantity != null) {
|
|
1213
|
+
if (!Number.isInteger(r.maxQuantity) || r.maxQuantity < r.minQuantity) {
|
|
1214
|
+
throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, `Bracket ${currency}[${i}]: maxQuantity (${r.maxQuantity}) must be an integer ≥ minQuantity (${r.minQuantity}).`);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
const amount = Number(r.amount);
|
|
1218
|
+
if (!Number.isFinite(amount) || amount < 0) {
|
|
1219
|
+
throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, `Bracket ${currency}[${i}]: amount must be ≥ 0 (got ${r.amount}).`);
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
/**
|
|
1224
|
+
* Overlap-freedom within a currency series (FR-015). Assumes the input is
|
|
1225
|
+
* sorted ascending by `minQuantity`.
|
|
1226
|
+
*/
|
|
1227
|
+
assertNoOverlap(currency, sorted) {
|
|
1228
|
+
for (let i = 1; i < sorted.length; i += 1) {
|
|
1229
|
+
const prev = sorted[i - 1];
|
|
1230
|
+
const curr = sorted[i];
|
|
1231
|
+
const prevMax = prev.maxQuantity ?? Number.POSITIVE_INFINITY;
|
|
1232
|
+
if (prevMax >= curr.minQuantity) {
|
|
1233
|
+
throw new HttpError(400, ERROR_CODES.VALIDATION_FAILED, `Bracket overlap in ${currency}: [${prev.minQuantity}..${prev.maxQuantity ?? '∞'}] overlaps [${curr.minQuantity}..${curr.maxQuantity ?? '∞'}].`);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
//# sourceMappingURL=price-list-service.js.map
|