@holoscript/plugin-retail-ecommerce 2.0.1
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/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/package.json +14 -0
- package/src/__tests__/retailsolver.test.ts +324 -0
- package/src/__tests__/runtime-integration.test.ts +108 -0
- package/src/index.ts +18 -0
- package/src/retailsolver.ts +446 -0
- package/src/runtime.ts +162 -0
- package/src/traits/CartTrait.ts +42 -0
- package/src/traits/CheckoutTrait.ts +29 -0
- package/src/traits/ProductCatalogTrait.ts +28 -0
- package/src/traits/ReturnTrait.ts +23 -0
- package/src/traits/ShippingRateTrait.ts +19 -0
- package/src/traits/types.ts +4 -0
- package/tsconfig.json +1 -0
- package/vitest.config.ts +17 -0
package/CHANGELOG.md
ADDED
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 HoloScript Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@holoscript/plugin-retail-ecommerce",
|
|
3
|
+
"version": "2.0.1",
|
|
4
|
+
"description": "HoloScript domain plugin for retail-ecommerce",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"peerDependencies": {
|
|
7
|
+
"@holoscript/core": "8.0.6"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "vitest run --passWithNoTests",
|
|
12
|
+
"test:coverage": "vitest run --coverage --passWithNoTests"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retail & e-commerce solver tests — retail-ecommerce-plugin
|
|
3
|
+
*
|
|
4
|
+
* Reference values verified against:
|
|
5
|
+
* - Harris FW (1913) Operations and Cost — EOQ formula
|
|
6
|
+
* - Philips RL (2005) Pricing and Revenue Optimization — elasticity
|
|
7
|
+
* - CLV standard industry formulas (AGSM)
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect } from 'vitest';
|
|
11
|
+
import {
|
|
12
|
+
economicOrderQuantity,
|
|
13
|
+
priceOptimization,
|
|
14
|
+
markdownOptimization,
|
|
15
|
+
customerLifetimeValue,
|
|
16
|
+
conversionFunnelAnalysis,
|
|
17
|
+
abcClassification,
|
|
18
|
+
inventoryMetrics,
|
|
19
|
+
buildRetailReceipt,
|
|
20
|
+
} from '../retailsolver';
|
|
21
|
+
|
|
22
|
+
// ─── EOQ ─────────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
describe('economicOrderQuantity', () => {
|
|
25
|
+
/**
|
|
26
|
+
* Classic EOQ example: D=1000, S=$10, H=$2.50/unit/yr
|
|
27
|
+
* EOQ = √(2×1000×10/2.5) = √8000 ≈ 89.44
|
|
28
|
+
* Orders/year = 1000/89.44 ≈ 11.18
|
|
29
|
+
* TAC = (1000/89.44)×10 + (89.44/2)×2.5 ≈ $223.61
|
|
30
|
+
*/
|
|
31
|
+
it('EOQ = √(2DS/H)', () => {
|
|
32
|
+
const r = economicOrderQuantity(1000, 10, 2.5);
|
|
33
|
+
expect(r.eoq).toBeCloseTo(Math.sqrt(2 * 1000 * 10 / 2.5), 1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('ordersPerYear = demand / EOQ', () => {
|
|
37
|
+
const r = economicOrderQuantity(1000, 10, 2.5);
|
|
38
|
+
expect(r.ordersPerYear).toBeCloseTo(1000 / r.eoq, 4);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('TAC = (D/EOQ)×S + (EOQ/2)×H', () => {
|
|
42
|
+
const r = economicOrderQuantity(1000, 10, 2.5);
|
|
43
|
+
const expected = (1000 / r.eoq) * 10 + (r.eoq / 2) * 2.5;
|
|
44
|
+
expect(r.totalAnnualCost).toBeCloseTo(expected, 1);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('reorder point = daily demand × lead time', () => {
|
|
48
|
+
const r = economicOrderQuantity(2500, 50, 5, 5, 250);
|
|
49
|
+
// Daily demand = 2500/250 = 10, leadTime = 5 → ROP = 50
|
|
50
|
+
expect(r.reorderPoint).toBeCloseTo(50, 4);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('higher ordering cost → higher EOQ', () => {
|
|
54
|
+
const r1 = economicOrderQuantity(1000, 10, 2.5);
|
|
55
|
+
const r2 = economicOrderQuantity(1000, 50, 2.5);
|
|
56
|
+
expect(r2.eoq).toBeGreaterThan(r1.eoq);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('throws for zero annual demand', () => {
|
|
60
|
+
expect(() => economicOrderQuantity(0, 10, 2.5)).toThrow();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('throws for zero holding cost', () => {
|
|
64
|
+
expect(() => economicOrderQuantity(1000, 10, 0)).toThrow();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// ─── Price optimization ───────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
describe('priceOptimization', () => {
|
|
71
|
+
/**
|
|
72
|
+
* Elastic demand: ε = -2 (elastic)
|
|
73
|
+
* Profit-maximizing price should be found via ternary search.
|
|
74
|
+
* With variable cost = 0: dπ/dP = Q(1 + 1/ε) = 0 → P* = P₀/(1+1/ε) ... but with variable cost = 0 and elasticity=-2, revenue is maximized at a price where MR=0.
|
|
75
|
+
*/
|
|
76
|
+
it('elastic demand (ε=-2): revenue maximized by lower price', () => {
|
|
77
|
+
// With ε=-2, revenue decreasing above current price → optimal < current
|
|
78
|
+
const r = priceOptimization(100, 1000, -2);
|
|
79
|
+
// Optimal price should give higher revenue than current in elastic region
|
|
80
|
+
expect(r.optimalRevenue).toBeGreaterThanOrEqual(r.currentRevenue - 0.01);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('optimalRevenue is positive and finite', () => {
|
|
84
|
+
// Solver maximizes PROFIT (not revenue); with variableCost=20, inelastic ε=-1.5
|
|
85
|
+
// raises price above $50 → higher margin but lower revenue. Revenue need not be ≥ current.
|
|
86
|
+
const r = priceOptimization(50, 500, -1.5, 20);
|
|
87
|
+
expect(r.optimalRevenue).toBeGreaterThan(0);
|
|
88
|
+
expect(Number.isFinite(r.optimalRevenue)).toBe(true);
|
|
89
|
+
expect(r.optimalPrice).toBeGreaterThan(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('priceDemandCurve has 21 points', () => {
|
|
93
|
+
const r = priceOptimization(100, 500, -1.5);
|
|
94
|
+
expect(r.priceDemandCurve).toHaveLength(21);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('demand decreases as price increases (law of demand)', () => {
|
|
98
|
+
const r = priceOptimization(100, 500, -1.5);
|
|
99
|
+
const curve = r.priceDemandCurve;
|
|
100
|
+
for (let i = 1; i < curve.length; i++) {
|
|
101
|
+
expect(curve[i].demand).toBeLessThanOrEqual(curve[i - 1].demand + 0.01);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('throws for non-negative elasticity', () => {
|
|
106
|
+
expect(() => priceOptimization(100, 500, 0)).toThrow();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('throws for zero current demand', () => {
|
|
110
|
+
expect(() => priceOptimization(100, 0, -1.5)).toThrow();
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ─── Markdown optimization ────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
describe('markdownOptimization', () => {
|
|
117
|
+
it('schedule has same number of entries as markdownSteps', () => {
|
|
118
|
+
const r = markdownOptimization(100, 500, 90, 5, -2.0);
|
|
119
|
+
expect(r.schedule).toHaveLength(4); // default 4 steps
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('prices decrease with each markdown step', () => {
|
|
123
|
+
const r = markdownOptimization(100, 500, 90, 5, -2.0);
|
|
124
|
+
for (let i = 1; i < r.schedule.length; i++) {
|
|
125
|
+
expect(r.schedule[i].price).toBeLessThanOrEqual(r.schedule[i - 1].price);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('expectedRevenue ≥ 0', () => {
|
|
130
|
+
const r = markdownOptimization(100, 500, 90, 5, -2.0);
|
|
131
|
+
expect(r.expectedRevenue).toBeGreaterThanOrEqual(0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('expectedUnsold ≥ 0', () => {
|
|
135
|
+
const r = markdownOptimization(100, 50, 30, 2, -3.0, [10]);
|
|
136
|
+
expect(r.expectedUnsold).toBeGreaterThanOrEqual(0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('throws for non-positive inventory', () => {
|
|
140
|
+
expect(() => markdownOptimization(100, 0, 90, 5, -2)).toThrow();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ─── Customer Lifetime Value ──────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
describe('customerLifetimeValue', () => {
|
|
147
|
+
/**
|
|
148
|
+
* AOV=$100, freq=4/yr, lifespan=5yr, r=0.10
|
|
149
|
+
* Simple CLV = 100 × 4 × 5 = $2000
|
|
150
|
+
* Discounted CLV = Σ_{t=1}^{5} 400 / 1.1^t ≈ 400 × 3.791 ≈ $1516
|
|
151
|
+
*/
|
|
152
|
+
it('simpleCLV = AOV × frequency × lifespan', () => {
|
|
153
|
+
const r = customerLifetimeValue(100, 4, 5, 0.10);
|
|
154
|
+
expect(r.simpleCLV).toBeCloseTo(2000, 4);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('discountedCLV < simpleCLV for positive discount rate', () => {
|
|
158
|
+
const r = customerLifetimeValue(100, 4, 5, 0.10);
|
|
159
|
+
expect(r.discountedCLV).toBeLessThan(r.simpleCLV);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('discountedCLV ≈ simpleCLV when rate = 0', () => {
|
|
163
|
+
const r = customerLifetimeValue(100, 4, 5, 0);
|
|
164
|
+
expect(r.discountedCLV).toBeCloseTo(r.simpleCLV, 0);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('CLV:CAC ratio computed when CAC provided', () => {
|
|
168
|
+
const r = customerLifetimeValue(100, 4, 5, 0.10, 200);
|
|
169
|
+
expect(r.clvToCac).not.toBeNull();
|
|
170
|
+
expect(r.clvToCac!).toBeCloseTo(r.discountedCLV / 200, 4);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('CLV:CAC null when CAC not provided', () => {
|
|
174
|
+
const r = customerLifetimeValue(100, 4, 5, 0.10);
|
|
175
|
+
expect(r.clvToCac).toBeNull();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('throws for zero AOV', () => {
|
|
179
|
+
expect(() => customerLifetimeValue(0, 4, 5, 0.10)).toThrow();
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ─── Conversion funnel ────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
describe('conversionFunnelAnalysis', () => {
|
|
186
|
+
/**
|
|
187
|
+
* Funnel: Visit → Cart → Checkout → Purchase
|
|
188
|
+
* 10000 → 2000 → 500 → 250
|
|
189
|
+
* Step conversions: 20%, 25%, 50%
|
|
190
|
+
* Overall: 250/10000 = 2.5%
|
|
191
|
+
*/
|
|
192
|
+
const stages = ['Visit', 'Cart', 'Checkout', 'Purchase'];
|
|
193
|
+
const counts = [10000, 2000, 500, 250];
|
|
194
|
+
|
|
195
|
+
it('step conversions correct', () => {
|
|
196
|
+
const r = conversionFunnelAnalysis(stages, counts, 75);
|
|
197
|
+
expect(r.stepConversions[0]).toBeCloseTo(0.20, 4);
|
|
198
|
+
expect(r.stepConversions[1]).toBeCloseTo(0.25, 4);
|
|
199
|
+
expect(r.stepConversions[2]).toBeCloseTo(0.50, 4);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('overall conversion = final / first', () => {
|
|
203
|
+
const r = conversionFunnelAnalysis(stages, counts, 75);
|
|
204
|
+
expect(r.overallConversion).toBeCloseTo(0.025, 4);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('sensitivity revenue is positive for each step', () => {
|
|
208
|
+
const r = conversionFunnelAnalysis(stages, counts, 75);
|
|
209
|
+
for (const s of r.sensitivityRevenue) expect(s).toBeGreaterThanOrEqual(0);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('throws when stages.length ≠ counts.length', () => {
|
|
213
|
+
expect(() => conversionFunnelAnalysis(['A', 'B'], [100], 50)).toThrow();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('throws for fewer than 2 stages', () => {
|
|
217
|
+
expect(() => conversionFunnelAnalysis(['A'], [100], 50)).toThrow();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ─── ABC classification ───────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
describe('abcClassification', () => {
|
|
224
|
+
const items = [
|
|
225
|
+
{ id: 'SKU-1', annualVolume: 100, unitCost: 500 }, // $50k — A
|
|
226
|
+
{ id: 'SKU-2', annualVolume: 500, unitCost: 20 }, // $10k — A/B
|
|
227
|
+
{ id: 'SKU-3', annualVolume: 1000, unitCost: 5 }, // $5k — B
|
|
228
|
+
{ id: 'SKU-4', annualVolume: 5000, unitCost: 0.5 }, // $2.5k — C
|
|
229
|
+
{ id: 'SKU-5', annualVolume: 2000, unitCost: 0.2 }, // $400 — C
|
|
230
|
+
];
|
|
231
|
+
|
|
232
|
+
it('class A item has highest annual value', () => {
|
|
233
|
+
const r = abcClassification(items);
|
|
234
|
+
expect(r.items[0].class).toBe('A');
|
|
235
|
+
expect(r.items[0].id).toBe('SKU-1');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('all items are classified', () => {
|
|
239
|
+
const r = abcClassification(items);
|
|
240
|
+
expect(r.items).toHaveLength(items.length);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('class A holds the largest share of total value (Pareto principle)', () => {
|
|
244
|
+
// With discrete items, class A may not hit exactly 80% — it holds up to 80% threshold
|
|
245
|
+
// SKU-1 ($50k) = 73.6% of total $67.9k — class A boundary stops adding items at 80%
|
|
246
|
+
const r = abcClassification(items);
|
|
247
|
+
expect(r.classValuePct.A).toBeGreaterThan(50);
|
|
248
|
+
expect(r.classValuePct.A + r.classValuePct.B + r.classValuePct.C).toBeCloseTo(100, 4);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('cumulative % is monotonically increasing', () => {
|
|
252
|
+
const r = abcClassification(items);
|
|
253
|
+
for (let i = 1; i < r.items.length; i++) {
|
|
254
|
+
expect(r.items[i].cumulativePct).toBeGreaterThanOrEqual(r.items[i - 1].cumulativePct);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('throws for empty items', () => {
|
|
259
|
+
expect(() => abcClassification([])).toThrow();
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// ─── Inventory metrics ────────────────────────────────────────────────────────
|
|
264
|
+
|
|
265
|
+
describe('inventoryMetrics', () => {
|
|
266
|
+
/**
|
|
267
|
+
* COGS=$500k, avg inventory=$100k → turnover=5, DSI=73 days
|
|
268
|
+
*/
|
|
269
|
+
it('inventory turnover = COGS / avgInventory', () => {
|
|
270
|
+
const r = inventoryMetrics(500_000, 100_000);
|
|
271
|
+
expect(r.inventoryTurnover).toBeCloseTo(5, 5);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('DSI = 365 / turnover', () => {
|
|
275
|
+
const r = inventoryMetrics(500_000, 100_000);
|
|
276
|
+
expect(r.dsi).toBeCloseTo(365 / 5, 4);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('withinTarget=true when DSI ≤ targetDsi', () => {
|
|
280
|
+
const r = inventoryMetrics(500_000, 100_000, 90);
|
|
281
|
+
expect(r.withinTarget).toBe(true);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('withinTarget=false when DSI > targetDsi', () => {
|
|
285
|
+
const r = inventoryMetrics(100_000, 100_000, 30); // DSI=365 > 30
|
|
286
|
+
expect(r.withinTarget).toBe(false);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('throws for zero COGS', () => {
|
|
290
|
+
expect(() => inventoryMetrics(0, 100_000)).toThrow();
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// ─── Receipt ─────────────────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
describe('buildRetailReceipt', () => {
|
|
297
|
+
it('produces receipt with plugin=retail-ecommerce and CAEL event', () => {
|
|
298
|
+
const eoq = economicOrderQuantity(1000, 10, 2.5);
|
|
299
|
+
const receipt = buildRetailReceipt({ eoq, converged: true });
|
|
300
|
+
expect(receipt.plugin).toBe('retail-ecommerce');
|
|
301
|
+
expect(receipt.cael.event).toBe('retail_ecommerce.retail_analysis');
|
|
302
|
+
expect(receipt.payloadHash).toBeTruthy();
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('accepted=true for healthy metrics', () => {
|
|
306
|
+
const clv = customerLifetimeValue(200, 5, 3, 0.10, 150); // CLV:CAC ≈ 3+
|
|
307
|
+
const receipt = buildRetailReceipt({ clv, converged: true });
|
|
308
|
+
expect(receipt.acceptance.accepted).toBe(true);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('accepted=false when CLV:CAC < 3', () => {
|
|
312
|
+
const clv = customerLifetimeValue(50, 1, 1, 0.10, 500); // poor CLV:CAC
|
|
313
|
+
const receipt = buildRetailReceipt({ clv, converged: true });
|
|
314
|
+
if (clv.clvToCac! < 3) {
|
|
315
|
+
expect(receipt.acceptance.accepted).toBe(false);
|
|
316
|
+
expect(receipt.acceptance.violations.length).toBeGreaterThan(0);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('uses provided runId', () => {
|
|
321
|
+
const receipt = buildRetailReceipt({ converged: true }, { runId: 'retail-run-42' });
|
|
322
|
+
expect(receipt.runId).toBe('retail-run-42');
|
|
323
|
+
});
|
|
324
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration proof: the retail-ecommerce `eoq` trait, once registered via the
|
|
3
|
+
* runtime's real `registerTrait` seam, is dispatched BY THE RUNTIME and runs
|
|
4
|
+
* the Harris-Wilson Economic Order Quantity solver — NOT called directly as a
|
|
5
|
+
* handler object (the thin convention every other trait test uses).
|
|
6
|
+
*
|
|
7
|
+
* Mirrors the energy-grid reference integration. Drives the real path:
|
|
8
|
+
* executeNode(orb) -> orb-executor -> applyDirectives ->
|
|
9
|
+
* traitHandlers.get('eoq').onAttach -> economicOrderQuantity.
|
|
10
|
+
* The negative control proves the registration is load-bearing (without it,
|
|
11
|
+
* the trait is a dead no-op — which is exactly the tier's status quo).
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect } from 'vitest';
|
|
14
|
+
import { HoloScriptRuntime } from '@holoscript/core/runtime';
|
|
15
|
+
import { registerRetailEcommerceTraitHandlers } from '../runtime';
|
|
16
|
+
|
|
17
|
+
// Hand-checkable EOQ: D=1000, S=10, H=2.5 => sqrt(2*1000*10/2.5) = sqrt(8000) ≈ 89.4427.
|
|
18
|
+
const EOQ_CONFIG = {
|
|
19
|
+
annualDemand: 1000,
|
|
20
|
+
orderingCost: 10,
|
|
21
|
+
holdingCostPerUnit: 2.5,
|
|
22
|
+
};
|
|
23
|
+
const EXPECTED_EOQ = Math.sqrt((2 * 1000 * 10) / 2.5); // 89.44271909999159
|
|
24
|
+
|
|
25
|
+
function eoqOrb(config: Record<string, unknown>): unknown {
|
|
26
|
+
return {
|
|
27
|
+
type: 'orb',
|
|
28
|
+
name: 'retail',
|
|
29
|
+
properties: {},
|
|
30
|
+
methods: [],
|
|
31
|
+
position: [0, 0, 0],
|
|
32
|
+
hologram: { shape: 'orb', color: '#fff', size: 1, glow: false, interactive: false },
|
|
33
|
+
directives: [{ type: 'trait', name: 'eoq', config }],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Flush the runtime's async emit dispatch so `on` listeners have fired. */
|
|
38
|
+
const flush = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
|
|
39
|
+
|
|
40
|
+
describe('retail-ecommerce -> HoloScript runtime integration (eoq)', () => {
|
|
41
|
+
it('runtime dispatch runs the EOQ solver for a registered @eoq orb', async () => {
|
|
42
|
+
const runtime = new HoloScriptRuntime();
|
|
43
|
+
registerRetailEcommerceTraitHandlers(runtime);
|
|
44
|
+
|
|
45
|
+
const solved: Array<Record<string, unknown>> = [];
|
|
46
|
+
runtime.on('eoq_solved', (e: unknown) => {
|
|
47
|
+
solved.push(e as Record<string, unknown>);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
await runtime.executeNode(eoqOrb(EOQ_CONFIG) as never);
|
|
51
|
+
await flush();
|
|
52
|
+
|
|
53
|
+
expect(solved).toHaveLength(1);
|
|
54
|
+
const summary = solved[0];
|
|
55
|
+
// EOQ = sqrt(2*D*S/H) = sqrt(8000) ≈ 89.44.
|
|
56
|
+
expect(summary.eoq as number).toBeCloseTo(EXPECTED_EOQ, 5);
|
|
57
|
+
expect(summary.eoq as number).toBeCloseTo(89.4427, 4);
|
|
58
|
+
// ordersPerYear = D / EOQ = 1000 / 89.4427 ≈ 11.1803.
|
|
59
|
+
expect(summary.ordersPerYear as number).toBeCloseTo(1000 / EXPECTED_EOQ, 5);
|
|
60
|
+
expect(summary.annualDemand).toBe(1000);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('NEGATIVE CONTROL: without registration the @eoq trait is a dead no-op', async () => {
|
|
64
|
+
const runtime = new HoloScriptRuntime(); // intentionally NOT registered
|
|
65
|
+
const solved: unknown[] = [];
|
|
66
|
+
runtime.on('eoq_solved', (e: unknown) => solved.push(e));
|
|
67
|
+
|
|
68
|
+
await runtime.executeNode(eoqOrb(EOQ_CONFIG) as never);
|
|
69
|
+
await flush();
|
|
70
|
+
|
|
71
|
+
expect(solved).toHaveLength(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('persists the solver result into durable runtime state on ATTACH', async () => {
|
|
75
|
+
const runtime = new HoloScriptRuntime();
|
|
76
|
+
registerRetailEcommerceTraitHandlers(runtime);
|
|
77
|
+
|
|
78
|
+
await runtime.executeNode(eoqOrb(EOQ_CONFIG) as never);
|
|
79
|
+
await flush();
|
|
80
|
+
|
|
81
|
+
const state = runtime.getState() as Record<string, unknown>;
|
|
82
|
+
const persisted = state['eoq:retail'] as
|
|
83
|
+
| { eoq?: number; annualDemand?: number }
|
|
84
|
+
| undefined;
|
|
85
|
+
expect(persisted).toBeDefined();
|
|
86
|
+
expect(persisted?.eoq).toBeCloseTo(EXPECTED_EOQ, 5);
|
|
87
|
+
expect(persisted?.annualDemand).toBe(1000);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('emits eoq_error (does not throw through the runtime) for missing inputs', async () => {
|
|
91
|
+
const runtime = new HoloScriptRuntime();
|
|
92
|
+
registerRetailEcommerceTraitHandlers(runtime);
|
|
93
|
+
|
|
94
|
+
const errors: Array<Record<string, unknown>> = [];
|
|
95
|
+
runtime.on('eoq_error', (e: unknown) => {
|
|
96
|
+
errors.push(e as Record<string, unknown>);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// holdingCostPerUnit omitted -> the handler emits eoq_error, never throws.
|
|
100
|
+
await runtime.executeNode(
|
|
101
|
+
eoqOrb({ annualDemand: 1000, orderingCost: 10 }) as never,
|
|
102
|
+
);
|
|
103
|
+
await flush();
|
|
104
|
+
|
|
105
|
+
expect(errors).toHaveLength(1);
|
|
106
|
+
expect(String(errors[0].error)).toContain('holdingCostPerUnit');
|
|
107
|
+
});
|
|
108
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { createCartHandler, type CartConfig, type CartItem, type CartState } from './traits/CartTrait';
|
|
2
|
+
export { createCheckoutHandler, type CheckoutConfig, type CheckoutStep, type PaymentMethod, type Address } from './traits/CheckoutTrait';
|
|
3
|
+
export { createProductCatalogHandler, type ProductCatalogConfig, type Product } from './traits/ProductCatalogTrait';
|
|
4
|
+
export { createShippingRateHandler, type ShippingRateConfig, type ShippingMethod } from './traits/ShippingRateTrait';
|
|
5
|
+
export { createReturnHandler, type ReturnConfig, type ReturnStatus } from './traits/ReturnTrait';
|
|
6
|
+
export * from './traits/types';
|
|
7
|
+
|
|
8
|
+
import { createCartHandler } from './traits/CartTrait';
|
|
9
|
+
import { createCheckoutHandler } from './traits/CheckoutTrait';
|
|
10
|
+
import { createProductCatalogHandler } from './traits/ProductCatalogTrait';
|
|
11
|
+
import { createShippingRateHandler } from './traits/ShippingRateTrait';
|
|
12
|
+
import { createReturnHandler } from './traits/ReturnTrait';
|
|
13
|
+
|
|
14
|
+
export * from './retailsolver';
|
|
15
|
+
export * from './runtime';
|
|
16
|
+
|
|
17
|
+
export const pluginMeta = { name: '@holoscript/plugin-retail-ecommerce', version: '1.0.0', traits: ['cart', 'checkout', 'product_catalog', 'shipping_rate', 'return', 'eoq', 'price_optimization', 'markdown', 'clv', 'conversion_funnel', 'abc_classification', 'inventory_metrics'] };
|
|
18
|
+
export const traitHandlers = [createCartHandler(), createCheckoutHandler(), createProductCatalogHandler(), createShippingRateHandler(), createReturnHandler()];
|
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retail & e-commerce solvers — retail-ecommerce-plugin
|
|
3
|
+
*
|
|
4
|
+
* Implements:
|
|
5
|
+
* - EOQ (Economic Order Quantity) inventory optimization
|
|
6
|
+
* - Demand elasticity and price optimization
|
|
7
|
+
* - Markdown optimization (progressive discounting)
|
|
8
|
+
* - Customer Lifetime Value (CLV) estimation
|
|
9
|
+
* - Conversion funnel analysis
|
|
10
|
+
* - ABC inventory classification
|
|
11
|
+
* - Days sales of inventory (DSI) and inventory turnover
|
|
12
|
+
*
|
|
13
|
+
* References:
|
|
14
|
+
* - Harris FW (1913) Operations and Cost — original EOQ paper
|
|
15
|
+
* - Philips RL (2005) Pricing and Revenue Optimization — demand elasticity
|
|
16
|
+
* - Fader PS et al. (2005) J.Marketing Res. — Pareto/NBD CLV model
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { buildDomainSimulationReceipt, type DomainSimulationReceipt } from '@holoscript/core';
|
|
20
|
+
|
|
21
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
export interface EOQResult {
|
|
24
|
+
/** Annual demand units */
|
|
25
|
+
annualDemand: number;
|
|
26
|
+
/** Ordering cost per order ($) */
|
|
27
|
+
orderingCost: number;
|
|
28
|
+
/** Holding cost per unit per year ($) */
|
|
29
|
+
holdingCostPerUnit: number;
|
|
30
|
+
/** Optimal order quantity units */
|
|
31
|
+
eoq: number;
|
|
32
|
+
/** Number of orders per year */
|
|
33
|
+
ordersPerYear: number;
|
|
34
|
+
/** Total annual inventory cost ($) */
|
|
35
|
+
totalAnnualCost: number;
|
|
36
|
+
/** Reorder point given lead time demand */
|
|
37
|
+
reorderPoint: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PriceOptimizationResult {
|
|
41
|
+
/** Current price */
|
|
42
|
+
currentPrice: number;
|
|
43
|
+
/** Price elasticity of demand (negative) */
|
|
44
|
+
elasticity: number;
|
|
45
|
+
/** Profit-maximizing price */
|
|
46
|
+
optimalPrice: number;
|
|
47
|
+
/** Revenue at current price */
|
|
48
|
+
currentRevenue: number;
|
|
49
|
+
/** Revenue at optimal price */
|
|
50
|
+
optimalRevenue: number;
|
|
51
|
+
/** Revenue uplift % */
|
|
52
|
+
revenueUpliftPct: number;
|
|
53
|
+
/** Price-demand curve points */
|
|
54
|
+
priceDemandCurve: Array<{ price: number; demand: number; revenue: number }>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface MarkdownResult {
|
|
58
|
+
/** Original price */
|
|
59
|
+
originalPrice: number;
|
|
60
|
+
/** Remaining inventory units */
|
|
61
|
+
inventory: number;
|
|
62
|
+
/** Days until season end */
|
|
63
|
+
daysRemaining: number;
|
|
64
|
+
/** Optimal markdown schedule */
|
|
65
|
+
schedule: Array<{ dayOfMarkdown: number; discountPct: number; price: number; expectedUnits: number }>;
|
|
66
|
+
/** Expected total revenue */
|
|
67
|
+
expectedRevenue: number;
|
|
68
|
+
/** Expected unsold units */
|
|
69
|
+
expectedUnsold: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface CLVResult {
|
|
73
|
+
/** Average purchase value */
|
|
74
|
+
avgOrderValue: number;
|
|
75
|
+
/** Purchase frequency per year */
|
|
76
|
+
purchaseFrequency: number;
|
|
77
|
+
/** Customer lifespan years */
|
|
78
|
+
lifespanYears: number;
|
|
79
|
+
/** Discount rate */
|
|
80
|
+
discountRate: number;
|
|
81
|
+
/** Simple CLV = AOV × frequency × lifespan */
|
|
82
|
+
simpleCLV: number;
|
|
83
|
+
/** Discounted CLV (NPV of future purchases) */
|
|
84
|
+
discountedCLV: number;
|
|
85
|
+
/** CLV-to-CAC ratio (if CAC provided) */
|
|
86
|
+
clvToCac: number | null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface FunnelAnalysisResult {
|
|
90
|
+
stages: string[];
|
|
91
|
+
counts: number[];
|
|
92
|
+
/** Conversion rate stage-to-stage */
|
|
93
|
+
stepConversions: number[];
|
|
94
|
+
/** Overall funnel conversion rate */
|
|
95
|
+
overallConversion: number;
|
|
96
|
+
/** Revenue impact of improving each step by 10pp */
|
|
97
|
+
sensitivityRevenue: number[];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface ABCClassification {
|
|
101
|
+
items: Array<{
|
|
102
|
+
id: string;
|
|
103
|
+
annualValue: number;
|
|
104
|
+
cumulativePct: number;
|
|
105
|
+
class: 'A' | 'B' | 'C';
|
|
106
|
+
}>;
|
|
107
|
+
/** Count of A, B, C items */
|
|
108
|
+
classCounts: { A: number; B: number; C: number };
|
|
109
|
+
/** % of total value in each class */
|
|
110
|
+
classValuePct: { A: number; B: number; C: number };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface RetailReceiptOptions {
|
|
114
|
+
runId?: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ─── EOQ ─────────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Classic Economic Order Quantity (Harris-Wilson formula).
|
|
121
|
+
* EOQ = √(2 × D × S / H)
|
|
122
|
+
* where D = annual demand, S = ordering cost, H = holding cost per unit/year.
|
|
123
|
+
*/
|
|
124
|
+
export function economicOrderQuantity(
|
|
125
|
+
annualDemand: number,
|
|
126
|
+
orderingCost: number,
|
|
127
|
+
holdingCostPerUnit: number,
|
|
128
|
+
leadTimeDays = 0,
|
|
129
|
+
workingDaysPerYear = 250,
|
|
130
|
+
): EOQResult {
|
|
131
|
+
if (annualDemand <= 0) throw new Error('annualDemand must be positive');
|
|
132
|
+
if (orderingCost <= 0) throw new Error('orderingCost must be positive');
|
|
133
|
+
if (holdingCostPerUnit <= 0) throw new Error('holdingCostPerUnit must be positive');
|
|
134
|
+
|
|
135
|
+
const eoq = Math.sqrt((2 * annualDemand * orderingCost) / holdingCostPerUnit);
|
|
136
|
+
const ordersPerYear = annualDemand / eoq;
|
|
137
|
+
const totalAnnualCost = (annualDemand / eoq) * orderingCost + (eoq / 2) * holdingCostPerUnit;
|
|
138
|
+
const dailyDemand = annualDemand / workingDaysPerYear;
|
|
139
|
+
const reorderPoint = dailyDemand * leadTimeDays;
|
|
140
|
+
|
|
141
|
+
return { annualDemand, orderingCost, holdingCostPerUnit, eoq, ordersPerYear, totalAnnualCost, reorderPoint };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─── Price optimization via demand elasticity ─────────────────────────────────
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Optimize price using constant-elasticity demand model.
|
|
148
|
+
* Q(P) = Q₀ × (P/P₀)^ε where ε < 0 (price elasticity)
|
|
149
|
+
*
|
|
150
|
+
* Revenue R(P) = P × Q(P)
|
|
151
|
+
* Optimal (revenue-maximizing) price: P* = P₀ / (1 + 1/ε) [markup formula]
|
|
152
|
+
* For elastic demand (ε < -1): P* < P₀ (lower price increases revenue)
|
|
153
|
+
* For inelastic demand (-1 < ε < 0): P* > P₀
|
|
154
|
+
*
|
|
155
|
+
* Variable cost per unit optional for profit maximization.
|
|
156
|
+
*/
|
|
157
|
+
export function priceOptimization(
|
|
158
|
+
currentPrice: number,
|
|
159
|
+
currentDemand: number,
|
|
160
|
+
elasticity: number,
|
|
161
|
+
variableCost = 0,
|
|
162
|
+
priceRange?: [number, number],
|
|
163
|
+
): PriceOptimizationResult {
|
|
164
|
+
if (currentPrice <= 0) throw new Error('currentPrice must be positive');
|
|
165
|
+
if (currentDemand <= 0) throw new Error('currentDemand must be positive');
|
|
166
|
+
if (elasticity >= 0) throw new Error('elasticity must be negative (price-demand law)');
|
|
167
|
+
|
|
168
|
+
const demandAt = (p: number) => currentDemand * Math.pow(p / currentPrice, elasticity);
|
|
169
|
+
const revenueAt = (p: number) => p * demandAt(p);
|
|
170
|
+
const profitAt = (p: number) => (p - variableCost) * demandAt(p);
|
|
171
|
+
|
|
172
|
+
// Profit-maximizing price via ternary search
|
|
173
|
+
const pLo = priceRange ? priceRange[0] : variableCost * 1.01;
|
|
174
|
+
const pHi = priceRange ? priceRange[1] : currentPrice * 3;
|
|
175
|
+
|
|
176
|
+
let lo = Math.max(pLo, variableCost + 0.01), hi = pHi;
|
|
177
|
+
for (let i = 0; i < 100; i++) {
|
|
178
|
+
const m1 = lo + (hi - lo) / 3;
|
|
179
|
+
const m2 = hi - (hi - lo) / 3;
|
|
180
|
+
if (profitAt(m1) < profitAt(m2)) lo = m1; else hi = m2;
|
|
181
|
+
}
|
|
182
|
+
const optimalPrice = (lo + hi) / 2;
|
|
183
|
+
|
|
184
|
+
const currentRevenue = revenueAt(currentPrice);
|
|
185
|
+
const optimalRevenue = revenueAt(optimalPrice);
|
|
186
|
+
const revenueUpliftPct = ((optimalRevenue - currentRevenue) / currentRevenue) * 100;
|
|
187
|
+
|
|
188
|
+
// Price-demand curve: 20 points from 50% to 200% of current price
|
|
189
|
+
const priceDemandCurve = Array.from({ length: 21 }, (_, i) => {
|
|
190
|
+
const p = currentPrice * (0.5 + i * 0.075);
|
|
191
|
+
return { price: p, demand: demandAt(p), revenue: revenueAt(p) };
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
return { currentPrice, elasticity, optimalPrice, currentRevenue, optimalRevenue, revenueUpliftPct, priceDemandCurve };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─── Markdown optimization ────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Progressive markdown optimization using a greedy sell-through target approach.
|
|
201
|
+
* Demand increases with discount depth per price elasticity.
|
|
202
|
+
*/
|
|
203
|
+
export function markdownOptimization(
|
|
204
|
+
originalPrice: number,
|
|
205
|
+
inventory: number,
|
|
206
|
+
daysRemaining: number,
|
|
207
|
+
baseDailySalesRate: number,
|
|
208
|
+
elasticity: number,
|
|
209
|
+
markdownSteps: number[] = [10, 20, 30, 50],
|
|
210
|
+
): MarkdownResult {
|
|
211
|
+
if (originalPrice <= 0) throw new Error('originalPrice must be positive');
|
|
212
|
+
if (inventory <= 0) throw new Error('inventory must be positive');
|
|
213
|
+
if (daysRemaining < 1) throw new Error('daysRemaining must be ≥ 1');
|
|
214
|
+
if (elasticity >= 0) throw new Error('elasticity must be negative');
|
|
215
|
+
|
|
216
|
+
// Simple greedy: evenly space markdowns across remaining days
|
|
217
|
+
const stepSize = Math.floor(daysRemaining / (markdownSteps.length + 1));
|
|
218
|
+
let remaining = inventory;
|
|
219
|
+
let totalRevenue = 0;
|
|
220
|
+
|
|
221
|
+
const schedule: MarkdownResult['schedule'] = [];
|
|
222
|
+
let currentPrice = originalPrice;
|
|
223
|
+
let currentRate = baseDailySalesRate;
|
|
224
|
+
let prevDay = 0;
|
|
225
|
+
|
|
226
|
+
for (let si = 0; si < markdownSteps.length; si++) {
|
|
227
|
+
const dayOfMarkdown = (si + 1) * stepSize;
|
|
228
|
+
const discountPct = markdownSteps[si];
|
|
229
|
+
const newPrice = originalPrice * (1 - discountPct / 100);
|
|
230
|
+
// Demand boost from discount via elasticity
|
|
231
|
+
const newRate = baseDailySalesRate * Math.pow(1 - discountPct / 100, elasticity);
|
|
232
|
+
|
|
233
|
+
// Sell from prevDay to dayOfMarkdown at currentRate/price
|
|
234
|
+
const daysAtCurrentPrice = dayOfMarkdown - prevDay;
|
|
235
|
+
const unitsSold = Math.min(remaining, Math.round(currentRate * daysAtCurrentPrice));
|
|
236
|
+
totalRevenue += unitsSold * currentPrice;
|
|
237
|
+
remaining = Math.max(0, remaining - unitsSold);
|
|
238
|
+
|
|
239
|
+
schedule.push({ dayOfMarkdown, discountPct, price: newPrice, expectedUnits: Math.round(newRate * stepSize) });
|
|
240
|
+
|
|
241
|
+
currentPrice = newPrice;
|
|
242
|
+
currentRate = newRate;
|
|
243
|
+
prevDay = dayOfMarkdown;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Final period after last markdown
|
|
247
|
+
const finalDays = daysRemaining - prevDay;
|
|
248
|
+
const finalUnits = Math.min(remaining, Math.round(currentRate * finalDays));
|
|
249
|
+
totalRevenue += finalUnits * currentPrice;
|
|
250
|
+
remaining = Math.max(0, remaining - finalUnits);
|
|
251
|
+
|
|
252
|
+
return { originalPrice, inventory, daysRemaining, schedule, expectedRevenue: totalRevenue, expectedUnsold: remaining };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ─── Customer Lifetime Value ──────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Compute CLV using simple and discounted (NPV) formulas.
|
|
259
|
+
* Simple: CLV = AOV × frequency × lifespan
|
|
260
|
+
* Discounted: CLV = Σ_{t=1}^{L} AOV × freq / (1+r)^t
|
|
261
|
+
*/
|
|
262
|
+
export function customerLifetimeValue(
|
|
263
|
+
avgOrderValue: number,
|
|
264
|
+
purchaseFrequency: number,
|
|
265
|
+
lifespanYears: number,
|
|
266
|
+
discountRate: number,
|
|
267
|
+
cac?: number,
|
|
268
|
+
): CLVResult {
|
|
269
|
+
if (avgOrderValue <= 0) throw new Error('avgOrderValue must be positive');
|
|
270
|
+
if (purchaseFrequency <= 0) throw new Error('purchaseFrequency must be positive');
|
|
271
|
+
if (lifespanYears <= 0) throw new Error('lifespanYears must be positive');
|
|
272
|
+
if (discountRate < 0) throw new Error('discountRate must be non-negative');
|
|
273
|
+
|
|
274
|
+
const simpleCLV = avgOrderValue * purchaseFrequency * lifespanYears;
|
|
275
|
+
|
|
276
|
+
// Discounted: annual revenue / (1+r)^year, summed
|
|
277
|
+
let discountedCLV = 0;
|
|
278
|
+
const annualRevenue = avgOrderValue * purchaseFrequency;
|
|
279
|
+
for (let y = 1; y <= Math.ceil(lifespanYears); y++) {
|
|
280
|
+
const weight = Math.min(1, lifespanYears - (y - 1)); // partial last year
|
|
281
|
+
discountedCLV += (weight * annualRevenue) / Math.pow(1 + discountRate, y);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const clvToCac = cac != null && cac > 0 ? discountedCLV / cac : null;
|
|
285
|
+
|
|
286
|
+
return { avgOrderValue, purchaseFrequency, lifespanYears, discountRate, simpleCLV, discountedCLV, clvToCac };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ─── Conversion funnel analysis ───────────────────────────────────────────────
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Analyze a conversion funnel and compute revenue sensitivity.
|
|
293
|
+
*/
|
|
294
|
+
export function conversionFunnelAnalysis(
|
|
295
|
+
stages: string[],
|
|
296
|
+
counts: number[],
|
|
297
|
+
avgOrderValue: number,
|
|
298
|
+
): FunnelAnalysisResult {
|
|
299
|
+
if (stages.length !== counts.length) throw new Error('stages and counts must have same length');
|
|
300
|
+
if (stages.length < 2) throw new Error('Funnel needs at least 2 stages');
|
|
301
|
+
if (counts.some(c => c < 0)) throw new Error('counts must be non-negative');
|
|
302
|
+
if (avgOrderValue <= 0) throw new Error('avgOrderValue must be positive');
|
|
303
|
+
|
|
304
|
+
const stepConversions = counts.slice(1).map((c, i) => counts[i] > 0 ? c / counts[i] : 0);
|
|
305
|
+
const overallConversion = counts[0] > 0 ? counts[counts.length - 1] / counts[0] : 0;
|
|
306
|
+
const currentRevenue = counts[counts.length - 1] * avgOrderValue;
|
|
307
|
+
|
|
308
|
+
// Sensitivity: if step i conversion improves by 10pp, how much extra revenue?
|
|
309
|
+
const sensitivityRevenue = stepConversions.map((conv, i) => {
|
|
310
|
+
const newConv = Math.min(1, conv + 0.10);
|
|
311
|
+
const upliftFactor = newConv / (conv || 1e-9);
|
|
312
|
+
// Downstream: all subsequent stages are multiplied by this factor
|
|
313
|
+
let newFinal = counts[i + 1] * upliftFactor;
|
|
314
|
+
for (let j = i + 2; j < counts.length; j++) {
|
|
315
|
+
newFinal *= (counts[j - 1] > 0 ? counts[j] / counts[j - 1] : 1);
|
|
316
|
+
}
|
|
317
|
+
return (newFinal - counts[counts.length - 1]) * avgOrderValue;
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
return { stages, counts, stepConversions, overallConversion, sensitivityRevenue };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ─── ABC inventory classification ─────────────────────────────────────────────
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Classify inventory items into A/B/C tiers by annual value contribution.
|
|
327
|
+
* A: top 80% of value (~20% of items)
|
|
328
|
+
* B: next 15% of value
|
|
329
|
+
* C: remaining 5% of value
|
|
330
|
+
*/
|
|
331
|
+
export function abcClassification(
|
|
332
|
+
items: Array<{ id: string; annualVolume: number; unitCost: number }>,
|
|
333
|
+
): ABCClassification {
|
|
334
|
+
if (items.length === 0) throw new Error('No items provided');
|
|
335
|
+
|
|
336
|
+
const withValues = items.map(item => ({
|
|
337
|
+
id: item.id,
|
|
338
|
+
annualValue: item.annualVolume * item.unitCost,
|
|
339
|
+
})).sort((a, b) => b.annualValue - a.annualValue);
|
|
340
|
+
|
|
341
|
+
const totalValue = withValues.reduce((a, b) => a + b.annualValue, 0);
|
|
342
|
+
let cumulative = 0;
|
|
343
|
+
const classCounts = { A: 0, B: 0, C: 0 };
|
|
344
|
+
const classValue = { A: 0, B: 0, C: 0 };
|
|
345
|
+
|
|
346
|
+
const classified = withValues.map(item => {
|
|
347
|
+
cumulative += item.annualValue;
|
|
348
|
+
const cumulativePct = (cumulative / totalValue) * 100;
|
|
349
|
+
const cls: 'A' | 'B' | 'C' = cumulativePct <= 80 ? 'A' : cumulativePct <= 95 ? 'B' : 'C';
|
|
350
|
+
classCounts[cls]++;
|
|
351
|
+
classValue[cls] += item.annualValue;
|
|
352
|
+
return { ...item, cumulativePct, class: cls };
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const classValuePct = {
|
|
356
|
+
A: (classValue.A / totalValue) * 100,
|
|
357
|
+
B: (classValue.B / totalValue) * 100,
|
|
358
|
+
C: (classValue.C / totalValue) * 100,
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
return { items: classified, classCounts, classValuePct };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ─── Inventory turnover & DSI ─────────────────────────────────────────────────
|
|
365
|
+
|
|
366
|
+
export interface InventoryMetrics {
|
|
367
|
+
/** Cost of goods sold */
|
|
368
|
+
cogs: number;
|
|
369
|
+
/** Average inventory value */
|
|
370
|
+
avgInventory: number;
|
|
371
|
+
/** Inventory turnover ratio = COGS / avg inventory */
|
|
372
|
+
inventoryTurnover: number;
|
|
373
|
+
/** Days sales of inventory = 365 / turnover */
|
|
374
|
+
dsi: number;
|
|
375
|
+
/** Target DSI (if provided) */
|
|
376
|
+
targetDsi?: number;
|
|
377
|
+
/** Whether current DSI is within target */
|
|
378
|
+
withinTarget?: boolean;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function inventoryMetrics(
|
|
382
|
+
cogs: number,
|
|
383
|
+
avgInventory: number,
|
|
384
|
+
targetDsi?: number,
|
|
385
|
+
): InventoryMetrics {
|
|
386
|
+
if (cogs <= 0) throw new Error('COGS must be positive');
|
|
387
|
+
if (avgInventory <= 0) throw new Error('avgInventory must be positive');
|
|
388
|
+
|
|
389
|
+
const inventoryTurnover = cogs / avgInventory;
|
|
390
|
+
const dsi = 365 / inventoryTurnover;
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
cogs, avgInventory, inventoryTurnover, dsi,
|
|
394
|
+
targetDsi,
|
|
395
|
+
withinTarget: targetDsi != null ? dsi <= targetDsi : undefined,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ─── Receipt ──────────────────────────────────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
export interface RetailAnalysisResult {
|
|
402
|
+
eoq?: EOQResult;
|
|
403
|
+
priceOpt?: PriceOptimizationResult;
|
|
404
|
+
markdown?: MarkdownResult;
|
|
405
|
+
clv?: CLVResult;
|
|
406
|
+
funnel?: FunnelAnalysisResult;
|
|
407
|
+
abc?: ABCClassification;
|
|
408
|
+
inventory?: InventoryMetrics;
|
|
409
|
+
converged: true;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export function buildRetailReceipt(
|
|
413
|
+
result: RetailAnalysisResult,
|
|
414
|
+
options?: RetailReceiptOptions,
|
|
415
|
+
): DomainSimulationReceipt {
|
|
416
|
+
const violations: Array<{ criterion: string; message: string }> = [];
|
|
417
|
+
|
|
418
|
+
if (result.inventory?.withinTarget === false) {
|
|
419
|
+
violations.push({
|
|
420
|
+
criterion: 'dsi',
|
|
421
|
+
message: `DSI ${result.inventory.dsi.toFixed(1)} days exceeds target ${result.inventory.targetDsi} days`,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
if (result.clv?.clvToCac != null && result.clv.clvToCac < 3) {
|
|
425
|
+
violations.push({
|
|
426
|
+
criterion: 'clv_to_cac',
|
|
427
|
+
message: `CLV:CAC ratio ${result.clv.clvToCac.toFixed(2)} is below recommended minimum of 3`,
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return buildDomainSimulationReceipt({
|
|
432
|
+
plugin: 'retail-ecommerce',
|
|
433
|
+
pluginVersion: '1.0.0',
|
|
434
|
+
runId: options?.runId ?? `retail-${Date.now().toString(36)}`,
|
|
435
|
+
solverConfig: { solverType: 'retail-analytics', scale: 'store' },
|
|
436
|
+
resultSummary: {
|
|
437
|
+
eoq: result.eoq?.eoq ?? null,
|
|
438
|
+
optimalPrice: result.priceOpt?.optimalPrice ?? null,
|
|
439
|
+
revenueUpliftPct: result.priceOpt?.revenueUpliftPct ?? null,
|
|
440
|
+
clv: result.clv?.discountedCLV ?? null,
|
|
441
|
+
overallConversion: result.funnel?.overallConversion ?? null,
|
|
442
|
+
},
|
|
443
|
+
cael: { version: 'cael.v1', event: 'retail_ecommerce.retail_analysis', solverType: 'retail-ecommerce.analytics' },
|
|
444
|
+
acceptance: { accepted: violations.length === 0, violations },
|
|
445
|
+
});
|
|
446
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime integration for @holoscript/plugin-retail-ecommerce.
|
|
3
|
+
*
|
|
4
|
+
* Bridges the previously dead-wired `eoq` trait into a behavioral
|
|
5
|
+
* TraitHandler that the HoloScript runtime actually dispatches
|
|
6
|
+
* (HoloScriptRuntime.registerTrait -> applyDirectives / updateTraits).
|
|
7
|
+
*
|
|
8
|
+
* Before this module the plugin declared trait NAMES only
|
|
9
|
+
* (pluginMeta.traits) and exported `economicOrderQuantity` plus the other
|
|
10
|
+
* retail solvers, but nothing invoked a solver THROUGH the runtime — the whole
|
|
11
|
+
* domain-plugin tier was built-but-dead-wired. This mirrors the energy-grid
|
|
12
|
+
* reference integration (`power_flow` -> `solveDCPowerFlow`): the `eoq` trait
|
|
13
|
+
* now runs the real Harris-Wilson Economic Order Quantity solver via the
|
|
14
|
+
* shared `registerPluginTraits` registrar. The remaining declared trait names
|
|
15
|
+
* follow the same registrar shape.
|
|
16
|
+
*/
|
|
17
|
+
import { registerPluginTraits } from '@holoscript/core/runtime';
|
|
18
|
+
import { economicOrderQuantity, type EOQResult } from './retailsolver';
|
|
19
|
+
|
|
20
|
+
/** Stable id for this plugin, used by the runtime collision guard. */
|
|
21
|
+
export const RETAIL_ECOMMERCE_PLUGIN_ID = 'retail-ecommerce' as const;
|
|
22
|
+
|
|
23
|
+
/** Config carried by an orb's `@eoq` trait directive. */
|
|
24
|
+
export interface EoqTraitConfig {
|
|
25
|
+
/** Annual demand units. Required; absence emits `eoq_error`. */
|
|
26
|
+
annualDemand?: number;
|
|
27
|
+
/** Ordering cost per order ($). Required; absence emits `eoq_error`. */
|
|
28
|
+
orderingCost?: number;
|
|
29
|
+
/** Holding cost per unit per year ($). Required; absence emits `eoq_error`. */
|
|
30
|
+
holdingCostPerUnit?: number;
|
|
31
|
+
/** Optional lead time (days) used to compute the reorder point. */
|
|
32
|
+
leadTimeDays?: number;
|
|
33
|
+
/** Optional working days per year forwarded to `economicOrderQuantity`. */
|
|
34
|
+
workingDaysPerYear?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Summary payload emitted on `eoq_solved`. */
|
|
38
|
+
export interface EoqSolvedEvent {
|
|
39
|
+
nodeId: string;
|
|
40
|
+
eoq: number;
|
|
41
|
+
ordersPerYear: number;
|
|
42
|
+
totalAnnualCost: number;
|
|
43
|
+
reorderPoint: number;
|
|
44
|
+
annualDemand: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Structural view of the runtime trait-handler contract. Matches
|
|
49
|
+
* `@holoscript/core` TraitTypes.TraitHandler at the call sites the runtime
|
|
50
|
+
* actually uses (onAttach / onUpdate receive the node, the directive config,
|
|
51
|
+
* and a context exposing `emit`). Declared locally so the plugin stays
|
|
52
|
+
* decoupled from core's full trait surface.
|
|
53
|
+
*/
|
|
54
|
+
export interface TraitDispatchContext {
|
|
55
|
+
emit: (event: string, payload?: unknown) => void;
|
|
56
|
+
setState?: (updates: Record<string, unknown>) => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface RuntimeTraitHandler {
|
|
60
|
+
name: string;
|
|
61
|
+
onAttach?: (node: unknown, config: EoqTraitConfig, context: TraitDispatchContext) => void;
|
|
62
|
+
onUpdate?: (
|
|
63
|
+
node: unknown,
|
|
64
|
+
config: EoqTraitConfig,
|
|
65
|
+
context: TraitDispatchContext,
|
|
66
|
+
delta: number,
|
|
67
|
+
) => void;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface EoqNode {
|
|
71
|
+
id?: string;
|
|
72
|
+
name?: string;
|
|
73
|
+
properties?: Record<string, unknown>;
|
|
74
|
+
__eoqResult?: EOQResult;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Solve EOQ from `config`, write the result onto the node, and emit. */
|
|
78
|
+
function solveOntoNode(
|
|
79
|
+
node: unknown,
|
|
80
|
+
config: EoqTraitConfig | undefined,
|
|
81
|
+
context: TraitDispatchContext,
|
|
82
|
+
): void {
|
|
83
|
+
const carrier = node as EoqNode;
|
|
84
|
+
const nodeId = carrier.id ?? carrier.name ?? 'unknown';
|
|
85
|
+
|
|
86
|
+
const annualDemand = config?.annualDemand;
|
|
87
|
+
const orderingCost = config?.orderingCost;
|
|
88
|
+
const holdingCostPerUnit = config?.holdingCostPerUnit;
|
|
89
|
+
|
|
90
|
+
if (
|
|
91
|
+
annualDemand === undefined ||
|
|
92
|
+
orderingCost === undefined ||
|
|
93
|
+
holdingCostPerUnit === undefined
|
|
94
|
+
) {
|
|
95
|
+
context.emit('eoq_error', {
|
|
96
|
+
nodeId,
|
|
97
|
+
error:
|
|
98
|
+
'eoq trait requires config.annualDemand, config.orderingCost, and config.holdingCostPerUnit',
|
|
99
|
+
});
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const result = economicOrderQuantity(
|
|
105
|
+
annualDemand,
|
|
106
|
+
orderingCost,
|
|
107
|
+
holdingCostPerUnit,
|
|
108
|
+
config?.leadTimeDays ?? 0,
|
|
109
|
+
config?.workingDaysPerYear ?? 250,
|
|
110
|
+
);
|
|
111
|
+
carrier.__eoqResult = result;
|
|
112
|
+
carrier.properties = {
|
|
113
|
+
...(carrier.properties ?? {}),
|
|
114
|
+
eoq: result.eoq,
|
|
115
|
+
ordersPerYear: result.ordersPerYear,
|
|
116
|
+
totalAnnualCost: result.totalAnnualCost,
|
|
117
|
+
};
|
|
118
|
+
const summary: EoqSolvedEvent = {
|
|
119
|
+
nodeId,
|
|
120
|
+
eoq: result.eoq,
|
|
121
|
+
ordersPerYear: result.ordersPerYear,
|
|
122
|
+
totalAnnualCost: result.totalAnnualCost,
|
|
123
|
+
reorderPoint: result.reorderPoint,
|
|
124
|
+
annualDemand: result.annualDemand,
|
|
125
|
+
};
|
|
126
|
+
context.setState?.({ [`eoq:${nodeId}`]: summary });
|
|
127
|
+
context.emit('eoq_solved', summary);
|
|
128
|
+
} catch (error) {
|
|
129
|
+
context.emit('eoq_error', {
|
|
130
|
+
nodeId,
|
|
131
|
+
error: error instanceof Error ? error.message : String(error),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Behavioral handler for the retail-ecommerce `eoq` trait. Runs the
|
|
138
|
+
* deterministic Harris-Wilson Economic Order Quantity solver whenever an orb
|
|
139
|
+
* carrying the trait is attached (and on each per-frame update), writing the
|
|
140
|
+
* result onto the node and emitting `eoq_solved` / `eoq_error`.
|
|
141
|
+
*/
|
|
142
|
+
export const eoqHandler: RuntimeTraitHandler = {
|
|
143
|
+
name: 'eoq',
|
|
144
|
+
onAttach: (node, config, context) => solveOntoNode(node, config, context),
|
|
145
|
+
onUpdate: (node, config, context) => solveOntoNode(node, config, context),
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** A runtime that can register behavioral trait handlers. */
|
|
149
|
+
export interface TraitRegistrar {
|
|
150
|
+
registerTrait(name: string, handler: unknown): void;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Register retail-ecommerce behavioral trait handlers into a runtime that
|
|
155
|
+
* exposes `registerTrait(name, handler)` — e.g. `@holoscript/core`
|
|
156
|
+
* HoloScriptRuntime. This is the consumption path the dead-wired tier was
|
|
157
|
+
* missing: after this call the runtime's directive dispatch (applyDirectives /
|
|
158
|
+
* updateTraits) will invoke the retail EOQ solver for `@eoq` orbs.
|
|
159
|
+
*/
|
|
160
|
+
export function registerRetailEcommerceTraitHandlers(registrar: TraitRegistrar): void {
|
|
161
|
+
registerPluginTraits(registrar, RETAIL_ECOMMERCE_PLUGIN_ID, [eoqHandler]);
|
|
162
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/** @cart Trait — Shopping cart management. @trait cart */
|
|
2
|
+
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
|
+
|
|
4
|
+
export interface CartItem { productId: string; quantity: number; price: number; variant?: string; name: string; }
|
|
5
|
+
export interface CartConfig { currency: string; items: CartItem[]; maxItems: number; taxRate: number; }
|
|
6
|
+
export interface CartState { items: CartItem[]; subtotal: number; tax: number; total: number; itemCount: number; }
|
|
7
|
+
|
|
8
|
+
const defaultConfig: CartConfig = { currency: 'USD', items: [], maxItems: 99, taxRate: 0 };
|
|
9
|
+
|
|
10
|
+
function computeTotals(items: CartItem[], taxRate: number): Pick<CartState, 'subtotal' | 'tax' | 'total' | 'itemCount'> {
|
|
11
|
+
const subtotal = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
|
|
12
|
+
const tax = subtotal * taxRate;
|
|
13
|
+
return { subtotal, tax, total: subtotal + tax, itemCount: items.reduce((sum, i) => sum + i.quantity, 0) };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createCartHandler(): TraitHandler<CartConfig> {
|
|
17
|
+
return {
|
|
18
|
+
name: 'cart', defaultConfig,
|
|
19
|
+
onAttach(node: HSPlusNode, config: CartConfig, ctx: TraitContext) {
|
|
20
|
+
const totals = computeTotals(config.items, config.taxRate);
|
|
21
|
+
node.__cartState = { items: [...config.items], ...totals };
|
|
22
|
+
ctx.emit?.('cart:created', { itemCount: totals.itemCount });
|
|
23
|
+
},
|
|
24
|
+
onDetach(node: HSPlusNode, _c: CartConfig, ctx: TraitContext) { delete node.__cartState; ctx.emit?.('cart:cleared'); },
|
|
25
|
+
onUpdate() {},
|
|
26
|
+
onEvent(node: HSPlusNode, config: CartConfig, ctx: TraitContext, event: TraitEvent) {
|
|
27
|
+
const s = node.__cartState as CartState | undefined; if (!s) return;
|
|
28
|
+
if (event.type === 'cart:add_item') {
|
|
29
|
+
const item = event.payload as unknown as CartItem;
|
|
30
|
+
const existing = s.items.find(i => i.productId === item.productId && i.variant === item.variant);
|
|
31
|
+
if (existing) existing.quantity += item.quantity; else s.items.push({ ...item });
|
|
32
|
+
Object.assign(s, computeTotals(s.items, config.taxRate));
|
|
33
|
+
ctx.emit?.('cart:updated', { itemCount: s.itemCount, total: s.total });
|
|
34
|
+
}
|
|
35
|
+
if (event.type === 'cart:remove_item') {
|
|
36
|
+
s.items = s.items.filter(i => i.productId !== (event.payload?.productId as string));
|
|
37
|
+
Object.assign(s, computeTotals(s.items, config.taxRate));
|
|
38
|
+
ctx.emit?.('cart:updated', { itemCount: s.itemCount, total: s.total });
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** @checkout Trait — Checkout flow management. @trait checkout */
|
|
2
|
+
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
|
+
|
|
4
|
+
export type CheckoutStep = 'shipping' | 'payment' | 'review' | 'confirm' | 'complete';
|
|
5
|
+
export type PaymentMethod = 'credit_card' | 'debit_card' | 'paypal' | 'crypto' | 'bank_transfer' | 'apple_pay';
|
|
6
|
+
export interface Address { line1: string; line2?: string; city: string; state: string; postalCode: string; country: string; }
|
|
7
|
+
export interface CheckoutConfig { step: CheckoutStep; paymentMethod: PaymentMethod; shippingAddress: Address | null; billingAddress: Address | null; orderTotal: number; tax: number; }
|
|
8
|
+
export interface CheckoutState { currentStep: CheckoutStep; isProcessing: boolean; orderId: string | null; error: string | null; }
|
|
9
|
+
|
|
10
|
+
const defaultConfig: CheckoutConfig = { step: 'shipping', paymentMethod: 'credit_card', shippingAddress: null, billingAddress: null, orderTotal: 0, tax: 0 };
|
|
11
|
+
|
|
12
|
+
export function createCheckoutHandler(): TraitHandler<CheckoutConfig> {
|
|
13
|
+
return {
|
|
14
|
+
name: 'checkout', defaultConfig,
|
|
15
|
+
onAttach(node: HSPlusNode, config: CheckoutConfig, ctx: TraitContext) { node.__checkoutState = { currentStep: config.step, isProcessing: false, orderId: null, error: null }; ctx.emit?.('checkout:started'); },
|
|
16
|
+
onDetach(node: HSPlusNode, _c: CheckoutConfig, ctx: TraitContext) { delete node.__checkoutState; ctx.emit?.('checkout:abandoned'); },
|
|
17
|
+
onUpdate() {},
|
|
18
|
+
onEvent(node: HSPlusNode, _c: CheckoutConfig, ctx: TraitContext, event: TraitEvent) {
|
|
19
|
+
const s = node.__checkoutState as CheckoutState | undefined; if (!s) return;
|
|
20
|
+
if (event.type === 'checkout:next_step') {
|
|
21
|
+
const steps: CheckoutStep[] = ['shipping', 'payment', 'review', 'confirm', 'complete'];
|
|
22
|
+
const idx = steps.indexOf(s.currentStep);
|
|
23
|
+
if (idx < steps.length - 1) { s.currentStep = steps[idx + 1]; ctx.emit?.('checkout:step_changed', { step: s.currentStep }); }
|
|
24
|
+
}
|
|
25
|
+
if (event.type === 'checkout:process_payment') { s.isProcessing = true; ctx.emit?.('checkout:processing'); }
|
|
26
|
+
if (event.type === 'checkout:complete') { s.currentStep = 'complete'; s.orderId = event.payload?.orderId as string; ctx.emit?.('checkout:completed', { orderId: s.orderId }); }
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** @product_catalog Trait — Product catalog with search and filtering. @trait product_catalog */
|
|
2
|
+
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
|
+
|
|
4
|
+
export interface Product { id: string; name: string; price: number; category: string; brand?: string; inStock: boolean; imageUrl?: string; }
|
|
5
|
+
export interface ProductCatalogConfig { products: Product[]; categories: string[]; sortBy: 'price_asc' | 'price_desc' | 'name' | 'newest'; pageSize: number; }
|
|
6
|
+
|
|
7
|
+
const defaultConfig: ProductCatalogConfig = { products: [], categories: [], sortBy: 'name', pageSize: 20 };
|
|
8
|
+
|
|
9
|
+
export function createProductCatalogHandler(): TraitHandler<ProductCatalogConfig> {
|
|
10
|
+
return {
|
|
11
|
+
name: 'product_catalog', defaultConfig,
|
|
12
|
+
onAttach(node: HSPlusNode, config: ProductCatalogConfig, ctx: TraitContext) { node.__catalogState = { currentPage: 0, filteredCount: config.products.length, activeFilters: {} }; ctx.emit?.('catalog:loaded', { productCount: config.products.length }); },
|
|
13
|
+
onDetach(node: HSPlusNode, _c: ProductCatalogConfig, ctx: TraitContext) { delete node.__catalogState; ctx.emit?.('catalog:unloaded'); },
|
|
14
|
+
onUpdate() {},
|
|
15
|
+
onEvent(node: HSPlusNode, config: ProductCatalogConfig, ctx: TraitContext, event: TraitEvent) {
|
|
16
|
+
if (event.type === 'catalog:search') {
|
|
17
|
+
const query = (event.payload?.query as string || '').toLowerCase();
|
|
18
|
+
const results = config.products.filter(p => p.name.toLowerCase().includes(query));
|
|
19
|
+
ctx.emit?.('catalog:results', { count: results.length, products: results.slice(0, config.pageSize) });
|
|
20
|
+
}
|
|
21
|
+
if (event.type === 'catalog:filter') {
|
|
22
|
+
const category = event.payload?.category as string;
|
|
23
|
+
const results = category ? config.products.filter(p => p.category === category) : config.products;
|
|
24
|
+
ctx.emit?.('catalog:filtered', { count: results.length, category });
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** @return Trait — Return and refund management. @trait return */
|
|
2
|
+
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
|
+
|
|
4
|
+
export type ReturnStatus = 'requested' | 'approved' | 'shipped' | 'received' | 'refunded' | 'denied';
|
|
5
|
+
export interface ReturnConfig { orderId: string; reason: string; status: ReturnStatus; refundAmount: number; returnWindowDays: number; }
|
|
6
|
+
|
|
7
|
+
const defaultConfig: ReturnConfig = { orderId: '', reason: '', status: 'requested', refundAmount: 0, returnWindowDays: 30 };
|
|
8
|
+
|
|
9
|
+
export function createReturnHandler(): TraitHandler<ReturnConfig> {
|
|
10
|
+
return {
|
|
11
|
+
name: 'return', defaultConfig,
|
|
12
|
+
onAttach(node: HSPlusNode, config: ReturnConfig, ctx: TraitContext) { node.__returnState = { ...config, requestedAt: Date.now() }; ctx.emit?.('return:created', { orderId: config.orderId }); },
|
|
13
|
+
onDetach(node: HSPlusNode, _c: ReturnConfig, ctx: TraitContext) { delete node.__returnState; ctx.emit?.('return:cancelled'); },
|
|
14
|
+
onUpdate() {},
|
|
15
|
+
onEvent(node: HSPlusNode, _c: ReturnConfig, ctx: TraitContext, event: TraitEvent) {
|
|
16
|
+
const s = node.__returnState as Record<string, unknown> | undefined; if (!s) return;
|
|
17
|
+
if (event.type === 'return:approve') { s.status = 'approved'; ctx.emit?.('return:approved'); }
|
|
18
|
+
if (event.type === 'return:ship') { s.status = 'shipped'; ctx.emit?.('return:shipped'); }
|
|
19
|
+
if (event.type === 'return:receive') { s.status = 'received'; ctx.emit?.('return:received'); }
|
|
20
|
+
if (event.type === 'return:refund') { s.status = 'refunded'; ctx.emit?.('return:refunded', { amount: s.refundAmount }); }
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** @shipping_rate Trait — Shipping cost calculation. @trait shipping_rate */
|
|
2
|
+
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
|
+
|
|
4
|
+
export type ShippingMethod = 'standard' | 'express' | 'overnight' | 'freight' | 'pickup';
|
|
5
|
+
export interface ShippingRateConfig { carrier: string; method: ShippingMethod; weightKg: number; dimensions: { lengthCm: number; widthCm: number; heightCm: number; }; origin: string; destination: string; rate: number; estimatedDays: number; }
|
|
6
|
+
|
|
7
|
+
const defaultConfig: ShippingRateConfig = { carrier: '', method: 'standard', weightKg: 0, dimensions: { lengthCm: 0, widthCm: 0, heightCm: 0 }, origin: '', destination: '', rate: 0, estimatedDays: 5 };
|
|
8
|
+
|
|
9
|
+
export function createShippingRateHandler(): TraitHandler<ShippingRateConfig> {
|
|
10
|
+
return {
|
|
11
|
+
name: 'shipping_rate', defaultConfig,
|
|
12
|
+
onAttach(node: HSPlusNode, config: ShippingRateConfig, ctx: TraitContext) { node.__shippingState = { calculatedRate: config.rate, carrier: config.carrier }; ctx.emit?.('shipping:rate_set', { rate: config.rate, method: config.method }); },
|
|
13
|
+
onDetach(node: HSPlusNode, _c: ShippingRateConfig, ctx: TraitContext) { delete node.__shippingState; ctx.emit?.('shipping:cleared'); },
|
|
14
|
+
onUpdate() {},
|
|
15
|
+
onEvent(_n: HSPlusNode, config: ShippingRateConfig, ctx: TraitContext, event: TraitEvent) {
|
|
16
|
+
if (event.type === 'shipping:calculate') { ctx.emit?.('shipping:calculated', { rate: config.rate, estimatedDays: config.estimatedDays, carrier: config.carrier }); }
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export interface HSPlusNode { id?: string; properties?: Record<string, unknown>; [key: string]: unknown; }
|
|
2
|
+
export interface TraitContext { emit?: (event: string, payload?: unknown) => void; getState?: () => Record<string, unknown>; setState?: (updates: Record<string, unknown>) => void; [key: string]: unknown; }
|
|
3
|
+
export interface TraitEvent { type: string; source?: string; payload?: Record<string, unknown>; [key: string]: unknown; }
|
|
4
|
+
export interface TraitHandler<TConfig = unknown> { name: string; defaultConfig: TConfig; onAttach(node: HSPlusNode, config: TConfig, ctx: TraitContext): void; onDetach(node: HSPlusNode, config: TConfig, ctx: TraitContext): void; onUpdate(node: HSPlusNode, config: TConfig, ctx: TraitContext, delta: number): void; onEvent(node: HSPlusNode, config: TConfig, ctx: TraitContext, event: TraitEvent): void; }
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "extends": "../../../tsconfig.json", "compilerOptions": { "outDir": "dist", "rootDir": "src", "declaration": true, "declarationMap": true }, "include": ["src"] }
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
|
|
4
|
+
export default defineConfig({
|
|
5
|
+
resolve: {
|
|
6
|
+
alias: {
|
|
7
|
+
'@holoscript/engine': resolve(__dirname, '../../engine/src'),
|
|
8
|
+
'@holoscript/core': resolve(__dirname, '../../core/src'),
|
|
9
|
+
},
|
|
10
|
+
},
|
|
11
|
+
test: {
|
|
12
|
+
globals: true,
|
|
13
|
+
environment: 'node',
|
|
14
|
+
include: ['src/**/*.test.ts'],
|
|
15
|
+
passWithNoTests: true,
|
|
16
|
+
},
|
|
17
|
+
});
|