merchi_sdk_js 0.6.0 → 0.7.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/package.json +4 -1
- package/src/job.js +2 -0
- package/src/job_note.js +24 -1
- package/src/job_operation_log.js +65 -0
- package/src/merchi.js +9 -0
- package/src/notification.js +1 -0
- package/src/pricing/__fixtures__/conditional_field_hidden.json +112 -0
- package/src/pricing/__fixtures__/conditional_field_visible.json +113 -0
- package/src/pricing/__fixtures__/group_product.json +130 -0
- package/src/pricing/__fixtures__/non_selectable_number_field.json +108 -0
- package/src/pricing/__fixtures__/quantity_break_discount.json +104 -0
- package/src/pricing/__fixtures__/simple_selectable_field.json +110 -0
- package/src/pricing/discount.js +14 -0
- package/src/pricing/estimate.js +172 -0
- package/src/pricing/golden.test.js +54 -0
- package/src/pricing/index.js +5 -0
- package/src/pricing/inventory.js +73 -0
- package/src/pricing/pricing.test.js +77 -0
- package/src/pricing/round.js +19 -0
- package/src/pricing/visibility.js +90 -0
- package/src/user.js +2 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
{
|
|
2
|
+
"bundle": {
|
|
3
|
+
"currency": "AUD",
|
|
4
|
+
"fields": [
|
|
5
|
+
{
|
|
6
|
+
"fieldType": 1,
|
|
7
|
+
"id": 6,
|
|
8
|
+
"independent": true,
|
|
9
|
+
"isSelectable": false,
|
|
10
|
+
"options": [],
|
|
11
|
+
"originalId": null,
|
|
12
|
+
"position": 1,
|
|
13
|
+
"selectedBy": [],
|
|
14
|
+
"variationCost": 11.0,
|
|
15
|
+
"variationCostDiscountGroup": null,
|
|
16
|
+
"variationUnitCost": 0.0,
|
|
17
|
+
"variationUnitCostDiscountGroup": null
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"groupFields": [
|
|
21
|
+
{
|
|
22
|
+
"fieldType": 2,
|
|
23
|
+
"id": 4,
|
|
24
|
+
"independent": false,
|
|
25
|
+
"isSelectable": true,
|
|
26
|
+
"options": [
|
|
27
|
+
{
|
|
28
|
+
"default": true,
|
|
29
|
+
"id": 3,
|
|
30
|
+
"originalId": null,
|
|
31
|
+
"position": 1,
|
|
32
|
+
"selectedBy": [],
|
|
33
|
+
"variationCost": 0.0,
|
|
34
|
+
"variationCostDiscountGroup": null,
|
|
35
|
+
"variationUnitCost": 0.0,
|
|
36
|
+
"variationUnitCostDiscountGroup": null
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"default": false,
|
|
40
|
+
"id": 4,
|
|
41
|
+
"originalId": null,
|
|
42
|
+
"position": 2,
|
|
43
|
+
"selectedBy": [],
|
|
44
|
+
"variationCost": 0.0,
|
|
45
|
+
"variationCostDiscountGroup": null,
|
|
46
|
+
"variationUnitCost": 13.0,
|
|
47
|
+
"variationUnitCostDiscountGroup": null
|
|
48
|
+
}
|
|
49
|
+
],
|
|
50
|
+
"originalId": null,
|
|
51
|
+
"position": 1,
|
|
52
|
+
"selectedBy": [],
|
|
53
|
+
"variationCost": 0.0,
|
|
54
|
+
"variationCostDiscountGroup": null,
|
|
55
|
+
"variationUnitCost": 0.0,
|
|
56
|
+
"variationUnitCostDiscountGroup": null
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"fieldType": 3,
|
|
60
|
+
"id": 5,
|
|
61
|
+
"independent": false,
|
|
62
|
+
"isSelectable": false,
|
|
63
|
+
"options": [],
|
|
64
|
+
"originalId": null,
|
|
65
|
+
"position": 2,
|
|
66
|
+
"selectedBy": [],
|
|
67
|
+
"variationCost": 0.0,
|
|
68
|
+
"variationCostDiscountGroup": null,
|
|
69
|
+
"variationUnitCost": 0.0,
|
|
70
|
+
"variationUnitCostDiscountGroup": null
|
|
71
|
+
}
|
|
72
|
+
],
|
|
73
|
+
"hasGroups": true,
|
|
74
|
+
"product": {
|
|
75
|
+
"discountGroup": {
|
|
76
|
+
"discounts": [
|
|
77
|
+
{
|
|
78
|
+
"amount": 10.0,
|
|
79
|
+
"lowerLimit": 100.0
|
|
80
|
+
}
|
|
81
|
+
],
|
|
82
|
+
"groupRestricted": false
|
|
83
|
+
},
|
|
84
|
+
"minimumPrice": 0.0,
|
|
85
|
+
"unitPrice": 20.0
|
|
86
|
+
},
|
|
87
|
+
"taxPercent": 10.0
|
|
88
|
+
},
|
|
89
|
+
"expected": {
|
|
90
|
+
"cost": 1800.0,
|
|
91
|
+
"costPerUnit": 18.0,
|
|
92
|
+
"groupCosts": [],
|
|
93
|
+
"taxAmount": 180.0,
|
|
94
|
+
"totalCost": 1980.0,
|
|
95
|
+
"visibleFieldIds": [
|
|
96
|
+
6
|
|
97
|
+
]
|
|
98
|
+
},
|
|
99
|
+
"name": "quantity_break_discount",
|
|
100
|
+
"selections": {
|
|
101
|
+
"fieldValues": {},
|
|
102
|
+
"quantity": 100
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
{
|
|
2
|
+
"bundle": {
|
|
3
|
+
"currency": "AUD",
|
|
4
|
+
"fields": [
|
|
5
|
+
{
|
|
6
|
+
"fieldType": 2,
|
|
7
|
+
"id": 1,
|
|
8
|
+
"independent": true,
|
|
9
|
+
"isSelectable": true,
|
|
10
|
+
"options": [
|
|
11
|
+
{
|
|
12
|
+
"default": true,
|
|
13
|
+
"id": 1,
|
|
14
|
+
"originalId": null,
|
|
15
|
+
"position": 1,
|
|
16
|
+
"selectedBy": [],
|
|
17
|
+
"variationCost": 50.0,
|
|
18
|
+
"variationCostDiscountGroup": null,
|
|
19
|
+
"variationUnitCost": 0.0,
|
|
20
|
+
"variationUnitCostDiscountGroup": null
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"default": false,
|
|
24
|
+
"id": 2,
|
|
25
|
+
"originalId": null,
|
|
26
|
+
"position": 2,
|
|
27
|
+
"selectedBy": [],
|
|
28
|
+
"variationCost": 50.0,
|
|
29
|
+
"variationCostDiscountGroup": null,
|
|
30
|
+
"variationUnitCost": 13.0,
|
|
31
|
+
"variationUnitCostDiscountGroup": null
|
|
32
|
+
}
|
|
33
|
+
],
|
|
34
|
+
"originalId": null,
|
|
35
|
+
"position": 1,
|
|
36
|
+
"selectedBy": [],
|
|
37
|
+
"variationCost": 0.0,
|
|
38
|
+
"variationCostDiscountGroup": null,
|
|
39
|
+
"variationUnitCost": 0.0,
|
|
40
|
+
"variationUnitCostDiscountGroup": null
|
|
41
|
+
}
|
|
42
|
+
],
|
|
43
|
+
"groupFields": [
|
|
44
|
+
{
|
|
45
|
+
"fieldType": 1,
|
|
46
|
+
"id": 3,
|
|
47
|
+
"independent": false,
|
|
48
|
+
"isSelectable": false,
|
|
49
|
+
"options": [],
|
|
50
|
+
"originalId": null,
|
|
51
|
+
"position": 1,
|
|
52
|
+
"selectedBy": [],
|
|
53
|
+
"variationCost": 11.0,
|
|
54
|
+
"variationCostDiscountGroup": null,
|
|
55
|
+
"variationUnitCost": 0.0,
|
|
56
|
+
"variationUnitCostDiscountGroup": null
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"fieldType": 3,
|
|
60
|
+
"id": 2,
|
|
61
|
+
"independent": false,
|
|
62
|
+
"isSelectable": false,
|
|
63
|
+
"options": [],
|
|
64
|
+
"originalId": null,
|
|
65
|
+
"position": 2,
|
|
66
|
+
"selectedBy": [],
|
|
67
|
+
"variationCost": 0.0,
|
|
68
|
+
"variationCostDiscountGroup": null,
|
|
69
|
+
"variationUnitCost": 0.0,
|
|
70
|
+
"variationUnitCostDiscountGroup": null
|
|
71
|
+
}
|
|
72
|
+
],
|
|
73
|
+
"hasGroups": true,
|
|
74
|
+
"product": {
|
|
75
|
+
"discountGroup": {
|
|
76
|
+
"discounts": [
|
|
77
|
+
{
|
|
78
|
+
"amount": 10.0,
|
|
79
|
+
"lowerLimit": 400.0
|
|
80
|
+
}
|
|
81
|
+
],
|
|
82
|
+
"groupRestricted": false
|
|
83
|
+
},
|
|
84
|
+
"minimumPrice": 0.0,
|
|
85
|
+
"unitPrice": 30.0
|
|
86
|
+
},
|
|
87
|
+
"taxPercent": 10.0
|
|
88
|
+
},
|
|
89
|
+
"expected": {
|
|
90
|
+
"cost": 350.0,
|
|
91
|
+
"costPerUnit": 30.0,
|
|
92
|
+
"groupCosts": [],
|
|
93
|
+
"taxAmount": 35.0,
|
|
94
|
+
"totalCost": 385.0,
|
|
95
|
+
"visibleFieldIds": [
|
|
96
|
+
1
|
|
97
|
+
]
|
|
98
|
+
},
|
|
99
|
+
"name": "simple_selectable_field",
|
|
100
|
+
"selections": {
|
|
101
|
+
"fieldValues": {
|
|
102
|
+
"1": {
|
|
103
|
+
"selectedOptionIds": [
|
|
104
|
+
1
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
"quantity": 10
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { roundHalfEven } from './round.js';
|
|
2
|
+
|
|
3
|
+
// Mirrors the server's discount_groups.get_discounted_price: pick the tier with
|
|
4
|
+
// the highest lowerLimit <= qty, apply price*(1 - amount/100), round to 3dp.
|
|
5
|
+
export function applyDiscount(price, qty, group) {
|
|
6
|
+
if (!group || group.discounts.length === 0) {
|
|
7
|
+
return roundHalfEven(price, 3);
|
|
8
|
+
}
|
|
9
|
+
const applicable = group.discounts
|
|
10
|
+
.filter((d) => d.lowerLimit <= qty)
|
|
11
|
+
.sort((a, b) => b.lowerLimit - a.lowerLimit);
|
|
12
|
+
const amount = applicable.length ? applicable[0].amount : 0;
|
|
13
|
+
return roundHalfEven(price * (1 - amount / 100), 3);
|
|
14
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { applyDiscount } from './discount.js';
|
|
2
|
+
import { roundHalfEven } from './round.js';
|
|
3
|
+
import { resolveVisibleFields } from './visibility.js';
|
|
4
|
+
|
|
5
|
+
function unitPriceAt(rules, qty) {
|
|
6
|
+
let unitPrice = applyDiscount(rules.product.unitPrice, qty, rules.product.discountGroup);
|
|
7
|
+
const mop = rules.product.minimumPrice;
|
|
8
|
+
if (qty > 0 && mop && unitPrice * qty < mop) {
|
|
9
|
+
unitPrice = mop / qty;
|
|
10
|
+
}
|
|
11
|
+
return unitPrice;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function variationSetupCost(source, totalQty) {
|
|
15
|
+
let cost = source.variationCost;
|
|
16
|
+
if (source.variationCostDiscountGroup) {
|
|
17
|
+
cost = applyDiscount(cost, totalQty, source.variationCostDiscountGroup);
|
|
18
|
+
}
|
|
19
|
+
return roundHalfEven(cost, 3);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function variationUnitCosts(source, groupQuantities) {
|
|
23
|
+
const uc = source.variationUnitCost;
|
|
24
|
+
const group = source.variationUnitCostDiscountGroup;
|
|
25
|
+
let list;
|
|
26
|
+
if (group) {
|
|
27
|
+
if (group.groupRestricted) {
|
|
28
|
+
list = groupQuantities.map((q) => applyDiscount(uc, q, group));
|
|
29
|
+
} else {
|
|
30
|
+
const total = groupQuantities.reduce((a, b) => a + b, 0);
|
|
31
|
+
const discounted = applyDiscount(uc, total, group);
|
|
32
|
+
list = groupQuantities.map(() => discounted);
|
|
33
|
+
}
|
|
34
|
+
} else {
|
|
35
|
+
list = groupQuantities.map(() => uc);
|
|
36
|
+
}
|
|
37
|
+
return list.map((c) => roundHalfEven(c, 3));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isEmpty(field, sel) {
|
|
41
|
+
if (!sel) return true;
|
|
42
|
+
if (field.isSelectable) {
|
|
43
|
+
return !sel.selectedOptionIds || sel.selectedOptionIds.length === 0;
|
|
44
|
+
}
|
|
45
|
+
return (
|
|
46
|
+
sel.value === undefined ||
|
|
47
|
+
sel.value === null ||
|
|
48
|
+
sel.value === '' ||
|
|
49
|
+
sel.value === 0
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function costFactor(field, sel, groupQuantities) {
|
|
54
|
+
const n = groupQuantities.length;
|
|
55
|
+
if (isEmpty(field, sel)) {
|
|
56
|
+
return { setup: 0, unitList: new Array(n).fill(0) };
|
|
57
|
+
}
|
|
58
|
+
const totalQty = groupQuantities.reduce((a, b) => a + b, 0);
|
|
59
|
+
if (field.isSelectable) {
|
|
60
|
+
let setup = 0;
|
|
61
|
+
const unitList = new Array(n).fill(0);
|
|
62
|
+
const optById = new Map();
|
|
63
|
+
for (const o of field.options) {
|
|
64
|
+
optById.set(o.id, o);
|
|
65
|
+
if (o.originalId != null) optById.set(o.originalId, o);
|
|
66
|
+
}
|
|
67
|
+
for (const optId of sel.selectedOptionIds) {
|
|
68
|
+
const opt = optById.get(optId);
|
|
69
|
+
if (!opt) continue;
|
|
70
|
+
setup += variationSetupCost(opt, totalQty);
|
|
71
|
+
const ucs = variationUnitCosts(opt, groupQuantities);
|
|
72
|
+
for (let i = 0; i < n; i++) unitList[i] += ucs[i];
|
|
73
|
+
}
|
|
74
|
+
return { setup, unitList };
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
setup: variationSetupCost(field, totalQty),
|
|
78
|
+
unitList: variationUnitCosts(field, groupQuantities),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function estimateQuote(rules, selections) {
|
|
83
|
+
if (rules.unsupported) {
|
|
84
|
+
return { unsupported: rules.unsupported };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const groupCosts = [];
|
|
88
|
+
let cost = 0;
|
|
89
|
+
let costPerUnit;
|
|
90
|
+
let groupQuantities;
|
|
91
|
+
|
|
92
|
+
// Mirror the server: the group-vs-single branch keys off whether the *job*
|
|
93
|
+
// actually has variation groups, not the product's capability.
|
|
94
|
+
const groups = selections.groups || [];
|
|
95
|
+
if (groups.length > 0) {
|
|
96
|
+
groupQuantities = groups.map((g) => g.quantity || 0);
|
|
97
|
+
const totalQty = groupQuantities.reduce((a, b) => a + b, 0);
|
|
98
|
+
const restricted = Boolean(
|
|
99
|
+
rules.product.discountGroup && rules.product.discountGroup.groupRestricted
|
|
100
|
+
);
|
|
101
|
+
const baseUnitPrice = unitPriceAt(rules, totalQty);
|
|
102
|
+
const perGroupCpu = [];
|
|
103
|
+
|
|
104
|
+
for (const group of groups) {
|
|
105
|
+
const gQty = group.quantity || 0;
|
|
106
|
+
const cpu = restricted ? unitPriceAt(rules, gQty) : baseUnitPrice;
|
|
107
|
+
perGroupCpu.push(cpu);
|
|
108
|
+
// A zero-qty group contributes exactly 0 (no field costs).
|
|
109
|
+
if (!gQty) {
|
|
110
|
+
groupCosts.push(0);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
// Per-group field visibility is scoped to that group's selections plus
|
|
114
|
+
// the independent selections.
|
|
115
|
+
const groupVisible = resolveVisibleFields(rules, {
|
|
116
|
+
fieldValues: selections.fieldValues,
|
|
117
|
+
groups: [group],
|
|
118
|
+
});
|
|
119
|
+
let groupVariationCost = 0;
|
|
120
|
+
for (const field of rules.groupFields) {
|
|
121
|
+
if (!groupVisible.has(field.id)) continue;
|
|
122
|
+
const { setup, unitList } = costFactor(field, group.fieldValues[field.id], [gQty]);
|
|
123
|
+
groupVariationCost += setup + unitList[0] * gQty;
|
|
124
|
+
}
|
|
125
|
+
const groupCost = gQty * cpu + groupVariationCost;
|
|
126
|
+
groupCosts.push(groupCost);
|
|
127
|
+
cost += groupCost;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
costPerUnit =
|
|
131
|
+
totalQty > 0
|
|
132
|
+
? perGroupCpu.reduce((acc, cpu, i) => acc + cpu * groupQuantities[i], 0) / totalQty
|
|
133
|
+
: baseUnitPrice;
|
|
134
|
+
|
|
135
|
+
// Independent field visibility is scoped to independent selections only.
|
|
136
|
+
const independentVisible = resolveVisibleFields(rules, {
|
|
137
|
+
quantity: selections.quantity,
|
|
138
|
+
fieldValues: selections.fieldValues,
|
|
139
|
+
});
|
|
140
|
+
for (const field of rules.fields) {
|
|
141
|
+
if (!independentVisible.has(field.id)) continue;
|
|
142
|
+
const { setup, unitList } = costFactor(field, selections.fieldValues[field.id], groupQuantities);
|
|
143
|
+
const unitTotal = unitList.reduce((acc, uc, i) => acc + uc * groupQuantities[i], 0);
|
|
144
|
+
cost += setup + unitTotal;
|
|
145
|
+
}
|
|
146
|
+
} else {
|
|
147
|
+
const qty = selections.quantity || 0;
|
|
148
|
+
groupQuantities = [qty];
|
|
149
|
+
costPerUnit = unitPriceAt(rules, qty);
|
|
150
|
+
cost = costPerUnit * qty;
|
|
151
|
+
const visible = resolveVisibleFields(rules, selections);
|
|
152
|
+
for (const field of rules.fields) {
|
|
153
|
+
if (!visible.has(field.id)) continue;
|
|
154
|
+
const { setup, unitList } = costFactor(field, selections.fieldValues[field.id], groupQuantities);
|
|
155
|
+
cost += setup + unitList[0] * qty;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Tax is derived from the UNROUNDED cost, then cost/tax rounded independently.
|
|
160
|
+
const unroundedCost = cost;
|
|
161
|
+
const roundedCost = roundHalfEven(unroundedCost, 2);
|
|
162
|
+
const unroundedTax = (unroundedCost * rules.taxPercent) / 100;
|
|
163
|
+
const taxAmount = roundHalfEven(unroundedTax, 2);
|
|
164
|
+
return {
|
|
165
|
+
costPerUnit: roundHalfEven(costPerUnit, 3),
|
|
166
|
+
cost: roundedCost,
|
|
167
|
+
taxAmount,
|
|
168
|
+
totalCost: roundHalfEven(unroundedCost + unroundedTax, 3),
|
|
169
|
+
groupCosts: groupCosts.map((c) => roundHalfEven(c, 3)),
|
|
170
|
+
currency: rules.currency,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import * as fs from 'node:fs';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { estimateQuote } from './estimate.js';
|
|
7
|
+
import { resolveVisibleFields } from './visibility.js';
|
|
8
|
+
|
|
9
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const FIXTURES_DIR = path.join(HERE, '__fixtures__');
|
|
11
|
+
|
|
12
|
+
// Fixtures are generated by the merchi_api fixture generator from the real
|
|
13
|
+
// specialised-order-estimate endpoint (the same set vendored into
|
|
14
|
+
// merchi_sdk_ts), so both SDKs are validated against one server source.
|
|
15
|
+
function loadFixtures() {
|
|
16
|
+
if (!fs.existsSync(FIXTURES_DIR)) return [];
|
|
17
|
+
return fs
|
|
18
|
+
.readdirSync(FIXTURES_DIR)
|
|
19
|
+
.filter((f) => f.endsWith('.json'))
|
|
20
|
+
.map((f) => JSON.parse(fs.readFileSync(path.join(FIXTURES_DIR, f), 'utf-8')));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function closeTo(actual, expected, dp) {
|
|
24
|
+
const tolerance = 0.5 * Math.pow(10, -dp);
|
|
25
|
+
assert.ok(
|
|
26
|
+
Math.abs(actual - expected) < tolerance,
|
|
27
|
+
`expected ${actual} to be within ${tolerance} of ${expected}`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const fixtures = loadFixtures();
|
|
32
|
+
|
|
33
|
+
test('pricing fixtures exist', () => {
|
|
34
|
+
assert.ok(fixtures.length > 0, 'no fixtures found');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
for (const fixture of fixtures) {
|
|
38
|
+
test(`golden parity: ${fixture.name} - estimateQuote matches server`, () => {
|
|
39
|
+
const result = estimateQuote(fixture.bundle, fixture.selections);
|
|
40
|
+
closeTo(result.cost, fixture.expected.cost, 2);
|
|
41
|
+
closeTo(result.costPerUnit, fixture.expected.costPerUnit, 3);
|
|
42
|
+
closeTo(result.taxAmount, fixture.expected.taxAmount, 2);
|
|
43
|
+
closeTo(result.totalCost, fixture.expected.totalCost, 2);
|
|
44
|
+
assert.equal(result.groupCosts.length, fixture.expected.groupCosts.length);
|
|
45
|
+
result.groupCosts.forEach((c, i) => closeTo(c, fixture.expected.groupCosts[i], 2));
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test(`golden parity: ${fixture.name} - resolveVisibleFields matches server`, () => {
|
|
49
|
+
const visible = [...resolveVisibleFields(fixture.bundle, fixture.selections)].sort(
|
|
50
|
+
(a, b) => a - b
|
|
51
|
+
);
|
|
52
|
+
assert.deepEqual(visible, fixture.expected.visibleFieldIds);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { roundHalfEven } from './round.js';
|
|
2
|
+
export { applyDiscount } from './discount.js';
|
|
3
|
+
export { resolveVisibleFields, resolveVisibleOptionIds } from './visibility.js';
|
|
4
|
+
export { resolveUnavailableOptionIds } from './inventory.js';
|
|
5
|
+
export { estimateQuote } from './estimate.js';
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Returns the set of option ids that should be DISABLED because the combination
|
|
2
|
+
// of (current inventory-relevant selections + that option) has no matching
|
|
3
|
+
// inventory with stock. Mirrors the server's subset match
|
|
4
|
+
// (inventories_subset_match_strategy): an inventory matches a selection when it
|
|
5
|
+
// contains every selected option; an option is available if any matching
|
|
6
|
+
// inventory has quantity > 0.
|
|
7
|
+
//
|
|
8
|
+
// Scope: pass the container's selections (independent fields use the top-level
|
|
9
|
+
// selections; a group uses its own + independent). Only options that appear in
|
|
10
|
+
// at least one inventory unit are inventory-tracked; all others are always
|
|
11
|
+
// available. Direct product inventories only (no shared inventory groups).
|
|
12
|
+
export function resolveUnavailableOptionIds(rules, selections) {
|
|
13
|
+
const unavailable = new Set();
|
|
14
|
+
const units = rules.inventoryUnits || [];
|
|
15
|
+
if (units.length === 0) return unavailable;
|
|
16
|
+
|
|
17
|
+
const allFields = [...rules.fields, ...rules.groupFields];
|
|
18
|
+
|
|
19
|
+
const canonical = new Map();
|
|
20
|
+
for (const f of allFields) {
|
|
21
|
+
for (const o of f.options) {
|
|
22
|
+
canonical.set(o.id, o.id);
|
|
23
|
+
if (o.originalId != null) canonical.set(o.originalId, o.id);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const canon = (id) => (canonical.has(id) ? canonical.get(id) : id);
|
|
27
|
+
|
|
28
|
+
const unitSets = units.map((u) => ({
|
|
29
|
+
quantity: u.quantity,
|
|
30
|
+
optionIds: new Set(u.optionIds.map(canon)),
|
|
31
|
+
}));
|
|
32
|
+
const inventoryOptionIds = new Set();
|
|
33
|
+
for (const u of unitSets) {
|
|
34
|
+
for (const id of u.optionIds) inventoryOptionIds.add(id);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const selectedByField = new Map();
|
|
38
|
+
const addSelections = (fieldValues) => {
|
|
39
|
+
for (const key of Object.keys(fieldValues)) {
|
|
40
|
+
const fieldId = Number(key);
|
|
41
|
+
const sel = fieldValues[fieldId];
|
|
42
|
+
if (!sel || !sel.selectedOptionIds) continue;
|
|
43
|
+
const set = selectedByField.get(fieldId) || new Set();
|
|
44
|
+
for (const oid of sel.selectedOptionIds) {
|
|
45
|
+
const c = canon(oid);
|
|
46
|
+
if (inventoryOptionIds.has(c)) set.add(c);
|
|
47
|
+
}
|
|
48
|
+
selectedByField.set(fieldId, set);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
addSelections(selections.fieldValues || {});
|
|
52
|
+
for (const group of selections.groups || []) addSelections(group.fieldValues || {});
|
|
53
|
+
|
|
54
|
+
const hasGroups = Boolean(selections.groups && selections.groups.length > 0);
|
|
55
|
+
const fieldsToConsider = hasGroups ? allFields : rules.fields;
|
|
56
|
+
|
|
57
|
+
for (const f of fieldsToConsider) {
|
|
58
|
+
for (const o of f.options) {
|
|
59
|
+
const candidate = canon(o.id);
|
|
60
|
+
if (!inventoryOptionIds.has(candidate)) continue;
|
|
61
|
+
const filter = [candidate];
|
|
62
|
+
for (const [fieldId, set] of selectedByField) {
|
|
63
|
+
if (fieldId === f.id) continue;
|
|
64
|
+
for (const sid of set) filter.push(sid);
|
|
65
|
+
}
|
|
66
|
+
const available = unitSets.some(
|
|
67
|
+
(u) => u.quantity > 0 && filter.every((id) => u.optionIds.has(id))
|
|
68
|
+
);
|
|
69
|
+
if (!available) unavailable.add(o.id);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return unavailable;
|
|
73
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { roundHalfEven } from './round.js';
|
|
4
|
+
import { applyDiscount } from './discount.js';
|
|
5
|
+
import { resolveUnavailableOptionIds } from './inventory.js';
|
|
6
|
+
import { estimateQuote } from './estimate.js';
|
|
7
|
+
|
|
8
|
+
test('roundHalfEven: decimal half-even', () => {
|
|
9
|
+
assert.equal(roundHalfEven(0.5, 0), 0);
|
|
10
|
+
assert.equal(roundHalfEven(1.5, 0), 2);
|
|
11
|
+
assert.equal(roundHalfEven(2.5, 0), 2);
|
|
12
|
+
assert.equal(roundHalfEven(2.675, 2), 2.68);
|
|
13
|
+
assert.equal(roundHalfEven(12.3456, 3), 12.346);
|
|
14
|
+
assert.equal(roundHalfEven(Infinity, 2), Infinity);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('applyDiscount: highest applicable tier', () => {
|
|
18
|
+
const group = {
|
|
19
|
+
groupRestricted: false,
|
|
20
|
+
discounts: [
|
|
21
|
+
{ lowerLimit: 100, amount: 5 },
|
|
22
|
+
{ lowerLimit: 500, amount: 12 },
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
assert.equal(applyDiscount(10, 50, null), 10);
|
|
26
|
+
assert.equal(applyDiscount(10, 50, group), 10);
|
|
27
|
+
assert.equal(applyDiscount(10, 100, group), 9.5);
|
|
28
|
+
assert.equal(applyDiscount(10, 500, group), 8.8);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('estimateQuote: simple linear price + tax', () => {
|
|
32
|
+
const rules = {
|
|
33
|
+
currency: 'AUD',
|
|
34
|
+
taxPercent: 10,
|
|
35
|
+
product: { unitPrice: 10, minimumPrice: null, discountGroup: null },
|
|
36
|
+
fields: [],
|
|
37
|
+
groupFields: [],
|
|
38
|
+
hasGroups: false,
|
|
39
|
+
};
|
|
40
|
+
const r = estimateQuote(rules, { quantity: 5, fieldValues: {} });
|
|
41
|
+
assert.equal(r.cost, 50);
|
|
42
|
+
assert.equal(r.taxAmount, 5);
|
|
43
|
+
assert.equal(r.totalCost, 55);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('resolveUnavailableOptionIds: 25mm makes Blue unavailable', () => {
|
|
47
|
+
const opt = (id) => ({
|
|
48
|
+
id, originalId: id, position: id, default: false,
|
|
49
|
+
variationCost: 0, variationUnitCost: 0,
|
|
50
|
+
variationCostDiscountGroup: null, variationUnitCostDiscountGroup: null,
|
|
51
|
+
selectedBy: [],
|
|
52
|
+
});
|
|
53
|
+
const f = (id, options) => ({
|
|
54
|
+
id, originalId: id, position: 0, fieldType: 2, independent: false,
|
|
55
|
+
isSelectable: true, selectedBy: [],
|
|
56
|
+
variationCost: 0, variationUnitCost: 0,
|
|
57
|
+
variationCostDiscountGroup: null, variationUnitCostDiscountGroup: null,
|
|
58
|
+
options: options.map(opt),
|
|
59
|
+
});
|
|
60
|
+
const rules = {
|
|
61
|
+
currency: 'AUD', taxPercent: 0,
|
|
62
|
+
product: { unitPrice: 0, minimumPrice: null, discountGroup: null },
|
|
63
|
+
fields: [], hasGroups: true, needsInventory: true,
|
|
64
|
+
groupFields: [f(800, [801, 802]), f(810, [811, 813])],
|
|
65
|
+
inventoryUnits: [
|
|
66
|
+
{ optionIds: [801, 813], quantity: 5 }, // 24mm-Blue ok
|
|
67
|
+
{ optionIds: [802, 811], quantity: 7 }, // 25mm-Red ok
|
|
68
|
+
{ optionIds: [802, 813], quantity: 0 }, // 25mm-Blue OUT
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
const u = resolveUnavailableOptionIds(rules, {
|
|
72
|
+
fieldValues: {},
|
|
73
|
+
groups: [{ quantity: 1, fieldValues: { 800: { selectedOptionIds: [802] } } }],
|
|
74
|
+
});
|
|
75
|
+
assert.equal(u.has(813), true);
|
|
76
|
+
assert.equal(u.has(811), false);
|
|
77
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Decimal half-even rounding (round half to even), mirroring the server's
|
|
2
|
+
// `Decimal(round(x, n))`. NOT float `round()` (e.g. roundHalfEven(2.675, 2) is
|
|
3
|
+
// 2.68, not 2.67). Targets the money domain (small magnitudes, dp <= 3); the
|
|
4
|
+
// EPS half-detection is tuned for that domain.
|
|
5
|
+
export function roundHalfEven(value, dp) {
|
|
6
|
+
if (!isFinite(value)) return value;
|
|
7
|
+
const factor = Math.pow(10, dp);
|
|
8
|
+
const scaled = value * factor;
|
|
9
|
+
const floor = Math.floor(scaled);
|
|
10
|
+
const diff = scaled - floor;
|
|
11
|
+
const EPS = 1e-9;
|
|
12
|
+
let rounded;
|
|
13
|
+
if (Math.abs(diff - 0.5) < EPS) {
|
|
14
|
+
rounded = floor % 2 === 0 ? floor : floor + 1;
|
|
15
|
+
} else {
|
|
16
|
+
rounded = Math.round(scaled);
|
|
17
|
+
}
|
|
18
|
+
return rounded / factor;
|
|
19
|
+
}
|