@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.cjs
CHANGED
|
@@ -51,6 +51,73 @@ function toCents(dollars) {
|
|
|
51
51
|
return cents;
|
|
52
52
|
}
|
|
53
53
|
//#endregion
|
|
54
|
+
//#region src/variables/putty.ts
|
|
55
|
+
function puttyRequiredOn(room, surface, categories) {
|
|
56
|
+
const selected = categories.filter((category) => category.surface === surface && room.selections.some((selection) => selection.categoryId === category.id));
|
|
57
|
+
return selected.length === 0 || selected.some((category) => category.requiresPutty);
|
|
58
|
+
}
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region src/variables/room.ts
|
|
61
|
+
function roomVariables(room) {
|
|
62
|
+
const perimeter = 2 * (room.lengthM + room.widthM);
|
|
63
|
+
return {
|
|
64
|
+
length: room.lengthM,
|
|
65
|
+
width: room.widthM,
|
|
66
|
+
height: room.heightM,
|
|
67
|
+
floorArea: room.lengthM * room.widthM,
|
|
68
|
+
perimeter,
|
|
69
|
+
wallArea: perimeter * room.heightM,
|
|
70
|
+
heatedFloor: room.heatedFloor,
|
|
71
|
+
roomType: room.roomTypeCode
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/variables/object.ts
|
|
76
|
+
const COUNTED_AS_ROOMS = [
|
|
77
|
+
"LIVING_ROOM",
|
|
78
|
+
"BEDROOM",
|
|
79
|
+
"KITCHEN_LIVING"
|
|
80
|
+
];
|
|
81
|
+
const WET_ROOMS = [
|
|
82
|
+
"BATHROOM",
|
|
83
|
+
"KITCHEN",
|
|
84
|
+
"KITCHEN_LIVING"
|
|
85
|
+
];
|
|
86
|
+
function totalAreaOf(scope, totalAreaSqm, roomsFloorArea) {
|
|
87
|
+
return scope === "PARTIAL" || totalAreaSqm === null ? roomsFloorArea : totalAreaSqm;
|
|
88
|
+
}
|
|
89
|
+
function objectVariables(configuration, categories) {
|
|
90
|
+
const rooms = configuration.rooms.map(roomVariables);
|
|
91
|
+
const sum = (pick) => rooms.reduce((total, room) => total + pick(room), 0);
|
|
92
|
+
const roomsFloorArea = sum((room) => room.floorArea);
|
|
93
|
+
return {
|
|
94
|
+
scope: configuration.scope,
|
|
95
|
+
totalArea: totalAreaOf(configuration.scope, configuration.totalAreaSqm, roomsFloorArea),
|
|
96
|
+
balconyArea: configuration.balconyAreaSqm,
|
|
97
|
+
balconyTiles: configuration.balconyTiles,
|
|
98
|
+
finishLevel: configuration.finishLevel,
|
|
99
|
+
roomsCount: configuration.rooms.filter((room) => COUNTED_AS_ROOMS.includes(room.roomTypeCode)).length,
|
|
100
|
+
bathroomsCount: configuration.rooms.filter((room) => room.roomTypeCode === "BATHROOM").length,
|
|
101
|
+
roomsFloorArea,
|
|
102
|
+
roomsWallArea: sum((room) => room.wallArea),
|
|
103
|
+
roomsPuttyWallArea: configuration.rooms.reduce((total, room) => puttyRequiredOn(room, "WALLS", categories) ? total + roomVariables(room).wallArea : total, 0),
|
|
104
|
+
roomsPerimeter: sum((room) => room.perimeter),
|
|
105
|
+
wetRoomsCount: rooms.filter((room) => WET_ROOMS.includes(room.roomType)).length
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
//#endregion
|
|
109
|
+
//#region src/variables/product.ts
|
|
110
|
+
function productVariablesOf(product, wastePercent) {
|
|
111
|
+
return {
|
|
112
|
+
price: product.priceCents / 100,
|
|
113
|
+
waste: wastePercent / 100,
|
|
114
|
+
unit: product.unit
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function productVariables(product, category) {
|
|
118
|
+
return productVariablesOf(product, product.wastePercentOverride ?? category.wastePercent);
|
|
119
|
+
}
|
|
120
|
+
//#endregion
|
|
54
121
|
//#region src/estimate/line.ts
|
|
55
122
|
const MAX_LINE_CENTS = 1e10;
|
|
56
123
|
function createLine(init) {
|
|
@@ -80,18 +147,25 @@ function failedLine(init, status, error) {
|
|
|
80
147
|
});
|
|
81
148
|
}
|
|
82
149
|
function pricedLine(init, dollars) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
params: { maxCents: MAX_LINE_CENTS }
|
|
86
|
-
});
|
|
87
|
-
return createLine({
|
|
150
|
+
const amountCents = componentCents(dollars);
|
|
151
|
+
return amountCents === null ? failedLine(init, "ERROR", outOfRange()) : createLine({
|
|
88
152
|
...init,
|
|
89
|
-
amountCents
|
|
153
|
+
amountCents
|
|
90
154
|
});
|
|
91
155
|
}
|
|
92
156
|
function roundQuantity(quantity) {
|
|
93
157
|
return Math.round(quantity * 1e3) / 1e3;
|
|
94
158
|
}
|
|
159
|
+
function componentCents(dollars) {
|
|
160
|
+
if (!Number.isFinite(dollars) || dollars < 0 || dollars * 100 > 1e10) return null;
|
|
161
|
+
return toCents(dollars);
|
|
162
|
+
}
|
|
163
|
+
function outOfRange() {
|
|
164
|
+
return {
|
|
165
|
+
code: "AMOUNT_OUT_OF_RANGE",
|
|
166
|
+
params: { maxCents: MAX_LINE_CENTS }
|
|
167
|
+
};
|
|
168
|
+
}
|
|
95
169
|
//#endregion
|
|
96
170
|
//#region src/estimate/line-id.ts
|
|
97
171
|
function lineId(section, ...parts) {
|
|
@@ -107,16 +181,39 @@ function unitQuantity(unit, rooms, pieces) {
|
|
|
107
181
|
case "ROOM_SQM": return rooms.reduce((sum, room) => sum + room.lengthM * room.widthM, 0);
|
|
108
182
|
}
|
|
109
183
|
}
|
|
110
|
-
function
|
|
184
|
+
function optionUnitPriceCents(option, coefficients) {
|
|
185
|
+
if (option.priceCoefficientKeys.length === 0) return option.priceCents;
|
|
186
|
+
let dollars = 0;
|
|
187
|
+
for (const key of option.priceCoefficientKeys) {
|
|
188
|
+
const value = Object.hasOwn(coefficients, key) ? coefficients[key] : void 0;
|
|
189
|
+
if (value === void 0) return { missingKey: key };
|
|
190
|
+
dollars += value;
|
|
191
|
+
}
|
|
192
|
+
return componentCents(dollars);
|
|
193
|
+
}
|
|
194
|
+
function optionLine(configuration, option, selection, coefficients = {}) {
|
|
111
195
|
const init = {
|
|
112
196
|
id: lineId(option.kind, "option", option.id),
|
|
113
197
|
section: option.kind,
|
|
114
198
|
optionId: option.id
|
|
115
199
|
};
|
|
200
|
+
if (!option.scopes.includes(configuration.scope)) return failedLine(init, "UNAVAILABLE", {
|
|
201
|
+
code: "OPTION_SCOPE_NOT_ALLOWED",
|
|
202
|
+
params: {
|
|
203
|
+
optionId: option.id,
|
|
204
|
+
scope: configuration.scope
|
|
205
|
+
}
|
|
206
|
+
});
|
|
116
207
|
if (!option.available) return failedLine(init, "UNAVAILABLE", {
|
|
117
208
|
code: "PRODUCT_UNAVAILABLE",
|
|
118
209
|
params: { optionId: option.id }
|
|
119
210
|
});
|
|
211
|
+
const unitPriceCents = optionUnitPriceCents(option, coefficients);
|
|
212
|
+
if (unitPriceCents === null) return failedLine(init, "ERROR", outOfRange());
|
|
213
|
+
if (typeof unitPriceCents !== "number") return failedLine(init, "ERROR", {
|
|
214
|
+
code: "UNKNOWN_COEFFICIENT",
|
|
215
|
+
params: { key: unitPriceCents.missingKey }
|
|
216
|
+
});
|
|
120
217
|
const selected = configuration.rooms.filter((room) => selection.roomClientIds?.includes(room.clientId));
|
|
121
218
|
const allowed = option.roomTypeCodes.length === 0 ? selected : selected.filter((room) => option.roomTypeCodes.includes(room.roomTypeCode));
|
|
122
219
|
const rejected = selected.filter((room) => !allowed.includes(room));
|
|
@@ -125,7 +222,7 @@ function optionLine(configuration, option, selection) {
|
|
|
125
222
|
...init,
|
|
126
223
|
quantity: roundQuantity(quantity),
|
|
127
224
|
unit: option.unit,
|
|
128
|
-
unitPriceCents
|
|
225
|
+
unitPriceCents,
|
|
129
226
|
warnings: rejected.map((room) => ({
|
|
130
227
|
code: "OPTION_ROOM_TYPE_NOT_ALLOWED",
|
|
131
228
|
params: {
|
|
@@ -133,12 +230,12 @@ function optionLine(configuration, option, selection) {
|
|
|
133
230
|
roomTypeCode: room.roomTypeCode
|
|
134
231
|
}
|
|
135
232
|
}))
|
|
136
|
-
},
|
|
233
|
+
}, unitPriceCents / 100 * quantity);
|
|
137
234
|
}
|
|
138
235
|
function optionLines(configuration, data, kind) {
|
|
139
236
|
return configuration.options.flatMap((selection) => {
|
|
140
237
|
const option = data.options.find((item) => item.id === selection.optionId);
|
|
141
|
-
return option?.kind === kind ? [optionLine(configuration, option, selection)] : [];
|
|
238
|
+
return option?.kind === kind ? [optionLine(configuration, option, selection, data.coefficients)] : [];
|
|
142
239
|
});
|
|
143
240
|
}
|
|
144
241
|
//#endregion
|
|
@@ -147,29 +244,6 @@ function additionalLines(configuration, data) {
|
|
|
147
244
|
return optionLines(configuration, data, "ADDITIONAL");
|
|
148
245
|
}
|
|
149
246
|
//#endregion
|
|
150
|
-
//#region src/estimate/base.ts
|
|
151
|
-
function baseLine(configuration, data) {
|
|
152
|
-
const init = {
|
|
153
|
-
id: lineId("BASE"),
|
|
154
|
-
section: "BASE"
|
|
155
|
-
};
|
|
156
|
-
const area = configuration.totalAreaSqm;
|
|
157
|
-
const rate = data.baseRates.find((candidate) => candidate.finishLevel === configuration.finishLevel && candidate.areaFromSqm <= area && area < candidate.areaToSqm);
|
|
158
|
-
if (rate === void 0) return failedLine(init, "NOT_CALCULATED", {
|
|
159
|
-
code: "BASE_RATE_NOT_FOUND",
|
|
160
|
-
params: {
|
|
161
|
-
totalAreaSqm: area,
|
|
162
|
-
finishLevel: configuration.finishLevel
|
|
163
|
-
}
|
|
164
|
-
});
|
|
165
|
-
return pricedLine({
|
|
166
|
-
...init,
|
|
167
|
-
quantity: roundQuantity(area),
|
|
168
|
-
unit: "SQM",
|
|
169
|
-
unitPriceCents: rate.rateCentsPerSqm
|
|
170
|
-
}, rate.rateCentsPerSqm / 100 * area);
|
|
171
|
-
}
|
|
172
|
-
//#endregion
|
|
173
247
|
//#region src/estimate/engineering.ts
|
|
174
248
|
function packageLine(configuration, item) {
|
|
175
249
|
const init = {
|
|
@@ -194,51 +268,7 @@ function packageLine(configuration, item) {
|
|
|
194
268
|
}, item.priceCents / 100 * quantity);
|
|
195
269
|
}
|
|
196
270
|
function engineeringLines(configuration, data) {
|
|
197
|
-
return [...data.packageItems.map((item) => packageLine(configuration, item)), ...optionLines(configuration, data, "ENGINEERING")];
|
|
198
|
-
}
|
|
199
|
-
//#endregion
|
|
200
|
-
//#region src/variables/object.ts
|
|
201
|
-
const COUNTED_AS_ROOMS = [
|
|
202
|
-
"LIVING_ROOM",
|
|
203
|
-
"BEDROOM",
|
|
204
|
-
"KITCHEN_LIVING"
|
|
205
|
-
];
|
|
206
|
-
function objectVariables(configuration) {
|
|
207
|
-
return {
|
|
208
|
-
totalArea: configuration.totalAreaSqm,
|
|
209
|
-
balconyArea: configuration.balconyAreaSqm,
|
|
210
|
-
balconyTiles: configuration.balconyTiles,
|
|
211
|
-
finishLevel: configuration.finishLevel,
|
|
212
|
-
roomsCount: configuration.rooms.filter((room) => COUNTED_AS_ROOMS.includes(room.roomTypeCode)).length,
|
|
213
|
-
bathroomsCount: configuration.rooms.filter((room) => room.roomTypeCode === "BATHROOM").length
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
//#endregion
|
|
217
|
-
//#region src/variables/product.ts
|
|
218
|
-
function productVariablesOf(product, wastePercent) {
|
|
219
|
-
return {
|
|
220
|
-
price: product.priceCents / 100,
|
|
221
|
-
waste: wastePercent / 100,
|
|
222
|
-
unit: product.unit
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
function productVariables(product, category) {
|
|
226
|
-
return productVariablesOf(product, product.wastePercentOverride ?? category.wastePercent);
|
|
227
|
-
}
|
|
228
|
-
//#endregion
|
|
229
|
-
//#region src/variables/room.ts
|
|
230
|
-
function roomVariables(room) {
|
|
231
|
-
const perimeter = 2 * (room.lengthM + room.widthM);
|
|
232
|
-
return {
|
|
233
|
-
length: room.lengthM,
|
|
234
|
-
width: room.widthM,
|
|
235
|
-
height: room.heightM,
|
|
236
|
-
floorArea: room.lengthM * room.widthM,
|
|
237
|
-
perimeter,
|
|
238
|
-
wallArea: perimeter * room.heightM,
|
|
239
|
-
heatedFloor: room.heatedFloor,
|
|
240
|
-
roomType: room.roomTypeCode
|
|
241
|
-
};
|
|
271
|
+
return [...(configuration.scope === "PARTIAL" ? data.packageItems.filter((item) => !item.includedInBase) : data.packageItems).map((item) => packageLine(configuration, item)), ...optionLines(configuration, data, "ENGINEERING")];
|
|
242
272
|
}
|
|
243
273
|
//#endregion
|
|
244
274
|
//#region src/formula/language.ts
|
|
@@ -672,13 +702,91 @@ function runNumericFormula(source, scope) {
|
|
|
672
702
|
};
|
|
673
703
|
}
|
|
674
704
|
//#endregion
|
|
705
|
+
//#region src/estimate/surfaces.ts
|
|
706
|
+
function surfaceRuleOf(data, surface) {
|
|
707
|
+
return data.rules.filter((rule) => rule.level === "SURFACE" && rule.surface === surface).sort((a, b) => a.sortOrder - b.sortOrder)[0];
|
|
708
|
+
}
|
|
709
|
+
function failedWith(line, error) {
|
|
710
|
+
return failedLine({
|
|
711
|
+
id: line.id,
|
|
712
|
+
section: line.section,
|
|
713
|
+
roomClientId: line.roomClientId,
|
|
714
|
+
categoryId: line.categoryId,
|
|
715
|
+
productId: line.productId,
|
|
716
|
+
ruleId: line.ruleId,
|
|
717
|
+
warnings: line.warnings
|
|
718
|
+
}, "ERROR", error);
|
|
719
|
+
}
|
|
720
|
+
function withComponent(line, component) {
|
|
721
|
+
const components = [...line.components ?? [], component];
|
|
722
|
+
const amountCents = components.reduce((sum, item) => sum + item.amountCents, 0);
|
|
723
|
+
if (amountCents > 1e10) return failedWith(line, outOfRange());
|
|
724
|
+
return {
|
|
725
|
+
...line,
|
|
726
|
+
components,
|
|
727
|
+
amountCents
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
function applySurfacePrep(data, room, lines, objectScope) {
|
|
731
|
+
const result = [...lines];
|
|
732
|
+
const variables = {
|
|
733
|
+
...roomVariables(room),
|
|
734
|
+
...objectScope
|
|
735
|
+
};
|
|
736
|
+
const areas = {
|
|
737
|
+
FLOOR: variables.floorArea,
|
|
738
|
+
CEILING: variables.floorArea,
|
|
739
|
+
WALLS: variables.wallArea
|
|
740
|
+
};
|
|
741
|
+
const surfaces = /* @__PURE__ */ new Map();
|
|
742
|
+
lines.forEach((line, index) => {
|
|
743
|
+
const category = data.categories.find((item) => item.id === line.categoryId);
|
|
744
|
+
if (line.status === "OK" && category !== void 0 && category.surface !== "NONE") {
|
|
745
|
+
const surface = category.surface;
|
|
746
|
+
surfaces.set(surface, [...surfaces.get(surface) ?? [], index]);
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
for (const [surface, indexes] of surfaces) {
|
|
750
|
+
const rule = surfaceRuleOf(data, surface);
|
|
751
|
+
if (rule === void 0) continue;
|
|
752
|
+
const outcome = runNumericFormula(rule.costFormula, {
|
|
753
|
+
variables: {
|
|
754
|
+
...variables,
|
|
755
|
+
surfaceArea: areas[surface],
|
|
756
|
+
puttyRequired: puttyRequiredOn(room, surface, data.categories)
|
|
757
|
+
},
|
|
758
|
+
coefficients: data.coefficients
|
|
759
|
+
});
|
|
760
|
+
const cents = outcome.ok ? componentCents(outcome.value) : null;
|
|
761
|
+
const carrierCategoryId = lines[indexes[0]]?.categoryId ?? null;
|
|
762
|
+
indexes.forEach((index, position) => {
|
|
763
|
+
const line = lines[index];
|
|
764
|
+
if (!outcome.ok || cents === null) {
|
|
765
|
+
result[index] = failedWith(line, outcome.ok ? outOfRange() : outcome.error);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
result[index] = withComponent(line, position === 0 ? {
|
|
769
|
+
kind: "PREP",
|
|
770
|
+
ruleId: rule.id,
|
|
771
|
+
amountCents: cents
|
|
772
|
+
} : {
|
|
773
|
+
kind: "PREP",
|
|
774
|
+
ruleId: rule.id,
|
|
775
|
+
amountCents: 0,
|
|
776
|
+
sharedWithCategoryId: carrierCategoryId ?? void 0
|
|
777
|
+
});
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
return result;
|
|
781
|
+
}
|
|
782
|
+
//#endregion
|
|
675
783
|
//#region src/estimate/materials.ts
|
|
676
784
|
const isMaterialRule = (rule) => rule.quantityFormula !== null;
|
|
677
785
|
function selectRule(data, categoryId, materialTypeId) {
|
|
678
786
|
const rules = data.rules.filter(isMaterialRule);
|
|
679
787
|
return rules.find((rule) => rule.level === "MATERIAL_TYPE" && rule.categoryId === categoryId && rule.materialTypeId === materialTypeId) ?? rules.find((rule) => rule.level === "CATEGORY" && rule.categoryId === categoryId);
|
|
680
788
|
}
|
|
681
|
-
function materialLine(configuration, data, room, categoryId, productId) {
|
|
789
|
+
function materialLine(configuration, data, room, categoryId, productId, objectScope) {
|
|
682
790
|
const init = {
|
|
683
791
|
id: lineId("MATERIALS", room.clientId, categoryId),
|
|
684
792
|
section: "MATERIALS",
|
|
@@ -710,7 +818,7 @@ function materialLine(configuration, data, room, categoryId, productId) {
|
|
|
710
818
|
const variables = {
|
|
711
819
|
...roomVariables(room),
|
|
712
820
|
...productVariables(product, category),
|
|
713
|
-
...
|
|
821
|
+
...objectScope
|
|
714
822
|
};
|
|
715
823
|
const { coefficients } = data;
|
|
716
824
|
const ruled = {
|
|
@@ -731,32 +839,74 @@ function materialLine(configuration, data, room, categoryId, productId) {
|
|
|
731
839
|
coefficients
|
|
732
840
|
});
|
|
733
841
|
if (!cost.ok) return failedLine(ruled, "ERROR", cost.error);
|
|
734
|
-
|
|
842
|
+
const materialCents = componentCents(cost.value);
|
|
843
|
+
if (materialCents === null) return failedLine(ruled, "ERROR", outOfRange());
|
|
844
|
+
const components = [{
|
|
845
|
+
kind: "MATERIAL",
|
|
846
|
+
ruleId: rule.id,
|
|
847
|
+
amountCents: materialCents
|
|
848
|
+
}];
|
|
849
|
+
const lineWarnings = [...warnings];
|
|
850
|
+
if (configuration.scope === "PARTIAL") {
|
|
851
|
+
let laborCents = 0;
|
|
852
|
+
if (rule.laborFormula === null) lineWarnings.push({
|
|
853
|
+
code: "LABOR_RATE_MISSING",
|
|
854
|
+
params: { categoryId }
|
|
855
|
+
});
|
|
856
|
+
else {
|
|
857
|
+
const labor = runNumericFormula(rule.laborFormula, {
|
|
858
|
+
variables: {
|
|
859
|
+
...variables,
|
|
860
|
+
quantity: quantity.value
|
|
861
|
+
},
|
|
862
|
+
coefficients
|
|
863
|
+
});
|
|
864
|
+
if (!labor.ok) return failedLine(ruled, "ERROR", labor.error);
|
|
865
|
+
const cents = componentCents(labor.value);
|
|
866
|
+
if (cents === null) return failedLine(ruled, "ERROR", outOfRange());
|
|
867
|
+
laborCents = cents;
|
|
868
|
+
}
|
|
869
|
+
components.push({
|
|
870
|
+
kind: "LABOR",
|
|
871
|
+
ruleId: rule.id,
|
|
872
|
+
amountCents: laborCents
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
const amountCents = components.reduce((sum, component) => sum + component.amountCents, 0);
|
|
876
|
+
if (amountCents > 1e10) return failedLine(ruled, "ERROR", outOfRange());
|
|
877
|
+
return createLine({
|
|
735
878
|
...ruled,
|
|
879
|
+
warnings: lineWarnings,
|
|
736
880
|
quantity: roundQuantity(quantity.value),
|
|
737
881
|
unit: product.unit,
|
|
738
|
-
unitPriceCents: product.priceCents
|
|
739
|
-
|
|
882
|
+
unitPriceCents: product.priceCents,
|
|
883
|
+
amountCents,
|
|
884
|
+
components
|
|
885
|
+
});
|
|
740
886
|
}
|
|
741
|
-
function materialLines(configuration, data) {
|
|
742
|
-
return configuration.rooms.flatMap((room) =>
|
|
887
|
+
function materialLines(configuration, data, objectScope) {
|
|
888
|
+
return configuration.rooms.flatMap((room) => {
|
|
889
|
+
const lines = room.selections.flatMap(({ categoryId, productId }) => productId === null ? [] : [materialLine(configuration, data, room, categoryId, productId, objectScope)]);
|
|
890
|
+
return configuration.scope === "PARTIAL" ? applySurfacePrep(data, room, lines, objectScope) : lines;
|
|
891
|
+
});
|
|
743
892
|
}
|
|
744
893
|
//#endregion
|
|
745
894
|
//#region src/estimate/object-rules.ts
|
|
746
|
-
function objectRuleLines(configuration, data) {
|
|
895
|
+
function objectRuleLines(configuration, data, section, variables) {
|
|
896
|
+
if (section === "BASE" && configuration.scope !== "FULL") return [];
|
|
747
897
|
const scope = {
|
|
748
|
-
variables
|
|
898
|
+
variables,
|
|
749
899
|
coefficients: data.coefficients
|
|
750
900
|
};
|
|
751
|
-
return data.rules.filter((rule) => rule.level === "OBJECT").sort((a, b) => a.sortOrder - b.sortOrder).map((rule) => {
|
|
901
|
+
return data.rules.filter((rule) => rule.level === "OBJECT" && rule.baseComponent === (section === "BASE")).sort((a, b) => a.sortOrder - b.sortOrder).map((rule) => {
|
|
752
902
|
const init = {
|
|
753
|
-
id: lineId(
|
|
754
|
-
section
|
|
903
|
+
id: lineId(section, rule.id),
|
|
904
|
+
section,
|
|
755
905
|
ruleId: rule.id
|
|
756
906
|
};
|
|
757
907
|
const outcome = runNumericFormula(rule.costFormula, scope);
|
|
758
908
|
return outcome.ok ? pricedLine(init, outcome.value) : failedLine(init, "ERROR", outcome.error);
|
|
759
|
-
});
|
|
909
|
+
}).filter((line) => section !== "BASE" || line.status !== "OK" || line.amountCents !== 0);
|
|
760
910
|
}
|
|
761
911
|
//#endregion
|
|
762
912
|
//#region src/schemas/common.ts
|
|
@@ -764,11 +914,17 @@ const LocalizedTextSchema = zod.z.object({
|
|
|
764
914
|
en: zod.z.string(),
|
|
765
915
|
uk: zod.z.string()
|
|
766
916
|
});
|
|
767
|
-
const
|
|
917
|
+
const ScopeSchema = zod.z.enum(["FULL", "PARTIAL"]);
|
|
918
|
+
const FinishLevelSchema = zod.z.enum([
|
|
919
|
+
"ROUGH",
|
|
920
|
+
"PRE_FINISH",
|
|
921
|
+
"LIVED_IN"
|
|
922
|
+
]);
|
|
768
923
|
const RuleLevelSchema = zod.z.enum([
|
|
769
924
|
"OBJECT",
|
|
770
925
|
"CATEGORY",
|
|
771
|
-
"MATERIAL_TYPE"
|
|
926
|
+
"MATERIAL_TYPE",
|
|
927
|
+
"SURFACE"
|
|
772
928
|
]);
|
|
773
929
|
const RateSourceSchema = zod.z.enum(["NBU", "MANUAL"]);
|
|
774
930
|
const SurfaceKindSchema = zod.z.enum([
|
|
@@ -795,7 +951,9 @@ const RoomTypeCodeSchema = zod.z.enum([
|
|
|
795
951
|
"BEDROOM",
|
|
796
952
|
"KITCHEN",
|
|
797
953
|
"KITCHEN_LIVING",
|
|
798
|
-
"BATHROOM"
|
|
954
|
+
"BATHROOM",
|
|
955
|
+
"HALLWAY",
|
|
956
|
+
"STORAGE"
|
|
799
957
|
]);
|
|
800
958
|
const CurrencySchema = zod.z.enum(["USD", "UAH"]);
|
|
801
959
|
const SectionSchema = zod.z.enum([
|
|
@@ -805,6 +963,11 @@ const SectionSchema = zod.z.enum([
|
|
|
805
963
|
"ENGINEERING",
|
|
806
964
|
"ADDITIONAL"
|
|
807
965
|
]);
|
|
966
|
+
const LineComponentKindSchema = zod.z.enum([
|
|
967
|
+
"MATERIAL",
|
|
968
|
+
"LABOR",
|
|
969
|
+
"PREP"
|
|
970
|
+
]);
|
|
808
971
|
const LineStatusSchema = zod.z.enum([
|
|
809
972
|
"OK",
|
|
810
973
|
"INCLUDED",
|
|
@@ -848,8 +1011,9 @@ function duplicateIndexes(items, key) {
|
|
|
848
1011
|
});
|
|
849
1012
|
}
|
|
850
1013
|
const ConfigurationSchema = zod.z.object({
|
|
1014
|
+
scope: ScopeSchema.default("FULL"),
|
|
851
1015
|
finishLevel: FinishLevelSchema,
|
|
852
|
-
totalAreaSqm: zod.z.number().positive(),
|
|
1016
|
+
totalAreaSqm: zod.z.number().positive().nullable(),
|
|
853
1017
|
balconyAreaSqm: zod.z.number().min(0),
|
|
854
1018
|
balconyTiles: zod.z.boolean(),
|
|
855
1019
|
rooms: zod.z.array(RoomConfigurationSchema),
|
|
@@ -861,6 +1025,11 @@ const ConfigurationSchema = zod.z.object({
|
|
|
861
1025
|
path,
|
|
862
1026
|
message: "Duplicate"
|
|
863
1027
|
});
|
|
1028
|
+
if (configuration.scope === "FULL" !== (configuration.totalAreaSqm !== null)) ctx.addIssue({
|
|
1029
|
+
code: "custom",
|
|
1030
|
+
path: ["totalAreaSqm"],
|
|
1031
|
+
message: configuration.scope === "FULL" ? "Required" : "Not allowed"
|
|
1032
|
+
});
|
|
864
1033
|
for (const index of duplicateIndexes(configuration.rooms, (room) => room.clientId)) addDuplicate([
|
|
865
1034
|
"rooms",
|
|
866
1035
|
index,
|
|
@@ -892,13 +1061,8 @@ const PricingLimitsSchema = zod.z.object({
|
|
|
892
1061
|
totalAreaSqm: RangeSchema,
|
|
893
1062
|
rooms: RangeSchema,
|
|
894
1063
|
roomSideM: RangeSchema,
|
|
895
|
-
roomHeightM: RangeSchema
|
|
896
|
-
|
|
897
|
-
const BaseRateSchema = zod.z.object({
|
|
898
|
-
finishLevel: FinishLevelSchema,
|
|
899
|
-
areaFromSqm: zod.z.number().min(0),
|
|
900
|
-
areaToSqm: zod.z.number(),
|
|
901
|
-
rateCentsPerSqm: zod.z.int().positive()
|
|
1064
|
+
roomHeightM: RangeSchema,
|
|
1065
|
+
missingAreaHintSqm: zod.z.number().min(0)
|
|
902
1066
|
});
|
|
903
1067
|
const PricingRuleSchema = zod.z.object({
|
|
904
1068
|
id: zod.z.string(),
|
|
@@ -907,9 +1071,16 @@ const PricingRuleSchema = zod.z.object({
|
|
|
907
1071
|
materialTypeId: zod.z.string().nullable(),
|
|
908
1072
|
name: LocalizedTextSchema,
|
|
909
1073
|
quantityFormula: zod.z.string().nullable(),
|
|
1074
|
+
surface: zod.z.enum([
|
|
1075
|
+
"FLOOR",
|
|
1076
|
+
"WALLS",
|
|
1077
|
+
"CEILING"
|
|
1078
|
+
]).nullable(),
|
|
1079
|
+
baseComponent: zod.z.boolean(),
|
|
910
1080
|
costFormula: zod.z.string().max(500),
|
|
1081
|
+
laborFormula: zod.z.string().max(500).nullable(),
|
|
911
1082
|
sortOrder: zod.z.int()
|
|
912
|
-
}).refine((rule) => rule.level === "OBJECT" === (rule.quantityFormula === null), { path: ["quantityFormula"] });
|
|
1083
|
+
}).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"] });
|
|
913
1084
|
const PricingRoomTypeSchema = zod.z.object({
|
|
914
1085
|
id: zod.z.string(),
|
|
915
1086
|
code: zod.z.string()
|
|
@@ -917,7 +1088,8 @@ const PricingRoomTypeSchema = zod.z.object({
|
|
|
917
1088
|
const PricingCategorySchema = zod.z.object({
|
|
918
1089
|
id: zod.z.string(),
|
|
919
1090
|
wastePercent: zod.z.number().min(0),
|
|
920
|
-
surface: SurfaceKindSchema
|
|
1091
|
+
surface: SurfaceKindSchema,
|
|
1092
|
+
requiresPutty: zod.z.boolean().default(true)
|
|
921
1093
|
});
|
|
922
1094
|
const PricingMaterialTypeSchema = zod.z.object({
|
|
923
1095
|
id: zod.z.string(),
|
|
@@ -939,22 +1111,43 @@ const PricingPackageItemSchema = zod.z.object({
|
|
|
939
1111
|
priceCents: CentsSchema.nullable(),
|
|
940
1112
|
unit: OptionUnitSchema.nullable()
|
|
941
1113
|
});
|
|
942
|
-
const
|
|
1114
|
+
const PricingOptionObjectSchema = zod.z.object({
|
|
943
1115
|
id: zod.z.string(),
|
|
944
1116
|
code: OptionCodeSchema.nullable(),
|
|
945
1117
|
kind: OptionKindSchema,
|
|
946
|
-
priceCents: CentsSchema,
|
|
1118
|
+
priceCents: CentsSchema.nullable(),
|
|
947
1119
|
unit: OptionUnitSchema,
|
|
948
1120
|
roomTypeCodes: zod.z.array(zod.z.string()),
|
|
1121
|
+
priceCoefficientKeys: zod.z.array(zod.z.string()),
|
|
1122
|
+
scopes: zod.z.array(ScopeSchema).min(1),
|
|
949
1123
|
minQuantity: zod.z.int().nullable(),
|
|
950
1124
|
maxQuantity: zod.z.int().nullable(),
|
|
951
1125
|
available: zod.z.boolean()
|
|
952
1126
|
});
|
|
1127
|
+
function findOptionPriceSourceViolation(priceCents, priceCoefficientKeys) {
|
|
1128
|
+
const hasKeys = priceCoefficientKeys.length > 0;
|
|
1129
|
+
if (priceCents !== null && hasKeys) return {
|
|
1130
|
+
path: "priceCents",
|
|
1131
|
+
message: "priceCents must be null when priceCoefficientKeys is set"
|
|
1132
|
+
};
|
|
1133
|
+
if (priceCents === null && !hasKeys) return {
|
|
1134
|
+
path: "priceCoefficientKeys",
|
|
1135
|
+
message: "priceCoefficientKeys is required when priceCents is null"
|
|
1136
|
+
};
|
|
1137
|
+
return null;
|
|
1138
|
+
}
|
|
1139
|
+
const PricingOptionSchema = PricingOptionObjectSchema.superRefine((option, context) => {
|
|
1140
|
+
const violation = findOptionPriceSourceViolation(option.priceCents, option.priceCoefficientKeys);
|
|
1141
|
+
if (violation !== null) context.addIssue({
|
|
1142
|
+
code: "custom",
|
|
1143
|
+
path: [violation.path],
|
|
1144
|
+
message: violation.message
|
|
1145
|
+
});
|
|
1146
|
+
});
|
|
953
1147
|
const PricingDataSchema = zod.z.object({
|
|
954
1148
|
version: zod.z.string().regex(/^[0-9a-f]{16}$/),
|
|
955
1149
|
exchangeRate: ExchangeRateSchema.nullable(),
|
|
956
1150
|
limits: PricingLimitsSchema,
|
|
957
|
-
baseRates: zod.z.array(BaseRateSchema),
|
|
958
1151
|
coefficients: zod.z.record(zod.z.string(), zod.z.number()),
|
|
959
1152
|
rules: zod.z.array(PricingRuleSchema),
|
|
960
1153
|
roomTypes: zod.z.array(PricingRoomTypeSchema),
|
|
@@ -970,6 +1163,12 @@ const EstimateMessageSchema = zod.z.object({
|
|
|
970
1163
|
code: zod.z.string(),
|
|
971
1164
|
params: zod.z.record(zod.z.string(), zod.z.unknown()).optional()
|
|
972
1165
|
});
|
|
1166
|
+
const LineComponentSchema = zod.z.object({
|
|
1167
|
+
kind: LineComponentKindSchema,
|
|
1168
|
+
ruleId: zod.z.string(),
|
|
1169
|
+
amountCents: zod.z.number().int().min(0),
|
|
1170
|
+
sharedWithCategoryId: zod.z.string().optional()
|
|
1171
|
+
});
|
|
973
1172
|
const EstimateLineSchema = zod.z.object({
|
|
974
1173
|
id: zod.z.string(),
|
|
975
1174
|
section: SectionSchema,
|
|
@@ -985,6 +1184,7 @@ const EstimateLineSchema = zod.z.object({
|
|
|
985
1184
|
unitPriceCents: CentsSchema.nullable(),
|
|
986
1185
|
amountCents: CentsSchema,
|
|
987
1186
|
amountUahKopecks: CentsSchema.nullable(),
|
|
1187
|
+
components: zod.z.array(LineComponentSchema).optional(),
|
|
988
1188
|
warnings: zod.z.array(EstimateMessageSchema),
|
|
989
1189
|
error: EstimateMessageSchema.nullable()
|
|
990
1190
|
});
|
|
@@ -1022,18 +1222,34 @@ const RuleSampleSchema = zod.z.object({
|
|
|
1022
1222
|
unit: ProductUnitSchema,
|
|
1023
1223
|
wastePercent: zod.z.number().min(0)
|
|
1024
1224
|
}).optional(),
|
|
1225
|
+
surfaceArea: zod.z.number().min(0).optional(),
|
|
1226
|
+
puttyRequired: zod.z.boolean().optional(),
|
|
1025
1227
|
object: zod.z.object({
|
|
1026
|
-
|
|
1228
|
+
scope: ScopeSchema,
|
|
1229
|
+
totalAreaSqm: zod.z.number().positive().nullable(),
|
|
1027
1230
|
balconyAreaSqm: zod.z.number().min(0),
|
|
1028
1231
|
balconyTiles: zod.z.boolean(),
|
|
1029
1232
|
finishLevel: FinishLevelSchema,
|
|
1030
1233
|
roomsCount: zod.z.int().min(0),
|
|
1031
|
-
bathroomsCount: zod.z.int().min(0)
|
|
1234
|
+
bathroomsCount: zod.z.int().min(0),
|
|
1235
|
+
roomsFloorArea: zod.z.number().min(0),
|
|
1236
|
+
roomsWallArea: zod.z.number().min(0),
|
|
1237
|
+
roomsPuttyWallArea: zod.z.number().min(0).optional(),
|
|
1238
|
+
roomsPerimeter: zod.z.number().min(0),
|
|
1239
|
+
wetRoomsCount: zod.z.int().min(0)
|
|
1032
1240
|
})
|
|
1241
|
+
}).superRefine((sample, ctx) => {
|
|
1242
|
+
const { scope, totalAreaSqm } = sample.object;
|
|
1243
|
+
if (scope === "FULL" !== (totalAreaSqm !== null)) ctx.addIssue({
|
|
1244
|
+
code: "custom",
|
|
1245
|
+
path: ["object", "totalAreaSqm"],
|
|
1246
|
+
message: scope === "FULL" ? "Required" : "Not allowed"
|
|
1247
|
+
});
|
|
1033
1248
|
});
|
|
1034
1249
|
const RulePreviewSchema = zod.z.object({
|
|
1035
1250
|
quantity: zod.z.number().nullable(),
|
|
1036
1251
|
amountCents: CentsSchema.nullable(),
|
|
1252
|
+
laborCents: CentsSchema.nullable(),
|
|
1037
1253
|
variables: zod.z.record(zod.z.string(), zod.z.union([
|
|
1038
1254
|
zod.z.number(),
|
|
1039
1255
|
zod.z.boolean(),
|
|
@@ -1068,20 +1284,28 @@ function isIncomplete(lines) {
|
|
|
1068
1284
|
}
|
|
1069
1285
|
//#endregion
|
|
1070
1286
|
//#region src/estimate/warnings.ts
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1287
|
+
const roundArea = (value) => Math.round(value * 1e4) / 1e4;
|
|
1288
|
+
function roomsAreaWarnings(configuration, limits) {
|
|
1289
|
+
const totalAreaSqm = configuration.totalAreaSqm;
|
|
1290
|
+
if (configuration.scope !== "FULL" || totalAreaSqm === null) return [];
|
|
1291
|
+
const roomsAreaSqm = roundArea(configuration.rooms.reduce((sum, room) => sum + roomVariables(room).floorArea, 0));
|
|
1292
|
+
if (roomsAreaSqm > totalAreaSqm) return [{
|
|
1074
1293
|
code: "ROOMS_AREA_EXCEEDS_TOTAL",
|
|
1075
1294
|
params: {
|
|
1076
1295
|
roomsAreaSqm,
|
|
1077
|
-
totalAreaSqm
|
|
1296
|
+
totalAreaSqm
|
|
1078
1297
|
}
|
|
1298
|
+
}];
|
|
1299
|
+
const missingSqm = roundArea(totalAreaSqm - roomsAreaSqm);
|
|
1300
|
+
return missingSqm > limits.missingAreaHintSqm ? [{
|
|
1301
|
+
code: "ROOMS_AREA_BELOW_TOTAL",
|
|
1302
|
+
params: { missingSqm }
|
|
1079
1303
|
}] : [];
|
|
1080
1304
|
}
|
|
1081
|
-
function estimateWarnings(lines, configuration, rate) {
|
|
1305
|
+
function estimateWarnings(lines, configuration, rate, limits) {
|
|
1082
1306
|
return [
|
|
1083
1307
|
...collectWarnings(lines),
|
|
1084
|
-
...roomsAreaWarnings(configuration),
|
|
1308
|
+
...roomsAreaWarnings(configuration, limits),
|
|
1085
1309
|
...configuration.currency === "UAH" && rate === null ? [{ code: "EXCHANGE_RATE_UNAVAILABLE" }] : []
|
|
1086
1310
|
];
|
|
1087
1311
|
}
|
|
@@ -1095,10 +1319,11 @@ function withKopecks(line, rate) {
|
|
|
1095
1319
|
}
|
|
1096
1320
|
function estimate(configuration, data) {
|
|
1097
1321
|
const rate = data.exchangeRate?.rate ?? null;
|
|
1322
|
+
const variables = objectVariables(configuration, data.categories);
|
|
1098
1323
|
const lines = [
|
|
1099
|
-
|
|
1100
|
-
...objectRuleLines(configuration, data),
|
|
1101
|
-
...materialLines(configuration, data),
|
|
1324
|
+
...objectRuleLines(configuration, data, "BASE", variables),
|
|
1325
|
+
...objectRuleLines(configuration, data, "OBJECT", variables),
|
|
1326
|
+
...materialLines(configuration, data, variables),
|
|
1102
1327
|
...engineeringLines(configuration, data),
|
|
1103
1328
|
...additionalLines(configuration, data)
|
|
1104
1329
|
].map((line) => withKopecks(line, rate));
|
|
@@ -1108,7 +1333,7 @@ function estimate(configuration, data) {
|
|
|
1108
1333
|
exchangeRate: data.exchangeRate,
|
|
1109
1334
|
lines,
|
|
1110
1335
|
totals: computeTotals(lines, rate !== null),
|
|
1111
|
-
warnings: estimateWarnings(lines, configuration, rate),
|
|
1336
|
+
warnings: estimateWarnings(lines, configuration, rate, data.limits),
|
|
1112
1337
|
incomplete: isIncomplete(lines)
|
|
1113
1338
|
};
|
|
1114
1339
|
}
|
|
@@ -1123,8 +1348,9 @@ function checkLimits(configuration, limits, options = []) {
|
|
|
1123
1348
|
max
|
|
1124
1349
|
});
|
|
1125
1350
|
};
|
|
1126
|
-
|
|
1127
|
-
check("
|
|
1351
|
+
const isFull = configuration.scope === "FULL";
|
|
1352
|
+
if (isFull && configuration.totalAreaSqm !== null) check("totalAreaSqm", configuration.totalAreaSqm, limits.totalAreaSqm.min, limits.totalAreaSqm.max);
|
|
1353
|
+
check("balconyAreaSqm", configuration.balconyAreaSqm, 0, isFull && configuration.totalAreaSqm !== null ? configuration.totalAreaSqm : limits.totalAreaSqm.max);
|
|
1128
1354
|
check("rooms", configuration.rooms.length, limits.rooms.min, limits.rooms.max);
|
|
1129
1355
|
configuration.rooms.forEach((room, index) => {
|
|
1130
1356
|
const path = `rooms[${index}]`;
|
|
@@ -1180,17 +1406,35 @@ function referencesOf(sources) {
|
|
|
1180
1406
|
}
|
|
1181
1407
|
function objectScope({ object }) {
|
|
1182
1408
|
return {
|
|
1183
|
-
|
|
1409
|
+
scope: object.scope,
|
|
1410
|
+
totalArea: totalAreaOf(object.scope, object.totalAreaSqm, object.roomsFloorArea),
|
|
1184
1411
|
balconyArea: object.balconyAreaSqm,
|
|
1185
1412
|
balconyTiles: object.balconyTiles,
|
|
1186
1413
|
finishLevel: object.finishLevel,
|
|
1187
1414
|
roomsCount: object.roomsCount,
|
|
1188
|
-
bathroomsCount: object.bathroomsCount
|
|
1415
|
+
bathroomsCount: object.bathroomsCount,
|
|
1416
|
+
roomsFloorArea: object.roomsFloorArea,
|
|
1417
|
+
roomsWallArea: object.roomsWallArea,
|
|
1418
|
+
roomsPuttyWallArea: object.roomsPuttyWallArea ?? object.roomsWallArea,
|
|
1419
|
+
roomsPerimeter: object.roomsPerimeter,
|
|
1420
|
+
wetRoomsCount: object.wetRoomsCount
|
|
1189
1421
|
};
|
|
1190
1422
|
}
|
|
1191
1423
|
function scopeOf(level, sample) {
|
|
1192
1424
|
if (level === "OBJECT") return { scope: objectScope(sample) };
|
|
1193
|
-
const { room, product } = sample;
|
|
1425
|
+
const { room, product, surfaceArea } = sample;
|
|
1426
|
+
if (level === "SURFACE") {
|
|
1427
|
+
if (room === void 0 || surfaceArea === void 0) return { error: {
|
|
1428
|
+
code: "SAMPLE_INCOMPLETE",
|
|
1429
|
+
params: { missing: room === void 0 ? "room" : "surfaceArea" }
|
|
1430
|
+
} };
|
|
1431
|
+
return { scope: {
|
|
1432
|
+
...roomVariables(room),
|
|
1433
|
+
...objectScope(sample),
|
|
1434
|
+
surfaceArea,
|
|
1435
|
+
puttyRequired: sample.puttyRequired ?? true
|
|
1436
|
+
} };
|
|
1437
|
+
}
|
|
1194
1438
|
if (room === void 0 || product === void 0) return { error: {
|
|
1195
1439
|
code: "SAMPLE_INCOMPLETE",
|
|
1196
1440
|
params: { missing: room === void 0 ? "room" : "product" }
|
|
@@ -1206,15 +1450,21 @@ function pick(source, names) {
|
|
|
1206
1450
|
}
|
|
1207
1451
|
function previewRule(input) {
|
|
1208
1452
|
const { level, quantityFormula, costFormula, coefficients } = input;
|
|
1209
|
-
const
|
|
1210
|
-
const
|
|
1453
|
+
const laborFormula = quantityFormula === null ? null : input.laborFormula ?? null;
|
|
1454
|
+
const references = referencesOf([
|
|
1455
|
+
quantityFormula,
|
|
1456
|
+
costFormula,
|
|
1457
|
+
laborFormula
|
|
1458
|
+
]);
|
|
1459
|
+
const report = (variables, quantity, amountCents, error, laborCents = null) => ({
|
|
1211
1460
|
quantity,
|
|
1212
1461
|
amountCents,
|
|
1462
|
+
laborCents,
|
|
1213
1463
|
variables: pick(variables, references.variables),
|
|
1214
1464
|
coefficients: pick(coefficients, references.coefficients),
|
|
1215
1465
|
error
|
|
1216
1466
|
});
|
|
1217
|
-
if (level === "OBJECT" !== (quantityFormula === null)) return report({}, null, null, { code: "QUANTITY_FORMULA_MISMATCH" });
|
|
1467
|
+
if ((level === "OBJECT" || level === "SURFACE") !== (quantityFormula === null)) return report({}, null, null, { code: "QUANTITY_FORMULA_MISMATCH" });
|
|
1218
1468
|
const prepared = scopeOf(level, input.sample);
|
|
1219
1469
|
if ("error" in prepared) return report({}, null, null, prepared.error);
|
|
1220
1470
|
let variables = prepared.scope;
|
|
@@ -1240,7 +1490,15 @@ function previewRule(input) {
|
|
|
1240
1490
|
id: "preview",
|
|
1241
1491
|
section: "OBJECT"
|
|
1242
1492
|
}, cost.value);
|
|
1243
|
-
|
|
1493
|
+
if (line.error !== null) return report(variables, quantity, null, line.error);
|
|
1494
|
+
if (laborFormula === null) return report(variables, quantity, line.amountCents, null);
|
|
1495
|
+
const labor = runNumericFormula(laborFormula, {
|
|
1496
|
+
variables,
|
|
1497
|
+
coefficients
|
|
1498
|
+
});
|
|
1499
|
+
if (!labor.ok) return report(variables, quantity, line.amountCents, labor.error);
|
|
1500
|
+
const laborCents = componentCents(labor.value);
|
|
1501
|
+
return laborCents === null ? report(variables, quantity, line.amountCents, outOfRange()) : report(variables, quantity, line.amountCents, null, laborCents);
|
|
1244
1502
|
}
|
|
1245
1503
|
//#endregion
|
|
1246
1504
|
//#region src/formula/suggest.ts
|
|
@@ -1312,10 +1570,18 @@ const FORMULA_SCOPE_VARIABLES = {
|
|
|
1312
1570
|
boolean("balconyTiles"),
|
|
1313
1571
|
enumeration("finishLevel", FinishLevelSchema.options),
|
|
1314
1572
|
number("roomsCount"),
|
|
1315
|
-
number("bathroomsCount")
|
|
1573
|
+
number("bathroomsCount"),
|
|
1574
|
+
enumeration("scope", ScopeSchema.options),
|
|
1575
|
+
number("roomsFloorArea"),
|
|
1576
|
+
number("roomsWallArea"),
|
|
1577
|
+
number("roomsPuttyWallArea"),
|
|
1578
|
+
number("roomsPerimeter"),
|
|
1579
|
+
number("wetRoomsCount")
|
|
1316
1580
|
]
|
|
1317
1581
|
};
|
|
1318
1582
|
const FORMULA_QUANTITY_VARIABLE = number("quantity");
|
|
1583
|
+
const FORMULA_SURFACE_AREA_VARIABLE = number("surfaceArea");
|
|
1584
|
+
const FORMULA_PUTTY_REQUIRED_VARIABLE = boolean("puttyRequired");
|
|
1319
1585
|
const FORMULA_VARIABLES = {
|
|
1320
1586
|
OBJECT: FORMULA_SCOPE_VARIABLES.object,
|
|
1321
1587
|
CATEGORY: [
|
|
@@ -1327,9 +1593,15 @@ const FORMULA_VARIABLES = {
|
|
|
1327
1593
|
...FORMULA_SCOPE_VARIABLES.room,
|
|
1328
1594
|
...FORMULA_SCOPE_VARIABLES.product,
|
|
1329
1595
|
...FORMULA_SCOPE_VARIABLES.object
|
|
1596
|
+
],
|
|
1597
|
+
SURFACE: [
|
|
1598
|
+
...FORMULA_SCOPE_VARIABLES.room,
|
|
1599
|
+
...FORMULA_SCOPE_VARIABLES.object,
|
|
1600
|
+
FORMULA_SURFACE_AREA_VARIABLE,
|
|
1601
|
+
FORMULA_PUTTY_REQUIRED_VARIABLE
|
|
1330
1602
|
]
|
|
1331
1603
|
};
|
|
1332
|
-
const ALL_VARIABLE_NAMES = new Set(FORMULA_VARIABLES.CATEGORY.map((variable) => variable.name));
|
|
1604
|
+
const ALL_VARIABLE_NAMES = new Set([...FORMULA_VARIABLES.CATEGORY, ...FORMULA_VARIABLES.SURFACE].map((variable) => variable.name));
|
|
1333
1605
|
function span(node) {
|
|
1334
1606
|
return {
|
|
1335
1607
|
position: node.start,
|
|
@@ -1353,7 +1625,7 @@ function validateFormula(input) {
|
|
|
1353
1625
|
const knownCoefficients = new Set(input.coefficientKeys);
|
|
1354
1626
|
const usedCoefficients = /* @__PURE__ */ new Set();
|
|
1355
1627
|
const errors = [];
|
|
1356
|
-
const quantityAllowed = input.kind
|
|
1628
|
+
const quantityAllowed = input.kind !== "quantity" && (input.level === "CATEGORY" || input.level === "MATERIAL_TYPE");
|
|
1357
1629
|
function mismatch(node, expected) {
|
|
1358
1630
|
errors.push({
|
|
1359
1631
|
code: "FORMULA_SYNTAX",
|
|
@@ -1541,7 +1813,6 @@ function validateFormula(input) {
|
|
|
1541
1813
|
};
|
|
1542
1814
|
}
|
|
1543
1815
|
//#endregion
|
|
1544
|
-
exports.BaseRateSchema = BaseRateSchema;
|
|
1545
1816
|
exports.COEFFICIENT_NAMESPACE = COEFFICIENT_NAMESPACE;
|
|
1546
1817
|
exports.CentsSchema = CentsSchema;
|
|
1547
1818
|
exports.ConfigurationSchema = ConfigurationSchema;
|
|
@@ -1555,10 +1826,14 @@ exports.ExchangeRateSchema = ExchangeRateSchema;
|
|
|
1555
1826
|
exports.FORMULA_FUNCTIONS = FORMULA_FUNCTIONS;
|
|
1556
1827
|
exports.FORMULA_FUNCTION_TABLE = FORMULA_FUNCTION_TABLE;
|
|
1557
1828
|
exports.FORMULA_LIMITS = FORMULA_LIMITS;
|
|
1829
|
+
exports.FORMULA_PUTTY_REQUIRED_VARIABLE = FORMULA_PUTTY_REQUIRED_VARIABLE;
|
|
1558
1830
|
exports.FORMULA_QUANTITY_VARIABLE = FORMULA_QUANTITY_VARIABLE;
|
|
1559
1831
|
exports.FORMULA_SCOPE_VARIABLES = FORMULA_SCOPE_VARIABLES;
|
|
1832
|
+
exports.FORMULA_SURFACE_AREA_VARIABLE = FORMULA_SURFACE_AREA_VARIABLE;
|
|
1560
1833
|
exports.FORMULA_VARIABLES = FORMULA_VARIABLES;
|
|
1561
1834
|
exports.FinishLevelSchema = FinishLevelSchema;
|
|
1835
|
+
exports.LineComponentKindSchema = LineComponentKindSchema;
|
|
1836
|
+
exports.LineComponentSchema = LineComponentSchema;
|
|
1562
1837
|
exports.LineStatusSchema = LineStatusSchema;
|
|
1563
1838
|
exports.LocalizedTextSchema = LocalizedTextSchema;
|
|
1564
1839
|
exports.OptionCodeSchema = OptionCodeSchema;
|
|
@@ -1583,6 +1858,7 @@ exports.RoomTypeCodeSchema = RoomTypeCodeSchema;
|
|
|
1583
1858
|
exports.RuleLevelSchema = RuleLevelSchema;
|
|
1584
1859
|
exports.RulePreviewSchema = RulePreviewSchema;
|
|
1585
1860
|
exports.RuleSampleSchema = RuleSampleSchema;
|
|
1861
|
+
exports.ScopeSchema = ScopeSchema;
|
|
1586
1862
|
exports.SectionSchema = SectionSchema;
|
|
1587
1863
|
exports.SectionTotalSchema = SectionTotalSchema;
|
|
1588
1864
|
exports.SurfaceKindSchema = SurfaceKindSchema;
|
|
@@ -1590,13 +1866,16 @@ exports.checkLimits = checkLimits;
|
|
|
1590
1866
|
exports.convertCentsToKopecks = convertCentsToKopecks;
|
|
1591
1867
|
exports.estimate = estimate;
|
|
1592
1868
|
exports.evaluate = evaluate;
|
|
1869
|
+
exports.findOptionPriceSourceViolation = findOptionPriceSourceViolation;
|
|
1593
1870
|
exports.levenshtein = levenshtein;
|
|
1594
1871
|
exports.objectVariables = objectVariables;
|
|
1595
1872
|
exports.parse = parse;
|
|
1596
1873
|
exports.previewRule = previewRule;
|
|
1597
1874
|
exports.productVariables = productVariables;
|
|
1598
1875
|
exports.productVariablesOf = productVariablesOf;
|
|
1876
|
+
exports.puttyRequiredOn = puttyRequiredOn;
|
|
1599
1877
|
exports.roomVariables = roomVariables;
|
|
1600
1878
|
exports.suggestNearest = suggestNearest;
|
|
1601
1879
|
exports.toCents = toCents;
|
|
1880
|
+
exports.totalAreaOf = totalAreaOf;
|
|
1602
1881
|
exports.validateFormula = validateFormula;
|