@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/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 };