@roomwise/calc-engine 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +285 -2
- package/dist/index.cjs +1614 -0
- package/dist/index.d.cts +806 -0
- package/dist/index.d.ts +806 -0
- package/dist/index.js +1530 -0
- package/package.json +53 -4
package/dist/index.js
ADDED
|
@@ -0,0 +1,1530 @@
|
|
|
1
|
+
import ternary from "@jsep-plugin/ternary";
|
|
2
|
+
import jsep from "jsep";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
//#region src/money/convert-uah.ts
|
|
5
|
+
const RATE_PATTERN = /^(\d+)(?:\.(\d{1,4}))?$/;
|
|
6
|
+
const RATE_SCALE = 10000n;
|
|
7
|
+
function scaleRate(rate) {
|
|
8
|
+
const match = RATE_PATTERN.exec(String(rate));
|
|
9
|
+
if (!match) throw new RangeError(`Invalid exchange rate: ${rate}`);
|
|
10
|
+
const scaled = BigInt(match[1]) * RATE_SCALE + BigInt((match[2] ?? "").padEnd(4, "0"));
|
|
11
|
+
if (scaled <= 0n) throw new RangeError(`Invalid exchange rate: ${rate}`);
|
|
12
|
+
return scaled;
|
|
13
|
+
}
|
|
14
|
+
function convertCentsToKopecks(cents, rate) {
|
|
15
|
+
if (!Number.isSafeInteger(cents) || cents < 0) throw new RangeError(`Invalid cents amount: ${cents}`);
|
|
16
|
+
const kopecks = (BigInt(cents) * scaleRate(rate) + RATE_SCALE / 2n) / RATE_SCALE;
|
|
17
|
+
if (kopecks > BigInt(Number.MAX_SAFE_INTEGER)) throw new RangeError(`Amount exceeds the safe integer range: ${cents}`);
|
|
18
|
+
return Number(kopecks);
|
|
19
|
+
}
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/money/to-cents.ts
|
|
22
|
+
function toCents(dollars) {
|
|
23
|
+
if (!Number.isFinite(dollars) || dollars < 0) throw new RangeError(`Invalid dollar amount: ${dollars}`);
|
|
24
|
+
const cents = Math.round(Number((dollars * 100).toPrecision(15)));
|
|
25
|
+
if (!Number.isSafeInteger(cents)) throw new RangeError(`Amount exceeds the safe integer range: ${dollars}`);
|
|
26
|
+
return cents;
|
|
27
|
+
}
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/estimate/line.ts
|
|
30
|
+
const MAX_LINE_CENTS = 1e10;
|
|
31
|
+
function createLine(init) {
|
|
32
|
+
return {
|
|
33
|
+
status: "OK",
|
|
34
|
+
roomClientId: null,
|
|
35
|
+
categoryId: null,
|
|
36
|
+
productId: null,
|
|
37
|
+
optionId: null,
|
|
38
|
+
packageItemId: null,
|
|
39
|
+
ruleId: null,
|
|
40
|
+
quantity: null,
|
|
41
|
+
unit: null,
|
|
42
|
+
unitPriceCents: null,
|
|
43
|
+
amountCents: 0,
|
|
44
|
+
amountUahKopecks: null,
|
|
45
|
+
warnings: [],
|
|
46
|
+
error: null,
|
|
47
|
+
...init
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function failedLine(init, status, error) {
|
|
51
|
+
return createLine({
|
|
52
|
+
...init,
|
|
53
|
+
status,
|
|
54
|
+
error
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function pricedLine(init, dollars) {
|
|
58
|
+
if (!Number.isFinite(dollars) || dollars < 0 || dollars * 100 > 1e10) return failedLine(init, "ERROR", {
|
|
59
|
+
code: "AMOUNT_OUT_OF_RANGE",
|
|
60
|
+
params: { maxCents: MAX_LINE_CENTS }
|
|
61
|
+
});
|
|
62
|
+
return createLine({
|
|
63
|
+
...init,
|
|
64
|
+
amountCents: toCents(dollars)
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
function roundQuantity(quantity) {
|
|
68
|
+
return Math.round(quantity * 1e3) / 1e3;
|
|
69
|
+
}
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/estimate/line-id.ts
|
|
72
|
+
function lineId(section, ...parts) {
|
|
73
|
+
return [section, ...parts].join(":");
|
|
74
|
+
}
|
|
75
|
+
//#endregion
|
|
76
|
+
//#region src/estimate/option-line.ts
|
|
77
|
+
function unitQuantity(unit, rooms, pieces) {
|
|
78
|
+
switch (unit) {
|
|
79
|
+
case "PIECE": return pieces;
|
|
80
|
+
case "PROJECT": return 1;
|
|
81
|
+
case "ROOM": return rooms.length;
|
|
82
|
+
case "ROOM_SQM": return rooms.reduce((sum, room) => sum + room.lengthM * room.widthM, 0);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function optionLine(configuration, option, selection) {
|
|
86
|
+
const init = {
|
|
87
|
+
id: lineId(option.kind, "option", option.id),
|
|
88
|
+
section: option.kind,
|
|
89
|
+
optionId: option.id
|
|
90
|
+
};
|
|
91
|
+
if (!option.available) return failedLine(init, "UNAVAILABLE", {
|
|
92
|
+
code: "PRODUCT_UNAVAILABLE",
|
|
93
|
+
params: { optionId: option.id }
|
|
94
|
+
});
|
|
95
|
+
const selected = configuration.rooms.filter((room) => selection.roomClientIds?.includes(room.clientId));
|
|
96
|
+
const allowed = option.roomTypeCodes.length === 0 ? selected : selected.filter((room) => option.roomTypeCodes.includes(room.roomTypeCode));
|
|
97
|
+
const rejected = selected.filter((room) => !allowed.includes(room));
|
|
98
|
+
const quantity = unitQuantity(option.unit, allowed, selection.quantity ?? option.minQuantity ?? 1);
|
|
99
|
+
return pricedLine({
|
|
100
|
+
...init,
|
|
101
|
+
quantity: roundQuantity(quantity),
|
|
102
|
+
unit: option.unit,
|
|
103
|
+
unitPriceCents: option.priceCents,
|
|
104
|
+
warnings: rejected.map((room) => ({
|
|
105
|
+
code: "OPTION_ROOM_TYPE_NOT_ALLOWED",
|
|
106
|
+
params: {
|
|
107
|
+
roomClientId: room.clientId,
|
|
108
|
+
roomTypeCode: room.roomTypeCode
|
|
109
|
+
}
|
|
110
|
+
}))
|
|
111
|
+
}, option.priceCents / 100 * quantity);
|
|
112
|
+
}
|
|
113
|
+
function optionLines(configuration, data, kind) {
|
|
114
|
+
return configuration.options.flatMap((selection) => {
|
|
115
|
+
const option = data.options.find((item) => item.id === selection.optionId);
|
|
116
|
+
return option?.kind === kind ? [optionLine(configuration, option, selection)] : [];
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
//#endregion
|
|
120
|
+
//#region src/estimate/additional.ts
|
|
121
|
+
function additionalLines(configuration, data) {
|
|
122
|
+
return optionLines(configuration, data, "ADDITIONAL");
|
|
123
|
+
}
|
|
124
|
+
//#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
|
+
//#region src/estimate/engineering.ts
|
|
149
|
+
function packageLine(configuration, item) {
|
|
150
|
+
const init = {
|
|
151
|
+
id: lineId("ENGINEERING", "package", item.id),
|
|
152
|
+
section: "ENGINEERING",
|
|
153
|
+
packageItemId: item.id
|
|
154
|
+
};
|
|
155
|
+
if (item.includedInBase) return createLine({
|
|
156
|
+
...init,
|
|
157
|
+
status: "INCLUDED"
|
|
158
|
+
});
|
|
159
|
+
if (item.priceCents === null || item.unit === null) return failedLine(init, "NOT_CALCULATED", {
|
|
160
|
+
code: "PACKAGE_ITEM_PRICE_MISSING",
|
|
161
|
+
params: { packageItemId: item.id }
|
|
162
|
+
});
|
|
163
|
+
const quantity = unitQuantity(item.unit, configuration.rooms, 1);
|
|
164
|
+
return pricedLine({
|
|
165
|
+
...init,
|
|
166
|
+
quantity: roundQuantity(quantity),
|
|
167
|
+
unit: item.unit,
|
|
168
|
+
unitPriceCents: item.priceCents
|
|
169
|
+
}, item.priceCents / 100 * quantity);
|
|
170
|
+
}
|
|
171
|
+
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
|
+
gas: configuration.gas,
|
|
187
|
+
kitchenType: configuration.kitchenType,
|
|
188
|
+
finishLevel: configuration.finishLevel,
|
|
189
|
+
roomsCount: configuration.rooms.filter((room) => COUNTED_AS_ROOMS.includes(room.roomTypeCode)).length,
|
|
190
|
+
bathroomsCount: configuration.rooms.filter((room) => room.roomTypeCode === "BATHROOM").length
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
//#endregion
|
|
194
|
+
//#region src/variables/product.ts
|
|
195
|
+
function productVariablesOf(product, wastePercent) {
|
|
196
|
+
return {
|
|
197
|
+
price: product.priceCents / 100,
|
|
198
|
+
waste: wastePercent / 100,
|
|
199
|
+
unit: product.unit
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function productVariables(product, category) {
|
|
203
|
+
return productVariablesOf(product, product.wastePercentOverride ?? category.wastePercent);
|
|
204
|
+
}
|
|
205
|
+
//#endregion
|
|
206
|
+
//#region src/variables/room.ts
|
|
207
|
+
function roomVariables(room) {
|
|
208
|
+
const perimeter = 2 * (room.lengthM + room.widthM);
|
|
209
|
+
return {
|
|
210
|
+
length: room.lengthM,
|
|
211
|
+
width: room.widthM,
|
|
212
|
+
height: room.heightM,
|
|
213
|
+
floorArea: room.lengthM * room.widthM,
|
|
214
|
+
perimeter,
|
|
215
|
+
wallArea: perimeter * room.heightM,
|
|
216
|
+
heatedFloor: room.heatedFloor,
|
|
217
|
+
roomType: room.roomTypeCode
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
//#endregion
|
|
221
|
+
//#region src/formula/language.ts
|
|
222
|
+
const COEFFICIENT_NAMESPACE = "coef";
|
|
223
|
+
const FORMULA_FUNCTION_TABLE = new Map([
|
|
224
|
+
{
|
|
225
|
+
name: "min",
|
|
226
|
+
minArgs: 1,
|
|
227
|
+
maxArgs: Number.POSITIVE_INFINITY,
|
|
228
|
+
apply: (args) => Math.min(...args)
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
name: "max",
|
|
232
|
+
minArgs: 1,
|
|
233
|
+
maxArgs: Number.POSITIVE_INFINITY,
|
|
234
|
+
apply: (args) => Math.max(...args)
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
name: "round",
|
|
238
|
+
minArgs: 1,
|
|
239
|
+
maxArgs: 1,
|
|
240
|
+
apply: (args) => Math.round(args[0])
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
name: "ceil",
|
|
244
|
+
minArgs: 1,
|
|
245
|
+
maxArgs: 1,
|
|
246
|
+
apply: (args) => Math.ceil(args[0])
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
name: "floor",
|
|
250
|
+
minArgs: 1,
|
|
251
|
+
maxArgs: 1,
|
|
252
|
+
apply: (args) => Math.floor(args[0])
|
|
253
|
+
}
|
|
254
|
+
].map((fn) => [fn.name, fn]));
|
|
255
|
+
const FORMULA_FUNCTIONS = [...FORMULA_FUNCTION_TABLE.keys()];
|
|
256
|
+
//#endregion
|
|
257
|
+
//#region src/formula/evaluate.ts
|
|
258
|
+
var EvaluationFailure = class {
|
|
259
|
+
error;
|
|
260
|
+
constructor(error) {
|
|
261
|
+
this.error = error;
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
function fail(node, code, params) {
|
|
265
|
+
throw new EvaluationFailure({
|
|
266
|
+
code,
|
|
267
|
+
position: node.start,
|
|
268
|
+
length: node.end - node.start,
|
|
269
|
+
...params === void 0 ? {} : { params }
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
function runtimeFail(node, reason, params) {
|
|
273
|
+
return fail(node, "FORMULA_RUNTIME_ERROR", {
|
|
274
|
+
...params,
|
|
275
|
+
reason
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
function mismatch(node, expected) {
|
|
279
|
+
return runtimeFail(node, "TYPE_MISMATCH", { expected });
|
|
280
|
+
}
|
|
281
|
+
function finite(node, value) {
|
|
282
|
+
return Number.isFinite(value) ? value : runtimeFail(node, "NOT_FINITE");
|
|
283
|
+
}
|
|
284
|
+
function asNumber(node, value) {
|
|
285
|
+
return typeof value === "number" ? value : mismatch(node, "number");
|
|
286
|
+
}
|
|
287
|
+
function asBoolean(node, value) {
|
|
288
|
+
return typeof value === "boolean" ? value : mismatch(node, "boolean");
|
|
289
|
+
}
|
|
290
|
+
function lookupVariable(node, scope) {
|
|
291
|
+
if (!Object.hasOwn(scope.variables, node.name)) return fail(node, "UNKNOWN_VARIABLE", { name: node.name });
|
|
292
|
+
return scope.variables[node.name];
|
|
293
|
+
}
|
|
294
|
+
function evaluateMember(node, scope) {
|
|
295
|
+
if (node.object.type !== "Identifier" || node.object.name !== "coef") return fail(node, "UNKNOWN_VARIABLE");
|
|
296
|
+
const key = node.property.name;
|
|
297
|
+
if (!Object.hasOwn(scope.coefficients, key)) return fail(node, "UNKNOWN_COEFFICIENT", { key });
|
|
298
|
+
return scope.coefficients[key];
|
|
299
|
+
}
|
|
300
|
+
function evaluateCall(node, scope) {
|
|
301
|
+
const spec = node.callee.type === "Identifier" ? FORMULA_FUNCTION_TABLE.get(node.callee.name) : void 0;
|
|
302
|
+
if (spec === void 0) return fail(node.callee, "UNKNOWN_FUNCTION");
|
|
303
|
+
if (node.args.length < spec.minArgs || node.args.length > spec.maxArgs) return mismatch(node, "argument count");
|
|
304
|
+
const args = node.args.map((arg) => asNumber(arg, walk(arg, scope)));
|
|
305
|
+
return finite(node, spec.apply(args));
|
|
306
|
+
}
|
|
307
|
+
function evaluateEquality(node, scope) {
|
|
308
|
+
const { left, right } = node;
|
|
309
|
+
const literal = [left, right].find((side) => side.type === "Literal" && typeof side.value === "string") ?? null;
|
|
310
|
+
if (literal !== null) {
|
|
311
|
+
const other = literal === left ? right : left;
|
|
312
|
+
const variable = other.type === "Identifier" ? lookupVariable(other, scope) : void 0;
|
|
313
|
+
if (typeof variable !== "string") return mismatch(other, "enum variable");
|
|
314
|
+
return node.operator === "==" ? variable === literal.value : variable !== literal.value;
|
|
315
|
+
}
|
|
316
|
+
const leftValue = walk(left, scope);
|
|
317
|
+
const rightValue = walk(right, scope);
|
|
318
|
+
if (typeof leftValue !== typeof rightValue) return mismatch(right, typeof leftValue);
|
|
319
|
+
return node.operator === "==" ? leftValue === rightValue : leftValue !== rightValue;
|
|
320
|
+
}
|
|
321
|
+
function evaluateBinary(node, scope) {
|
|
322
|
+
const { operator, left, right } = node;
|
|
323
|
+
if (operator === "==" || operator === "!=") return evaluateEquality(node, scope);
|
|
324
|
+
if (operator === "&&" || operator === "||") {
|
|
325
|
+
const leftValue = asBoolean(left, walk(left, scope));
|
|
326
|
+
if (operator === "&&" ? !leftValue : leftValue) return leftValue;
|
|
327
|
+
return asBoolean(right, walk(right, scope));
|
|
328
|
+
}
|
|
329
|
+
const a = asNumber(left, walk(left, scope));
|
|
330
|
+
const b = asNumber(right, walk(right, scope));
|
|
331
|
+
switch (operator) {
|
|
332
|
+
case "<": return a < b;
|
|
333
|
+
case ">": return a > b;
|
|
334
|
+
case "<=": return a <= b;
|
|
335
|
+
case ">=": return a >= b;
|
|
336
|
+
case "+": return finite(node, a + b);
|
|
337
|
+
case "-": return finite(node, a - b);
|
|
338
|
+
case "*": return finite(node, a * b);
|
|
339
|
+
case "/": return b === 0 ? runtimeFail(node, "DIVISION_BY_ZERO") : finite(node, a / b);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
function walk(node, scope) {
|
|
343
|
+
switch (node.type) {
|
|
344
|
+
case "Literal": return typeof node.value === "string" ? mismatch(node, "number or boolean") : node.value;
|
|
345
|
+
case "Identifier": return lookupVariable(node, scope);
|
|
346
|
+
case "Member": return evaluateMember(node, scope);
|
|
347
|
+
case "Call": return evaluateCall(node, scope);
|
|
348
|
+
case "Unary": {
|
|
349
|
+
const argument = walk(node.argument, scope);
|
|
350
|
+
return node.operator === "!" ? !asBoolean(node.argument, argument) : finite(node, -asNumber(node.argument, argument));
|
|
351
|
+
}
|
|
352
|
+
case "Binary": return evaluateBinary(node, scope);
|
|
353
|
+
case "Conditional": return asBoolean(node.test, walk(node.test, scope)) ? walk(node.consequent, scope) : walk(node.alternate, scope);
|
|
354
|
+
default: return runtimeFail(node, "TYPE_MISMATCH", { expected: "known node" });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
function evaluate(ast, scope) {
|
|
358
|
+
try {
|
|
359
|
+
return {
|
|
360
|
+
ok: true,
|
|
361
|
+
value: walk(ast, scope)
|
|
362
|
+
};
|
|
363
|
+
} catch (error) {
|
|
364
|
+
if (error instanceof EvaluationFailure) return {
|
|
365
|
+
ok: false,
|
|
366
|
+
error: error.error
|
|
367
|
+
};
|
|
368
|
+
throw error;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
//#endregion
|
|
372
|
+
//#region src/formula/grammar.ts
|
|
373
|
+
const SEMICOLON_CODE = 59;
|
|
374
|
+
const REMOVED_BINARY_OPERATORS = [
|
|
375
|
+
"|",
|
|
376
|
+
"^",
|
|
377
|
+
"&",
|
|
378
|
+
"<<",
|
|
379
|
+
">>",
|
|
380
|
+
">>>",
|
|
381
|
+
"**",
|
|
382
|
+
"%",
|
|
383
|
+
"??",
|
|
384
|
+
"===",
|
|
385
|
+
"!==",
|
|
386
|
+
"in",
|
|
387
|
+
"instanceof"
|
|
388
|
+
];
|
|
389
|
+
const REMOVED_UNARY_OPERATORS = ["~", "+"];
|
|
390
|
+
const REMOVED_LITERALS = ["null"];
|
|
391
|
+
const { Jsep } = jsep;
|
|
392
|
+
function trimEnd(source, end) {
|
|
393
|
+
let result = Math.min(end, source.length);
|
|
394
|
+
while (result > 0 && /\s/.test(source.charAt(result - 1))) result -= 1;
|
|
395
|
+
return result;
|
|
396
|
+
}
|
|
397
|
+
function configure() {
|
|
398
|
+
for (const operator of REMOVED_BINARY_OPERATORS) Jsep.removeBinaryOp(operator);
|
|
399
|
+
for (const operator of REMOVED_UNARY_OPERATORS) Jsep.removeUnaryOp(operator);
|
|
400
|
+
for (const literal of REMOVED_LITERALS) Jsep.removeLiteral(literal);
|
|
401
|
+
jsep.plugins.register(ternary);
|
|
402
|
+
jsep.hooks.add("gobble-spaces", function rejectSemicolon() {
|
|
403
|
+
if (this.code === SEMICOLON_CODE) this.throwError("Unexpected \";\"");
|
|
404
|
+
});
|
|
405
|
+
const { gobbleToken, gobbleIdentifier } = Jsep.prototype;
|
|
406
|
+
Jsep.prototype.gobbleToken = function stampToken() {
|
|
407
|
+
this.gobbleSpaces();
|
|
408
|
+
const start = this.index;
|
|
409
|
+
const node = gobbleToken.call(this);
|
|
410
|
+
if (node) {
|
|
411
|
+
node.start ??= start;
|
|
412
|
+
node.end ??= trimEnd(this.expr, this.index);
|
|
413
|
+
}
|
|
414
|
+
return node;
|
|
415
|
+
};
|
|
416
|
+
Jsep.prototype.gobbleIdentifier = function stampIdentifier() {
|
|
417
|
+
const start = this.index;
|
|
418
|
+
const node = gobbleIdentifier.call(this);
|
|
419
|
+
node.start = start;
|
|
420
|
+
node.end = this.index;
|
|
421
|
+
return node;
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
configure();
|
|
425
|
+
function parseRaw(source) {
|
|
426
|
+
return jsep(source);
|
|
427
|
+
}
|
|
428
|
+
//#endregion
|
|
429
|
+
//#region src/formula/parse.ts
|
|
430
|
+
const FORMULA_LIMITS = {
|
|
431
|
+
maxLength: 500,
|
|
432
|
+
maxDepth: 32,
|
|
433
|
+
maxNodes: 200
|
|
434
|
+
};
|
|
435
|
+
var ParseFailure = class extends Error {
|
|
436
|
+
error;
|
|
437
|
+
constructor(error) {
|
|
438
|
+
super(error.code);
|
|
439
|
+
this.error = error;
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
function syntaxFailure(detail, span) {
|
|
443
|
+
return new ParseFailure({
|
|
444
|
+
code: "FORMULA_SYNTAX",
|
|
445
|
+
position: span.start,
|
|
446
|
+
length: span.end - span.start,
|
|
447
|
+
params: { detail }
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
function span$1(raw, children) {
|
|
451
|
+
return {
|
|
452
|
+
start: Math.min(raw.start ?? Number.POSITIVE_INFINITY, ...children.map((node) => node.start)),
|
|
453
|
+
end: Math.max(raw.end ?? Number.NEGATIVE_INFINITY, ...children.map((node) => node.end))
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
function child(raw) {
|
|
457
|
+
return raw;
|
|
458
|
+
}
|
|
459
|
+
function convertIdentifier(raw) {
|
|
460
|
+
return {
|
|
461
|
+
type: "Identifier",
|
|
462
|
+
name: String(raw.name),
|
|
463
|
+
...span$1(raw, [])
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
function convert(raw) {
|
|
467
|
+
switch (raw.type) {
|
|
468
|
+
case "Literal": return {
|
|
469
|
+
type: "Literal",
|
|
470
|
+
value: raw.value,
|
|
471
|
+
...span$1(raw, [])
|
|
472
|
+
};
|
|
473
|
+
case "Identifier": return convertIdentifier(raw);
|
|
474
|
+
case "MemberExpression": {
|
|
475
|
+
const property = child(raw.property);
|
|
476
|
+
if (raw.computed || raw.optional || property.type !== "Identifier") throw syntaxFailure("Only dotted access is supported", span$1(raw, []));
|
|
477
|
+
const object = convert(child(raw.object));
|
|
478
|
+
const identifier = convertIdentifier(property);
|
|
479
|
+
return {
|
|
480
|
+
type: "Member",
|
|
481
|
+
object,
|
|
482
|
+
property: identifier,
|
|
483
|
+
...span$1(raw, [object, identifier])
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
case "CallExpression": {
|
|
487
|
+
const callee = convert(child(raw.callee));
|
|
488
|
+
const args = raw.arguments.map(convert);
|
|
489
|
+
return {
|
|
490
|
+
type: "Call",
|
|
491
|
+
callee,
|
|
492
|
+
args,
|
|
493
|
+
...span$1(raw, [callee, ...args])
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
case "UnaryExpression": {
|
|
497
|
+
const operator = String(raw.operator);
|
|
498
|
+
const argument = convert(child(raw.argument));
|
|
499
|
+
return {
|
|
500
|
+
type: "Unary",
|
|
501
|
+
operator,
|
|
502
|
+
argument,
|
|
503
|
+
...span$1(raw, [argument])
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
case "BinaryExpression": {
|
|
507
|
+
const operator = String(raw.operator);
|
|
508
|
+
const left = convert(child(raw.left));
|
|
509
|
+
const right = convert(child(raw.right));
|
|
510
|
+
return {
|
|
511
|
+
type: "Binary",
|
|
512
|
+
operator,
|
|
513
|
+
left,
|
|
514
|
+
right,
|
|
515
|
+
...span$1(raw, [left, right])
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
case "ConditionalExpression": {
|
|
519
|
+
const test = convert(child(raw.test));
|
|
520
|
+
const consequent = convert(child(raw.consequent));
|
|
521
|
+
const alternate = convert(child(raw.alternate));
|
|
522
|
+
return {
|
|
523
|
+
type: "Conditional",
|
|
524
|
+
test,
|
|
525
|
+
consequent,
|
|
526
|
+
alternate,
|
|
527
|
+
...span$1(raw, [
|
|
528
|
+
test,
|
|
529
|
+
consequent,
|
|
530
|
+
alternate
|
|
531
|
+
])
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
case "Compound": throw syntaxFailure("Unexpected expression", raw.body.map(convert)[1]);
|
|
535
|
+
case "SequenceExpression": throw syntaxFailure("Sequences are not supported", raw.expressions.map(convert)[1]);
|
|
536
|
+
default: throw syntaxFailure(`${raw.type} is not supported`, span$1(raw, []));
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
function children(node) {
|
|
540
|
+
switch (node.type) {
|
|
541
|
+
case "Literal":
|
|
542
|
+
case "Identifier": return [];
|
|
543
|
+
case "Member": return [node.object, node.property];
|
|
544
|
+
case "Call": return [node.callee, ...node.args];
|
|
545
|
+
case "Unary": return [node.argument];
|
|
546
|
+
case "Binary": return [node.left, node.right];
|
|
547
|
+
case "Conditional": return [
|
|
548
|
+
node.test,
|
|
549
|
+
node.consequent,
|
|
550
|
+
node.alternate
|
|
551
|
+
];
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
function complexityFailure(limit, max, node) {
|
|
555
|
+
return new ParseFailure({
|
|
556
|
+
code: "FORMULA_TOO_COMPLEX",
|
|
557
|
+
position: node.start,
|
|
558
|
+
length: node.end - node.start,
|
|
559
|
+
params: {
|
|
560
|
+
limit,
|
|
561
|
+
max
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
function checkComplexity(root) {
|
|
566
|
+
let count = 0;
|
|
567
|
+
const visit = (node, depth) => {
|
|
568
|
+
if (depth > FORMULA_LIMITS.maxDepth) throw complexityFailure("DEPTH", FORMULA_LIMITS.maxDepth, node);
|
|
569
|
+
count += 1;
|
|
570
|
+
if (count > FORMULA_LIMITS.maxNodes) throw complexityFailure("NODES", FORMULA_LIMITS.maxNodes, node);
|
|
571
|
+
for (const next of children(node)) visit(next, depth + 1);
|
|
572
|
+
};
|
|
573
|
+
visit(root, 1);
|
|
574
|
+
}
|
|
575
|
+
function jsepFailure(source, thrown) {
|
|
576
|
+
const { index, description } = thrown;
|
|
577
|
+
return new ParseFailure({
|
|
578
|
+
code: "FORMULA_SYNTAX",
|
|
579
|
+
position: index,
|
|
580
|
+
length: Number(index < source.length),
|
|
581
|
+
params: { detail: description }
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
function parse(source) {
|
|
585
|
+
try {
|
|
586
|
+
if (source.length > FORMULA_LIMITS.maxLength) throw new ParseFailure({
|
|
587
|
+
code: "FORMULA_TOO_COMPLEX",
|
|
588
|
+
position: FORMULA_LIMITS.maxLength,
|
|
589
|
+
length: source.length - FORMULA_LIMITS.maxLength,
|
|
590
|
+
params: {
|
|
591
|
+
limit: "LENGTH",
|
|
592
|
+
max: FORMULA_LIMITS.maxLength
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
if (source.trim() === "") throw syntaxFailure("Formula is empty", {
|
|
596
|
+
start: 0,
|
|
597
|
+
end: source.length
|
|
598
|
+
});
|
|
599
|
+
let raw;
|
|
600
|
+
try {
|
|
601
|
+
raw = parseRaw(source);
|
|
602
|
+
} catch (thrown) {
|
|
603
|
+
throw jsepFailure(source, thrown);
|
|
604
|
+
}
|
|
605
|
+
const ast = convert(raw);
|
|
606
|
+
checkComplexity(ast);
|
|
607
|
+
return {
|
|
608
|
+
ok: true,
|
|
609
|
+
ast
|
|
610
|
+
};
|
|
611
|
+
} catch (thrown) {
|
|
612
|
+
if (!(thrown instanceof ParseFailure)) throw thrown;
|
|
613
|
+
return {
|
|
614
|
+
ok: false,
|
|
615
|
+
error: thrown.error
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
//#endregion
|
|
620
|
+
//#region src/estimate/run-formula.ts
|
|
621
|
+
function message(code, params) {
|
|
622
|
+
return params === void 0 ? { code } : {
|
|
623
|
+
code,
|
|
624
|
+
params: { ...params }
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
function runtimeError(reason) {
|
|
628
|
+
return {
|
|
629
|
+
ok: false,
|
|
630
|
+
error: message("FORMULA_RUNTIME_ERROR", { reason })
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
function runNumericFormula(source, scope) {
|
|
634
|
+
const parsed = parse(source);
|
|
635
|
+
if (!parsed.ok) return {
|
|
636
|
+
ok: false,
|
|
637
|
+
error: message(parsed.error.code, parsed.error.params)
|
|
638
|
+
};
|
|
639
|
+
const result = evaluate(parsed.ast, scope);
|
|
640
|
+
if (!result.ok) return {
|
|
641
|
+
ok: false,
|
|
642
|
+
error: message(result.error.code, result.error.params)
|
|
643
|
+
};
|
|
644
|
+
if (typeof result.value !== "number") return runtimeError("TYPE_MISMATCH");
|
|
645
|
+
if (result.value < 0) return runtimeError("NEGATIVE");
|
|
646
|
+
return {
|
|
647
|
+
ok: true,
|
|
648
|
+
value: result.value
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
//#endregion
|
|
652
|
+
//#region src/estimate/materials.ts
|
|
653
|
+
const isMaterialRule = (rule) => rule.quantityFormula !== null;
|
|
654
|
+
function selectRule(data, categoryId, materialTypeId) {
|
|
655
|
+
const rules = data.rules.filter(isMaterialRule);
|
|
656
|
+
return rules.find((rule) => rule.level === "MATERIAL_TYPE" && rule.categoryId === categoryId && rule.materialTypeId === materialTypeId) ?? rules.find((rule) => rule.level === "CATEGORY" && rule.categoryId === categoryId);
|
|
657
|
+
}
|
|
658
|
+
function materialLine(configuration, data, room, categoryId, productId) {
|
|
659
|
+
const init = {
|
|
660
|
+
id: lineId("MATERIALS", room.clientId, categoryId),
|
|
661
|
+
section: "MATERIALS",
|
|
662
|
+
roomClientId: room.clientId,
|
|
663
|
+
categoryId,
|
|
664
|
+
productId
|
|
665
|
+
};
|
|
666
|
+
const category = data.categories.find((item) => item.id === categoryId);
|
|
667
|
+
const product = data.products.find((item) => item.id === productId && item.categoryId === categoryId);
|
|
668
|
+
if (product === void 0 || category === void 0 || !product.available) return failedLine(init, "UNAVAILABLE", {
|
|
669
|
+
code: "PRODUCT_UNAVAILABLE",
|
|
670
|
+
params: { productId }
|
|
671
|
+
});
|
|
672
|
+
const warnings = room.heatedFloor && category.surface === "FLOOR" && !product.heatedFloorCompatible ? [{
|
|
673
|
+
code: "HEATED_FLOOR_INCOMPATIBLE",
|
|
674
|
+
params: { productId }
|
|
675
|
+
}] : [];
|
|
676
|
+
const rule = selectRule(data, categoryId, product.materialTypeId);
|
|
677
|
+
if (rule === void 0) return failedLine({
|
|
678
|
+
...init,
|
|
679
|
+
warnings
|
|
680
|
+
}, "NOT_CALCULATED", {
|
|
681
|
+
code: "RULE_NOT_FOUND",
|
|
682
|
+
params: {
|
|
683
|
+
categoryId,
|
|
684
|
+
materialTypeId: product.materialTypeId
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
const variables = {
|
|
688
|
+
...roomVariables(room),
|
|
689
|
+
...productVariables(product, category),
|
|
690
|
+
...objectVariables(configuration)
|
|
691
|
+
};
|
|
692
|
+
const { coefficients } = data;
|
|
693
|
+
const ruled = {
|
|
694
|
+
...init,
|
|
695
|
+
warnings,
|
|
696
|
+
ruleId: rule.id
|
|
697
|
+
};
|
|
698
|
+
const quantity = runNumericFormula(rule.quantityFormula, {
|
|
699
|
+
variables,
|
|
700
|
+
coefficients
|
|
701
|
+
});
|
|
702
|
+
if (!quantity.ok) return failedLine(ruled, "ERROR", quantity.error);
|
|
703
|
+
const cost = runNumericFormula(rule.costFormula, {
|
|
704
|
+
variables: {
|
|
705
|
+
...variables,
|
|
706
|
+
quantity: quantity.value
|
|
707
|
+
},
|
|
708
|
+
coefficients
|
|
709
|
+
});
|
|
710
|
+
if (!cost.ok) return failedLine(ruled, "ERROR", cost.error);
|
|
711
|
+
return pricedLine({
|
|
712
|
+
...ruled,
|
|
713
|
+
quantity: roundQuantity(quantity.value),
|
|
714
|
+
unit: product.unit,
|
|
715
|
+
unitPriceCents: product.priceCents
|
|
716
|
+
}, cost.value);
|
|
717
|
+
}
|
|
718
|
+
function materialLines(configuration, data) {
|
|
719
|
+
return configuration.rooms.flatMap((room) => room.selections.flatMap(({ categoryId, productId }) => productId === null ? [] : [materialLine(configuration, data, room, categoryId, productId)]));
|
|
720
|
+
}
|
|
721
|
+
//#endregion
|
|
722
|
+
//#region src/estimate/object-rules.ts
|
|
723
|
+
function objectRuleLines(configuration, data) {
|
|
724
|
+
const scope = {
|
|
725
|
+
variables: objectVariables(configuration),
|
|
726
|
+
coefficients: data.coefficients
|
|
727
|
+
};
|
|
728
|
+
return data.rules.filter((rule) => rule.level === "OBJECT").sort((a, b) => a.sortOrder - b.sortOrder).map((rule) => {
|
|
729
|
+
const init = {
|
|
730
|
+
id: lineId("OBJECT", rule.id),
|
|
731
|
+
section: "OBJECT",
|
|
732
|
+
ruleId: rule.id
|
|
733
|
+
};
|
|
734
|
+
const outcome = runNumericFormula(rule.costFormula, scope);
|
|
735
|
+
return outcome.ok ? pricedLine(init, outcome.value) : failedLine(init, "ERROR", outcome.error);
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
//#endregion
|
|
739
|
+
//#region src/schemas/common.ts
|
|
740
|
+
const LocalizedTextSchema = z.object({
|
|
741
|
+
en: z.string(),
|
|
742
|
+
uk: z.string()
|
|
743
|
+
});
|
|
744
|
+
const FinishLevelSchema = z.enum(["ROUGH", "PRE_FINISH"]);
|
|
745
|
+
const RuleLevelSchema = z.enum([
|
|
746
|
+
"OBJECT",
|
|
747
|
+
"CATEGORY",
|
|
748
|
+
"MATERIAL_TYPE"
|
|
749
|
+
]);
|
|
750
|
+
const RateSourceSchema = z.enum(["NBU", "MANUAL"]);
|
|
751
|
+
const SurfaceKindSchema = z.enum([
|
|
752
|
+
"NONE",
|
|
753
|
+
"FLOOR",
|
|
754
|
+
"WALLS",
|
|
755
|
+
"CEILING"
|
|
756
|
+
]);
|
|
757
|
+
const ProductUnitSchema = z.enum([
|
|
758
|
+
"SQM",
|
|
759
|
+
"LINEAR_M",
|
|
760
|
+
"PIECE"
|
|
761
|
+
]);
|
|
762
|
+
const OptionUnitSchema = z.enum([
|
|
763
|
+
"PIECE",
|
|
764
|
+
"ROOM_SQM",
|
|
765
|
+
"ROOM",
|
|
766
|
+
"PROJECT"
|
|
767
|
+
]);
|
|
768
|
+
const OptionKindSchema = z.enum(["ENGINEERING", "ADDITIONAL"]);
|
|
769
|
+
const OptionCodeSchema = z.enum(["HEATED_FLOOR"]);
|
|
770
|
+
const RoomTypeCodeSchema = z.enum([
|
|
771
|
+
"LIVING_ROOM",
|
|
772
|
+
"BEDROOM",
|
|
773
|
+
"KITCHEN",
|
|
774
|
+
"KITCHEN_LIVING",
|
|
775
|
+
"BATHROOM"
|
|
776
|
+
]);
|
|
777
|
+
const KitchenTypeSchema = z.enum(["SEPARATE", "KITCHEN_LIVING"]);
|
|
778
|
+
const CurrencySchema = z.enum(["USD", "UAH"]);
|
|
779
|
+
const SectionSchema = z.enum([
|
|
780
|
+
"BASE",
|
|
781
|
+
"OBJECT",
|
|
782
|
+
"MATERIALS",
|
|
783
|
+
"ENGINEERING",
|
|
784
|
+
"ADDITIONAL"
|
|
785
|
+
]);
|
|
786
|
+
const LineStatusSchema = z.enum([
|
|
787
|
+
"OK",
|
|
788
|
+
"INCLUDED",
|
|
789
|
+
"NOT_CALCULATED",
|
|
790
|
+
"UNAVAILABLE",
|
|
791
|
+
"ERROR"
|
|
792
|
+
]);
|
|
793
|
+
const CentsSchema = z.int().min(0);
|
|
794
|
+
const RangeSchema = z.object({
|
|
795
|
+
min: z.number(),
|
|
796
|
+
max: z.number()
|
|
797
|
+
});
|
|
798
|
+
//#endregion
|
|
799
|
+
//#region src/schemas/configuration.ts
|
|
800
|
+
const RoomSelectionSchema = z.object({
|
|
801
|
+
categoryId: z.string(),
|
|
802
|
+
productId: z.string().nullable()
|
|
803
|
+
});
|
|
804
|
+
const RoomConfigurationSchema = z.object({
|
|
805
|
+
clientId: z.string(),
|
|
806
|
+
name: z.string().trim().min(1).max(60).optional(),
|
|
807
|
+
roomTypeCode: RoomTypeCodeSchema,
|
|
808
|
+
lengthM: z.number().positive(),
|
|
809
|
+
widthM: z.number().positive(),
|
|
810
|
+
heightM: z.number().positive(),
|
|
811
|
+
heatedFloor: z.boolean(),
|
|
812
|
+
selections: z.array(RoomSelectionSchema)
|
|
813
|
+
});
|
|
814
|
+
const OptionSelectionSchema = z.object({
|
|
815
|
+
optionId: z.string(),
|
|
816
|
+
quantity: z.int().positive().optional(),
|
|
817
|
+
roomClientIds: z.array(z.string()).optional()
|
|
818
|
+
});
|
|
819
|
+
function duplicateIndexes(items, key) {
|
|
820
|
+
const seen = /* @__PURE__ */ new Set();
|
|
821
|
+
return items.flatMap((item, index) => {
|
|
822
|
+
const value = key(item);
|
|
823
|
+
if (seen.has(value)) return [index];
|
|
824
|
+
seen.add(value);
|
|
825
|
+
return [];
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
const ConfigurationSchema = z.object({
|
|
829
|
+
finishLevel: FinishLevelSchema,
|
|
830
|
+
totalAreaSqm: z.number().positive(),
|
|
831
|
+
balconyAreaSqm: z.number().min(0),
|
|
832
|
+
balconyTiles: z.boolean(),
|
|
833
|
+
gas: z.boolean(),
|
|
834
|
+
kitchenType: KitchenTypeSchema,
|
|
835
|
+
rooms: z.array(RoomConfigurationSchema),
|
|
836
|
+
options: z.array(OptionSelectionSchema),
|
|
837
|
+
currency: CurrencySchema
|
|
838
|
+
}).superRefine((configuration, ctx) => {
|
|
839
|
+
const addDuplicate = (path) => ctx.addIssue({
|
|
840
|
+
code: "custom",
|
|
841
|
+
path,
|
|
842
|
+
message: "Duplicate"
|
|
843
|
+
});
|
|
844
|
+
for (const index of duplicateIndexes(configuration.rooms, (room) => room.clientId)) addDuplicate([
|
|
845
|
+
"rooms",
|
|
846
|
+
index,
|
|
847
|
+
"clientId"
|
|
848
|
+
]);
|
|
849
|
+
configuration.rooms.forEach((room, roomIndex) => {
|
|
850
|
+
for (const index of duplicateIndexes(room.selections, (selection) => selection.categoryId)) addDuplicate([
|
|
851
|
+
"rooms",
|
|
852
|
+
roomIndex,
|
|
853
|
+
"selections",
|
|
854
|
+
index,
|
|
855
|
+
"categoryId"
|
|
856
|
+
]);
|
|
857
|
+
});
|
|
858
|
+
for (const index of duplicateIndexes(configuration.options, (option) => option.optionId)) addDuplicate([
|
|
859
|
+
"options",
|
|
860
|
+
index,
|
|
861
|
+
"optionId"
|
|
862
|
+
]);
|
|
863
|
+
});
|
|
864
|
+
//#endregion
|
|
865
|
+
//#region src/schemas/pricing-data.ts
|
|
866
|
+
const ExchangeRateSchema = z.object({
|
|
867
|
+
rate: z.string().regex(/^\d+\.\d{4}$/),
|
|
868
|
+
date: z.string(),
|
|
869
|
+
source: RateSourceSchema
|
|
870
|
+
});
|
|
871
|
+
const PricingLimitsSchema = z.object({
|
|
872
|
+
totalAreaSqm: RangeSchema,
|
|
873
|
+
rooms: RangeSchema,
|
|
874
|
+
roomSideM: RangeSchema,
|
|
875
|
+
roomHeightM: RangeSchema
|
|
876
|
+
});
|
|
877
|
+
const BaseRateSchema = z.object({
|
|
878
|
+
finishLevel: FinishLevelSchema,
|
|
879
|
+
areaFromSqm: z.number().min(0),
|
|
880
|
+
areaToSqm: z.number(),
|
|
881
|
+
rateCentsPerSqm: z.int().positive()
|
|
882
|
+
});
|
|
883
|
+
const PricingRuleSchema = z.object({
|
|
884
|
+
id: z.string(),
|
|
885
|
+
level: RuleLevelSchema,
|
|
886
|
+
categoryId: z.string().nullable(),
|
|
887
|
+
materialTypeId: z.string().nullable(),
|
|
888
|
+
name: LocalizedTextSchema,
|
|
889
|
+
quantityFormula: z.string().nullable(),
|
|
890
|
+
costFormula: z.string().max(500),
|
|
891
|
+
sortOrder: z.int()
|
|
892
|
+
}).refine((rule) => rule.level === "OBJECT" === (rule.quantityFormula === null), { path: ["quantityFormula"] });
|
|
893
|
+
const PricingRoomTypeSchema = z.object({
|
|
894
|
+
id: z.string(),
|
|
895
|
+
code: z.string()
|
|
896
|
+
});
|
|
897
|
+
const PricingCategorySchema = z.object({
|
|
898
|
+
id: z.string(),
|
|
899
|
+
wastePercent: z.number().min(0),
|
|
900
|
+
surface: SurfaceKindSchema
|
|
901
|
+
});
|
|
902
|
+
const PricingMaterialTypeSchema = z.object({
|
|
903
|
+
id: z.string(),
|
|
904
|
+
code: z.string()
|
|
905
|
+
});
|
|
906
|
+
const PricingProductSchema = z.object({
|
|
907
|
+
id: z.string(),
|
|
908
|
+
categoryId: z.string(),
|
|
909
|
+
materialTypeId: z.string(),
|
|
910
|
+
priceCents: CentsSchema,
|
|
911
|
+
unit: ProductUnitSchema,
|
|
912
|
+
wastePercentOverride: z.number().min(0).nullable(),
|
|
913
|
+
heatedFloorCompatible: z.boolean(),
|
|
914
|
+
available: z.boolean()
|
|
915
|
+
});
|
|
916
|
+
const PricingPackageItemSchema = z.object({
|
|
917
|
+
id: z.string(),
|
|
918
|
+
includedInBase: z.boolean(),
|
|
919
|
+
priceCents: CentsSchema.nullable(),
|
|
920
|
+
unit: OptionUnitSchema.nullable()
|
|
921
|
+
});
|
|
922
|
+
const PricingOptionSchema = z.object({
|
|
923
|
+
id: z.string(),
|
|
924
|
+
code: OptionCodeSchema.nullable(),
|
|
925
|
+
kind: OptionKindSchema,
|
|
926
|
+
priceCents: CentsSchema,
|
|
927
|
+
unit: OptionUnitSchema,
|
|
928
|
+
roomTypeCodes: z.array(z.string()),
|
|
929
|
+
minQuantity: z.int().nullable(),
|
|
930
|
+
maxQuantity: z.int().nullable(),
|
|
931
|
+
available: z.boolean()
|
|
932
|
+
});
|
|
933
|
+
const PricingDataSchema = z.object({
|
|
934
|
+
version: z.string().regex(/^[0-9a-f]{16}$/),
|
|
935
|
+
exchangeRate: ExchangeRateSchema.nullable(),
|
|
936
|
+
limits: PricingLimitsSchema,
|
|
937
|
+
baseRates: z.array(BaseRateSchema),
|
|
938
|
+
coefficients: z.record(z.string(), z.number()),
|
|
939
|
+
rules: z.array(PricingRuleSchema),
|
|
940
|
+
roomTypes: z.array(PricingRoomTypeSchema),
|
|
941
|
+
categories: z.array(PricingCategorySchema),
|
|
942
|
+
materialTypes: z.array(PricingMaterialTypeSchema),
|
|
943
|
+
products: z.array(PricingProductSchema),
|
|
944
|
+
packageItems: z.array(PricingPackageItemSchema),
|
|
945
|
+
options: z.array(PricingOptionSchema)
|
|
946
|
+
});
|
|
947
|
+
//#endregion
|
|
948
|
+
//#region src/schemas/estimate.ts
|
|
949
|
+
const EstimateMessageSchema = z.object({
|
|
950
|
+
code: z.string(),
|
|
951
|
+
params: z.record(z.string(), z.unknown()).optional()
|
|
952
|
+
});
|
|
953
|
+
const EstimateLineSchema = z.object({
|
|
954
|
+
id: z.string(),
|
|
955
|
+
section: SectionSchema,
|
|
956
|
+
status: LineStatusSchema,
|
|
957
|
+
roomClientId: z.string().nullable(),
|
|
958
|
+
categoryId: z.string().nullable(),
|
|
959
|
+
productId: z.string().nullable(),
|
|
960
|
+
optionId: z.string().nullable(),
|
|
961
|
+
packageItemId: z.string().nullable(),
|
|
962
|
+
ruleId: z.string().nullable(),
|
|
963
|
+
quantity: z.number().nullable(),
|
|
964
|
+
unit: z.string().nullable(),
|
|
965
|
+
unitPriceCents: CentsSchema.nullable(),
|
|
966
|
+
amountCents: CentsSchema,
|
|
967
|
+
amountUahKopecks: CentsSchema.nullable(),
|
|
968
|
+
warnings: z.array(EstimateMessageSchema),
|
|
969
|
+
error: EstimateMessageSchema.nullable()
|
|
970
|
+
});
|
|
971
|
+
const SectionTotalSchema = z.object({
|
|
972
|
+
usdCents: CentsSchema,
|
|
973
|
+
uahKopecks: CentsSchema.nullable()
|
|
974
|
+
});
|
|
975
|
+
const EstimateTotalsSchema = z.object({
|
|
976
|
+
usdCents: CentsSchema,
|
|
977
|
+
uahKopecks: CentsSchema.nullable(),
|
|
978
|
+
sections: z.record(SectionSchema, SectionTotalSchema)
|
|
979
|
+
});
|
|
980
|
+
const EstimateWarningSchema = EstimateMessageSchema.extend({ lineId: z.string().optional() });
|
|
981
|
+
const EstimateSchema = z.object({
|
|
982
|
+
pricingVersion: z.string(),
|
|
983
|
+
currency: CurrencySchema,
|
|
984
|
+
exchangeRate: ExchangeRateSchema.nullable(),
|
|
985
|
+
lines: z.array(EstimateLineSchema),
|
|
986
|
+
totals: EstimateTotalsSchema,
|
|
987
|
+
warnings: z.array(EstimateWarningSchema),
|
|
988
|
+
incomplete: z.boolean()
|
|
989
|
+
});
|
|
990
|
+
//#endregion
|
|
991
|
+
//#region src/schemas/preview.ts
|
|
992
|
+
const RuleSampleSchema = z.object({
|
|
993
|
+
room: z.object({
|
|
994
|
+
roomTypeCode: RoomTypeCodeSchema,
|
|
995
|
+
lengthM: z.number().positive(),
|
|
996
|
+
widthM: z.number().positive(),
|
|
997
|
+
heightM: z.number().positive(),
|
|
998
|
+
heatedFloor: z.boolean()
|
|
999
|
+
}).optional(),
|
|
1000
|
+
product: z.object({
|
|
1001
|
+
priceCents: CentsSchema,
|
|
1002
|
+
unit: ProductUnitSchema,
|
|
1003
|
+
wastePercent: z.number().min(0)
|
|
1004
|
+
}).optional(),
|
|
1005
|
+
object: z.object({
|
|
1006
|
+
totalAreaSqm: z.number().positive(),
|
|
1007
|
+
balconyAreaSqm: z.number().min(0),
|
|
1008
|
+
balconyTiles: z.boolean(),
|
|
1009
|
+
gas: z.boolean(),
|
|
1010
|
+
kitchenType: KitchenTypeSchema,
|
|
1011
|
+
finishLevel: FinishLevelSchema,
|
|
1012
|
+
roomsCount: z.int().min(0),
|
|
1013
|
+
bathroomsCount: z.int().min(0)
|
|
1014
|
+
})
|
|
1015
|
+
});
|
|
1016
|
+
const RulePreviewSchema = z.object({
|
|
1017
|
+
quantity: z.number().nullable(),
|
|
1018
|
+
amountCents: CentsSchema.nullable(),
|
|
1019
|
+
variables: z.record(z.string(), z.union([
|
|
1020
|
+
z.number(),
|
|
1021
|
+
z.boolean(),
|
|
1022
|
+
z.string()
|
|
1023
|
+
])),
|
|
1024
|
+
coefficients: z.record(z.string(), z.number()),
|
|
1025
|
+
error: EstimateMessageSchema.nullable()
|
|
1026
|
+
});
|
|
1027
|
+
//#endregion
|
|
1028
|
+
//#region src/estimate/totals.ts
|
|
1029
|
+
function sumTotal(lines, hasRate) {
|
|
1030
|
+
return {
|
|
1031
|
+
usdCents: lines.reduce((sum, line) => sum + line.amountCents, 0),
|
|
1032
|
+
uahKopecks: hasRate ? lines.reduce((sum, line) => sum + (line.amountUahKopecks ?? 0), 0) : null
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
function computeTotals(lines, hasRate) {
|
|
1036
|
+
const sections = Object.fromEntries(SectionSchema.options.map((section) => [section, sumTotal(lines.filter((line) => line.section === section), hasRate)]));
|
|
1037
|
+
return {
|
|
1038
|
+
...sumTotal(lines, hasRate),
|
|
1039
|
+
sections
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
function collectWarnings(lines) {
|
|
1043
|
+
return lines.flatMap((line) => line.warnings.map((warning) => ({
|
|
1044
|
+
...warning,
|
|
1045
|
+
lineId: line.id
|
|
1046
|
+
})));
|
|
1047
|
+
}
|
|
1048
|
+
function isIncomplete(lines) {
|
|
1049
|
+
return lines.some((line) => line.status === "ERROR" || line.status === "NOT_CALCULATED");
|
|
1050
|
+
}
|
|
1051
|
+
//#endregion
|
|
1052
|
+
//#region src/estimate/warnings.ts
|
|
1053
|
+
function roomsAreaWarnings(configuration) {
|
|
1054
|
+
const roomsAreaSqm = Math.round(configuration.rooms.reduce((sum, room) => sum + roomVariables(room).floorArea, 0) * 1e4) / 1e4;
|
|
1055
|
+
return roomsAreaSqm > configuration.totalAreaSqm ? [{
|
|
1056
|
+
code: "ROOMS_AREA_EXCEEDS_TOTAL",
|
|
1057
|
+
params: {
|
|
1058
|
+
roomsAreaSqm,
|
|
1059
|
+
totalAreaSqm: configuration.totalAreaSqm
|
|
1060
|
+
}
|
|
1061
|
+
}] : [];
|
|
1062
|
+
}
|
|
1063
|
+
function estimateWarnings(lines, configuration, rate) {
|
|
1064
|
+
return [
|
|
1065
|
+
...collectWarnings(lines),
|
|
1066
|
+
...roomsAreaWarnings(configuration),
|
|
1067
|
+
...configuration.currency === "UAH" && rate === null ? [{ code: "EXCHANGE_RATE_UNAVAILABLE" }] : []
|
|
1068
|
+
];
|
|
1069
|
+
}
|
|
1070
|
+
//#endregion
|
|
1071
|
+
//#region src/estimate/estimate.ts
|
|
1072
|
+
function withKopecks(line, rate) {
|
|
1073
|
+
return {
|
|
1074
|
+
...line,
|
|
1075
|
+
amountUahKopecks: rate === null ? null : convertCentsToKopecks(line.amountCents, rate)
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
function estimate(configuration, data) {
|
|
1079
|
+
const rate = data.exchangeRate?.rate ?? null;
|
|
1080
|
+
const lines = [
|
|
1081
|
+
baseLine(configuration, data),
|
|
1082
|
+
...objectRuleLines(configuration, data),
|
|
1083
|
+
...materialLines(configuration, data),
|
|
1084
|
+
...engineeringLines(configuration, data),
|
|
1085
|
+
...additionalLines(configuration, data)
|
|
1086
|
+
].map((line) => withKopecks(line, rate));
|
|
1087
|
+
return {
|
|
1088
|
+
pricingVersion: data.version,
|
|
1089
|
+
currency: configuration.currency,
|
|
1090
|
+
exchangeRate: data.exchangeRate,
|
|
1091
|
+
lines,
|
|
1092
|
+
totals: computeTotals(lines, rate !== null),
|
|
1093
|
+
warnings: estimateWarnings(lines, configuration, rate),
|
|
1094
|
+
incomplete: isIncomplete(lines)
|
|
1095
|
+
};
|
|
1096
|
+
}
|
|
1097
|
+
//#endregion
|
|
1098
|
+
//#region src/estimate/limits.ts
|
|
1099
|
+
function checkLimits(configuration, limits, options = []) {
|
|
1100
|
+
const violations = [];
|
|
1101
|
+
const check = (field, value, min, max) => {
|
|
1102
|
+
if (value < min || value > max) violations.push({
|
|
1103
|
+
field,
|
|
1104
|
+
min,
|
|
1105
|
+
max
|
|
1106
|
+
});
|
|
1107
|
+
};
|
|
1108
|
+
check("totalAreaSqm", configuration.totalAreaSqm, limits.totalAreaSqm.min, limits.totalAreaSqm.max);
|
|
1109
|
+
check("balconyAreaSqm", configuration.balconyAreaSqm, 0, configuration.totalAreaSqm);
|
|
1110
|
+
check("rooms", configuration.rooms.length, limits.rooms.min, limits.rooms.max);
|
|
1111
|
+
configuration.rooms.forEach((room, index) => {
|
|
1112
|
+
const path = `rooms[${index}]`;
|
|
1113
|
+
check(`${path}.lengthM`, room.lengthM, limits.roomSideM.min, limits.roomSideM.max);
|
|
1114
|
+
check(`${path}.widthM`, room.widthM, limits.roomSideM.min, limits.roomSideM.max);
|
|
1115
|
+
check(`${path}.heightM`, room.heightM, limits.roomHeightM.min, limits.roomHeightM.max);
|
|
1116
|
+
});
|
|
1117
|
+
configuration.options.forEach((selection, index) => {
|
|
1118
|
+
const option = options.find((candidate) => candidate.id === selection.optionId);
|
|
1119
|
+
if (option?.unit !== "PIECE" || selection.quantity === void 0) return;
|
|
1120
|
+
check(`options[${index}].quantity`, selection.quantity, option.minQuantity ?? 1, option.maxQuantity ?? Number.MAX_SAFE_INTEGER);
|
|
1121
|
+
});
|
|
1122
|
+
return violations;
|
|
1123
|
+
}
|
|
1124
|
+
//#endregion
|
|
1125
|
+
//#region src/formula/preview.ts
|
|
1126
|
+
function collectReferences(node, found) {
|
|
1127
|
+
switch (node.type) {
|
|
1128
|
+
case "Identifier":
|
|
1129
|
+
found.variables.add(node.name);
|
|
1130
|
+
return;
|
|
1131
|
+
case "Member":
|
|
1132
|
+
if (node.object.type === "Identifier" && node.object.name === "coef") found.coefficients.add(node.property.name);
|
|
1133
|
+
return;
|
|
1134
|
+
case "Call":
|
|
1135
|
+
node.args.forEach((argument) => collectReferences(argument, found));
|
|
1136
|
+
return;
|
|
1137
|
+
case "Unary":
|
|
1138
|
+
collectReferences(node.argument, found);
|
|
1139
|
+
return;
|
|
1140
|
+
case "Binary":
|
|
1141
|
+
collectReferences(node.left, found);
|
|
1142
|
+
collectReferences(node.right, found);
|
|
1143
|
+
return;
|
|
1144
|
+
case "Conditional":
|
|
1145
|
+
collectReferences(node.test, found);
|
|
1146
|
+
collectReferences(node.consequent, found);
|
|
1147
|
+
collectReferences(node.alternate, found);
|
|
1148
|
+
return;
|
|
1149
|
+
case "Literal": return;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
function referencesOf(sources) {
|
|
1153
|
+
const found = {
|
|
1154
|
+
variables: /* @__PURE__ */ new Set(),
|
|
1155
|
+
coefficients: /* @__PURE__ */ new Set()
|
|
1156
|
+
};
|
|
1157
|
+
for (const source of sources) {
|
|
1158
|
+
const parsed = source === null ? void 0 : parse(source);
|
|
1159
|
+
if (parsed?.ok) collectReferences(parsed.ast, found);
|
|
1160
|
+
}
|
|
1161
|
+
return found;
|
|
1162
|
+
}
|
|
1163
|
+
function objectScope({ object }) {
|
|
1164
|
+
return {
|
|
1165
|
+
totalArea: object.totalAreaSqm,
|
|
1166
|
+
balconyArea: object.balconyAreaSqm,
|
|
1167
|
+
balconyTiles: object.balconyTiles,
|
|
1168
|
+
gas: object.gas,
|
|
1169
|
+
kitchenType: object.kitchenType,
|
|
1170
|
+
finishLevel: object.finishLevel,
|
|
1171
|
+
roomsCount: object.roomsCount,
|
|
1172
|
+
bathroomsCount: object.bathroomsCount
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
function scopeOf(level, sample) {
|
|
1176
|
+
if (level === "OBJECT") return { scope: objectScope(sample) };
|
|
1177
|
+
const { room, product } = sample;
|
|
1178
|
+
if (room === void 0 || product === void 0) return { error: {
|
|
1179
|
+
code: "SAMPLE_INCOMPLETE",
|
|
1180
|
+
params: { missing: room === void 0 ? "room" : "product" }
|
|
1181
|
+
} };
|
|
1182
|
+
return { scope: {
|
|
1183
|
+
...roomVariables(room),
|
|
1184
|
+
...productVariablesOf(product, product.wastePercent),
|
|
1185
|
+
...objectScope(sample)
|
|
1186
|
+
} };
|
|
1187
|
+
}
|
|
1188
|
+
function pick(source, names) {
|
|
1189
|
+
return Object.fromEntries(Object.entries(source).filter(([name]) => names.has(name)));
|
|
1190
|
+
}
|
|
1191
|
+
function previewRule(input) {
|
|
1192
|
+
const { level, quantityFormula, costFormula, coefficients } = input;
|
|
1193
|
+
const references = referencesOf([quantityFormula, costFormula]);
|
|
1194
|
+
const report = (variables, quantity, amountCents, error) => ({
|
|
1195
|
+
quantity,
|
|
1196
|
+
amountCents,
|
|
1197
|
+
variables: pick(variables, references.variables),
|
|
1198
|
+
coefficients: pick(coefficients, references.coefficients),
|
|
1199
|
+
error
|
|
1200
|
+
});
|
|
1201
|
+
if (level === "OBJECT" !== (quantityFormula === null)) return report({}, null, null, { code: "QUANTITY_FORMULA_MISMATCH" });
|
|
1202
|
+
const prepared = scopeOf(level, input.sample);
|
|
1203
|
+
if ("error" in prepared) return report({}, null, null, prepared.error);
|
|
1204
|
+
let variables = prepared.scope;
|
|
1205
|
+
let quantity = null;
|
|
1206
|
+
if (quantityFormula !== null) {
|
|
1207
|
+
const raw = runNumericFormula(quantityFormula, {
|
|
1208
|
+
variables,
|
|
1209
|
+
coefficients
|
|
1210
|
+
});
|
|
1211
|
+
if (!raw.ok) return report(variables, null, null, raw.error);
|
|
1212
|
+
variables = {
|
|
1213
|
+
...variables,
|
|
1214
|
+
quantity: raw.value
|
|
1215
|
+
};
|
|
1216
|
+
quantity = roundQuantity(raw.value);
|
|
1217
|
+
}
|
|
1218
|
+
const cost = runNumericFormula(costFormula, {
|
|
1219
|
+
variables,
|
|
1220
|
+
coefficients
|
|
1221
|
+
});
|
|
1222
|
+
if (!cost.ok) return report(variables, quantity, null, cost.error);
|
|
1223
|
+
const line = pricedLine({
|
|
1224
|
+
id: "preview",
|
|
1225
|
+
section: "OBJECT"
|
|
1226
|
+
}, cost.value);
|
|
1227
|
+
return line.error === null ? report(variables, quantity, line.amountCents, null) : report(variables, quantity, null, line.error);
|
|
1228
|
+
}
|
|
1229
|
+
//#endregion
|
|
1230
|
+
//#region src/formula/suggest.ts
|
|
1231
|
+
function levenshtein(a, b) {
|
|
1232
|
+
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
1233
|
+
for (let i = 1; i <= a.length; i++) {
|
|
1234
|
+
const current = [i];
|
|
1235
|
+
for (let j = 1; j <= b.length; j++) {
|
|
1236
|
+
const substitution = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
1237
|
+
current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + substitution);
|
|
1238
|
+
}
|
|
1239
|
+
previous = current;
|
|
1240
|
+
}
|
|
1241
|
+
return previous[b.length];
|
|
1242
|
+
}
|
|
1243
|
+
function suggestNearest(name, candidates, maxDistance = 2) {
|
|
1244
|
+
let best;
|
|
1245
|
+
let bestDistance = maxDistance + 1;
|
|
1246
|
+
for (const candidate of candidates) {
|
|
1247
|
+
const distance = levenshtein(name, candidate);
|
|
1248
|
+
if (distance < bestDistance || distance === bestDistance && best !== void 0 && candidate < best) {
|
|
1249
|
+
best = candidate;
|
|
1250
|
+
bestDistance = distance;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
return best;
|
|
1254
|
+
}
|
|
1255
|
+
//#endregion
|
|
1256
|
+
//#region src/formula/validate.ts
|
|
1257
|
+
const NUMBER = { kind: "number" };
|
|
1258
|
+
const BOOLEAN = { kind: "boolean" };
|
|
1259
|
+
const STRING = { kind: "string" };
|
|
1260
|
+
const UNKNOWN = { kind: "unknown" };
|
|
1261
|
+
function typeName(type) {
|
|
1262
|
+
return type.kind === "enum" ? type.name : type.kind;
|
|
1263
|
+
}
|
|
1264
|
+
const number = (name) => ({
|
|
1265
|
+
name,
|
|
1266
|
+
kind: "number"
|
|
1267
|
+
});
|
|
1268
|
+
const boolean = (name) => ({
|
|
1269
|
+
name,
|
|
1270
|
+
kind: "boolean"
|
|
1271
|
+
});
|
|
1272
|
+
const enumeration = (name, values) => ({
|
|
1273
|
+
name,
|
|
1274
|
+
kind: "enum",
|
|
1275
|
+
values
|
|
1276
|
+
});
|
|
1277
|
+
const FORMULA_SCOPE_VARIABLES = {
|
|
1278
|
+
room: [
|
|
1279
|
+
number("length"),
|
|
1280
|
+
number("width"),
|
|
1281
|
+
number("height"),
|
|
1282
|
+
number("floorArea"),
|
|
1283
|
+
number("perimeter"),
|
|
1284
|
+
number("wallArea"),
|
|
1285
|
+
boolean("heatedFloor"),
|
|
1286
|
+
enumeration("roomType", RoomTypeCodeSchema.options)
|
|
1287
|
+
],
|
|
1288
|
+
product: [
|
|
1289
|
+
number("price"),
|
|
1290
|
+
number("waste"),
|
|
1291
|
+
enumeration("unit", ProductUnitSchema.options)
|
|
1292
|
+
],
|
|
1293
|
+
object: [
|
|
1294
|
+
number("totalArea"),
|
|
1295
|
+
number("balconyArea"),
|
|
1296
|
+
boolean("balconyTiles"),
|
|
1297
|
+
boolean("gas"),
|
|
1298
|
+
enumeration("kitchenType", KitchenTypeSchema.options),
|
|
1299
|
+
enumeration("finishLevel", FinishLevelSchema.options),
|
|
1300
|
+
number("roomsCount"),
|
|
1301
|
+
number("bathroomsCount")
|
|
1302
|
+
]
|
|
1303
|
+
};
|
|
1304
|
+
const FORMULA_QUANTITY_VARIABLE = number("quantity");
|
|
1305
|
+
const FORMULA_VARIABLES = {
|
|
1306
|
+
OBJECT: FORMULA_SCOPE_VARIABLES.object,
|
|
1307
|
+
CATEGORY: [
|
|
1308
|
+
...FORMULA_SCOPE_VARIABLES.room,
|
|
1309
|
+
...FORMULA_SCOPE_VARIABLES.product,
|
|
1310
|
+
...FORMULA_SCOPE_VARIABLES.object
|
|
1311
|
+
],
|
|
1312
|
+
MATERIAL_TYPE: [
|
|
1313
|
+
...FORMULA_SCOPE_VARIABLES.room,
|
|
1314
|
+
...FORMULA_SCOPE_VARIABLES.product,
|
|
1315
|
+
...FORMULA_SCOPE_VARIABLES.object
|
|
1316
|
+
]
|
|
1317
|
+
};
|
|
1318
|
+
const ALL_VARIABLE_NAMES = new Set(FORMULA_VARIABLES.CATEGORY.map((variable) => variable.name));
|
|
1319
|
+
function span(node) {
|
|
1320
|
+
return {
|
|
1321
|
+
position: node.start,
|
|
1322
|
+
length: node.end - node.start
|
|
1323
|
+
};
|
|
1324
|
+
}
|
|
1325
|
+
function validateFormula(input) {
|
|
1326
|
+
const parsed = parse(input.source);
|
|
1327
|
+
if (!parsed.ok) return {
|
|
1328
|
+
valid: false,
|
|
1329
|
+
coefficientKeys: [],
|
|
1330
|
+
errors: [parsed.error]
|
|
1331
|
+
};
|
|
1332
|
+
const variables = FORMULA_VARIABLES[input.level];
|
|
1333
|
+
const variableTypes = new Map(variables.map((variable) => [variable.name, variable.kind === "enum" ? {
|
|
1334
|
+
kind: "enum",
|
|
1335
|
+
name: variable.name,
|
|
1336
|
+
values: variable.values ?? []
|
|
1337
|
+
} : { kind: variable.kind }]));
|
|
1338
|
+
const allowedNames = variables.map((variable) => variable.name);
|
|
1339
|
+
const knownCoefficients = new Set(input.coefficientKeys);
|
|
1340
|
+
const usedCoefficients = /* @__PURE__ */ new Set();
|
|
1341
|
+
const errors = [];
|
|
1342
|
+
const quantityAllowed = input.kind === "cost" && input.level !== "OBJECT";
|
|
1343
|
+
function mismatch(node, expected) {
|
|
1344
|
+
errors.push({
|
|
1345
|
+
code: "FORMULA_SYNTAX",
|
|
1346
|
+
...span(node),
|
|
1347
|
+
params: {
|
|
1348
|
+
reason: "TYPE_MISMATCH",
|
|
1349
|
+
expected
|
|
1350
|
+
}
|
|
1351
|
+
});
|
|
1352
|
+
}
|
|
1353
|
+
function expectType(node, expected) {
|
|
1354
|
+
const type = infer(node);
|
|
1355
|
+
if (type.kind === "unknown") return false;
|
|
1356
|
+
if (type.kind !== expected) {
|
|
1357
|
+
mismatch(node, expected);
|
|
1358
|
+
return false;
|
|
1359
|
+
}
|
|
1360
|
+
return true;
|
|
1361
|
+
}
|
|
1362
|
+
function identifier(node) {
|
|
1363
|
+
const { name } = node;
|
|
1364
|
+
if (name === FORMULA_QUANTITY_VARIABLE.name) {
|
|
1365
|
+
if (!quantityAllowed) {
|
|
1366
|
+
errors.push({
|
|
1367
|
+
code: "QUANTITY_NOT_ALLOWED",
|
|
1368
|
+
...span(node)
|
|
1369
|
+
});
|
|
1370
|
+
return UNKNOWN;
|
|
1371
|
+
}
|
|
1372
|
+
return NUMBER;
|
|
1373
|
+
}
|
|
1374
|
+
const type = variableTypes.get(name);
|
|
1375
|
+
if (type !== void 0) return type;
|
|
1376
|
+
if (ALL_VARIABLE_NAMES.has(name)) {
|
|
1377
|
+
errors.push({
|
|
1378
|
+
code: "VARIABLE_NOT_ALLOWED_AT_LEVEL",
|
|
1379
|
+
...span(node),
|
|
1380
|
+
params: {
|
|
1381
|
+
variable: name,
|
|
1382
|
+
level: input.level
|
|
1383
|
+
}
|
|
1384
|
+
});
|
|
1385
|
+
return UNKNOWN;
|
|
1386
|
+
}
|
|
1387
|
+
const suggestion = suggestNearest(name, allowedNames);
|
|
1388
|
+
errors.push({
|
|
1389
|
+
code: "UNKNOWN_VARIABLE",
|
|
1390
|
+
...span(node),
|
|
1391
|
+
params: suggestion === void 0 ? { name } : {
|
|
1392
|
+
name,
|
|
1393
|
+
suggestion
|
|
1394
|
+
}
|
|
1395
|
+
});
|
|
1396
|
+
return UNKNOWN;
|
|
1397
|
+
}
|
|
1398
|
+
function member(node) {
|
|
1399
|
+
const { object, property } = node;
|
|
1400
|
+
if (object.type !== "Identifier" || object.name !== "coef") {
|
|
1401
|
+
errors.push({
|
|
1402
|
+
code: "UNKNOWN_VARIABLE",
|
|
1403
|
+
...span(node),
|
|
1404
|
+
params: { name: input.source.slice(node.start, node.end) }
|
|
1405
|
+
});
|
|
1406
|
+
return UNKNOWN;
|
|
1407
|
+
}
|
|
1408
|
+
usedCoefficients.add(property.name);
|
|
1409
|
+
if (!knownCoefficients.has(property.name)) {
|
|
1410
|
+
errors.push({
|
|
1411
|
+
code: "UNKNOWN_COEFFICIENT",
|
|
1412
|
+
...span(node),
|
|
1413
|
+
params: { key: property.name }
|
|
1414
|
+
});
|
|
1415
|
+
return UNKNOWN;
|
|
1416
|
+
}
|
|
1417
|
+
return NUMBER;
|
|
1418
|
+
}
|
|
1419
|
+
function call(node) {
|
|
1420
|
+
const { callee } = node;
|
|
1421
|
+
const fn = callee.type === "Identifier" ? FORMULA_FUNCTION_TABLE.get(callee.name) : void 0;
|
|
1422
|
+
if (fn === void 0) {
|
|
1423
|
+
errors.push({
|
|
1424
|
+
code: "UNKNOWN_FUNCTION",
|
|
1425
|
+
...span(callee),
|
|
1426
|
+
params: { name: input.source.slice(callee.start, callee.end) }
|
|
1427
|
+
});
|
|
1428
|
+
for (const argument of node.args) infer(argument);
|
|
1429
|
+
return UNKNOWN;
|
|
1430
|
+
}
|
|
1431
|
+
let sound = true;
|
|
1432
|
+
if (node.args.length < fn.minArgs || node.args.length > fn.maxArgs) {
|
|
1433
|
+
errors.push({
|
|
1434
|
+
code: "FORMULA_SYNTAX",
|
|
1435
|
+
...span(node),
|
|
1436
|
+
params: {
|
|
1437
|
+
reason: "ARGUMENT_COUNT",
|
|
1438
|
+
min: fn.minArgs,
|
|
1439
|
+
...Number.isFinite(fn.maxArgs) ? { max: fn.maxArgs } : {}
|
|
1440
|
+
}
|
|
1441
|
+
});
|
|
1442
|
+
sound = false;
|
|
1443
|
+
}
|
|
1444
|
+
for (const argument of node.args) sound = expectType(argument, "number") && sound;
|
|
1445
|
+
return sound ? NUMBER : UNKNOWN;
|
|
1446
|
+
}
|
|
1447
|
+
function enumLiteral(literal, other) {
|
|
1448
|
+
const type = infer(other);
|
|
1449
|
+
if (type.kind === "unknown") return;
|
|
1450
|
+
if (other.type !== "Identifier" || type.kind !== "enum") {
|
|
1451
|
+
mismatch(other, "enum variable");
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
if (!type.values.includes(literal.value)) errors.push({
|
|
1455
|
+
code: "FORMULA_SYNTAX",
|
|
1456
|
+
...span(literal),
|
|
1457
|
+
params: {
|
|
1458
|
+
reason: "UNKNOWN_ENUM_VALUE",
|
|
1459
|
+
value: literal.value,
|
|
1460
|
+
variable: type.name
|
|
1461
|
+
}
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
function equality(node) {
|
|
1465
|
+
const { left, right } = node;
|
|
1466
|
+
const literal = [left, right].find((side) => side.type === "Literal" && typeof side.value === "string");
|
|
1467
|
+
if (literal !== void 0) {
|
|
1468
|
+
enumLiteral(literal, literal === left ? right : left);
|
|
1469
|
+
return BOOLEAN;
|
|
1470
|
+
}
|
|
1471
|
+
const a = infer(left);
|
|
1472
|
+
const b = infer(right);
|
|
1473
|
+
if (a.kind !== "unknown" && b.kind !== "unknown" && a.kind !== b.kind) mismatch(right, typeName(a));
|
|
1474
|
+
return BOOLEAN;
|
|
1475
|
+
}
|
|
1476
|
+
function binary(node) {
|
|
1477
|
+
const { operator, left, right } = node;
|
|
1478
|
+
if (operator === "==" || operator === "!=") return equality(node);
|
|
1479
|
+
const logical = operator === "&&" || operator === "||";
|
|
1480
|
+
const operand = logical ? "boolean" : "number";
|
|
1481
|
+
const leftSound = expectType(left, operand);
|
|
1482
|
+
const rightSound = expectType(right, operand);
|
|
1483
|
+
if (!(leftSound && rightSound)) return UNKNOWN;
|
|
1484
|
+
const arithmetic = [
|
|
1485
|
+
"+",
|
|
1486
|
+
"-",
|
|
1487
|
+
"*",
|
|
1488
|
+
"/"
|
|
1489
|
+
].includes(operator);
|
|
1490
|
+
return logical || !arithmetic ? BOOLEAN : NUMBER;
|
|
1491
|
+
}
|
|
1492
|
+
function unary(node) {
|
|
1493
|
+
const operand = node.operator === "!" ? "boolean" : "number";
|
|
1494
|
+
return expectType(node.argument, operand) ? { kind: operand } : UNKNOWN;
|
|
1495
|
+
}
|
|
1496
|
+
function conditional(node) {
|
|
1497
|
+
const testSound = expectType(node.test, "boolean");
|
|
1498
|
+
const consequent = infer(node.consequent);
|
|
1499
|
+
const alternate = infer(node.alternate);
|
|
1500
|
+
if (consequent.kind === "unknown") return alternate;
|
|
1501
|
+
if (alternate.kind === "unknown") return consequent;
|
|
1502
|
+
if (consequent.kind !== alternate.kind) {
|
|
1503
|
+
mismatch(node.alternate, typeName(consequent));
|
|
1504
|
+
return UNKNOWN;
|
|
1505
|
+
}
|
|
1506
|
+
return testSound ? consequent : UNKNOWN;
|
|
1507
|
+
}
|
|
1508
|
+
function infer(node) {
|
|
1509
|
+
switch (node.type) {
|
|
1510
|
+
case "Literal":
|
|
1511
|
+
if (typeof node.value === "string") return STRING;
|
|
1512
|
+
return typeof node.value === "number" ? NUMBER : BOOLEAN;
|
|
1513
|
+
case "Identifier": return identifier(node);
|
|
1514
|
+
case "Member": return member(node);
|
|
1515
|
+
case "Call": return call(node);
|
|
1516
|
+
case "Unary": return unary(node);
|
|
1517
|
+
case "Binary": return binary(node);
|
|
1518
|
+
case "Conditional": return conditional(node);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
const resultType = infer(parsed.ast);
|
|
1522
|
+
if (resultType.kind !== "unknown" && resultType.kind !== "number") mismatch(parsed.ast, "number");
|
|
1523
|
+
return {
|
|
1524
|
+
valid: errors.length === 0,
|
|
1525
|
+
coefficientKeys: [...usedCoefficients],
|
|
1526
|
+
errors
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1529
|
+
//#endregion
|
|
1530
|
+
export { BaseRateSchema, COEFFICIENT_NAMESPACE, CentsSchema, ConfigurationSchema, CurrencySchema, EstimateLineSchema, EstimateMessageSchema, EstimateSchema, EstimateTotalsSchema, EstimateWarningSchema, ExchangeRateSchema, FORMULA_FUNCTIONS, FORMULA_FUNCTION_TABLE, FORMULA_LIMITS, FORMULA_QUANTITY_VARIABLE, FORMULA_SCOPE_VARIABLES, FORMULA_VARIABLES, FinishLevelSchema, KitchenTypeSchema, LineStatusSchema, LocalizedTextSchema, OptionCodeSchema, OptionKindSchema, OptionSelectionSchema, OptionUnitSchema, PricingCategorySchema, PricingDataSchema, PricingLimitsSchema, PricingMaterialTypeSchema, PricingOptionSchema, PricingPackageItemSchema, PricingProductSchema, PricingRoomTypeSchema, PricingRuleSchema, ProductUnitSchema, RangeSchema, RateSourceSchema, RoomConfigurationSchema, RoomSelectionSchema, RoomTypeCodeSchema, RuleLevelSchema, RulePreviewSchema, RuleSampleSchema, SectionSchema, SectionTotalSchema, SurfaceKindSchema, checkLimits, convertCentsToKopecks, estimate, evaluate, levenshtein, objectVariables, parse, previewRule, productVariables, productVariablesOf, roomVariables, suggestNearest, toCents, validateFormula };
|