@roomwise/calc-engine 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -5
- package/dist/index.cjs +226 -122
- package/dist/index.d.cts +11 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +226 -122
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ Lines are produced in this order: base cost, object rules (by `sortOrder`), mate
|
|
|
47
47
|
- Base cost: `OBJECT` rules with `baseComponent: true`, one `BASE` line each. Only in `FULL`; a line that is `OK` with a zero amount is left out.
|
|
48
48
|
- Object rules: the other `OBJECT` level rules, one line each.
|
|
49
49
|
- Materials: for every room selection with a product, the `MATERIAL_TYPE` rule for the category and the product's material type is used if one exists, otherwise the `CATEGORY` rule. Without either, the line is `NOT_CALCULATED` (`RULE_NOT_FOUND`). Unknown or unavailable products give `UNAVAILABLE`. Each line carries [components](#line-components).
|
|
50
|
-
- Engineering and additional: package items marked `includedInBase` become `INCLUDED` lines with a zero amount
|
|
50
|
+
- Engineering and additional: package items marked `includedInBase` become `INCLUDED` lines with a zero amount. In `PARTIAL` no package items are priced at all; the section holds only options. Options are priced by unit (`PIECE`, `ROOM_SQM`, `ROOM`, `PROJECT`). An option has exactly one price source: `priceCents` with no `priceCoefficientKeys`, or `priceCents: null` with keys, in which case the unit price is the sum of those coefficients in dollars (`PricingDataSchema` rejects both and neither; read the price from the line's `unitPriceCents`). A missing coefficient gives an `ERROR` line (`UNKNOWN_COEFFICIENT`). An option whose `scopes` do not include the configuration scope is `UNAVAILABLE` with `OPTION_SCOPE_NOT_ALLOWED`.
|
|
51
51
|
|
|
52
52
|
Result fields:
|
|
53
53
|
|
|
@@ -193,7 +193,7 @@ Every schema is a zod schema with an inferred type of the same name without the
|
|
|
193
193
|
| `totalAreaSqm` | required | `null` |
|
|
194
194
|
| `BASE` section | `OBJECT` rules with `baseComponent: true`, one line each | none |
|
|
195
195
|
| Material lines | `MATERIAL` component only | `MATERIAL`, `LABOR` and `PREP` components |
|
|
196
|
-
|
|
|
196
|
+
| Engineering package items | priced, `INCLUDED` with `includedInBase` | none |
|
|
197
197
|
| `totalArea` variable | `totalAreaSqm` | `roomsFloorArea` |
|
|
198
198
|
| Area warnings | `ROOMS_AREA_EXCEEDS_TOTAL`, `ROOMS_AREA_BELOW_TOTAL` | none |
|
|
199
199
|
|
|
@@ -203,11 +203,12 @@ The base cost has no per-area rate table. It is built from rules whose formulas
|
|
|
203
203
|
|
|
204
204
|
A material line is the sum of its components:
|
|
205
205
|
|
|
206
|
-
- `MATERIAL`: the rule's `costFormula`.
|
|
206
|
+
- `MATERIAL`: the rule's `costFormula`. When the pricing data lists accompanying materials in `accessories`, this is the product itself and the accessories are separate components.
|
|
207
|
+
- `ACCESSORY`: an accompanying material such as underlay or tile glue, with the `coefficientKey` it comes from. Only accessory coefficients referenced by the formula count. The formula is evaluated with all of them set to zero for the `MATERIAL` part and once per accessory with only that coefficient restored; when the parts do not add up to the full cost, as in `(price + coef.a) * (1 + coef.b)`, the line is not split. Parts are rounded to cents and the rounding remainder goes to the last `ACCESSORY`; an accessory left at zero is dropped. A formula that uses `heatedFloor ? coef.a : coef.b` yields only the branch in effect. If any evaluation fails, the parts do not add up or a part would be negative, the line keeps a single `MATERIAL` component. Surface, object and option lines are not split.
|
|
207
208
|
- `LABOR`: the rule's `laborFormula`, which only `CATEGORY` and `MATERIAL_TYPE` rules can have. It is evaluated in `PARTIAL` only. Without a formula the component is 0 and the line gets `LABOR_RATE_MISSING`.
|
|
208
209
|
- `PREP`: surface preparation, from a `SURFACE` rule.
|
|
209
210
|
|
|
210
|
-
Every component is rounded to whole cents on its own and `amountCents` is the sum of the rounded components. A `FULL` line has a
|
|
211
|
+
Every component is rounded to whole cents on its own and `amountCents` is the sum of the rounded components. A `FULL` line has a `MATERIAL` component and its `ACCESSORY` components, if any. Each component carries the `ruleId` of the rule that produced it; ids are plain strings.
|
|
211
212
|
|
|
212
213
|
### Surface preparation
|
|
213
214
|
|
|
@@ -256,7 +257,7 @@ Availability by rule level:
|
|
|
256
257
|
- `CATEGORY` and `MATERIAL_TYPE`: room, product and object variables. `quantity` is available in cost and labor formulas, not in the quantity formula. `laborFormula` sees the same variables as `costFormula`.
|
|
257
258
|
- `SURFACE`: room and object variables plus `surfaceArea` and `puttyRequired`. `quantity` and the product variables are not available.
|
|
258
259
|
|
|
259
|
-
Coefficients are read as `coef.<key>`; keys come from the pricing data. The cost and labor formulas receive `quantity` unrounded, while the line shows it rounded to 3 decimals.
|
|
260
|
+
Coefficients are read as `coef.<key>`; keys come from the pricing data. `PricingData.accessories` is an optional map from coefficient key to a `{ en, uk }` name; the keys that exist in `coefficients` are the accompanying materials shown as `ACCESSORY` components, and the name is looked up by `coefficientKey`. The cost and labor formulas receive `quantity` unrounded, while the line shows it rounded to 3 decimals.
|
|
260
261
|
|
|
261
262
|
### Syntax
|
|
262
263
|
|
package/dist/index.cjs
CHANGED
|
@@ -322,7 +322,9 @@ function packageLine(configuration, item) {
|
|
|
322
322
|
}, item.priceCents / 100 * quantity);
|
|
323
323
|
}
|
|
324
324
|
function engineeringLines(configuration, data) {
|
|
325
|
-
|
|
325
|
+
const options = optionLines(configuration, data, "ENGINEERING");
|
|
326
|
+
if (configuration.scope === "PARTIAL") return options;
|
|
327
|
+
return [...data.packageItems.map((item) => packageLine(configuration, item)), ...options];
|
|
326
328
|
}
|
|
327
329
|
//#endregion
|
|
328
330
|
//#region src/formula/language.ts
|
|
@@ -361,121 +363,6 @@ const FORMULA_FUNCTION_TABLE = new Map([
|
|
|
361
363
|
].map((fn) => [fn.name, fn]));
|
|
362
364
|
const FORMULA_FUNCTIONS = [...FORMULA_FUNCTION_TABLE.keys()];
|
|
363
365
|
//#endregion
|
|
364
|
-
//#region src/formula/evaluate.ts
|
|
365
|
-
var EvaluationFailure = class {
|
|
366
|
-
error;
|
|
367
|
-
constructor(error) {
|
|
368
|
-
this.error = error;
|
|
369
|
-
}
|
|
370
|
-
};
|
|
371
|
-
function fail(node, code, params) {
|
|
372
|
-
throw new EvaluationFailure({
|
|
373
|
-
code,
|
|
374
|
-
position: node.start,
|
|
375
|
-
length: node.end - node.start,
|
|
376
|
-
...params === void 0 ? {} : { params }
|
|
377
|
-
});
|
|
378
|
-
}
|
|
379
|
-
function runtimeFail(node, reason, params) {
|
|
380
|
-
return fail(node, "FORMULA_RUNTIME_ERROR", {
|
|
381
|
-
...params,
|
|
382
|
-
reason
|
|
383
|
-
});
|
|
384
|
-
}
|
|
385
|
-
function mismatch(node, expected) {
|
|
386
|
-
return runtimeFail(node, "TYPE_MISMATCH", { expected });
|
|
387
|
-
}
|
|
388
|
-
function finite(node, value) {
|
|
389
|
-
return Number.isFinite(value) ? value : runtimeFail(node, "NOT_FINITE");
|
|
390
|
-
}
|
|
391
|
-
function asNumber(node, value) {
|
|
392
|
-
return typeof value === "number" ? value : mismatch(node, "number");
|
|
393
|
-
}
|
|
394
|
-
function asBoolean(node, value) {
|
|
395
|
-
return typeof value === "boolean" ? value : mismatch(node, "boolean");
|
|
396
|
-
}
|
|
397
|
-
function lookupVariable(node, scope) {
|
|
398
|
-
if (!Object.hasOwn(scope.variables, node.name)) return fail(node, "UNKNOWN_VARIABLE", { name: node.name });
|
|
399
|
-
return scope.variables[node.name];
|
|
400
|
-
}
|
|
401
|
-
function evaluateMember(node, scope) {
|
|
402
|
-
if (node.object.type !== "Identifier" || node.object.name !== "coef") return fail(node, "UNKNOWN_VARIABLE");
|
|
403
|
-
const key = node.property.name;
|
|
404
|
-
if (!Object.hasOwn(scope.coefficients, key)) return fail(node, "UNKNOWN_COEFFICIENT", { key });
|
|
405
|
-
return scope.coefficients[key];
|
|
406
|
-
}
|
|
407
|
-
function evaluateCall(node, scope) {
|
|
408
|
-
const spec = node.callee.type === "Identifier" ? FORMULA_FUNCTION_TABLE.get(node.callee.name) : void 0;
|
|
409
|
-
if (spec === void 0) return fail(node.callee, "UNKNOWN_FUNCTION");
|
|
410
|
-
if (node.args.length < spec.minArgs || node.args.length > spec.maxArgs) return mismatch(node, "argument count");
|
|
411
|
-
const args = node.args.map((arg) => asNumber(arg, walk(arg, scope)));
|
|
412
|
-
return finite(node, spec.apply(args));
|
|
413
|
-
}
|
|
414
|
-
function evaluateEquality(node, scope) {
|
|
415
|
-
const { left, right } = node;
|
|
416
|
-
const literal = [left, right].find((side) => side.type === "Literal" && typeof side.value === "string") ?? null;
|
|
417
|
-
if (literal !== null) {
|
|
418
|
-
const other = literal === left ? right : left;
|
|
419
|
-
const variable = other.type === "Identifier" ? lookupVariable(other, scope) : void 0;
|
|
420
|
-
if (typeof variable !== "string") return mismatch(other, "enum variable");
|
|
421
|
-
return node.operator === "==" ? variable === literal.value : variable !== literal.value;
|
|
422
|
-
}
|
|
423
|
-
const leftValue = walk(left, scope);
|
|
424
|
-
const rightValue = walk(right, scope);
|
|
425
|
-
if (typeof leftValue !== typeof rightValue) return mismatch(right, typeof leftValue);
|
|
426
|
-
return node.operator === "==" ? leftValue === rightValue : leftValue !== rightValue;
|
|
427
|
-
}
|
|
428
|
-
function evaluateBinary(node, scope) {
|
|
429
|
-
const { operator, left, right } = node;
|
|
430
|
-
if (operator === "==" || operator === "!=") return evaluateEquality(node, scope);
|
|
431
|
-
if (operator === "&&" || operator === "||") {
|
|
432
|
-
const leftValue = asBoolean(left, walk(left, scope));
|
|
433
|
-
if (operator === "&&" ? !leftValue : leftValue) return leftValue;
|
|
434
|
-
return asBoolean(right, walk(right, scope));
|
|
435
|
-
}
|
|
436
|
-
const a = asNumber(left, walk(left, scope));
|
|
437
|
-
const b = asNumber(right, walk(right, scope));
|
|
438
|
-
switch (operator) {
|
|
439
|
-
case "<": return a < b;
|
|
440
|
-
case ">": return a > b;
|
|
441
|
-
case "<=": return a <= b;
|
|
442
|
-
case ">=": return a >= b;
|
|
443
|
-
case "+": return finite(node, a + b);
|
|
444
|
-
case "-": return finite(node, a - b);
|
|
445
|
-
case "*": return finite(node, a * b);
|
|
446
|
-
case "/": return b === 0 ? runtimeFail(node, "DIVISION_BY_ZERO") : finite(node, a / b);
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
function walk(node, scope) {
|
|
450
|
-
switch (node.type) {
|
|
451
|
-
case "Literal": return typeof node.value === "string" ? mismatch(node, "number or boolean") : node.value;
|
|
452
|
-
case "Identifier": return lookupVariable(node, scope);
|
|
453
|
-
case "Member": return evaluateMember(node, scope);
|
|
454
|
-
case "Call": return evaluateCall(node, scope);
|
|
455
|
-
case "Unary": {
|
|
456
|
-
const argument = walk(node.argument, scope);
|
|
457
|
-
return node.operator === "!" ? !asBoolean(node.argument, argument) : finite(node, -asNumber(node.argument, argument));
|
|
458
|
-
}
|
|
459
|
-
case "Binary": return evaluateBinary(node, scope);
|
|
460
|
-
case "Conditional": return asBoolean(node.test, walk(node.test, scope)) ? walk(node.consequent, scope) : walk(node.alternate, scope);
|
|
461
|
-
default: return runtimeFail(node, "TYPE_MISMATCH", { expected: "known node" });
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
function evaluate(ast, scope) {
|
|
465
|
-
try {
|
|
466
|
-
return {
|
|
467
|
-
ok: true,
|
|
468
|
-
value: walk(ast, scope)
|
|
469
|
-
};
|
|
470
|
-
} catch (error) {
|
|
471
|
-
if (error instanceof EvaluationFailure) return {
|
|
472
|
-
ok: false,
|
|
473
|
-
error: error.error
|
|
474
|
-
};
|
|
475
|
-
throw error;
|
|
476
|
-
}
|
|
477
|
-
}
|
|
478
|
-
//#endregion
|
|
479
366
|
//#region src/formula/grammar.ts
|
|
480
367
|
const SEMICOLON_CODE = 59;
|
|
481
368
|
const REMOVED_BINARY_OPERATORS = [
|
|
@@ -724,6 +611,121 @@ function parse(source) {
|
|
|
724
611
|
}
|
|
725
612
|
}
|
|
726
613
|
//#endregion
|
|
614
|
+
//#region src/formula/evaluate.ts
|
|
615
|
+
var EvaluationFailure = class {
|
|
616
|
+
error;
|
|
617
|
+
constructor(error) {
|
|
618
|
+
this.error = error;
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
function fail(node, code, params) {
|
|
622
|
+
throw new EvaluationFailure({
|
|
623
|
+
code,
|
|
624
|
+
position: node.start,
|
|
625
|
+
length: node.end - node.start,
|
|
626
|
+
...params === void 0 ? {} : { params }
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
function runtimeFail(node, reason, params) {
|
|
630
|
+
return fail(node, "FORMULA_RUNTIME_ERROR", {
|
|
631
|
+
...params,
|
|
632
|
+
reason
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
function mismatch(node, expected) {
|
|
636
|
+
return runtimeFail(node, "TYPE_MISMATCH", { expected });
|
|
637
|
+
}
|
|
638
|
+
function finite(node, value) {
|
|
639
|
+
return Number.isFinite(value) ? value : runtimeFail(node, "NOT_FINITE");
|
|
640
|
+
}
|
|
641
|
+
function asNumber(node, value) {
|
|
642
|
+
return typeof value === "number" ? value : mismatch(node, "number");
|
|
643
|
+
}
|
|
644
|
+
function asBoolean(node, value) {
|
|
645
|
+
return typeof value === "boolean" ? value : mismatch(node, "boolean");
|
|
646
|
+
}
|
|
647
|
+
function lookupVariable(node, scope) {
|
|
648
|
+
if (!Object.hasOwn(scope.variables, node.name)) return fail(node, "UNKNOWN_VARIABLE", { name: node.name });
|
|
649
|
+
return scope.variables[node.name];
|
|
650
|
+
}
|
|
651
|
+
function evaluateMember(node, scope) {
|
|
652
|
+
if (node.object.type !== "Identifier" || node.object.name !== "coef") return fail(node, "UNKNOWN_VARIABLE");
|
|
653
|
+
const key = node.property.name;
|
|
654
|
+
if (!Object.hasOwn(scope.coefficients, key)) return fail(node, "UNKNOWN_COEFFICIENT", { key });
|
|
655
|
+
return scope.coefficients[key];
|
|
656
|
+
}
|
|
657
|
+
function evaluateCall(node, scope) {
|
|
658
|
+
const spec = node.callee.type === "Identifier" ? FORMULA_FUNCTION_TABLE.get(node.callee.name) : void 0;
|
|
659
|
+
if (spec === void 0) return fail(node.callee, "UNKNOWN_FUNCTION");
|
|
660
|
+
if (node.args.length < spec.minArgs || node.args.length > spec.maxArgs) return mismatch(node, "argument count");
|
|
661
|
+
const args = node.args.map((arg) => asNumber(arg, walk(arg, scope)));
|
|
662
|
+
return finite(node, spec.apply(args));
|
|
663
|
+
}
|
|
664
|
+
function evaluateEquality(node, scope) {
|
|
665
|
+
const { left, right } = node;
|
|
666
|
+
const literal = [left, right].find((side) => side.type === "Literal" && typeof side.value === "string") ?? null;
|
|
667
|
+
if (literal !== null) {
|
|
668
|
+
const other = literal === left ? right : left;
|
|
669
|
+
const variable = other.type === "Identifier" ? lookupVariable(other, scope) : void 0;
|
|
670
|
+
if (typeof variable !== "string") return mismatch(other, "enum variable");
|
|
671
|
+
return node.operator === "==" ? variable === literal.value : variable !== literal.value;
|
|
672
|
+
}
|
|
673
|
+
const leftValue = walk(left, scope);
|
|
674
|
+
const rightValue = walk(right, scope);
|
|
675
|
+
if (typeof leftValue !== typeof rightValue) return mismatch(right, typeof leftValue);
|
|
676
|
+
return node.operator === "==" ? leftValue === rightValue : leftValue !== rightValue;
|
|
677
|
+
}
|
|
678
|
+
function evaluateBinary(node, scope) {
|
|
679
|
+
const { operator, left, right } = node;
|
|
680
|
+
if (operator === "==" || operator === "!=") return evaluateEquality(node, scope);
|
|
681
|
+
if (operator === "&&" || operator === "||") {
|
|
682
|
+
const leftValue = asBoolean(left, walk(left, scope));
|
|
683
|
+
if (operator === "&&" ? !leftValue : leftValue) return leftValue;
|
|
684
|
+
return asBoolean(right, walk(right, scope));
|
|
685
|
+
}
|
|
686
|
+
const a = asNumber(left, walk(left, scope));
|
|
687
|
+
const b = asNumber(right, walk(right, scope));
|
|
688
|
+
switch (operator) {
|
|
689
|
+
case "<": return a < b;
|
|
690
|
+
case ">": return a > b;
|
|
691
|
+
case "<=": return a <= b;
|
|
692
|
+
case ">=": return a >= b;
|
|
693
|
+
case "+": return finite(node, a + b);
|
|
694
|
+
case "-": return finite(node, a - b);
|
|
695
|
+
case "*": return finite(node, a * b);
|
|
696
|
+
case "/": return b === 0 ? runtimeFail(node, "DIVISION_BY_ZERO") : finite(node, a / b);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
function walk(node, scope) {
|
|
700
|
+
switch (node.type) {
|
|
701
|
+
case "Literal": return typeof node.value === "string" ? mismatch(node, "number or boolean") : node.value;
|
|
702
|
+
case "Identifier": return lookupVariable(node, scope);
|
|
703
|
+
case "Member": return evaluateMember(node, scope);
|
|
704
|
+
case "Call": return evaluateCall(node, scope);
|
|
705
|
+
case "Unary": {
|
|
706
|
+
const argument = walk(node.argument, scope);
|
|
707
|
+
return node.operator === "!" ? !asBoolean(node.argument, argument) : finite(node, -asNumber(node.argument, argument));
|
|
708
|
+
}
|
|
709
|
+
case "Binary": return evaluateBinary(node, scope);
|
|
710
|
+
case "Conditional": return asBoolean(node.test, walk(node.test, scope)) ? walk(node.consequent, scope) : walk(node.alternate, scope);
|
|
711
|
+
default: return runtimeFail(node, "TYPE_MISMATCH", { expected: "known node" });
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
function evaluate(ast, scope) {
|
|
715
|
+
try {
|
|
716
|
+
return {
|
|
717
|
+
ok: true,
|
|
718
|
+
value: walk(ast, scope)
|
|
719
|
+
};
|
|
720
|
+
} catch (error) {
|
|
721
|
+
if (error instanceof EvaluationFailure) return {
|
|
722
|
+
ok: false,
|
|
723
|
+
error: error.error
|
|
724
|
+
};
|
|
725
|
+
throw error;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
//#endregion
|
|
727
729
|
//#region src/estimate/run-formula.ts
|
|
728
730
|
function message(code, params) {
|
|
729
731
|
return params === void 0 ? { code } : {
|
|
@@ -833,11 +835,108 @@ function applySurfacePrep(data, room, lines, objectScope) {
|
|
|
833
835
|
}
|
|
834
836
|
//#endregion
|
|
835
837
|
//#region src/estimate/materials.ts
|
|
838
|
+
const ADDITIVITY_TOLERANCE = 1e-9;
|
|
836
839
|
const isMaterialRule = (rule) => rule.quantityFormula !== null;
|
|
837
840
|
function selectRule(data, categoryId, materialTypeId) {
|
|
838
841
|
const rules = data.rules.filter(isMaterialRule);
|
|
839
842
|
return rules.find((rule) => rule.level === "MATERIAL_TYPE" && rule.categoryId === categoryId && rule.materialTypeId === materialTypeId) ?? rules.find((rule) => rule.level === "CATEGORY" && rule.categoryId === categoryId);
|
|
840
843
|
}
|
|
844
|
+
function referencedCoefficients(source) {
|
|
845
|
+
const keys = /* @__PURE__ */ new Set();
|
|
846
|
+
const parsed = parse(source);
|
|
847
|
+
if (!parsed.ok) return keys;
|
|
848
|
+
const visit = (node) => {
|
|
849
|
+
switch (node.type) {
|
|
850
|
+
case "Member":
|
|
851
|
+
if (node.object.type === "Identifier" && node.object.name === "coef") keys.add(node.property.name);
|
|
852
|
+
visit(node.object);
|
|
853
|
+
return;
|
|
854
|
+
case "Call":
|
|
855
|
+
visit(node.callee);
|
|
856
|
+
node.args.forEach(visit);
|
|
857
|
+
return;
|
|
858
|
+
case "Unary":
|
|
859
|
+
visit(node.argument);
|
|
860
|
+
return;
|
|
861
|
+
case "Binary":
|
|
862
|
+
visit(node.left);
|
|
863
|
+
visit(node.right);
|
|
864
|
+
return;
|
|
865
|
+
case "Conditional":
|
|
866
|
+
visit(node.test);
|
|
867
|
+
visit(node.consequent);
|
|
868
|
+
visit(node.alternate);
|
|
869
|
+
return;
|
|
870
|
+
default: return;
|
|
871
|
+
}
|
|
872
|
+
};
|
|
873
|
+
visit(parsed.ast);
|
|
874
|
+
return keys;
|
|
875
|
+
}
|
|
876
|
+
function splitAccessories(rule, data, variables, fullValue, fullCents) {
|
|
877
|
+
const whole = [{
|
|
878
|
+
kind: "MATERIAL",
|
|
879
|
+
ruleId: rule.id,
|
|
880
|
+
amountCents: fullCents
|
|
881
|
+
}];
|
|
882
|
+
const accessories = data.accessories ?? {};
|
|
883
|
+
const keys = [...referencedCoefficients(rule.costFormula)].filter((key) => Object.hasOwn(accessories, key) && Object.hasOwn(data.coefficients, key));
|
|
884
|
+
if (keys.length === 0) return whole;
|
|
885
|
+
const zeroed = Object.fromEntries(keys.map((key) => [key, 0]));
|
|
886
|
+
const costValue = (overrides) => {
|
|
887
|
+
const outcome = runNumericFormula(rule.costFormula, {
|
|
888
|
+
variables,
|
|
889
|
+
coefficients: {
|
|
890
|
+
...data.coefficients,
|
|
891
|
+
...overrides
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
return outcome.ok ? outcome.value : null;
|
|
895
|
+
};
|
|
896
|
+
const productValue = costValue(zeroed);
|
|
897
|
+
if (productValue === null) return whole;
|
|
898
|
+
const productCents = componentCents(productValue);
|
|
899
|
+
if (productCents === null) return whole;
|
|
900
|
+
const parts = [];
|
|
901
|
+
for (const key of keys) {
|
|
902
|
+
const withKey = costValue({
|
|
903
|
+
...zeroed,
|
|
904
|
+
[key]: data.coefficients[key]
|
|
905
|
+
});
|
|
906
|
+
if (withKey === null || withKey < productValue) return whole;
|
|
907
|
+
parts.push({
|
|
908
|
+
key,
|
|
909
|
+
value: withKey - productValue
|
|
910
|
+
});
|
|
911
|
+
}
|
|
912
|
+
const partsValue = parts.reduce((sum, part) => sum + part.value, 0);
|
|
913
|
+
if (Math.abs(productValue + partsValue - fullValue) > ADDITIVITY_TOLERANCE * Math.max(1, Math.abs(fullValue))) return whole;
|
|
914
|
+
const priced = [];
|
|
915
|
+
for (const part of parts) {
|
|
916
|
+
const cents = componentCents(part.value);
|
|
917
|
+
if (cents === null) return whole;
|
|
918
|
+
priced.push({
|
|
919
|
+
key: part.key,
|
|
920
|
+
cents
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
const remainder = fullCents - productCents - priced.reduce((sum, part) => sum + part.cents, 0);
|
|
924
|
+
const last = priced[priced.length - 1];
|
|
925
|
+
last.cents += remainder;
|
|
926
|
+
if (last.cents < 0) return whole;
|
|
927
|
+
const kept = priced.filter((part) => part.cents > 0);
|
|
928
|
+
if (kept.length === 0) return whole;
|
|
929
|
+
return [{
|
|
930
|
+
kind: "MATERIAL",
|
|
931
|
+
ruleId: rule.id,
|
|
932
|
+
amountCents: productCents
|
|
933
|
+
}, ...kept.map((part) => ({
|
|
934
|
+
kind: "ACCESSORY",
|
|
935
|
+
ruleId: rule.id,
|
|
936
|
+
coefficientKey: part.key,
|
|
937
|
+
amountCents: part.cents
|
|
938
|
+
}))];
|
|
939
|
+
}
|
|
841
940
|
function materialLine(configuration, data, room, selection, objectScope) {
|
|
842
941
|
const { categoryId } = selection;
|
|
843
942
|
const productId = selection.productId;
|
|
@@ -901,11 +1000,10 @@ function materialLine(configuration, data, room, selection, objectScope) {
|
|
|
901
1000
|
if (!cost.ok) return failedLine(ruled, "ERROR", cost.error);
|
|
902
1001
|
const materialCents = componentCents(cost.value);
|
|
903
1002
|
if (materialCents === null) return failedLine(ruled, "ERROR", outOfRange());
|
|
904
|
-
const components =
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
}];
|
|
1003
|
+
const components = splitAccessories(rule, data, {
|
|
1004
|
+
...variables,
|
|
1005
|
+
quantity: quantity.value
|
|
1006
|
+
}, cost.value, materialCents);
|
|
909
1007
|
const lineWarnings = [...warnings];
|
|
910
1008
|
if (configuration.scope === "PARTIAL") {
|
|
911
1009
|
let laborCents = 0;
|
|
@@ -1025,6 +1123,7 @@ const SectionSchema = zod.z.enum([
|
|
|
1025
1123
|
]);
|
|
1026
1124
|
const LineComponentKindSchema = zod.z.enum([
|
|
1027
1125
|
"MATERIAL",
|
|
1126
|
+
"ACCESSORY",
|
|
1028
1127
|
"LABOR",
|
|
1029
1128
|
"PREP"
|
|
1030
1129
|
]);
|
|
@@ -1210,6 +1309,7 @@ const PricingDataSchema = zod.z.object({
|
|
|
1210
1309
|
exchangeRate: ExchangeRateSchema.nullable(),
|
|
1211
1310
|
limits: PricingLimitsSchema,
|
|
1212
1311
|
coefficients: zod.z.record(zod.z.string(), zod.z.number()),
|
|
1312
|
+
accessories: zod.z.record(zod.z.string(), LocalizedTextSchema).optional(),
|
|
1213
1313
|
rules: zod.z.array(PricingRuleSchema),
|
|
1214
1314
|
roomTypes: zod.z.array(PricingRoomTypeSchema),
|
|
1215
1315
|
categories: zod.z.array(PricingCategorySchema),
|
|
@@ -1228,7 +1328,11 @@ const LineComponentSchema = zod.z.object({
|
|
|
1228
1328
|
kind: LineComponentKindSchema,
|
|
1229
1329
|
ruleId: zod.z.string(),
|
|
1230
1330
|
amountCents: zod.z.number().int().min(0),
|
|
1231
|
-
sharedWithCategoryId: zod.z.string().optional()
|
|
1331
|
+
sharedWithCategoryId: zod.z.string().optional(),
|
|
1332
|
+
coefficientKey: zod.z.string().optional()
|
|
1333
|
+
}).refine((component) => component.kind === "ACCESSORY" === (component.coefficientKey !== void 0), {
|
|
1334
|
+
path: ["coefficientKey"],
|
|
1335
|
+
message: "coefficientKey is required for ACCESSORY and only for it"
|
|
1232
1336
|
});
|
|
1233
1337
|
const EstimateLineSchema = zod.z.object({
|
|
1234
1338
|
id: zod.z.string(),
|
package/dist/index.d.cts
CHANGED
|
@@ -82,6 +82,7 @@ export declare const SectionSchema: z.ZodEnum<{
|
|
|
82
82
|
export type Section = z.infer<typeof SectionSchema>;
|
|
83
83
|
export declare const LineComponentKindSchema: z.ZodEnum<{
|
|
84
84
|
MATERIAL: "MATERIAL";
|
|
85
|
+
ACCESSORY: "ACCESSORY";
|
|
85
86
|
LABOR: "LABOR";
|
|
86
87
|
PREP: "PREP";
|
|
87
88
|
}>;
|
|
@@ -194,12 +195,14 @@ export type EstimateMessage = z.infer<typeof EstimateMessageSchema>;
|
|
|
194
195
|
export declare const LineComponentSchema: z.ZodObject<{
|
|
195
196
|
kind: z.ZodEnum<{
|
|
196
197
|
MATERIAL: "MATERIAL";
|
|
198
|
+
ACCESSORY: "ACCESSORY";
|
|
197
199
|
LABOR: "LABOR";
|
|
198
200
|
PREP: "PREP";
|
|
199
201
|
}>;
|
|
200
202
|
ruleId: z.ZodString;
|
|
201
203
|
amountCents: z.ZodNumber;
|
|
202
204
|
sharedWithCategoryId: z.ZodOptional<z.ZodString>;
|
|
205
|
+
coefficientKey: z.ZodOptional<z.ZodString>;
|
|
203
206
|
}, z.core.$strip>;
|
|
204
207
|
export type LineComponent = z.infer<typeof LineComponentSchema>;
|
|
205
208
|
export declare const EstimateLineSchema: z.ZodObject<{
|
|
@@ -232,12 +235,14 @@ export declare const EstimateLineSchema: z.ZodObject<{
|
|
|
232
235
|
components: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
233
236
|
kind: z.ZodEnum<{
|
|
234
237
|
MATERIAL: "MATERIAL";
|
|
238
|
+
ACCESSORY: "ACCESSORY";
|
|
235
239
|
LABOR: "LABOR";
|
|
236
240
|
PREP: "PREP";
|
|
237
241
|
}>;
|
|
238
242
|
ruleId: z.ZodString;
|
|
239
243
|
amountCents: z.ZodNumber;
|
|
240
244
|
sharedWithCategoryId: z.ZodOptional<z.ZodString>;
|
|
245
|
+
coefficientKey: z.ZodOptional<z.ZodString>;
|
|
241
246
|
}, z.core.$strip>>>;
|
|
242
247
|
warnings: z.ZodArray<z.ZodObject<{
|
|
243
248
|
code: z.ZodString;
|
|
@@ -319,12 +324,14 @@ export declare const EstimateSchema: z.ZodObject<{
|
|
|
319
324
|
components: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
320
325
|
kind: z.ZodEnum<{
|
|
321
326
|
MATERIAL: "MATERIAL";
|
|
327
|
+
ACCESSORY: "ACCESSORY";
|
|
322
328
|
LABOR: "LABOR";
|
|
323
329
|
PREP: "PREP";
|
|
324
330
|
}>;
|
|
325
331
|
ruleId: z.ZodString;
|
|
326
332
|
amountCents: z.ZodNumber;
|
|
327
333
|
sharedWithCategoryId: z.ZodOptional<z.ZodString>;
|
|
334
|
+
coefficientKey: z.ZodOptional<z.ZodString>;
|
|
328
335
|
}, z.core.$strip>>>;
|
|
329
336
|
warnings: z.ZodArray<z.ZodObject<{
|
|
330
337
|
code: z.ZodString;
|
|
@@ -589,6 +596,10 @@ export declare const PricingDataSchema: z.ZodObject<{
|
|
|
589
596
|
missingAreaHintSqm: z.ZodNumber;
|
|
590
597
|
}, z.core.$strip>;
|
|
591
598
|
coefficients: z.ZodRecord<z.ZodString, z.ZodNumber>;
|
|
599
|
+
accessories: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
600
|
+
en: z.ZodString;
|
|
601
|
+
uk: z.ZodString;
|
|
602
|
+
}, z.core.$strip>>>;
|
|
592
603
|
rules: z.ZodArray<z.ZodObject<{
|
|
593
604
|
id: z.ZodString;
|
|
594
605
|
level: z.ZodEnum<{
|
package/dist/index.d.ts
CHANGED
|
@@ -82,6 +82,7 @@ export declare const SectionSchema: z.ZodEnum<{
|
|
|
82
82
|
export type Section = z.infer<typeof SectionSchema>;
|
|
83
83
|
export declare const LineComponentKindSchema: z.ZodEnum<{
|
|
84
84
|
MATERIAL: "MATERIAL";
|
|
85
|
+
ACCESSORY: "ACCESSORY";
|
|
85
86
|
LABOR: "LABOR";
|
|
86
87
|
PREP: "PREP";
|
|
87
88
|
}>;
|
|
@@ -194,12 +195,14 @@ export type EstimateMessage = z.infer<typeof EstimateMessageSchema>;
|
|
|
194
195
|
export declare const LineComponentSchema: z.ZodObject<{
|
|
195
196
|
kind: z.ZodEnum<{
|
|
196
197
|
MATERIAL: "MATERIAL";
|
|
198
|
+
ACCESSORY: "ACCESSORY";
|
|
197
199
|
LABOR: "LABOR";
|
|
198
200
|
PREP: "PREP";
|
|
199
201
|
}>;
|
|
200
202
|
ruleId: z.ZodString;
|
|
201
203
|
amountCents: z.ZodNumber;
|
|
202
204
|
sharedWithCategoryId: z.ZodOptional<z.ZodString>;
|
|
205
|
+
coefficientKey: z.ZodOptional<z.ZodString>;
|
|
203
206
|
}, z.core.$strip>;
|
|
204
207
|
export type LineComponent = z.infer<typeof LineComponentSchema>;
|
|
205
208
|
export declare const EstimateLineSchema: z.ZodObject<{
|
|
@@ -232,12 +235,14 @@ export declare const EstimateLineSchema: z.ZodObject<{
|
|
|
232
235
|
components: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
233
236
|
kind: z.ZodEnum<{
|
|
234
237
|
MATERIAL: "MATERIAL";
|
|
238
|
+
ACCESSORY: "ACCESSORY";
|
|
235
239
|
LABOR: "LABOR";
|
|
236
240
|
PREP: "PREP";
|
|
237
241
|
}>;
|
|
238
242
|
ruleId: z.ZodString;
|
|
239
243
|
amountCents: z.ZodNumber;
|
|
240
244
|
sharedWithCategoryId: z.ZodOptional<z.ZodString>;
|
|
245
|
+
coefficientKey: z.ZodOptional<z.ZodString>;
|
|
241
246
|
}, z.core.$strip>>>;
|
|
242
247
|
warnings: z.ZodArray<z.ZodObject<{
|
|
243
248
|
code: z.ZodString;
|
|
@@ -319,12 +324,14 @@ export declare const EstimateSchema: z.ZodObject<{
|
|
|
319
324
|
components: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
320
325
|
kind: z.ZodEnum<{
|
|
321
326
|
MATERIAL: "MATERIAL";
|
|
327
|
+
ACCESSORY: "ACCESSORY";
|
|
322
328
|
LABOR: "LABOR";
|
|
323
329
|
PREP: "PREP";
|
|
324
330
|
}>;
|
|
325
331
|
ruleId: z.ZodString;
|
|
326
332
|
amountCents: z.ZodNumber;
|
|
327
333
|
sharedWithCategoryId: z.ZodOptional<z.ZodString>;
|
|
334
|
+
coefficientKey: z.ZodOptional<z.ZodString>;
|
|
328
335
|
}, z.core.$strip>>>;
|
|
329
336
|
warnings: z.ZodArray<z.ZodObject<{
|
|
330
337
|
code: z.ZodString;
|
|
@@ -589,6 +596,10 @@ export declare const PricingDataSchema: z.ZodObject<{
|
|
|
589
596
|
missingAreaHintSqm: z.ZodNumber;
|
|
590
597
|
}, z.core.$strip>;
|
|
591
598
|
coefficients: z.ZodRecord<z.ZodString, z.ZodNumber>;
|
|
599
|
+
accessories: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
600
|
+
en: z.ZodString;
|
|
601
|
+
uk: z.ZodString;
|
|
602
|
+
}, z.core.$strip>>>;
|
|
592
603
|
rules: z.ZodArray<z.ZodObject<{
|
|
593
604
|
id: z.ZodString;
|
|
594
605
|
level: z.ZodEnum<{
|
package/dist/index.js
CHANGED
|
@@ -297,7 +297,9 @@ function packageLine(configuration, item) {
|
|
|
297
297
|
}, item.priceCents / 100 * quantity);
|
|
298
298
|
}
|
|
299
299
|
function engineeringLines(configuration, data) {
|
|
300
|
-
|
|
300
|
+
const options = optionLines(configuration, data, "ENGINEERING");
|
|
301
|
+
if (configuration.scope === "PARTIAL") return options;
|
|
302
|
+
return [...data.packageItems.map((item) => packageLine(configuration, item)), ...options];
|
|
301
303
|
}
|
|
302
304
|
//#endregion
|
|
303
305
|
//#region src/formula/language.ts
|
|
@@ -336,121 +338,6 @@ const FORMULA_FUNCTION_TABLE = new Map([
|
|
|
336
338
|
].map((fn) => [fn.name, fn]));
|
|
337
339
|
const FORMULA_FUNCTIONS = [...FORMULA_FUNCTION_TABLE.keys()];
|
|
338
340
|
//#endregion
|
|
339
|
-
//#region src/formula/evaluate.ts
|
|
340
|
-
var EvaluationFailure = class {
|
|
341
|
-
error;
|
|
342
|
-
constructor(error) {
|
|
343
|
-
this.error = error;
|
|
344
|
-
}
|
|
345
|
-
};
|
|
346
|
-
function fail(node, code, params) {
|
|
347
|
-
throw new EvaluationFailure({
|
|
348
|
-
code,
|
|
349
|
-
position: node.start,
|
|
350
|
-
length: node.end - node.start,
|
|
351
|
-
...params === void 0 ? {} : { params }
|
|
352
|
-
});
|
|
353
|
-
}
|
|
354
|
-
function runtimeFail(node, reason, params) {
|
|
355
|
-
return fail(node, "FORMULA_RUNTIME_ERROR", {
|
|
356
|
-
...params,
|
|
357
|
-
reason
|
|
358
|
-
});
|
|
359
|
-
}
|
|
360
|
-
function mismatch(node, expected) {
|
|
361
|
-
return runtimeFail(node, "TYPE_MISMATCH", { expected });
|
|
362
|
-
}
|
|
363
|
-
function finite(node, value) {
|
|
364
|
-
return Number.isFinite(value) ? value : runtimeFail(node, "NOT_FINITE");
|
|
365
|
-
}
|
|
366
|
-
function asNumber(node, value) {
|
|
367
|
-
return typeof value === "number" ? value : mismatch(node, "number");
|
|
368
|
-
}
|
|
369
|
-
function asBoolean(node, value) {
|
|
370
|
-
return typeof value === "boolean" ? value : mismatch(node, "boolean");
|
|
371
|
-
}
|
|
372
|
-
function lookupVariable(node, scope) {
|
|
373
|
-
if (!Object.hasOwn(scope.variables, node.name)) return fail(node, "UNKNOWN_VARIABLE", { name: node.name });
|
|
374
|
-
return scope.variables[node.name];
|
|
375
|
-
}
|
|
376
|
-
function evaluateMember(node, scope) {
|
|
377
|
-
if (node.object.type !== "Identifier" || node.object.name !== "coef") return fail(node, "UNKNOWN_VARIABLE");
|
|
378
|
-
const key = node.property.name;
|
|
379
|
-
if (!Object.hasOwn(scope.coefficients, key)) return fail(node, "UNKNOWN_COEFFICIENT", { key });
|
|
380
|
-
return scope.coefficients[key];
|
|
381
|
-
}
|
|
382
|
-
function evaluateCall(node, scope) {
|
|
383
|
-
const spec = node.callee.type === "Identifier" ? FORMULA_FUNCTION_TABLE.get(node.callee.name) : void 0;
|
|
384
|
-
if (spec === void 0) return fail(node.callee, "UNKNOWN_FUNCTION");
|
|
385
|
-
if (node.args.length < spec.minArgs || node.args.length > spec.maxArgs) return mismatch(node, "argument count");
|
|
386
|
-
const args = node.args.map((arg) => asNumber(arg, walk(arg, scope)));
|
|
387
|
-
return finite(node, spec.apply(args));
|
|
388
|
-
}
|
|
389
|
-
function evaluateEquality(node, scope) {
|
|
390
|
-
const { left, right } = node;
|
|
391
|
-
const literal = [left, right].find((side) => side.type === "Literal" && typeof side.value === "string") ?? null;
|
|
392
|
-
if (literal !== null) {
|
|
393
|
-
const other = literal === left ? right : left;
|
|
394
|
-
const variable = other.type === "Identifier" ? lookupVariable(other, scope) : void 0;
|
|
395
|
-
if (typeof variable !== "string") return mismatch(other, "enum variable");
|
|
396
|
-
return node.operator === "==" ? variable === literal.value : variable !== literal.value;
|
|
397
|
-
}
|
|
398
|
-
const leftValue = walk(left, scope);
|
|
399
|
-
const rightValue = walk(right, scope);
|
|
400
|
-
if (typeof leftValue !== typeof rightValue) return mismatch(right, typeof leftValue);
|
|
401
|
-
return node.operator === "==" ? leftValue === rightValue : leftValue !== rightValue;
|
|
402
|
-
}
|
|
403
|
-
function evaluateBinary(node, scope) {
|
|
404
|
-
const { operator, left, right } = node;
|
|
405
|
-
if (operator === "==" || operator === "!=") return evaluateEquality(node, scope);
|
|
406
|
-
if (operator === "&&" || operator === "||") {
|
|
407
|
-
const leftValue = asBoolean(left, walk(left, scope));
|
|
408
|
-
if (operator === "&&" ? !leftValue : leftValue) return leftValue;
|
|
409
|
-
return asBoolean(right, walk(right, scope));
|
|
410
|
-
}
|
|
411
|
-
const a = asNumber(left, walk(left, scope));
|
|
412
|
-
const b = asNumber(right, walk(right, scope));
|
|
413
|
-
switch (operator) {
|
|
414
|
-
case "<": return a < b;
|
|
415
|
-
case ">": return a > b;
|
|
416
|
-
case "<=": return a <= b;
|
|
417
|
-
case ">=": return a >= b;
|
|
418
|
-
case "+": return finite(node, a + b);
|
|
419
|
-
case "-": return finite(node, a - b);
|
|
420
|
-
case "*": return finite(node, a * b);
|
|
421
|
-
case "/": return b === 0 ? runtimeFail(node, "DIVISION_BY_ZERO") : finite(node, a / b);
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
function walk(node, scope) {
|
|
425
|
-
switch (node.type) {
|
|
426
|
-
case "Literal": return typeof node.value === "string" ? mismatch(node, "number or boolean") : node.value;
|
|
427
|
-
case "Identifier": return lookupVariable(node, scope);
|
|
428
|
-
case "Member": return evaluateMember(node, scope);
|
|
429
|
-
case "Call": return evaluateCall(node, scope);
|
|
430
|
-
case "Unary": {
|
|
431
|
-
const argument = walk(node.argument, scope);
|
|
432
|
-
return node.operator === "!" ? !asBoolean(node.argument, argument) : finite(node, -asNumber(node.argument, argument));
|
|
433
|
-
}
|
|
434
|
-
case "Binary": return evaluateBinary(node, scope);
|
|
435
|
-
case "Conditional": return asBoolean(node.test, walk(node.test, scope)) ? walk(node.consequent, scope) : walk(node.alternate, scope);
|
|
436
|
-
default: return runtimeFail(node, "TYPE_MISMATCH", { expected: "known node" });
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
function evaluate(ast, scope) {
|
|
440
|
-
try {
|
|
441
|
-
return {
|
|
442
|
-
ok: true,
|
|
443
|
-
value: walk(ast, scope)
|
|
444
|
-
};
|
|
445
|
-
} catch (error) {
|
|
446
|
-
if (error instanceof EvaluationFailure) return {
|
|
447
|
-
ok: false,
|
|
448
|
-
error: error.error
|
|
449
|
-
};
|
|
450
|
-
throw error;
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
//#endregion
|
|
454
341
|
//#region src/formula/grammar.ts
|
|
455
342
|
const SEMICOLON_CODE = 59;
|
|
456
343
|
const REMOVED_BINARY_OPERATORS = [
|
|
@@ -699,6 +586,121 @@ function parse(source) {
|
|
|
699
586
|
}
|
|
700
587
|
}
|
|
701
588
|
//#endregion
|
|
589
|
+
//#region src/formula/evaluate.ts
|
|
590
|
+
var EvaluationFailure = class {
|
|
591
|
+
error;
|
|
592
|
+
constructor(error) {
|
|
593
|
+
this.error = error;
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
function fail(node, code, params) {
|
|
597
|
+
throw new EvaluationFailure({
|
|
598
|
+
code,
|
|
599
|
+
position: node.start,
|
|
600
|
+
length: node.end - node.start,
|
|
601
|
+
...params === void 0 ? {} : { params }
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
function runtimeFail(node, reason, params) {
|
|
605
|
+
return fail(node, "FORMULA_RUNTIME_ERROR", {
|
|
606
|
+
...params,
|
|
607
|
+
reason
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
function mismatch(node, expected) {
|
|
611
|
+
return runtimeFail(node, "TYPE_MISMATCH", { expected });
|
|
612
|
+
}
|
|
613
|
+
function finite(node, value) {
|
|
614
|
+
return Number.isFinite(value) ? value : runtimeFail(node, "NOT_FINITE");
|
|
615
|
+
}
|
|
616
|
+
function asNumber(node, value) {
|
|
617
|
+
return typeof value === "number" ? value : mismatch(node, "number");
|
|
618
|
+
}
|
|
619
|
+
function asBoolean(node, value) {
|
|
620
|
+
return typeof value === "boolean" ? value : mismatch(node, "boolean");
|
|
621
|
+
}
|
|
622
|
+
function lookupVariable(node, scope) {
|
|
623
|
+
if (!Object.hasOwn(scope.variables, node.name)) return fail(node, "UNKNOWN_VARIABLE", { name: node.name });
|
|
624
|
+
return scope.variables[node.name];
|
|
625
|
+
}
|
|
626
|
+
function evaluateMember(node, scope) {
|
|
627
|
+
if (node.object.type !== "Identifier" || node.object.name !== "coef") return fail(node, "UNKNOWN_VARIABLE");
|
|
628
|
+
const key = node.property.name;
|
|
629
|
+
if (!Object.hasOwn(scope.coefficients, key)) return fail(node, "UNKNOWN_COEFFICIENT", { key });
|
|
630
|
+
return scope.coefficients[key];
|
|
631
|
+
}
|
|
632
|
+
function evaluateCall(node, scope) {
|
|
633
|
+
const spec = node.callee.type === "Identifier" ? FORMULA_FUNCTION_TABLE.get(node.callee.name) : void 0;
|
|
634
|
+
if (spec === void 0) return fail(node.callee, "UNKNOWN_FUNCTION");
|
|
635
|
+
if (node.args.length < spec.minArgs || node.args.length > spec.maxArgs) return mismatch(node, "argument count");
|
|
636
|
+
const args = node.args.map((arg) => asNumber(arg, walk(arg, scope)));
|
|
637
|
+
return finite(node, spec.apply(args));
|
|
638
|
+
}
|
|
639
|
+
function evaluateEquality(node, scope) {
|
|
640
|
+
const { left, right } = node;
|
|
641
|
+
const literal = [left, right].find((side) => side.type === "Literal" && typeof side.value === "string") ?? null;
|
|
642
|
+
if (literal !== null) {
|
|
643
|
+
const other = literal === left ? right : left;
|
|
644
|
+
const variable = other.type === "Identifier" ? lookupVariable(other, scope) : void 0;
|
|
645
|
+
if (typeof variable !== "string") return mismatch(other, "enum variable");
|
|
646
|
+
return node.operator === "==" ? variable === literal.value : variable !== literal.value;
|
|
647
|
+
}
|
|
648
|
+
const leftValue = walk(left, scope);
|
|
649
|
+
const rightValue = walk(right, scope);
|
|
650
|
+
if (typeof leftValue !== typeof rightValue) return mismatch(right, typeof leftValue);
|
|
651
|
+
return node.operator === "==" ? leftValue === rightValue : leftValue !== rightValue;
|
|
652
|
+
}
|
|
653
|
+
function evaluateBinary(node, scope) {
|
|
654
|
+
const { operator, left, right } = node;
|
|
655
|
+
if (operator === "==" || operator === "!=") return evaluateEquality(node, scope);
|
|
656
|
+
if (operator === "&&" || operator === "||") {
|
|
657
|
+
const leftValue = asBoolean(left, walk(left, scope));
|
|
658
|
+
if (operator === "&&" ? !leftValue : leftValue) return leftValue;
|
|
659
|
+
return asBoolean(right, walk(right, scope));
|
|
660
|
+
}
|
|
661
|
+
const a = asNumber(left, walk(left, scope));
|
|
662
|
+
const b = asNumber(right, walk(right, scope));
|
|
663
|
+
switch (operator) {
|
|
664
|
+
case "<": return a < b;
|
|
665
|
+
case ">": return a > b;
|
|
666
|
+
case "<=": return a <= b;
|
|
667
|
+
case ">=": return a >= b;
|
|
668
|
+
case "+": return finite(node, a + b);
|
|
669
|
+
case "-": return finite(node, a - b);
|
|
670
|
+
case "*": return finite(node, a * b);
|
|
671
|
+
case "/": return b === 0 ? runtimeFail(node, "DIVISION_BY_ZERO") : finite(node, a / b);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
function walk(node, scope) {
|
|
675
|
+
switch (node.type) {
|
|
676
|
+
case "Literal": return typeof node.value === "string" ? mismatch(node, "number or boolean") : node.value;
|
|
677
|
+
case "Identifier": return lookupVariable(node, scope);
|
|
678
|
+
case "Member": return evaluateMember(node, scope);
|
|
679
|
+
case "Call": return evaluateCall(node, scope);
|
|
680
|
+
case "Unary": {
|
|
681
|
+
const argument = walk(node.argument, scope);
|
|
682
|
+
return node.operator === "!" ? !asBoolean(node.argument, argument) : finite(node, -asNumber(node.argument, argument));
|
|
683
|
+
}
|
|
684
|
+
case "Binary": return evaluateBinary(node, scope);
|
|
685
|
+
case "Conditional": return asBoolean(node.test, walk(node.test, scope)) ? walk(node.consequent, scope) : walk(node.alternate, scope);
|
|
686
|
+
default: return runtimeFail(node, "TYPE_MISMATCH", { expected: "known node" });
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
function evaluate(ast, scope) {
|
|
690
|
+
try {
|
|
691
|
+
return {
|
|
692
|
+
ok: true,
|
|
693
|
+
value: walk(ast, scope)
|
|
694
|
+
};
|
|
695
|
+
} catch (error) {
|
|
696
|
+
if (error instanceof EvaluationFailure) return {
|
|
697
|
+
ok: false,
|
|
698
|
+
error: error.error
|
|
699
|
+
};
|
|
700
|
+
throw error;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
//#endregion
|
|
702
704
|
//#region src/estimate/run-formula.ts
|
|
703
705
|
function message(code, params) {
|
|
704
706
|
return params === void 0 ? { code } : {
|
|
@@ -808,11 +810,108 @@ function applySurfacePrep(data, room, lines, objectScope) {
|
|
|
808
810
|
}
|
|
809
811
|
//#endregion
|
|
810
812
|
//#region src/estimate/materials.ts
|
|
813
|
+
const ADDITIVITY_TOLERANCE = 1e-9;
|
|
811
814
|
const isMaterialRule = (rule) => rule.quantityFormula !== null;
|
|
812
815
|
function selectRule(data, categoryId, materialTypeId) {
|
|
813
816
|
const rules = data.rules.filter(isMaterialRule);
|
|
814
817
|
return rules.find((rule) => rule.level === "MATERIAL_TYPE" && rule.categoryId === categoryId && rule.materialTypeId === materialTypeId) ?? rules.find((rule) => rule.level === "CATEGORY" && rule.categoryId === categoryId);
|
|
815
818
|
}
|
|
819
|
+
function referencedCoefficients(source) {
|
|
820
|
+
const keys = /* @__PURE__ */ new Set();
|
|
821
|
+
const parsed = parse(source);
|
|
822
|
+
if (!parsed.ok) return keys;
|
|
823
|
+
const visit = (node) => {
|
|
824
|
+
switch (node.type) {
|
|
825
|
+
case "Member":
|
|
826
|
+
if (node.object.type === "Identifier" && node.object.name === "coef") keys.add(node.property.name);
|
|
827
|
+
visit(node.object);
|
|
828
|
+
return;
|
|
829
|
+
case "Call":
|
|
830
|
+
visit(node.callee);
|
|
831
|
+
node.args.forEach(visit);
|
|
832
|
+
return;
|
|
833
|
+
case "Unary":
|
|
834
|
+
visit(node.argument);
|
|
835
|
+
return;
|
|
836
|
+
case "Binary":
|
|
837
|
+
visit(node.left);
|
|
838
|
+
visit(node.right);
|
|
839
|
+
return;
|
|
840
|
+
case "Conditional":
|
|
841
|
+
visit(node.test);
|
|
842
|
+
visit(node.consequent);
|
|
843
|
+
visit(node.alternate);
|
|
844
|
+
return;
|
|
845
|
+
default: return;
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
visit(parsed.ast);
|
|
849
|
+
return keys;
|
|
850
|
+
}
|
|
851
|
+
function splitAccessories(rule, data, variables, fullValue, fullCents) {
|
|
852
|
+
const whole = [{
|
|
853
|
+
kind: "MATERIAL",
|
|
854
|
+
ruleId: rule.id,
|
|
855
|
+
amountCents: fullCents
|
|
856
|
+
}];
|
|
857
|
+
const accessories = data.accessories ?? {};
|
|
858
|
+
const keys = [...referencedCoefficients(rule.costFormula)].filter((key) => Object.hasOwn(accessories, key) && Object.hasOwn(data.coefficients, key));
|
|
859
|
+
if (keys.length === 0) return whole;
|
|
860
|
+
const zeroed = Object.fromEntries(keys.map((key) => [key, 0]));
|
|
861
|
+
const costValue = (overrides) => {
|
|
862
|
+
const outcome = runNumericFormula(rule.costFormula, {
|
|
863
|
+
variables,
|
|
864
|
+
coefficients: {
|
|
865
|
+
...data.coefficients,
|
|
866
|
+
...overrides
|
|
867
|
+
}
|
|
868
|
+
});
|
|
869
|
+
return outcome.ok ? outcome.value : null;
|
|
870
|
+
};
|
|
871
|
+
const productValue = costValue(zeroed);
|
|
872
|
+
if (productValue === null) return whole;
|
|
873
|
+
const productCents = componentCents(productValue);
|
|
874
|
+
if (productCents === null) return whole;
|
|
875
|
+
const parts = [];
|
|
876
|
+
for (const key of keys) {
|
|
877
|
+
const withKey = costValue({
|
|
878
|
+
...zeroed,
|
|
879
|
+
[key]: data.coefficients[key]
|
|
880
|
+
});
|
|
881
|
+
if (withKey === null || withKey < productValue) return whole;
|
|
882
|
+
parts.push({
|
|
883
|
+
key,
|
|
884
|
+
value: withKey - productValue
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
const partsValue = parts.reduce((sum, part) => sum + part.value, 0);
|
|
888
|
+
if (Math.abs(productValue + partsValue - fullValue) > ADDITIVITY_TOLERANCE * Math.max(1, Math.abs(fullValue))) return whole;
|
|
889
|
+
const priced = [];
|
|
890
|
+
for (const part of parts) {
|
|
891
|
+
const cents = componentCents(part.value);
|
|
892
|
+
if (cents === null) return whole;
|
|
893
|
+
priced.push({
|
|
894
|
+
key: part.key,
|
|
895
|
+
cents
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
const remainder = fullCents - productCents - priced.reduce((sum, part) => sum + part.cents, 0);
|
|
899
|
+
const last = priced[priced.length - 1];
|
|
900
|
+
last.cents += remainder;
|
|
901
|
+
if (last.cents < 0) return whole;
|
|
902
|
+
const kept = priced.filter((part) => part.cents > 0);
|
|
903
|
+
if (kept.length === 0) return whole;
|
|
904
|
+
return [{
|
|
905
|
+
kind: "MATERIAL",
|
|
906
|
+
ruleId: rule.id,
|
|
907
|
+
amountCents: productCents
|
|
908
|
+
}, ...kept.map((part) => ({
|
|
909
|
+
kind: "ACCESSORY",
|
|
910
|
+
ruleId: rule.id,
|
|
911
|
+
coefficientKey: part.key,
|
|
912
|
+
amountCents: part.cents
|
|
913
|
+
}))];
|
|
914
|
+
}
|
|
816
915
|
function materialLine(configuration, data, room, selection, objectScope) {
|
|
817
916
|
const { categoryId } = selection;
|
|
818
917
|
const productId = selection.productId;
|
|
@@ -876,11 +975,10 @@ function materialLine(configuration, data, room, selection, objectScope) {
|
|
|
876
975
|
if (!cost.ok) return failedLine(ruled, "ERROR", cost.error);
|
|
877
976
|
const materialCents = componentCents(cost.value);
|
|
878
977
|
if (materialCents === null) return failedLine(ruled, "ERROR", outOfRange());
|
|
879
|
-
const components =
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
}];
|
|
978
|
+
const components = splitAccessories(rule, data, {
|
|
979
|
+
...variables,
|
|
980
|
+
quantity: quantity.value
|
|
981
|
+
}, cost.value, materialCents);
|
|
884
982
|
const lineWarnings = [...warnings];
|
|
885
983
|
if (configuration.scope === "PARTIAL") {
|
|
886
984
|
let laborCents = 0;
|
|
@@ -1000,6 +1098,7 @@ const SectionSchema = z.enum([
|
|
|
1000
1098
|
]);
|
|
1001
1099
|
const LineComponentKindSchema = z.enum([
|
|
1002
1100
|
"MATERIAL",
|
|
1101
|
+
"ACCESSORY",
|
|
1003
1102
|
"LABOR",
|
|
1004
1103
|
"PREP"
|
|
1005
1104
|
]);
|
|
@@ -1185,6 +1284,7 @@ const PricingDataSchema = z.object({
|
|
|
1185
1284
|
exchangeRate: ExchangeRateSchema.nullable(),
|
|
1186
1285
|
limits: PricingLimitsSchema,
|
|
1187
1286
|
coefficients: z.record(z.string(), z.number()),
|
|
1287
|
+
accessories: z.record(z.string(), LocalizedTextSchema).optional(),
|
|
1188
1288
|
rules: z.array(PricingRuleSchema),
|
|
1189
1289
|
roomTypes: z.array(PricingRoomTypeSchema),
|
|
1190
1290
|
categories: z.array(PricingCategorySchema),
|
|
@@ -1203,7 +1303,11 @@ const LineComponentSchema = z.object({
|
|
|
1203
1303
|
kind: LineComponentKindSchema,
|
|
1204
1304
|
ruleId: z.string(),
|
|
1205
1305
|
amountCents: z.number().int().min(0),
|
|
1206
|
-
sharedWithCategoryId: z.string().optional()
|
|
1306
|
+
sharedWithCategoryId: z.string().optional(),
|
|
1307
|
+
coefficientKey: z.string().optional()
|
|
1308
|
+
}).refine((component) => component.kind === "ACCESSORY" === (component.coefficientKey !== void 0), {
|
|
1309
|
+
path: ["coefficientKey"],
|
|
1310
|
+
message: "coefficientKey is required for ACCESSORY and only for it"
|
|
1207
1311
|
});
|
|
1208
1312
|
const EstimateLineSchema = z.object({
|
|
1209
1313
|
id: z.string(),
|