merchi_sdk_js 0.6.1 → 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_operation_log.js +65 -0
- package/src/merchi.js +9 -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "merchi_sdk_js",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "src/merchi.js",
|
|
6
6
|
"module": "src/merchi.js",
|
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
"files": [
|
|
9
9
|
"src/"
|
|
10
10
|
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "node --test"
|
|
13
|
+
},
|
|
11
14
|
"dependencies": {
|
|
12
15
|
"axios": "^0.27.2",
|
|
13
16
|
"browser-or-node": "^2.1.1",
|
package/src/job.js
CHANGED
|
@@ -30,6 +30,7 @@ import { VariationsGroup } from './variations_group.js';
|
|
|
30
30
|
import { Item } from './item.js';
|
|
31
31
|
import { JobComment } from './job_comment.js';
|
|
32
32
|
import { JobNote } from './job_note.js';
|
|
33
|
+
import { JobOperationLog } from './job_operation_log.js';
|
|
33
34
|
import { Reminder } from './reminder.js';
|
|
34
35
|
import { InternalTag } from './internal_tag.js';
|
|
35
36
|
|
|
@@ -43,6 +44,7 @@ export function Job() {
|
|
|
43
44
|
addPropertyTo(this, 'currency');
|
|
44
45
|
addPropertyTo(this, 'quantity');
|
|
45
46
|
addPropertyTo(this, 'jobNotes', JobNote);
|
|
47
|
+
addPropertyTo(this, 'operationLogs', JobOperationLog);
|
|
46
48
|
addPropertyTo(this, 'jobType');
|
|
47
49
|
addPropertyTo(this, 'product', Product);
|
|
48
50
|
addPropertyTo(this, 'supplyChainRequestProduct', Product);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { generateUUID } from './uuid.js';
|
|
2
|
+
import {
|
|
3
|
+
addPropertyTo,
|
|
4
|
+
fromJson,
|
|
5
|
+
getOne,
|
|
6
|
+
getList,
|
|
7
|
+
fromJsonList,
|
|
8
|
+
deleteOne,
|
|
9
|
+
} from './model.js';
|
|
10
|
+
import { Job } from './job.js';
|
|
11
|
+
import { User } from './user.js';
|
|
12
|
+
|
|
13
|
+
export function JobOperationLog() {
|
|
14
|
+
this.resource = '/job_operation_logs';
|
|
15
|
+
this.json = 'jobOperationLog';
|
|
16
|
+
this.temporaryId = generateUUID();
|
|
17
|
+
|
|
18
|
+
addPropertyTo(this, 'id');
|
|
19
|
+
addPropertyTo(this, 'job', Job);
|
|
20
|
+
addPropertyTo(this, 'user', User);
|
|
21
|
+
addPropertyTo(this, 'sourceType');
|
|
22
|
+
addPropertyTo(this, 'aiInvolved');
|
|
23
|
+
addPropertyTo(this, 'action');
|
|
24
|
+
addPropertyTo(this, 'payloadJson');
|
|
25
|
+
addPropertyTo(this, 'changesJson');
|
|
26
|
+
addPropertyTo(this, 'operationJson');
|
|
27
|
+
addPropertyTo(this, 'createdAt');
|
|
28
|
+
|
|
29
|
+
this.get = function (success, error, embed) {
|
|
30
|
+
var self = this;
|
|
31
|
+
function handleResponse(result) {
|
|
32
|
+
success(
|
|
33
|
+
fromJson(self, result[self.json], { makesDirty: false })
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
getOne({
|
|
37
|
+
resource: this.resource,
|
|
38
|
+
id: this.id(),
|
|
39
|
+
success: handleResponse,
|
|
40
|
+
error: error,
|
|
41
|
+
embed: embed,
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
this.delete = function (options) {
|
|
46
|
+
options = options || {};
|
|
47
|
+
var success = options.success || function () {},
|
|
48
|
+
error = options.error || function () {};
|
|
49
|
+
deleteOne(this.resource + '/' + this.id(), success, error);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function JobOperationLogs() {
|
|
54
|
+
this.resource = '/job_operation_logs';
|
|
55
|
+
this.json = 'jobOperationLogs';
|
|
56
|
+
this.single = JobOperationLog;
|
|
57
|
+
|
|
58
|
+
this.get = function (success, error, parameters) {
|
|
59
|
+
var self = this;
|
|
60
|
+
function handleResponse(result) {
|
|
61
|
+
success(fromJsonList(self, result, { makesDirty: false }));
|
|
62
|
+
}
|
|
63
|
+
getList(this.resource, handleResponse, error, parameters || {});
|
|
64
|
+
};
|
|
65
|
+
}
|
package/src/merchi.js
CHANGED
|
@@ -54,6 +54,7 @@ import { Invoice, Invoices } from './invoice.js';
|
|
|
54
54
|
import { Job, Jobs } from './job.js';
|
|
55
55
|
import { JobComment } from './job_comment.js';
|
|
56
56
|
import { JobNote } from './job_note.js';
|
|
57
|
+
import { JobOperationLog, JobOperationLogs } from './job_operation_log.js';
|
|
57
58
|
import { MerchiFile, MerchiFiles } from './merchi_file.js';
|
|
58
59
|
import { Menu } from './menu.js';
|
|
59
60
|
import { MenuItem } from './menu_item.js';
|
|
@@ -96,6 +97,12 @@ import {
|
|
|
96
97
|
normalizeProductReviewApiJson,
|
|
97
98
|
wireProductReviewCreateBody,
|
|
98
99
|
} from './product_review.js';
|
|
100
|
+
import * as pricing from './pricing/index.js';
|
|
101
|
+
|
|
102
|
+
// Pure client-side pricing calculator (mirrors merchi_sdk_ts `pricing`):
|
|
103
|
+
// estimateQuote, resolveVisibleFields, resolveVisibleOptionIds,
|
|
104
|
+
// resolveUnavailableOptionIds, applyDiscount, roundHalfEven.
|
|
105
|
+
export { pricing };
|
|
99
106
|
|
|
100
107
|
export function merchi(backendUri, websocketUri) {
|
|
101
108
|
getGlobal().merchiJsonpHandlers = {};
|
|
@@ -1067,6 +1074,8 @@ export function merchi(backendUri, websocketUri) {
|
|
|
1067
1074
|
'jobs': new Jobs(),
|
|
1068
1075
|
'JobComment': JobComment,
|
|
1069
1076
|
'JobNote': JobNote,
|
|
1077
|
+
'JobOperationLog': JobOperationLog,
|
|
1078
|
+
'jobOperationLogs': new JobOperationLogs(),
|
|
1070
1079
|
'Cart': Cart,
|
|
1071
1080
|
'CartItem': CartItem,
|
|
1072
1081
|
'Bank': Bank,
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
{
|
|
2
|
+
"bundle": {
|
|
3
|
+
"currency": "AUD",
|
|
4
|
+
"fields": [
|
|
5
|
+
{
|
|
6
|
+
"fieldType": 2,
|
|
7
|
+
"id": 13,
|
|
8
|
+
"independent": true,
|
|
9
|
+
"isSelectable": true,
|
|
10
|
+
"options": [
|
|
11
|
+
{
|
|
12
|
+
"default": true,
|
|
13
|
+
"id": 9,
|
|
14
|
+
"originalId": null,
|
|
15
|
+
"position": 1,
|
|
16
|
+
"selectedBy": [],
|
|
17
|
+
"variationCost": 0.0,
|
|
18
|
+
"variationCostDiscountGroup": null,
|
|
19
|
+
"variationUnitCost": 0.0,
|
|
20
|
+
"variationUnitCostDiscountGroup": null
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"default": false,
|
|
24
|
+
"id": 10,
|
|
25
|
+
"originalId": null,
|
|
26
|
+
"position": 2,
|
|
27
|
+
"selectedBy": [],
|
|
28
|
+
"variationCost": 0.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
|
+
"fieldType": 1,
|
|
44
|
+
"id": 15,
|
|
45
|
+
"independent": true,
|
|
46
|
+
"isSelectable": false,
|
|
47
|
+
"options": [],
|
|
48
|
+
"originalId": null,
|
|
49
|
+
"position": 1,
|
|
50
|
+
"selectedBy": [
|
|
51
|
+
10
|
|
52
|
+
],
|
|
53
|
+
"variationCost": 0.0,
|
|
54
|
+
"variationCostDiscountGroup": null,
|
|
55
|
+
"variationUnitCost": 0.0,
|
|
56
|
+
"variationUnitCostDiscountGroup": null
|
|
57
|
+
}
|
|
58
|
+
],
|
|
59
|
+
"groupFields": [
|
|
60
|
+
{
|
|
61
|
+
"fieldType": 3,
|
|
62
|
+
"id": 14,
|
|
63
|
+
"independent": false,
|
|
64
|
+
"isSelectable": false,
|
|
65
|
+
"options": [],
|
|
66
|
+
"originalId": null,
|
|
67
|
+
"position": 2,
|
|
68
|
+
"selectedBy": [],
|
|
69
|
+
"variationCost": 0.0,
|
|
70
|
+
"variationCostDiscountGroup": null,
|
|
71
|
+
"variationUnitCost": 0.0,
|
|
72
|
+
"variationUnitCostDiscountGroup": null
|
|
73
|
+
}
|
|
74
|
+
],
|
|
75
|
+
"hasGroups": true,
|
|
76
|
+
"product": {
|
|
77
|
+
"discountGroup": {
|
|
78
|
+
"discounts": [
|
|
79
|
+
{
|
|
80
|
+
"amount": 10.0,
|
|
81
|
+
"lowerLimit": 400.0
|
|
82
|
+
}
|
|
83
|
+
],
|
|
84
|
+
"groupRestricted": false
|
|
85
|
+
},
|
|
86
|
+
"minimumPrice": 0.0,
|
|
87
|
+
"unitPrice": 30.0
|
|
88
|
+
},
|
|
89
|
+
"taxPercent": 10.0
|
|
90
|
+
},
|
|
91
|
+
"expected": {
|
|
92
|
+
"cost": 150.0,
|
|
93
|
+
"costPerUnit": 30.0,
|
|
94
|
+
"groupCosts": [],
|
|
95
|
+
"taxAmount": 15.0,
|
|
96
|
+
"totalCost": 165.0,
|
|
97
|
+
"visibleFieldIds": [
|
|
98
|
+
13
|
|
99
|
+
]
|
|
100
|
+
},
|
|
101
|
+
"name": "conditional_field_hidden",
|
|
102
|
+
"selections": {
|
|
103
|
+
"fieldValues": {
|
|
104
|
+
"13": {
|
|
105
|
+
"selectedOptionIds": [
|
|
106
|
+
9
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
"quantity": 5
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
{
|
|
2
|
+
"bundle": {
|
|
3
|
+
"currency": "AUD",
|
|
4
|
+
"fields": [
|
|
5
|
+
{
|
|
6
|
+
"fieldType": 2,
|
|
7
|
+
"id": 13,
|
|
8
|
+
"independent": true,
|
|
9
|
+
"isSelectable": true,
|
|
10
|
+
"options": [
|
|
11
|
+
{
|
|
12
|
+
"default": true,
|
|
13
|
+
"id": 9,
|
|
14
|
+
"originalId": null,
|
|
15
|
+
"position": 1,
|
|
16
|
+
"selectedBy": [],
|
|
17
|
+
"variationCost": 0.0,
|
|
18
|
+
"variationCostDiscountGroup": null,
|
|
19
|
+
"variationUnitCost": 0.0,
|
|
20
|
+
"variationUnitCostDiscountGroup": null
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"default": false,
|
|
24
|
+
"id": 10,
|
|
25
|
+
"originalId": null,
|
|
26
|
+
"position": 2,
|
|
27
|
+
"selectedBy": [],
|
|
28
|
+
"variationCost": 0.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
|
+
"fieldType": 1,
|
|
44
|
+
"id": 15,
|
|
45
|
+
"independent": true,
|
|
46
|
+
"isSelectable": false,
|
|
47
|
+
"options": [],
|
|
48
|
+
"originalId": null,
|
|
49
|
+
"position": 1,
|
|
50
|
+
"selectedBy": [
|
|
51
|
+
10
|
|
52
|
+
],
|
|
53
|
+
"variationCost": 0.0,
|
|
54
|
+
"variationCostDiscountGroup": null,
|
|
55
|
+
"variationUnitCost": 0.0,
|
|
56
|
+
"variationUnitCostDiscountGroup": null
|
|
57
|
+
}
|
|
58
|
+
],
|
|
59
|
+
"groupFields": [
|
|
60
|
+
{
|
|
61
|
+
"fieldType": 3,
|
|
62
|
+
"id": 14,
|
|
63
|
+
"independent": false,
|
|
64
|
+
"isSelectable": false,
|
|
65
|
+
"options": [],
|
|
66
|
+
"originalId": null,
|
|
67
|
+
"position": 2,
|
|
68
|
+
"selectedBy": [],
|
|
69
|
+
"variationCost": 0.0,
|
|
70
|
+
"variationCostDiscountGroup": null,
|
|
71
|
+
"variationUnitCost": 0.0,
|
|
72
|
+
"variationUnitCostDiscountGroup": null
|
|
73
|
+
}
|
|
74
|
+
],
|
|
75
|
+
"hasGroups": true,
|
|
76
|
+
"product": {
|
|
77
|
+
"discountGroup": {
|
|
78
|
+
"discounts": [
|
|
79
|
+
{
|
|
80
|
+
"amount": 10.0,
|
|
81
|
+
"lowerLimit": 400.0
|
|
82
|
+
}
|
|
83
|
+
],
|
|
84
|
+
"groupRestricted": false
|
|
85
|
+
},
|
|
86
|
+
"minimumPrice": 0.0,
|
|
87
|
+
"unitPrice": 30.0
|
|
88
|
+
},
|
|
89
|
+
"taxPercent": 10.0
|
|
90
|
+
},
|
|
91
|
+
"expected": {
|
|
92
|
+
"cost": 215.0,
|
|
93
|
+
"costPerUnit": 30.0,
|
|
94
|
+
"groupCosts": [],
|
|
95
|
+
"taxAmount": 21.5,
|
|
96
|
+
"totalCost": 236.5,
|
|
97
|
+
"visibleFieldIds": [
|
|
98
|
+
13,
|
|
99
|
+
15
|
|
100
|
+
]
|
|
101
|
+
},
|
|
102
|
+
"name": "conditional_field_visible",
|
|
103
|
+
"selections": {
|
|
104
|
+
"fieldValues": {
|
|
105
|
+
"13": {
|
|
106
|
+
"selectedOptionIds": [
|
|
107
|
+
10
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
"quantity": 5
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
{
|
|
2
|
+
"bundle": {
|
|
3
|
+
"currency": "AUD",
|
|
4
|
+
"fields": [
|
|
5
|
+
{
|
|
6
|
+
"fieldType": 1,
|
|
7
|
+
"id": 12,
|
|
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": 10,
|
|
24
|
+
"independent": false,
|
|
25
|
+
"isSelectable": true,
|
|
26
|
+
"options": [
|
|
27
|
+
{
|
|
28
|
+
"default": true,
|
|
29
|
+
"id": 7,
|
|
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": 8,
|
|
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": 11,
|
|
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": 645.0,
|
|
91
|
+
"costPerUnit": 30.0,
|
|
92
|
+
"groupCosts": [
|
|
93
|
+
215.0,
|
|
94
|
+
430.0
|
|
95
|
+
],
|
|
96
|
+
"taxAmount": 64.5,
|
|
97
|
+
"totalCost": 709.5,
|
|
98
|
+
"visibleFieldIds": [
|
|
99
|
+
10,
|
|
100
|
+
11,
|
|
101
|
+
12
|
|
102
|
+
]
|
|
103
|
+
},
|
|
104
|
+
"name": "group_product",
|
|
105
|
+
"selections": {
|
|
106
|
+
"fieldValues": {},
|
|
107
|
+
"groups": [
|
|
108
|
+
{
|
|
109
|
+
"fieldValues": {
|
|
110
|
+
"10": {
|
|
111
|
+
"selectedOptionIds": [
|
|
112
|
+
8
|
|
113
|
+
]
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
"quantity": 5
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
"fieldValues": {
|
|
120
|
+
"10": {
|
|
121
|
+
"selectedOptionIds": [
|
|
122
|
+
8
|
|
123
|
+
]
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
"quantity": 10
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
{
|
|
2
|
+
"bundle": {
|
|
3
|
+
"currency": "AUD",
|
|
4
|
+
"fields": [
|
|
5
|
+
{
|
|
6
|
+
"fieldType": 5,
|
|
7
|
+
"id": 9,
|
|
8
|
+
"independent": true,
|
|
9
|
+
"isSelectable": false,
|
|
10
|
+
"options": [],
|
|
11
|
+
"originalId": null,
|
|
12
|
+
"position": 1,
|
|
13
|
+
"selectedBy": [],
|
|
14
|
+
"variationCost": 25.0,
|
|
15
|
+
"variationCostDiscountGroup": null,
|
|
16
|
+
"variationUnitCost": 5.0,
|
|
17
|
+
"variationUnitCostDiscountGroup": null
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"groupFields": [
|
|
21
|
+
{
|
|
22
|
+
"fieldType": 2,
|
|
23
|
+
"id": 7,
|
|
24
|
+
"independent": false,
|
|
25
|
+
"isSelectable": true,
|
|
26
|
+
"options": [
|
|
27
|
+
{
|
|
28
|
+
"default": true,
|
|
29
|
+
"id": 5,
|
|
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": 6,
|
|
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": 8,
|
|
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": 375.0,
|
|
91
|
+
"costPerUnit": 30.0,
|
|
92
|
+
"groupCosts": [],
|
|
93
|
+
"taxAmount": 37.5,
|
|
94
|
+
"totalCost": 412.5,
|
|
95
|
+
"visibleFieldIds": [
|
|
96
|
+
9
|
|
97
|
+
]
|
|
98
|
+
},
|
|
99
|
+
"name": "non_selectable_number_field",
|
|
100
|
+
"selections": {
|
|
101
|
+
"fieldValues": {
|
|
102
|
+
"9": {
|
|
103
|
+
"value": "7"
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
"quantity": 10
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
function collectSelectedOptionIds(selections) {
|
|
2
|
+
const ids = [];
|
|
3
|
+
const addFrom = (fieldValues) => {
|
|
4
|
+
for (const key of Object.keys(fieldValues)) {
|
|
5
|
+
const sel = fieldValues[Number(key)];
|
|
6
|
+
if (sel && sel.selectedOptionIds) ids.push(...sel.selectedOptionIds);
|
|
7
|
+
}
|
|
8
|
+
};
|
|
9
|
+
addFrom(selections.fieldValues || {});
|
|
10
|
+
for (const group of selections.groups || []) addFrom(group.fieldValues || {});
|
|
11
|
+
return ids;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Shared machinery for field- and option-level conditional visibility. Mirrors
|
|
15
|
+
// the server's `check_selected_by_fullfilled` recursion: a `selectedBy` list is
|
|
16
|
+
// fulfilled when any of its trigger option ids is currently selected AND that
|
|
17
|
+
// selecting option is itself fulfilled (its own `selectedBy` and its owning
|
|
18
|
+
// field's `selectedBy`), with a `checked` accumulator to break cycles.
|
|
19
|
+
function buildResolver(rules, selections) {
|
|
20
|
+
const allFields = [...rules.fields, ...rules.groupFields];
|
|
21
|
+
const optionsById = new Map();
|
|
22
|
+
const fieldByOptionId = new Map();
|
|
23
|
+
for (const f of allFields) {
|
|
24
|
+
for (const o of f.options) {
|
|
25
|
+
optionsById.set(o.id, o);
|
|
26
|
+
fieldByOptionId.set(o.id, f);
|
|
27
|
+
if (o.originalId != null) {
|
|
28
|
+
optionsById.set(o.originalId, o);
|
|
29
|
+
fieldByOptionId.set(o.originalId, f);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const selectedOptionIds = collectSelectedOptionIds(selections);
|
|
35
|
+
|
|
36
|
+
const sameOption = (a, b) => {
|
|
37
|
+
const oa = optionsById.get(a);
|
|
38
|
+
return oa != null && oa === optionsById.get(b);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const isFulfilled = (selectedBy, checked) => {
|
|
42
|
+
if (!selectedBy || selectedBy.length === 0) return true;
|
|
43
|
+
for (const triggerId of selectedBy) {
|
|
44
|
+
for (const selId of selectedOptionIds) {
|
|
45
|
+
if (!sameOption(triggerId, selId)) continue;
|
|
46
|
+
if (checked.includes(selId)) return true;
|
|
47
|
+
const opt = optionsById.get(selId);
|
|
48
|
+
const field = fieldByOptionId.get(selId);
|
|
49
|
+
const nextChecked = [...checked, selId];
|
|
50
|
+
if (
|
|
51
|
+
isFulfilled(opt.selectedBy, nextChecked) &&
|
|
52
|
+
isFulfilled(field.selectedBy, nextChecked)
|
|
53
|
+
) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Group fields only materialise as variations when the job actually has
|
|
62
|
+
// groups (mirrors the server: a non-group job exposes no group fields).
|
|
63
|
+
const hasGroups = Boolean(selections.groups && selections.groups.length > 0);
|
|
64
|
+
const fieldsToConsider = hasGroups ? allFields : rules.fields;
|
|
65
|
+
|
|
66
|
+
return { allFields, fieldsToConsider, isFulfilled };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function resolveVisibleFields(rules, selections) {
|
|
70
|
+
const { fieldsToConsider, isFulfilled } = buildResolver(rules, selections);
|
|
71
|
+
const visible = new Set();
|
|
72
|
+
for (const f of fieldsToConsider) {
|
|
73
|
+
if (isFulfilled(f.selectedBy, [])) visible.add(f.id);
|
|
74
|
+
}
|
|
75
|
+
return visible;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Option ids whose own `selectedBy` is fulfilled (i.e. options that should be
|
|
79
|
+
// visible/selectable). Mirrors the server's per-option
|
|
80
|
+
// `isVisible = check_selected_by_fullfilled(option.selected_by)`.
|
|
81
|
+
export function resolveVisibleOptionIds(rules, selections) {
|
|
82
|
+
const { fieldsToConsider, isFulfilled } = buildResolver(rules, selections);
|
|
83
|
+
const visible = new Set();
|
|
84
|
+
for (const f of fieldsToConsider) {
|
|
85
|
+
for (const o of f.options) {
|
|
86
|
+
if (isFulfilled(o.selectedBy, [])) visible.add(o.id);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return visible;
|
|
90
|
+
}
|
package/src/user.js
CHANGED
|
@@ -18,6 +18,7 @@ import { UserCompany } from './user_company.js';
|
|
|
18
18
|
import { Reminder } from './reminder.js';
|
|
19
19
|
import { InternalTag } from './internal_tag.js';
|
|
20
20
|
import { DomainTag } from './domain_tag.js';
|
|
21
|
+
import { JobOperationLog } from './job_operation_log.js';
|
|
21
22
|
|
|
22
23
|
export function User() {
|
|
23
24
|
this.resource = '/users';
|
|
@@ -62,6 +63,7 @@ export function User() {
|
|
|
62
63
|
addPropertyTo(this, 'tags', DomainTag);
|
|
63
64
|
addPropertyTo(this, 'internalTags', InternalTag);
|
|
64
65
|
addPropertyTo(this, 'reminders', Reminder);
|
|
66
|
+
addPropertyTo(this, 'jobOperationLogs', JobOperationLog);
|
|
65
67
|
|
|
66
68
|
this.create = function (success, error, embed, as_domain) {
|
|
67
69
|
var self = this,
|