@roomwise/calc-engine 0.4.0 → 0.6.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/README.md +69 -23
- package/dist/index.cjs +411 -132
- package/dist/index.d.cts +126 -28
- package/dist/index.d.ts +126 -28
- package/dist/index.js +404 -132
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -26,6 +26,73 @@ function toCents(dollars) {
|
|
|
26
26
|
return cents;
|
|
27
27
|
}
|
|
28
28
|
//#endregion
|
|
29
|
+
//#region src/variables/putty.ts
|
|
30
|
+
function puttyRequiredOn(room, surface, categories) {
|
|
31
|
+
const selected = categories.filter((category) => category.surface === surface && room.selections.some((selection) => selection.categoryId === category.id));
|
|
32
|
+
return selected.length === 0 || selected.some((category) => category.requiresPutty);
|
|
33
|
+
}
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region src/variables/room.ts
|
|
36
|
+
function roomVariables(room) {
|
|
37
|
+
const perimeter = 2 * (room.lengthM + room.widthM);
|
|
38
|
+
return {
|
|
39
|
+
length: room.lengthM,
|
|
40
|
+
width: room.widthM,
|
|
41
|
+
height: room.heightM,
|
|
42
|
+
floorArea: room.lengthM * room.widthM,
|
|
43
|
+
perimeter,
|
|
44
|
+
wallArea: perimeter * room.heightM,
|
|
45
|
+
heatedFloor: room.heatedFloor,
|
|
46
|
+
roomType: room.roomTypeCode
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region src/variables/object.ts
|
|
51
|
+
const COUNTED_AS_ROOMS = [
|
|
52
|
+
"LIVING_ROOM",
|
|
53
|
+
"BEDROOM",
|
|
54
|
+
"KITCHEN_LIVING"
|
|
55
|
+
];
|
|
56
|
+
const WET_ROOMS = [
|
|
57
|
+
"BATHROOM",
|
|
58
|
+
"KITCHEN",
|
|
59
|
+
"KITCHEN_LIVING"
|
|
60
|
+
];
|
|
61
|
+
function totalAreaOf(scope, totalAreaSqm, roomsFloorArea) {
|
|
62
|
+
return scope === "PARTIAL" || totalAreaSqm === null ? roomsFloorArea : totalAreaSqm;
|
|
63
|
+
}
|
|
64
|
+
function objectVariables(configuration, categories) {
|
|
65
|
+
const rooms = configuration.rooms.map(roomVariables);
|
|
66
|
+
const sum = (pick) => rooms.reduce((total, room) => total + pick(room), 0);
|
|
67
|
+
const roomsFloorArea = sum((room) => room.floorArea);
|
|
68
|
+
return {
|
|
69
|
+
scope: configuration.scope,
|
|
70
|
+
totalArea: totalAreaOf(configuration.scope, configuration.totalAreaSqm, roomsFloorArea),
|
|
71
|
+
balconyArea: configuration.balconyAreaSqm,
|
|
72
|
+
balconyTiles: configuration.balconyTiles,
|
|
73
|
+
finishLevel: configuration.finishLevel,
|
|
74
|
+
roomsCount: configuration.rooms.filter((room) => COUNTED_AS_ROOMS.includes(room.roomTypeCode)).length,
|
|
75
|
+
bathroomsCount: configuration.rooms.filter((room) => room.roomTypeCode === "BATHROOM").length,
|
|
76
|
+
roomsFloorArea,
|
|
77
|
+
roomsWallArea: sum((room) => room.wallArea),
|
|
78
|
+
roomsPuttyWallArea: configuration.rooms.reduce((total, room) => puttyRequiredOn(room, "WALLS", categories) ? total + roomVariables(room).wallArea : total, 0),
|
|
79
|
+
roomsPerimeter: sum((room) => room.perimeter),
|
|
80
|
+
wetRoomsCount: rooms.filter((room) => WET_ROOMS.includes(room.roomType)).length
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
//#endregion
|
|
84
|
+
//#region src/variables/product.ts
|
|
85
|
+
function productVariablesOf(product, wastePercent) {
|
|
86
|
+
return {
|
|
87
|
+
price: product.priceCents / 100,
|
|
88
|
+
waste: wastePercent / 100,
|
|
89
|
+
unit: product.unit
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function productVariables(product, category) {
|
|
93
|
+
return productVariablesOf(product, product.wastePercentOverride ?? category.wastePercent);
|
|
94
|
+
}
|
|
95
|
+
//#endregion
|
|
29
96
|
//#region src/estimate/line.ts
|
|
30
97
|
const MAX_LINE_CENTS = 1e10;
|
|
31
98
|
function createLine(init) {
|
|
@@ -55,18 +122,25 @@ function failedLine(init, status, error) {
|
|
|
55
122
|
});
|
|
56
123
|
}
|
|
57
124
|
function pricedLine(init, dollars) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
params: { maxCents: MAX_LINE_CENTS }
|
|
61
|
-
});
|
|
62
|
-
return createLine({
|
|
125
|
+
const amountCents = componentCents(dollars);
|
|
126
|
+
return amountCents === null ? failedLine(init, "ERROR", outOfRange()) : createLine({
|
|
63
127
|
...init,
|
|
64
|
-
amountCents
|
|
128
|
+
amountCents
|
|
65
129
|
});
|
|
66
130
|
}
|
|
67
131
|
function roundQuantity(quantity) {
|
|
68
132
|
return Math.round(quantity * 1e3) / 1e3;
|
|
69
133
|
}
|
|
134
|
+
function componentCents(dollars) {
|
|
135
|
+
if (!Number.isFinite(dollars) || dollars < 0 || dollars * 100 > 1e10) return null;
|
|
136
|
+
return toCents(dollars);
|
|
137
|
+
}
|
|
138
|
+
function outOfRange() {
|
|
139
|
+
return {
|
|
140
|
+
code: "AMOUNT_OUT_OF_RANGE",
|
|
141
|
+
params: { maxCents: MAX_LINE_CENTS }
|
|
142
|
+
};
|
|
143
|
+
}
|
|
70
144
|
//#endregion
|
|
71
145
|
//#region src/estimate/line-id.ts
|
|
72
146
|
function lineId(section, ...parts) {
|
|
@@ -82,16 +156,39 @@ function unitQuantity(unit, rooms, pieces) {
|
|
|
82
156
|
case "ROOM_SQM": return rooms.reduce((sum, room) => sum + room.lengthM * room.widthM, 0);
|
|
83
157
|
}
|
|
84
158
|
}
|
|
85
|
-
function
|
|
159
|
+
function optionUnitPriceCents(option, coefficients) {
|
|
160
|
+
if (option.priceCoefficientKeys.length === 0) return option.priceCents;
|
|
161
|
+
let dollars = 0;
|
|
162
|
+
for (const key of option.priceCoefficientKeys) {
|
|
163
|
+
const value = Object.hasOwn(coefficients, key) ? coefficients[key] : void 0;
|
|
164
|
+
if (value === void 0) return { missingKey: key };
|
|
165
|
+
dollars += value;
|
|
166
|
+
}
|
|
167
|
+
return componentCents(dollars);
|
|
168
|
+
}
|
|
169
|
+
function optionLine(configuration, option, selection, coefficients = {}) {
|
|
86
170
|
const init = {
|
|
87
171
|
id: lineId(option.kind, "option", option.id),
|
|
88
172
|
section: option.kind,
|
|
89
173
|
optionId: option.id
|
|
90
174
|
};
|
|
175
|
+
if (!option.scopes.includes(configuration.scope)) return failedLine(init, "UNAVAILABLE", {
|
|
176
|
+
code: "OPTION_SCOPE_NOT_ALLOWED",
|
|
177
|
+
params: {
|
|
178
|
+
optionId: option.id,
|
|
179
|
+
scope: configuration.scope
|
|
180
|
+
}
|
|
181
|
+
});
|
|
91
182
|
if (!option.available) return failedLine(init, "UNAVAILABLE", {
|
|
92
183
|
code: "PRODUCT_UNAVAILABLE",
|
|
93
184
|
params: { optionId: option.id }
|
|
94
185
|
});
|
|
186
|
+
const unitPriceCents = optionUnitPriceCents(option, coefficients);
|
|
187
|
+
if (unitPriceCents === null) return failedLine(init, "ERROR", outOfRange());
|
|
188
|
+
if (typeof unitPriceCents !== "number") return failedLine(init, "ERROR", {
|
|
189
|
+
code: "UNKNOWN_COEFFICIENT",
|
|
190
|
+
params: { key: unitPriceCents.missingKey }
|
|
191
|
+
});
|
|
95
192
|
const selected = configuration.rooms.filter((room) => selection.roomClientIds?.includes(room.clientId));
|
|
96
193
|
const allowed = option.roomTypeCodes.length === 0 ? selected : selected.filter((room) => option.roomTypeCodes.includes(room.roomTypeCode));
|
|
97
194
|
const rejected = selected.filter((room) => !allowed.includes(room));
|
|
@@ -100,7 +197,7 @@ function optionLine(configuration, option, selection) {
|
|
|
100
197
|
...init,
|
|
101
198
|
quantity: roundQuantity(quantity),
|
|
102
199
|
unit: option.unit,
|
|
103
|
-
unitPriceCents
|
|
200
|
+
unitPriceCents,
|
|
104
201
|
warnings: rejected.map((room) => ({
|
|
105
202
|
code: "OPTION_ROOM_TYPE_NOT_ALLOWED",
|
|
106
203
|
params: {
|
|
@@ -108,12 +205,12 @@ function optionLine(configuration, option, selection) {
|
|
|
108
205
|
roomTypeCode: room.roomTypeCode
|
|
109
206
|
}
|
|
110
207
|
}))
|
|
111
|
-
},
|
|
208
|
+
}, unitPriceCents / 100 * quantity);
|
|
112
209
|
}
|
|
113
210
|
function optionLines(configuration, data, kind) {
|
|
114
211
|
return configuration.options.flatMap((selection) => {
|
|
115
212
|
const option = data.options.find((item) => item.id === selection.optionId);
|
|
116
|
-
return option?.kind === kind ? [optionLine(configuration, option, selection)] : [];
|
|
213
|
+
return option?.kind === kind ? [optionLine(configuration, option, selection, data.coefficients)] : [];
|
|
117
214
|
});
|
|
118
215
|
}
|
|
119
216
|
//#endregion
|
|
@@ -122,29 +219,6 @@ function additionalLines(configuration, data) {
|
|
|
122
219
|
return optionLines(configuration, data, "ADDITIONAL");
|
|
123
220
|
}
|
|
124
221
|
//#endregion
|
|
125
|
-
//#region src/estimate/base.ts
|
|
126
|
-
function baseLine(configuration, data) {
|
|
127
|
-
const init = {
|
|
128
|
-
id: lineId("BASE"),
|
|
129
|
-
section: "BASE"
|
|
130
|
-
};
|
|
131
|
-
const area = configuration.totalAreaSqm;
|
|
132
|
-
const rate = data.baseRates.find((candidate) => candidate.finishLevel === configuration.finishLevel && candidate.areaFromSqm <= area && area < candidate.areaToSqm);
|
|
133
|
-
if (rate === void 0) return failedLine(init, "NOT_CALCULATED", {
|
|
134
|
-
code: "BASE_RATE_NOT_FOUND",
|
|
135
|
-
params: {
|
|
136
|
-
totalAreaSqm: area,
|
|
137
|
-
finishLevel: configuration.finishLevel
|
|
138
|
-
}
|
|
139
|
-
});
|
|
140
|
-
return pricedLine({
|
|
141
|
-
...init,
|
|
142
|
-
quantity: roundQuantity(area),
|
|
143
|
-
unit: "SQM",
|
|
144
|
-
unitPriceCents: rate.rateCentsPerSqm
|
|
145
|
-
}, rate.rateCentsPerSqm / 100 * area);
|
|
146
|
-
}
|
|
147
|
-
//#endregion
|
|
148
222
|
//#region src/estimate/engineering.ts
|
|
149
223
|
function packageLine(configuration, item) {
|
|
150
224
|
const init = {
|
|
@@ -169,51 +243,7 @@ function packageLine(configuration, item) {
|
|
|
169
243
|
}, item.priceCents / 100 * quantity);
|
|
170
244
|
}
|
|
171
245
|
function engineeringLines(configuration, data) {
|
|
172
|
-
return [...data.packageItems.map((item) => packageLine(configuration, item)), ...optionLines(configuration, data, "ENGINEERING")];
|
|
173
|
-
}
|
|
174
|
-
//#endregion
|
|
175
|
-
//#region src/variables/object.ts
|
|
176
|
-
const COUNTED_AS_ROOMS = [
|
|
177
|
-
"LIVING_ROOM",
|
|
178
|
-
"BEDROOM",
|
|
179
|
-
"KITCHEN_LIVING"
|
|
180
|
-
];
|
|
181
|
-
function objectVariables(configuration) {
|
|
182
|
-
return {
|
|
183
|
-
totalArea: configuration.totalAreaSqm,
|
|
184
|
-
balconyArea: configuration.balconyAreaSqm,
|
|
185
|
-
balconyTiles: configuration.balconyTiles,
|
|
186
|
-
finishLevel: configuration.finishLevel,
|
|
187
|
-
roomsCount: configuration.rooms.filter((room) => COUNTED_AS_ROOMS.includes(room.roomTypeCode)).length,
|
|
188
|
-
bathroomsCount: configuration.rooms.filter((room) => room.roomTypeCode === "BATHROOM").length
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
//#endregion
|
|
192
|
-
//#region src/variables/product.ts
|
|
193
|
-
function productVariablesOf(product, wastePercent) {
|
|
194
|
-
return {
|
|
195
|
-
price: product.priceCents / 100,
|
|
196
|
-
waste: wastePercent / 100,
|
|
197
|
-
unit: product.unit
|
|
198
|
-
};
|
|
199
|
-
}
|
|
200
|
-
function productVariables(product, category) {
|
|
201
|
-
return productVariablesOf(product, product.wastePercentOverride ?? category.wastePercent);
|
|
202
|
-
}
|
|
203
|
-
//#endregion
|
|
204
|
-
//#region src/variables/room.ts
|
|
205
|
-
function roomVariables(room) {
|
|
206
|
-
const perimeter = 2 * (room.lengthM + room.widthM);
|
|
207
|
-
return {
|
|
208
|
-
length: room.lengthM,
|
|
209
|
-
width: room.widthM,
|
|
210
|
-
height: room.heightM,
|
|
211
|
-
floorArea: room.lengthM * room.widthM,
|
|
212
|
-
perimeter,
|
|
213
|
-
wallArea: perimeter * room.heightM,
|
|
214
|
-
heatedFloor: room.heatedFloor,
|
|
215
|
-
roomType: room.roomTypeCode
|
|
216
|
-
};
|
|
246
|
+
return [...(configuration.scope === "PARTIAL" ? data.packageItems.filter((item) => !item.includedInBase) : data.packageItems).map((item) => packageLine(configuration, item)), ...optionLines(configuration, data, "ENGINEERING")];
|
|
217
247
|
}
|
|
218
248
|
//#endregion
|
|
219
249
|
//#region src/formula/language.ts
|
|
@@ -647,13 +677,91 @@ function runNumericFormula(source, scope) {
|
|
|
647
677
|
};
|
|
648
678
|
}
|
|
649
679
|
//#endregion
|
|
680
|
+
//#region src/estimate/surfaces.ts
|
|
681
|
+
function surfaceRuleOf(data, surface) {
|
|
682
|
+
return data.rules.filter((rule) => rule.level === "SURFACE" && rule.surface === surface).sort((a, b) => a.sortOrder - b.sortOrder)[0];
|
|
683
|
+
}
|
|
684
|
+
function failedWith(line, error) {
|
|
685
|
+
return failedLine({
|
|
686
|
+
id: line.id,
|
|
687
|
+
section: line.section,
|
|
688
|
+
roomClientId: line.roomClientId,
|
|
689
|
+
categoryId: line.categoryId,
|
|
690
|
+
productId: line.productId,
|
|
691
|
+
ruleId: line.ruleId,
|
|
692
|
+
warnings: line.warnings
|
|
693
|
+
}, "ERROR", error);
|
|
694
|
+
}
|
|
695
|
+
function withComponent(line, component) {
|
|
696
|
+
const components = [...line.components ?? [], component];
|
|
697
|
+
const amountCents = components.reduce((sum, item) => sum + item.amountCents, 0);
|
|
698
|
+
if (amountCents > 1e10) return failedWith(line, outOfRange());
|
|
699
|
+
return {
|
|
700
|
+
...line,
|
|
701
|
+
components,
|
|
702
|
+
amountCents
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
function applySurfacePrep(data, room, lines, objectScope) {
|
|
706
|
+
const result = [...lines];
|
|
707
|
+
const variables = {
|
|
708
|
+
...roomVariables(room),
|
|
709
|
+
...objectScope
|
|
710
|
+
};
|
|
711
|
+
const areas = {
|
|
712
|
+
FLOOR: variables.floorArea,
|
|
713
|
+
CEILING: variables.floorArea,
|
|
714
|
+
WALLS: variables.wallArea
|
|
715
|
+
};
|
|
716
|
+
const surfaces = /* @__PURE__ */ new Map();
|
|
717
|
+
lines.forEach((line, index) => {
|
|
718
|
+
const category = data.categories.find((item) => item.id === line.categoryId);
|
|
719
|
+
if (line.status === "OK" && category !== void 0 && category.surface !== "NONE") {
|
|
720
|
+
const surface = category.surface;
|
|
721
|
+
surfaces.set(surface, [...surfaces.get(surface) ?? [], index]);
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
for (const [surface, indexes] of surfaces) {
|
|
725
|
+
const rule = surfaceRuleOf(data, surface);
|
|
726
|
+
if (rule === void 0) continue;
|
|
727
|
+
const outcome = runNumericFormula(rule.costFormula, {
|
|
728
|
+
variables: {
|
|
729
|
+
...variables,
|
|
730
|
+
surfaceArea: areas[surface],
|
|
731
|
+
puttyRequired: puttyRequiredOn(room, surface, data.categories)
|
|
732
|
+
},
|
|
733
|
+
coefficients: data.coefficients
|
|
734
|
+
});
|
|
735
|
+
const cents = outcome.ok ? componentCents(outcome.value) : null;
|
|
736
|
+
const carrierCategoryId = lines[indexes[0]]?.categoryId ?? null;
|
|
737
|
+
indexes.forEach((index, position) => {
|
|
738
|
+
const line = lines[index];
|
|
739
|
+
if (!outcome.ok || cents === null) {
|
|
740
|
+
result[index] = failedWith(line, outcome.ok ? outOfRange() : outcome.error);
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
result[index] = withComponent(line, position === 0 ? {
|
|
744
|
+
kind: "PREP",
|
|
745
|
+
ruleId: rule.id,
|
|
746
|
+
amountCents: cents
|
|
747
|
+
} : {
|
|
748
|
+
kind: "PREP",
|
|
749
|
+
ruleId: rule.id,
|
|
750
|
+
amountCents: 0,
|
|
751
|
+
sharedWithCategoryId: carrierCategoryId ?? void 0
|
|
752
|
+
});
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
return result;
|
|
756
|
+
}
|
|
757
|
+
//#endregion
|
|
650
758
|
//#region src/estimate/materials.ts
|
|
651
759
|
const isMaterialRule = (rule) => rule.quantityFormula !== null;
|
|
652
760
|
function selectRule(data, categoryId, materialTypeId) {
|
|
653
761
|
const rules = data.rules.filter(isMaterialRule);
|
|
654
762
|
return rules.find((rule) => rule.level === "MATERIAL_TYPE" && rule.categoryId === categoryId && rule.materialTypeId === materialTypeId) ?? rules.find((rule) => rule.level === "CATEGORY" && rule.categoryId === categoryId);
|
|
655
763
|
}
|
|
656
|
-
function materialLine(configuration, data, room, categoryId, productId) {
|
|
764
|
+
function materialLine(configuration, data, room, categoryId, productId, objectScope) {
|
|
657
765
|
const init = {
|
|
658
766
|
id: lineId("MATERIALS", room.clientId, categoryId),
|
|
659
767
|
section: "MATERIALS",
|
|
@@ -685,7 +793,7 @@ function materialLine(configuration, data, room, categoryId, productId) {
|
|
|
685
793
|
const variables = {
|
|
686
794
|
...roomVariables(room),
|
|
687
795
|
...productVariables(product, category),
|
|
688
|
-
...
|
|
796
|
+
...objectScope
|
|
689
797
|
};
|
|
690
798
|
const { coefficients } = data;
|
|
691
799
|
const ruled = {
|
|
@@ -706,32 +814,74 @@ function materialLine(configuration, data, room, categoryId, productId) {
|
|
|
706
814
|
coefficients
|
|
707
815
|
});
|
|
708
816
|
if (!cost.ok) return failedLine(ruled, "ERROR", cost.error);
|
|
709
|
-
|
|
817
|
+
const materialCents = componentCents(cost.value);
|
|
818
|
+
if (materialCents === null) return failedLine(ruled, "ERROR", outOfRange());
|
|
819
|
+
const components = [{
|
|
820
|
+
kind: "MATERIAL",
|
|
821
|
+
ruleId: rule.id,
|
|
822
|
+
amountCents: materialCents
|
|
823
|
+
}];
|
|
824
|
+
const lineWarnings = [...warnings];
|
|
825
|
+
if (configuration.scope === "PARTIAL") {
|
|
826
|
+
let laborCents = 0;
|
|
827
|
+
if (rule.laborFormula === null) lineWarnings.push({
|
|
828
|
+
code: "LABOR_RATE_MISSING",
|
|
829
|
+
params: { categoryId }
|
|
830
|
+
});
|
|
831
|
+
else {
|
|
832
|
+
const labor = runNumericFormula(rule.laborFormula, {
|
|
833
|
+
variables: {
|
|
834
|
+
...variables,
|
|
835
|
+
quantity: quantity.value
|
|
836
|
+
},
|
|
837
|
+
coefficients
|
|
838
|
+
});
|
|
839
|
+
if (!labor.ok) return failedLine(ruled, "ERROR", labor.error);
|
|
840
|
+
const cents = componentCents(labor.value);
|
|
841
|
+
if (cents === null) return failedLine(ruled, "ERROR", outOfRange());
|
|
842
|
+
laborCents = cents;
|
|
843
|
+
}
|
|
844
|
+
components.push({
|
|
845
|
+
kind: "LABOR",
|
|
846
|
+
ruleId: rule.id,
|
|
847
|
+
amountCents: laborCents
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
const amountCents = components.reduce((sum, component) => sum + component.amountCents, 0);
|
|
851
|
+
if (amountCents > 1e10) return failedLine(ruled, "ERROR", outOfRange());
|
|
852
|
+
return createLine({
|
|
710
853
|
...ruled,
|
|
854
|
+
warnings: lineWarnings,
|
|
711
855
|
quantity: roundQuantity(quantity.value),
|
|
712
856
|
unit: product.unit,
|
|
713
|
-
unitPriceCents: product.priceCents
|
|
714
|
-
|
|
857
|
+
unitPriceCents: product.priceCents,
|
|
858
|
+
amountCents,
|
|
859
|
+
components
|
|
860
|
+
});
|
|
715
861
|
}
|
|
716
|
-
function materialLines(configuration, data) {
|
|
717
|
-
return configuration.rooms.flatMap((room) =>
|
|
862
|
+
function materialLines(configuration, data, objectScope) {
|
|
863
|
+
return configuration.rooms.flatMap((room) => {
|
|
864
|
+
const lines = room.selections.flatMap(({ categoryId, productId }) => productId === null ? [] : [materialLine(configuration, data, room, categoryId, productId, objectScope)]);
|
|
865
|
+
return configuration.scope === "PARTIAL" ? applySurfacePrep(data, room, lines, objectScope) : lines;
|
|
866
|
+
});
|
|
718
867
|
}
|
|
719
868
|
//#endregion
|
|
720
869
|
//#region src/estimate/object-rules.ts
|
|
721
|
-
function objectRuleLines(configuration, data) {
|
|
870
|
+
function objectRuleLines(configuration, data, section, variables) {
|
|
871
|
+
if (section === "BASE" && configuration.scope !== "FULL") return [];
|
|
722
872
|
const scope = {
|
|
723
|
-
variables
|
|
873
|
+
variables,
|
|
724
874
|
coefficients: data.coefficients
|
|
725
875
|
};
|
|
726
|
-
return data.rules.filter((rule) => rule.level === "OBJECT").sort((a, b) => a.sortOrder - b.sortOrder).map((rule) => {
|
|
876
|
+
return data.rules.filter((rule) => rule.level === "OBJECT" && rule.baseComponent === (section === "BASE")).sort((a, b) => a.sortOrder - b.sortOrder).map((rule) => {
|
|
727
877
|
const init = {
|
|
728
|
-
id: lineId(
|
|
729
|
-
section
|
|
878
|
+
id: lineId(section, rule.id),
|
|
879
|
+
section,
|
|
730
880
|
ruleId: rule.id
|
|
731
881
|
};
|
|
732
882
|
const outcome = runNumericFormula(rule.costFormula, scope);
|
|
733
883
|
return outcome.ok ? pricedLine(init, outcome.value) : failedLine(init, "ERROR", outcome.error);
|
|
734
|
-
});
|
|
884
|
+
}).filter((line) => section !== "BASE" || line.status !== "OK" || line.amountCents !== 0);
|
|
735
885
|
}
|
|
736
886
|
//#endregion
|
|
737
887
|
//#region src/schemas/common.ts
|
|
@@ -739,11 +889,17 @@ const LocalizedTextSchema = z.object({
|
|
|
739
889
|
en: z.string(),
|
|
740
890
|
uk: z.string()
|
|
741
891
|
});
|
|
742
|
-
const
|
|
892
|
+
const ScopeSchema = z.enum(["FULL", "PARTIAL"]);
|
|
893
|
+
const FinishLevelSchema = z.enum([
|
|
894
|
+
"ROUGH",
|
|
895
|
+
"PRE_FINISH",
|
|
896
|
+
"LIVED_IN"
|
|
897
|
+
]);
|
|
743
898
|
const RuleLevelSchema = z.enum([
|
|
744
899
|
"OBJECT",
|
|
745
900
|
"CATEGORY",
|
|
746
|
-
"MATERIAL_TYPE"
|
|
901
|
+
"MATERIAL_TYPE",
|
|
902
|
+
"SURFACE"
|
|
747
903
|
]);
|
|
748
904
|
const RateSourceSchema = z.enum(["NBU", "MANUAL"]);
|
|
749
905
|
const SurfaceKindSchema = z.enum([
|
|
@@ -770,7 +926,9 @@ const RoomTypeCodeSchema = z.enum([
|
|
|
770
926
|
"BEDROOM",
|
|
771
927
|
"KITCHEN",
|
|
772
928
|
"KITCHEN_LIVING",
|
|
773
|
-
"BATHROOM"
|
|
929
|
+
"BATHROOM",
|
|
930
|
+
"HALLWAY",
|
|
931
|
+
"STORAGE"
|
|
774
932
|
]);
|
|
775
933
|
const CurrencySchema = z.enum(["USD", "UAH"]);
|
|
776
934
|
const SectionSchema = z.enum([
|
|
@@ -780,6 +938,11 @@ const SectionSchema = z.enum([
|
|
|
780
938
|
"ENGINEERING",
|
|
781
939
|
"ADDITIONAL"
|
|
782
940
|
]);
|
|
941
|
+
const LineComponentKindSchema = z.enum([
|
|
942
|
+
"MATERIAL",
|
|
943
|
+
"LABOR",
|
|
944
|
+
"PREP"
|
|
945
|
+
]);
|
|
783
946
|
const LineStatusSchema = z.enum([
|
|
784
947
|
"OK",
|
|
785
948
|
"INCLUDED",
|
|
@@ -823,8 +986,9 @@ function duplicateIndexes(items, key) {
|
|
|
823
986
|
});
|
|
824
987
|
}
|
|
825
988
|
const ConfigurationSchema = z.object({
|
|
989
|
+
scope: ScopeSchema.default("FULL"),
|
|
826
990
|
finishLevel: FinishLevelSchema,
|
|
827
|
-
totalAreaSqm: z.number().positive(),
|
|
991
|
+
totalAreaSqm: z.number().positive().nullable(),
|
|
828
992
|
balconyAreaSqm: z.number().min(0),
|
|
829
993
|
balconyTiles: z.boolean(),
|
|
830
994
|
rooms: z.array(RoomConfigurationSchema),
|
|
@@ -836,6 +1000,11 @@ const ConfigurationSchema = z.object({
|
|
|
836
1000
|
path,
|
|
837
1001
|
message: "Duplicate"
|
|
838
1002
|
});
|
|
1003
|
+
if (configuration.scope === "FULL" !== (configuration.totalAreaSqm !== null)) ctx.addIssue({
|
|
1004
|
+
code: "custom",
|
|
1005
|
+
path: ["totalAreaSqm"],
|
|
1006
|
+
message: configuration.scope === "FULL" ? "Required" : "Not allowed"
|
|
1007
|
+
});
|
|
839
1008
|
for (const index of duplicateIndexes(configuration.rooms, (room) => room.clientId)) addDuplicate([
|
|
840
1009
|
"rooms",
|
|
841
1010
|
index,
|
|
@@ -867,13 +1036,8 @@ const PricingLimitsSchema = z.object({
|
|
|
867
1036
|
totalAreaSqm: RangeSchema,
|
|
868
1037
|
rooms: RangeSchema,
|
|
869
1038
|
roomSideM: RangeSchema,
|
|
870
|
-
roomHeightM: RangeSchema
|
|
871
|
-
|
|
872
|
-
const BaseRateSchema = z.object({
|
|
873
|
-
finishLevel: FinishLevelSchema,
|
|
874
|
-
areaFromSqm: z.number().min(0),
|
|
875
|
-
areaToSqm: z.number(),
|
|
876
|
-
rateCentsPerSqm: z.int().positive()
|
|
1039
|
+
roomHeightM: RangeSchema,
|
|
1040
|
+
missingAreaHintSqm: z.number().min(0)
|
|
877
1041
|
});
|
|
878
1042
|
const PricingRuleSchema = z.object({
|
|
879
1043
|
id: z.string(),
|
|
@@ -882,9 +1046,16 @@ const PricingRuleSchema = z.object({
|
|
|
882
1046
|
materialTypeId: z.string().nullable(),
|
|
883
1047
|
name: LocalizedTextSchema,
|
|
884
1048
|
quantityFormula: z.string().nullable(),
|
|
1049
|
+
surface: z.enum([
|
|
1050
|
+
"FLOOR",
|
|
1051
|
+
"WALLS",
|
|
1052
|
+
"CEILING"
|
|
1053
|
+
]).nullable(),
|
|
1054
|
+
baseComponent: z.boolean(),
|
|
885
1055
|
costFormula: z.string().max(500),
|
|
1056
|
+
laborFormula: z.string().max(500).nullable(),
|
|
886
1057
|
sortOrder: z.int()
|
|
887
|
-
}).refine((rule) => rule.level === "OBJECT" === (rule.quantityFormula === null), { path: ["quantityFormula"] });
|
|
1058
|
+
}).refine((rule) => (rule.level === "OBJECT" || rule.level === "SURFACE") === (rule.quantityFormula === null), { path: ["quantityFormula"] }).refine((rule) => rule.level === "SURFACE" === (rule.surface !== null), { path: ["surface"] }).refine((rule) => rule.level !== "SURFACE" || rule.categoryId === null, { path: ["categoryId"] }).refine((rule) => !rule.baseComponent || rule.level === "OBJECT", { path: ["baseComponent"] }).refine((rule) => rule.laborFormula === null || rule.level === "CATEGORY" || rule.level === "MATERIAL_TYPE", { path: ["laborFormula"] });
|
|
888
1059
|
const PricingRoomTypeSchema = z.object({
|
|
889
1060
|
id: z.string(),
|
|
890
1061
|
code: z.string()
|
|
@@ -892,7 +1063,8 @@ const PricingRoomTypeSchema = z.object({
|
|
|
892
1063
|
const PricingCategorySchema = z.object({
|
|
893
1064
|
id: z.string(),
|
|
894
1065
|
wastePercent: z.number().min(0),
|
|
895
|
-
surface: SurfaceKindSchema
|
|
1066
|
+
surface: SurfaceKindSchema,
|
|
1067
|
+
requiresPutty: z.boolean().default(true)
|
|
896
1068
|
});
|
|
897
1069
|
const PricingMaterialTypeSchema = z.object({
|
|
898
1070
|
id: z.string(),
|
|
@@ -914,22 +1086,43 @@ const PricingPackageItemSchema = z.object({
|
|
|
914
1086
|
priceCents: CentsSchema.nullable(),
|
|
915
1087
|
unit: OptionUnitSchema.nullable()
|
|
916
1088
|
});
|
|
917
|
-
const
|
|
1089
|
+
const PricingOptionObjectSchema = z.object({
|
|
918
1090
|
id: z.string(),
|
|
919
1091
|
code: OptionCodeSchema.nullable(),
|
|
920
1092
|
kind: OptionKindSchema,
|
|
921
|
-
priceCents: CentsSchema,
|
|
1093
|
+
priceCents: CentsSchema.nullable(),
|
|
922
1094
|
unit: OptionUnitSchema,
|
|
923
1095
|
roomTypeCodes: z.array(z.string()),
|
|
1096
|
+
priceCoefficientKeys: z.array(z.string()),
|
|
1097
|
+
scopes: z.array(ScopeSchema).min(1),
|
|
924
1098
|
minQuantity: z.int().nullable(),
|
|
925
1099
|
maxQuantity: z.int().nullable(),
|
|
926
1100
|
available: z.boolean()
|
|
927
1101
|
});
|
|
1102
|
+
function findOptionPriceSourceViolation(priceCents, priceCoefficientKeys) {
|
|
1103
|
+
const hasKeys = priceCoefficientKeys.length > 0;
|
|
1104
|
+
if (priceCents !== null && hasKeys) return {
|
|
1105
|
+
path: "priceCents",
|
|
1106
|
+
message: "priceCents must be null when priceCoefficientKeys is set"
|
|
1107
|
+
};
|
|
1108
|
+
if (priceCents === null && !hasKeys) return {
|
|
1109
|
+
path: "priceCoefficientKeys",
|
|
1110
|
+
message: "priceCoefficientKeys is required when priceCents is null"
|
|
1111
|
+
};
|
|
1112
|
+
return null;
|
|
1113
|
+
}
|
|
1114
|
+
const PricingOptionSchema = PricingOptionObjectSchema.superRefine((option, context) => {
|
|
1115
|
+
const violation = findOptionPriceSourceViolation(option.priceCents, option.priceCoefficientKeys);
|
|
1116
|
+
if (violation !== null) context.addIssue({
|
|
1117
|
+
code: "custom",
|
|
1118
|
+
path: [violation.path],
|
|
1119
|
+
message: violation.message
|
|
1120
|
+
});
|
|
1121
|
+
});
|
|
928
1122
|
const PricingDataSchema = z.object({
|
|
929
1123
|
version: z.string().regex(/^[0-9a-f]{16}$/),
|
|
930
1124
|
exchangeRate: ExchangeRateSchema.nullable(),
|
|
931
1125
|
limits: PricingLimitsSchema,
|
|
932
|
-
baseRates: z.array(BaseRateSchema),
|
|
933
1126
|
coefficients: z.record(z.string(), z.number()),
|
|
934
1127
|
rules: z.array(PricingRuleSchema),
|
|
935
1128
|
roomTypes: z.array(PricingRoomTypeSchema),
|
|
@@ -945,6 +1138,12 @@ const EstimateMessageSchema = z.object({
|
|
|
945
1138
|
code: z.string(),
|
|
946
1139
|
params: z.record(z.string(), z.unknown()).optional()
|
|
947
1140
|
});
|
|
1141
|
+
const LineComponentSchema = z.object({
|
|
1142
|
+
kind: LineComponentKindSchema,
|
|
1143
|
+
ruleId: z.string(),
|
|
1144
|
+
amountCents: z.number().int().min(0),
|
|
1145
|
+
sharedWithCategoryId: z.string().optional()
|
|
1146
|
+
});
|
|
948
1147
|
const EstimateLineSchema = z.object({
|
|
949
1148
|
id: z.string(),
|
|
950
1149
|
section: SectionSchema,
|
|
@@ -960,6 +1159,7 @@ const EstimateLineSchema = z.object({
|
|
|
960
1159
|
unitPriceCents: CentsSchema.nullable(),
|
|
961
1160
|
amountCents: CentsSchema,
|
|
962
1161
|
amountUahKopecks: CentsSchema.nullable(),
|
|
1162
|
+
components: z.array(LineComponentSchema).optional(),
|
|
963
1163
|
warnings: z.array(EstimateMessageSchema),
|
|
964
1164
|
error: EstimateMessageSchema.nullable()
|
|
965
1165
|
});
|
|
@@ -997,18 +1197,34 @@ const RuleSampleSchema = z.object({
|
|
|
997
1197
|
unit: ProductUnitSchema,
|
|
998
1198
|
wastePercent: z.number().min(0)
|
|
999
1199
|
}).optional(),
|
|
1200
|
+
surfaceArea: z.number().min(0).optional(),
|
|
1201
|
+
puttyRequired: z.boolean().optional(),
|
|
1000
1202
|
object: z.object({
|
|
1001
|
-
|
|
1203
|
+
scope: ScopeSchema,
|
|
1204
|
+
totalAreaSqm: z.number().positive().nullable(),
|
|
1002
1205
|
balconyAreaSqm: z.number().min(0),
|
|
1003
1206
|
balconyTiles: z.boolean(),
|
|
1004
1207
|
finishLevel: FinishLevelSchema,
|
|
1005
1208
|
roomsCount: z.int().min(0),
|
|
1006
|
-
bathroomsCount: z.int().min(0)
|
|
1209
|
+
bathroomsCount: z.int().min(0),
|
|
1210
|
+
roomsFloorArea: z.number().min(0),
|
|
1211
|
+
roomsWallArea: z.number().min(0),
|
|
1212
|
+
roomsPuttyWallArea: z.number().min(0).optional(),
|
|
1213
|
+
roomsPerimeter: z.number().min(0),
|
|
1214
|
+
wetRoomsCount: z.int().min(0)
|
|
1007
1215
|
})
|
|
1216
|
+
}).superRefine((sample, ctx) => {
|
|
1217
|
+
const { scope, totalAreaSqm } = sample.object;
|
|
1218
|
+
if (scope === "FULL" !== (totalAreaSqm !== null)) ctx.addIssue({
|
|
1219
|
+
code: "custom",
|
|
1220
|
+
path: ["object", "totalAreaSqm"],
|
|
1221
|
+
message: scope === "FULL" ? "Required" : "Not allowed"
|
|
1222
|
+
});
|
|
1008
1223
|
});
|
|
1009
1224
|
const RulePreviewSchema = z.object({
|
|
1010
1225
|
quantity: z.number().nullable(),
|
|
1011
1226
|
amountCents: CentsSchema.nullable(),
|
|
1227
|
+
laborCents: CentsSchema.nullable(),
|
|
1012
1228
|
variables: z.record(z.string(), z.union([
|
|
1013
1229
|
z.number(),
|
|
1014
1230
|
z.boolean(),
|
|
@@ -1043,20 +1259,28 @@ function isIncomplete(lines) {
|
|
|
1043
1259
|
}
|
|
1044
1260
|
//#endregion
|
|
1045
1261
|
//#region src/estimate/warnings.ts
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1262
|
+
const roundArea = (value) => Math.round(value * 1e4) / 1e4;
|
|
1263
|
+
function roomsAreaWarnings(configuration, limits) {
|
|
1264
|
+
const totalAreaSqm = configuration.totalAreaSqm;
|
|
1265
|
+
if (configuration.scope !== "FULL" || totalAreaSqm === null) return [];
|
|
1266
|
+
const roomsAreaSqm = roundArea(configuration.rooms.reduce((sum, room) => sum + roomVariables(room).floorArea, 0));
|
|
1267
|
+
if (roomsAreaSqm > totalAreaSqm) return [{
|
|
1049
1268
|
code: "ROOMS_AREA_EXCEEDS_TOTAL",
|
|
1050
1269
|
params: {
|
|
1051
1270
|
roomsAreaSqm,
|
|
1052
|
-
totalAreaSqm
|
|
1271
|
+
totalAreaSqm
|
|
1053
1272
|
}
|
|
1273
|
+
}];
|
|
1274
|
+
const missingSqm = roundArea(totalAreaSqm - roomsAreaSqm);
|
|
1275
|
+
return missingSqm > limits.missingAreaHintSqm ? [{
|
|
1276
|
+
code: "ROOMS_AREA_BELOW_TOTAL",
|
|
1277
|
+
params: { missingSqm }
|
|
1054
1278
|
}] : [];
|
|
1055
1279
|
}
|
|
1056
|
-
function estimateWarnings(lines, configuration, rate) {
|
|
1280
|
+
function estimateWarnings(lines, configuration, rate, limits) {
|
|
1057
1281
|
return [
|
|
1058
1282
|
...collectWarnings(lines),
|
|
1059
|
-
...roomsAreaWarnings(configuration),
|
|
1283
|
+
...roomsAreaWarnings(configuration, limits),
|
|
1060
1284
|
...configuration.currency === "UAH" && rate === null ? [{ code: "EXCHANGE_RATE_UNAVAILABLE" }] : []
|
|
1061
1285
|
];
|
|
1062
1286
|
}
|
|
@@ -1070,10 +1294,11 @@ function withKopecks(line, rate) {
|
|
|
1070
1294
|
}
|
|
1071
1295
|
function estimate(configuration, data) {
|
|
1072
1296
|
const rate = data.exchangeRate?.rate ?? null;
|
|
1297
|
+
const variables = objectVariables(configuration, data.categories);
|
|
1073
1298
|
const lines = [
|
|
1074
|
-
|
|
1075
|
-
...objectRuleLines(configuration, data),
|
|
1076
|
-
...materialLines(configuration, data),
|
|
1299
|
+
...objectRuleLines(configuration, data, "BASE", variables),
|
|
1300
|
+
...objectRuleLines(configuration, data, "OBJECT", variables),
|
|
1301
|
+
...materialLines(configuration, data, variables),
|
|
1077
1302
|
...engineeringLines(configuration, data),
|
|
1078
1303
|
...additionalLines(configuration, data)
|
|
1079
1304
|
].map((line) => withKopecks(line, rate));
|
|
@@ -1083,7 +1308,7 @@ function estimate(configuration, data) {
|
|
|
1083
1308
|
exchangeRate: data.exchangeRate,
|
|
1084
1309
|
lines,
|
|
1085
1310
|
totals: computeTotals(lines, rate !== null),
|
|
1086
|
-
warnings: estimateWarnings(lines, configuration, rate),
|
|
1311
|
+
warnings: estimateWarnings(lines, configuration, rate, data.limits),
|
|
1087
1312
|
incomplete: isIncomplete(lines)
|
|
1088
1313
|
};
|
|
1089
1314
|
}
|
|
@@ -1098,8 +1323,9 @@ function checkLimits(configuration, limits, options = []) {
|
|
|
1098
1323
|
max
|
|
1099
1324
|
});
|
|
1100
1325
|
};
|
|
1101
|
-
|
|
1102
|
-
check("
|
|
1326
|
+
const isFull = configuration.scope === "FULL";
|
|
1327
|
+
if (isFull && configuration.totalAreaSqm !== null) check("totalAreaSqm", configuration.totalAreaSqm, limits.totalAreaSqm.min, limits.totalAreaSqm.max);
|
|
1328
|
+
check("balconyAreaSqm", configuration.balconyAreaSqm, 0, isFull && configuration.totalAreaSqm !== null ? configuration.totalAreaSqm : limits.totalAreaSqm.max);
|
|
1103
1329
|
check("rooms", configuration.rooms.length, limits.rooms.min, limits.rooms.max);
|
|
1104
1330
|
configuration.rooms.forEach((room, index) => {
|
|
1105
1331
|
const path = `rooms[${index}]`;
|
|
@@ -1155,17 +1381,35 @@ function referencesOf(sources) {
|
|
|
1155
1381
|
}
|
|
1156
1382
|
function objectScope({ object }) {
|
|
1157
1383
|
return {
|
|
1158
|
-
|
|
1384
|
+
scope: object.scope,
|
|
1385
|
+
totalArea: totalAreaOf(object.scope, object.totalAreaSqm, object.roomsFloorArea),
|
|
1159
1386
|
balconyArea: object.balconyAreaSqm,
|
|
1160
1387
|
balconyTiles: object.balconyTiles,
|
|
1161
1388
|
finishLevel: object.finishLevel,
|
|
1162
1389
|
roomsCount: object.roomsCount,
|
|
1163
|
-
bathroomsCount: object.bathroomsCount
|
|
1390
|
+
bathroomsCount: object.bathroomsCount,
|
|
1391
|
+
roomsFloorArea: object.roomsFloorArea,
|
|
1392
|
+
roomsWallArea: object.roomsWallArea,
|
|
1393
|
+
roomsPuttyWallArea: object.roomsPuttyWallArea ?? object.roomsWallArea,
|
|
1394
|
+
roomsPerimeter: object.roomsPerimeter,
|
|
1395
|
+
wetRoomsCount: object.wetRoomsCount
|
|
1164
1396
|
};
|
|
1165
1397
|
}
|
|
1166
1398
|
function scopeOf(level, sample) {
|
|
1167
1399
|
if (level === "OBJECT") return { scope: objectScope(sample) };
|
|
1168
|
-
const { room, product } = sample;
|
|
1400
|
+
const { room, product, surfaceArea } = sample;
|
|
1401
|
+
if (level === "SURFACE") {
|
|
1402
|
+
if (room === void 0 || surfaceArea === void 0) return { error: {
|
|
1403
|
+
code: "SAMPLE_INCOMPLETE",
|
|
1404
|
+
params: { missing: room === void 0 ? "room" : "surfaceArea" }
|
|
1405
|
+
} };
|
|
1406
|
+
return { scope: {
|
|
1407
|
+
...roomVariables(room),
|
|
1408
|
+
...objectScope(sample),
|
|
1409
|
+
surfaceArea,
|
|
1410
|
+
puttyRequired: sample.puttyRequired ?? true
|
|
1411
|
+
} };
|
|
1412
|
+
}
|
|
1169
1413
|
if (room === void 0 || product === void 0) return { error: {
|
|
1170
1414
|
code: "SAMPLE_INCOMPLETE",
|
|
1171
1415
|
params: { missing: room === void 0 ? "room" : "product" }
|
|
@@ -1181,15 +1425,21 @@ function pick(source, names) {
|
|
|
1181
1425
|
}
|
|
1182
1426
|
function previewRule(input) {
|
|
1183
1427
|
const { level, quantityFormula, costFormula, coefficients } = input;
|
|
1184
|
-
const
|
|
1185
|
-
const
|
|
1428
|
+
const laborFormula = quantityFormula === null ? null : input.laborFormula ?? null;
|
|
1429
|
+
const references = referencesOf([
|
|
1430
|
+
quantityFormula,
|
|
1431
|
+
costFormula,
|
|
1432
|
+
laborFormula
|
|
1433
|
+
]);
|
|
1434
|
+
const report = (variables, quantity, amountCents, error, laborCents = null) => ({
|
|
1186
1435
|
quantity,
|
|
1187
1436
|
amountCents,
|
|
1437
|
+
laborCents,
|
|
1188
1438
|
variables: pick(variables, references.variables),
|
|
1189
1439
|
coefficients: pick(coefficients, references.coefficients),
|
|
1190
1440
|
error
|
|
1191
1441
|
});
|
|
1192
|
-
if (level === "OBJECT" !== (quantityFormula === null)) return report({}, null, null, { code: "QUANTITY_FORMULA_MISMATCH" });
|
|
1442
|
+
if ((level === "OBJECT" || level === "SURFACE") !== (quantityFormula === null)) return report({}, null, null, { code: "QUANTITY_FORMULA_MISMATCH" });
|
|
1193
1443
|
const prepared = scopeOf(level, input.sample);
|
|
1194
1444
|
if ("error" in prepared) return report({}, null, null, prepared.error);
|
|
1195
1445
|
let variables = prepared.scope;
|
|
@@ -1215,7 +1465,15 @@ function previewRule(input) {
|
|
|
1215
1465
|
id: "preview",
|
|
1216
1466
|
section: "OBJECT"
|
|
1217
1467
|
}, cost.value);
|
|
1218
|
-
|
|
1468
|
+
if (line.error !== null) return report(variables, quantity, null, line.error);
|
|
1469
|
+
if (laborFormula === null) return report(variables, quantity, line.amountCents, null);
|
|
1470
|
+
const labor = runNumericFormula(laborFormula, {
|
|
1471
|
+
variables,
|
|
1472
|
+
coefficients
|
|
1473
|
+
});
|
|
1474
|
+
if (!labor.ok) return report(variables, quantity, line.amountCents, labor.error);
|
|
1475
|
+
const laborCents = componentCents(labor.value);
|
|
1476
|
+
return laborCents === null ? report(variables, quantity, line.amountCents, outOfRange()) : report(variables, quantity, line.amountCents, null, laborCents);
|
|
1219
1477
|
}
|
|
1220
1478
|
//#endregion
|
|
1221
1479
|
//#region src/formula/suggest.ts
|
|
@@ -1287,10 +1545,18 @@ const FORMULA_SCOPE_VARIABLES = {
|
|
|
1287
1545
|
boolean("balconyTiles"),
|
|
1288
1546
|
enumeration("finishLevel", FinishLevelSchema.options),
|
|
1289
1547
|
number("roomsCount"),
|
|
1290
|
-
number("bathroomsCount")
|
|
1548
|
+
number("bathroomsCount"),
|
|
1549
|
+
enumeration("scope", ScopeSchema.options),
|
|
1550
|
+
number("roomsFloorArea"),
|
|
1551
|
+
number("roomsWallArea"),
|
|
1552
|
+
number("roomsPuttyWallArea"),
|
|
1553
|
+
number("roomsPerimeter"),
|
|
1554
|
+
number("wetRoomsCount")
|
|
1291
1555
|
]
|
|
1292
1556
|
};
|
|
1293
1557
|
const FORMULA_QUANTITY_VARIABLE = number("quantity");
|
|
1558
|
+
const FORMULA_SURFACE_AREA_VARIABLE = number("surfaceArea");
|
|
1559
|
+
const FORMULA_PUTTY_REQUIRED_VARIABLE = boolean("puttyRequired");
|
|
1294
1560
|
const FORMULA_VARIABLES = {
|
|
1295
1561
|
OBJECT: FORMULA_SCOPE_VARIABLES.object,
|
|
1296
1562
|
CATEGORY: [
|
|
@@ -1302,9 +1568,15 @@ const FORMULA_VARIABLES = {
|
|
|
1302
1568
|
...FORMULA_SCOPE_VARIABLES.room,
|
|
1303
1569
|
...FORMULA_SCOPE_VARIABLES.product,
|
|
1304
1570
|
...FORMULA_SCOPE_VARIABLES.object
|
|
1571
|
+
],
|
|
1572
|
+
SURFACE: [
|
|
1573
|
+
...FORMULA_SCOPE_VARIABLES.room,
|
|
1574
|
+
...FORMULA_SCOPE_VARIABLES.object,
|
|
1575
|
+
FORMULA_SURFACE_AREA_VARIABLE,
|
|
1576
|
+
FORMULA_PUTTY_REQUIRED_VARIABLE
|
|
1305
1577
|
]
|
|
1306
1578
|
};
|
|
1307
|
-
const ALL_VARIABLE_NAMES = new Set(FORMULA_VARIABLES.CATEGORY.map((variable) => variable.name));
|
|
1579
|
+
const ALL_VARIABLE_NAMES = new Set([...FORMULA_VARIABLES.CATEGORY, ...FORMULA_VARIABLES.SURFACE].map((variable) => variable.name));
|
|
1308
1580
|
function span(node) {
|
|
1309
1581
|
return {
|
|
1310
1582
|
position: node.start,
|
|
@@ -1328,7 +1600,7 @@ function validateFormula(input) {
|
|
|
1328
1600
|
const knownCoefficients = new Set(input.coefficientKeys);
|
|
1329
1601
|
const usedCoefficients = /* @__PURE__ */ new Set();
|
|
1330
1602
|
const errors = [];
|
|
1331
|
-
const quantityAllowed = input.kind
|
|
1603
|
+
const quantityAllowed = input.kind !== "quantity" && (input.level === "CATEGORY" || input.level === "MATERIAL_TYPE");
|
|
1332
1604
|
function mismatch(node, expected) {
|
|
1333
1605
|
errors.push({
|
|
1334
1606
|
code: "FORMULA_SYNTAX",
|
|
@@ -1516,4 +1788,4 @@ function validateFormula(input) {
|
|
|
1516
1788
|
};
|
|
1517
1789
|
}
|
|
1518
1790
|
//#endregion
|
|
1519
|
-
export {
|
|
1791
|
+
export { COEFFICIENT_NAMESPACE, CentsSchema, ConfigurationSchema, CurrencySchema, EstimateLineSchema, EstimateMessageSchema, EstimateSchema, EstimateTotalsSchema, EstimateWarningSchema, ExchangeRateSchema, FORMULA_FUNCTIONS, FORMULA_FUNCTION_TABLE, FORMULA_LIMITS, FORMULA_PUTTY_REQUIRED_VARIABLE, FORMULA_QUANTITY_VARIABLE, FORMULA_SCOPE_VARIABLES, FORMULA_SURFACE_AREA_VARIABLE, FORMULA_VARIABLES, FinishLevelSchema, LineComponentKindSchema, LineComponentSchema, LineStatusSchema, LocalizedTextSchema, OptionCodeSchema, OptionKindSchema, OptionSelectionSchema, OptionUnitSchema, PricingCategorySchema, PricingDataSchema, PricingLimitsSchema, PricingMaterialTypeSchema, PricingOptionSchema, PricingPackageItemSchema, PricingProductSchema, PricingRoomTypeSchema, PricingRuleSchema, ProductUnitSchema, RangeSchema, RateSourceSchema, RoomConfigurationSchema, RoomSelectionSchema, RoomTypeCodeSchema, RuleLevelSchema, RulePreviewSchema, RuleSampleSchema, ScopeSchema, SectionSchema, SectionTotalSchema, SurfaceKindSchema, checkLimits, convertCentsToKopecks, estimate, evaluate, findOptionPriceSourceViolation, levenshtein, objectVariables, parse, previewRule, productVariables, productVariablesOf, puttyRequiredOn, roomVariables, suggestNearest, toCents, totalAreaOf, validateFormula };
|