@roomwise/calc-engine 0.8.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 +4 -3
- package/dist/index.cjs +223 -121
- package/dist/index.d.cts +14 -3
- package/dist/index.d.ts +14 -3
- package/dist/index.js +223 -121
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -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
|
@@ -363,121 +363,6 @@ const FORMULA_FUNCTION_TABLE = new Map([
|
|
|
363
363
|
].map((fn) => [fn.name, fn]));
|
|
364
364
|
const FORMULA_FUNCTIONS = [...FORMULA_FUNCTION_TABLE.keys()];
|
|
365
365
|
//#endregion
|
|
366
|
-
//#region src/formula/evaluate.ts
|
|
367
|
-
var EvaluationFailure = class {
|
|
368
|
-
error;
|
|
369
|
-
constructor(error) {
|
|
370
|
-
this.error = error;
|
|
371
|
-
}
|
|
372
|
-
};
|
|
373
|
-
function fail(node, code, params) {
|
|
374
|
-
throw new EvaluationFailure({
|
|
375
|
-
code,
|
|
376
|
-
position: node.start,
|
|
377
|
-
length: node.end - node.start,
|
|
378
|
-
...params === void 0 ? {} : { params }
|
|
379
|
-
});
|
|
380
|
-
}
|
|
381
|
-
function runtimeFail(node, reason, params) {
|
|
382
|
-
return fail(node, "FORMULA_RUNTIME_ERROR", {
|
|
383
|
-
...params,
|
|
384
|
-
reason
|
|
385
|
-
});
|
|
386
|
-
}
|
|
387
|
-
function mismatch(node, expected) {
|
|
388
|
-
return runtimeFail(node, "TYPE_MISMATCH", { expected });
|
|
389
|
-
}
|
|
390
|
-
function finite(node, value) {
|
|
391
|
-
return Number.isFinite(value) ? value : runtimeFail(node, "NOT_FINITE");
|
|
392
|
-
}
|
|
393
|
-
function asNumber(node, value) {
|
|
394
|
-
return typeof value === "number" ? value : mismatch(node, "number");
|
|
395
|
-
}
|
|
396
|
-
function asBoolean(node, value) {
|
|
397
|
-
return typeof value === "boolean" ? value : mismatch(node, "boolean");
|
|
398
|
-
}
|
|
399
|
-
function lookupVariable(node, scope) {
|
|
400
|
-
if (!Object.hasOwn(scope.variables, node.name)) return fail(node, "UNKNOWN_VARIABLE", { name: node.name });
|
|
401
|
-
return scope.variables[node.name];
|
|
402
|
-
}
|
|
403
|
-
function evaluateMember(node, scope) {
|
|
404
|
-
if (node.object.type !== "Identifier" || node.object.name !== "coef") return fail(node, "UNKNOWN_VARIABLE");
|
|
405
|
-
const key = node.property.name;
|
|
406
|
-
if (!Object.hasOwn(scope.coefficients, key)) return fail(node, "UNKNOWN_COEFFICIENT", { key });
|
|
407
|
-
return scope.coefficients[key];
|
|
408
|
-
}
|
|
409
|
-
function evaluateCall(node, scope) {
|
|
410
|
-
const spec = node.callee.type === "Identifier" ? FORMULA_FUNCTION_TABLE.get(node.callee.name) : void 0;
|
|
411
|
-
if (spec === void 0) return fail(node.callee, "UNKNOWN_FUNCTION");
|
|
412
|
-
if (node.args.length < spec.minArgs || node.args.length > spec.maxArgs) return mismatch(node, "argument count");
|
|
413
|
-
const args = node.args.map((arg) => asNumber(arg, walk(arg, scope)));
|
|
414
|
-
return finite(node, spec.apply(args));
|
|
415
|
-
}
|
|
416
|
-
function evaluateEquality(node, scope) {
|
|
417
|
-
const { left, right } = node;
|
|
418
|
-
const literal = [left, right].find((side) => side.type === "Literal" && typeof side.value === "string") ?? null;
|
|
419
|
-
if (literal !== null) {
|
|
420
|
-
const other = literal === left ? right : left;
|
|
421
|
-
const variable = other.type === "Identifier" ? lookupVariable(other, scope) : void 0;
|
|
422
|
-
if (typeof variable !== "string") return mismatch(other, "enum variable");
|
|
423
|
-
return node.operator === "==" ? variable === literal.value : variable !== literal.value;
|
|
424
|
-
}
|
|
425
|
-
const leftValue = walk(left, scope);
|
|
426
|
-
const rightValue = walk(right, scope);
|
|
427
|
-
if (typeof leftValue !== typeof rightValue) return mismatch(right, typeof leftValue);
|
|
428
|
-
return node.operator === "==" ? leftValue === rightValue : leftValue !== rightValue;
|
|
429
|
-
}
|
|
430
|
-
function evaluateBinary(node, scope) {
|
|
431
|
-
const { operator, left, right } = node;
|
|
432
|
-
if (operator === "==" || operator === "!=") return evaluateEquality(node, scope);
|
|
433
|
-
if (operator === "&&" || operator === "||") {
|
|
434
|
-
const leftValue = asBoolean(left, walk(left, scope));
|
|
435
|
-
if (operator === "&&" ? !leftValue : leftValue) return leftValue;
|
|
436
|
-
return asBoolean(right, walk(right, scope));
|
|
437
|
-
}
|
|
438
|
-
const a = asNumber(left, walk(left, scope));
|
|
439
|
-
const b = asNumber(right, walk(right, scope));
|
|
440
|
-
switch (operator) {
|
|
441
|
-
case "<": return a < b;
|
|
442
|
-
case ">": return a > b;
|
|
443
|
-
case "<=": return a <= b;
|
|
444
|
-
case ">=": return a >= b;
|
|
445
|
-
case "+": return finite(node, a + b);
|
|
446
|
-
case "-": return finite(node, a - b);
|
|
447
|
-
case "*": return finite(node, a * b);
|
|
448
|
-
case "/": return b === 0 ? runtimeFail(node, "DIVISION_BY_ZERO") : finite(node, a / b);
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
function walk(node, scope) {
|
|
452
|
-
switch (node.type) {
|
|
453
|
-
case "Literal": return typeof node.value === "string" ? mismatch(node, "number or boolean") : node.value;
|
|
454
|
-
case "Identifier": return lookupVariable(node, scope);
|
|
455
|
-
case "Member": return evaluateMember(node, scope);
|
|
456
|
-
case "Call": return evaluateCall(node, scope);
|
|
457
|
-
case "Unary": {
|
|
458
|
-
const argument = walk(node.argument, scope);
|
|
459
|
-
return node.operator === "!" ? !asBoolean(node.argument, argument) : finite(node, -asNumber(node.argument, argument));
|
|
460
|
-
}
|
|
461
|
-
case "Binary": return evaluateBinary(node, scope);
|
|
462
|
-
case "Conditional": return asBoolean(node.test, walk(node.test, scope)) ? walk(node.consequent, scope) : walk(node.alternate, scope);
|
|
463
|
-
default: return runtimeFail(node, "TYPE_MISMATCH", { expected: "known node" });
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
function evaluate(ast, scope) {
|
|
467
|
-
try {
|
|
468
|
-
return {
|
|
469
|
-
ok: true,
|
|
470
|
-
value: walk(ast, scope)
|
|
471
|
-
};
|
|
472
|
-
} catch (error) {
|
|
473
|
-
if (error instanceof EvaluationFailure) return {
|
|
474
|
-
ok: false,
|
|
475
|
-
error: error.error
|
|
476
|
-
};
|
|
477
|
-
throw error;
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
//#endregion
|
|
481
366
|
//#region src/formula/grammar.ts
|
|
482
367
|
const SEMICOLON_CODE = 59;
|
|
483
368
|
const REMOVED_BINARY_OPERATORS = [
|
|
@@ -726,6 +611,121 @@ function parse(source) {
|
|
|
726
611
|
}
|
|
727
612
|
}
|
|
728
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
|
|
729
729
|
//#region src/estimate/run-formula.ts
|
|
730
730
|
function message(code, params) {
|
|
731
731
|
return params === void 0 ? { code } : {
|
|
@@ -835,11 +835,108 @@ function applySurfacePrep(data, room, lines, objectScope) {
|
|
|
835
835
|
}
|
|
836
836
|
//#endregion
|
|
837
837
|
//#region src/estimate/materials.ts
|
|
838
|
+
const ADDITIVITY_TOLERANCE = 1e-9;
|
|
838
839
|
const isMaterialRule = (rule) => rule.quantityFormula !== null;
|
|
839
840
|
function selectRule(data, categoryId, materialTypeId) {
|
|
840
841
|
const rules = data.rules.filter(isMaterialRule);
|
|
841
842
|
return rules.find((rule) => rule.level === "MATERIAL_TYPE" && rule.categoryId === categoryId && rule.materialTypeId === materialTypeId) ?? rules.find((rule) => rule.level === "CATEGORY" && rule.categoryId === categoryId);
|
|
842
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
|
+
}
|
|
843
940
|
function materialLine(configuration, data, room, selection, objectScope) {
|
|
844
941
|
const { categoryId } = selection;
|
|
845
942
|
const productId = selection.productId;
|
|
@@ -903,11 +1000,10 @@ function materialLine(configuration, data, room, selection, objectScope) {
|
|
|
903
1000
|
if (!cost.ok) return failedLine(ruled, "ERROR", cost.error);
|
|
904
1001
|
const materialCents = componentCents(cost.value);
|
|
905
1002
|
if (materialCents === null) return failedLine(ruled, "ERROR", outOfRange());
|
|
906
|
-
const components =
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
}];
|
|
1003
|
+
const components = splitAccessories(rule, data, {
|
|
1004
|
+
...variables,
|
|
1005
|
+
quantity: quantity.value
|
|
1006
|
+
}, cost.value, materialCents);
|
|
911
1007
|
const lineWarnings = [...warnings];
|
|
912
1008
|
if (configuration.scope === "PARTIAL") {
|
|
913
1009
|
let laborCents = 0;
|
|
@@ -1027,6 +1123,7 @@ const SectionSchema = zod.z.enum([
|
|
|
1027
1123
|
]);
|
|
1028
1124
|
const LineComponentKindSchema = zod.z.enum([
|
|
1029
1125
|
"MATERIAL",
|
|
1126
|
+
"ACCESSORY",
|
|
1030
1127
|
"LABOR",
|
|
1031
1128
|
"PREP"
|
|
1032
1129
|
]);
|
|
@@ -1212,6 +1309,7 @@ const PricingDataSchema = zod.z.object({
|
|
|
1212
1309
|
exchangeRate: ExchangeRateSchema.nullable(),
|
|
1213
1310
|
limits: PricingLimitsSchema,
|
|
1214
1311
|
coefficients: zod.z.record(zod.z.string(), zod.z.number()),
|
|
1312
|
+
accessories: zod.z.record(zod.z.string(), LocalizedTextSchema).optional(),
|
|
1215
1313
|
rules: zod.z.array(PricingRuleSchema),
|
|
1216
1314
|
roomTypes: zod.z.array(PricingRoomTypeSchema),
|
|
1217
1315
|
categories: zod.z.array(PricingCategorySchema),
|
|
@@ -1230,7 +1328,11 @@ const LineComponentSchema = zod.z.object({
|
|
|
1230
1328
|
kind: LineComponentKindSchema,
|
|
1231
1329
|
ruleId: zod.z.string(),
|
|
1232
1330
|
amountCents: zod.z.number().int().min(0),
|
|
1233
|
-
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"
|
|
1234
1336
|
});
|
|
1235
1337
|
const EstimateLineSchema = zod.z.object({
|
|
1236
1338
|
id: zod.z.string(),
|
package/dist/index.d.cts
CHANGED
|
@@ -29,10 +29,10 @@ export declare const RateSourceSchema: z.ZodEnum<{
|
|
|
29
29
|
}>;
|
|
30
30
|
export type RateSource = z.infer<typeof RateSourceSchema>;
|
|
31
31
|
export declare const SurfaceKindSchema: z.ZodEnum<{
|
|
32
|
+
NONE: "NONE";
|
|
32
33
|
FLOOR: "FLOOR";
|
|
33
34
|
WALLS: "WALLS";
|
|
34
35
|
CEILING: "CEILING";
|
|
35
|
-
NONE: "NONE";
|
|
36
36
|
}>;
|
|
37
37
|
export type SurfaceKind = z.infer<typeof SurfaceKindSchema>;
|
|
38
38
|
export declare const ProductUnitSchema: z.ZodEnum<{
|
|
@@ -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;
|
|
@@ -488,10 +495,10 @@ export declare const PricingCategorySchema: z.ZodObject<{
|
|
|
488
495
|
id: z.ZodString;
|
|
489
496
|
wastePercent: z.ZodNumber;
|
|
490
497
|
surface: z.ZodEnum<{
|
|
498
|
+
NONE: "NONE";
|
|
491
499
|
FLOOR: "FLOOR";
|
|
492
500
|
WALLS: "WALLS";
|
|
493
501
|
CEILING: "CEILING";
|
|
494
|
-
NONE: "NONE";
|
|
495
502
|
}>;
|
|
496
503
|
requiresPutty: z.ZodDefault<z.ZodBoolean>;
|
|
497
504
|
}, z.core.$strip>;
|
|
@@ -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<{
|
|
@@ -622,10 +633,10 @@ export declare const PricingDataSchema: z.ZodObject<{
|
|
|
622
633
|
id: z.ZodString;
|
|
623
634
|
wastePercent: z.ZodNumber;
|
|
624
635
|
surface: z.ZodEnum<{
|
|
636
|
+
NONE: "NONE";
|
|
625
637
|
FLOOR: "FLOOR";
|
|
626
638
|
WALLS: "WALLS";
|
|
627
639
|
CEILING: "CEILING";
|
|
628
|
-
NONE: "NONE";
|
|
629
640
|
}>;
|
|
630
641
|
requiresPutty: z.ZodDefault<z.ZodBoolean>;
|
|
631
642
|
}, z.core.$strip>>;
|
package/dist/index.d.ts
CHANGED
|
@@ -29,10 +29,10 @@ export declare const RateSourceSchema: z.ZodEnum<{
|
|
|
29
29
|
}>;
|
|
30
30
|
export type RateSource = z.infer<typeof RateSourceSchema>;
|
|
31
31
|
export declare const SurfaceKindSchema: z.ZodEnum<{
|
|
32
|
+
NONE: "NONE";
|
|
32
33
|
FLOOR: "FLOOR";
|
|
33
34
|
WALLS: "WALLS";
|
|
34
35
|
CEILING: "CEILING";
|
|
35
|
-
NONE: "NONE";
|
|
36
36
|
}>;
|
|
37
37
|
export type SurfaceKind = z.infer<typeof SurfaceKindSchema>;
|
|
38
38
|
export declare const ProductUnitSchema: z.ZodEnum<{
|
|
@@ -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;
|
|
@@ -488,10 +495,10 @@ export declare const PricingCategorySchema: z.ZodObject<{
|
|
|
488
495
|
id: z.ZodString;
|
|
489
496
|
wastePercent: z.ZodNumber;
|
|
490
497
|
surface: z.ZodEnum<{
|
|
498
|
+
NONE: "NONE";
|
|
491
499
|
FLOOR: "FLOOR";
|
|
492
500
|
WALLS: "WALLS";
|
|
493
501
|
CEILING: "CEILING";
|
|
494
|
-
NONE: "NONE";
|
|
495
502
|
}>;
|
|
496
503
|
requiresPutty: z.ZodDefault<z.ZodBoolean>;
|
|
497
504
|
}, z.core.$strip>;
|
|
@@ -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<{
|
|
@@ -622,10 +633,10 @@ export declare const PricingDataSchema: z.ZodObject<{
|
|
|
622
633
|
id: z.ZodString;
|
|
623
634
|
wastePercent: z.ZodNumber;
|
|
624
635
|
surface: z.ZodEnum<{
|
|
636
|
+
NONE: "NONE";
|
|
625
637
|
FLOOR: "FLOOR";
|
|
626
638
|
WALLS: "WALLS";
|
|
627
639
|
CEILING: "CEILING";
|
|
628
|
-
NONE: "NONE";
|
|
629
640
|
}>;
|
|
630
641
|
requiresPutty: z.ZodDefault<z.ZodBoolean>;
|
|
631
642
|
}, z.core.$strip>>;
|
package/dist/index.js
CHANGED
|
@@ -338,121 +338,6 @@ const FORMULA_FUNCTION_TABLE = new Map([
|
|
|
338
338
|
].map((fn) => [fn.name, fn]));
|
|
339
339
|
const FORMULA_FUNCTIONS = [...FORMULA_FUNCTION_TABLE.keys()];
|
|
340
340
|
//#endregion
|
|
341
|
-
//#region src/formula/evaluate.ts
|
|
342
|
-
var EvaluationFailure = class {
|
|
343
|
-
error;
|
|
344
|
-
constructor(error) {
|
|
345
|
-
this.error = error;
|
|
346
|
-
}
|
|
347
|
-
};
|
|
348
|
-
function fail(node, code, params) {
|
|
349
|
-
throw new EvaluationFailure({
|
|
350
|
-
code,
|
|
351
|
-
position: node.start,
|
|
352
|
-
length: node.end - node.start,
|
|
353
|
-
...params === void 0 ? {} : { params }
|
|
354
|
-
});
|
|
355
|
-
}
|
|
356
|
-
function runtimeFail(node, reason, params) {
|
|
357
|
-
return fail(node, "FORMULA_RUNTIME_ERROR", {
|
|
358
|
-
...params,
|
|
359
|
-
reason
|
|
360
|
-
});
|
|
361
|
-
}
|
|
362
|
-
function mismatch(node, expected) {
|
|
363
|
-
return runtimeFail(node, "TYPE_MISMATCH", { expected });
|
|
364
|
-
}
|
|
365
|
-
function finite(node, value) {
|
|
366
|
-
return Number.isFinite(value) ? value : runtimeFail(node, "NOT_FINITE");
|
|
367
|
-
}
|
|
368
|
-
function asNumber(node, value) {
|
|
369
|
-
return typeof value === "number" ? value : mismatch(node, "number");
|
|
370
|
-
}
|
|
371
|
-
function asBoolean(node, value) {
|
|
372
|
-
return typeof value === "boolean" ? value : mismatch(node, "boolean");
|
|
373
|
-
}
|
|
374
|
-
function lookupVariable(node, scope) {
|
|
375
|
-
if (!Object.hasOwn(scope.variables, node.name)) return fail(node, "UNKNOWN_VARIABLE", { name: node.name });
|
|
376
|
-
return scope.variables[node.name];
|
|
377
|
-
}
|
|
378
|
-
function evaluateMember(node, scope) {
|
|
379
|
-
if (node.object.type !== "Identifier" || node.object.name !== "coef") return fail(node, "UNKNOWN_VARIABLE");
|
|
380
|
-
const key = node.property.name;
|
|
381
|
-
if (!Object.hasOwn(scope.coefficients, key)) return fail(node, "UNKNOWN_COEFFICIENT", { key });
|
|
382
|
-
return scope.coefficients[key];
|
|
383
|
-
}
|
|
384
|
-
function evaluateCall(node, scope) {
|
|
385
|
-
const spec = node.callee.type === "Identifier" ? FORMULA_FUNCTION_TABLE.get(node.callee.name) : void 0;
|
|
386
|
-
if (spec === void 0) return fail(node.callee, "UNKNOWN_FUNCTION");
|
|
387
|
-
if (node.args.length < spec.minArgs || node.args.length > spec.maxArgs) return mismatch(node, "argument count");
|
|
388
|
-
const args = node.args.map((arg) => asNumber(arg, walk(arg, scope)));
|
|
389
|
-
return finite(node, spec.apply(args));
|
|
390
|
-
}
|
|
391
|
-
function evaluateEquality(node, scope) {
|
|
392
|
-
const { left, right } = node;
|
|
393
|
-
const literal = [left, right].find((side) => side.type === "Literal" && typeof side.value === "string") ?? null;
|
|
394
|
-
if (literal !== null) {
|
|
395
|
-
const other = literal === left ? right : left;
|
|
396
|
-
const variable = other.type === "Identifier" ? lookupVariable(other, scope) : void 0;
|
|
397
|
-
if (typeof variable !== "string") return mismatch(other, "enum variable");
|
|
398
|
-
return node.operator === "==" ? variable === literal.value : variable !== literal.value;
|
|
399
|
-
}
|
|
400
|
-
const leftValue = walk(left, scope);
|
|
401
|
-
const rightValue = walk(right, scope);
|
|
402
|
-
if (typeof leftValue !== typeof rightValue) return mismatch(right, typeof leftValue);
|
|
403
|
-
return node.operator === "==" ? leftValue === rightValue : leftValue !== rightValue;
|
|
404
|
-
}
|
|
405
|
-
function evaluateBinary(node, scope) {
|
|
406
|
-
const { operator, left, right } = node;
|
|
407
|
-
if (operator === "==" || operator === "!=") return evaluateEquality(node, scope);
|
|
408
|
-
if (operator === "&&" || operator === "||") {
|
|
409
|
-
const leftValue = asBoolean(left, walk(left, scope));
|
|
410
|
-
if (operator === "&&" ? !leftValue : leftValue) return leftValue;
|
|
411
|
-
return asBoolean(right, walk(right, scope));
|
|
412
|
-
}
|
|
413
|
-
const a = asNumber(left, walk(left, scope));
|
|
414
|
-
const b = asNumber(right, walk(right, scope));
|
|
415
|
-
switch (operator) {
|
|
416
|
-
case "<": return a < b;
|
|
417
|
-
case ">": return a > b;
|
|
418
|
-
case "<=": return a <= b;
|
|
419
|
-
case ">=": return a >= b;
|
|
420
|
-
case "+": return finite(node, a + b);
|
|
421
|
-
case "-": return finite(node, a - b);
|
|
422
|
-
case "*": return finite(node, a * b);
|
|
423
|
-
case "/": return b === 0 ? runtimeFail(node, "DIVISION_BY_ZERO") : finite(node, a / b);
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
function walk(node, scope) {
|
|
427
|
-
switch (node.type) {
|
|
428
|
-
case "Literal": return typeof node.value === "string" ? mismatch(node, "number or boolean") : node.value;
|
|
429
|
-
case "Identifier": return lookupVariable(node, scope);
|
|
430
|
-
case "Member": return evaluateMember(node, scope);
|
|
431
|
-
case "Call": return evaluateCall(node, scope);
|
|
432
|
-
case "Unary": {
|
|
433
|
-
const argument = walk(node.argument, scope);
|
|
434
|
-
return node.operator === "!" ? !asBoolean(node.argument, argument) : finite(node, -asNumber(node.argument, argument));
|
|
435
|
-
}
|
|
436
|
-
case "Binary": return evaluateBinary(node, scope);
|
|
437
|
-
case "Conditional": return asBoolean(node.test, walk(node.test, scope)) ? walk(node.consequent, scope) : walk(node.alternate, scope);
|
|
438
|
-
default: return runtimeFail(node, "TYPE_MISMATCH", { expected: "known node" });
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
function evaluate(ast, scope) {
|
|
442
|
-
try {
|
|
443
|
-
return {
|
|
444
|
-
ok: true,
|
|
445
|
-
value: walk(ast, scope)
|
|
446
|
-
};
|
|
447
|
-
} catch (error) {
|
|
448
|
-
if (error instanceof EvaluationFailure) return {
|
|
449
|
-
ok: false,
|
|
450
|
-
error: error.error
|
|
451
|
-
};
|
|
452
|
-
throw error;
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
//#endregion
|
|
456
341
|
//#region src/formula/grammar.ts
|
|
457
342
|
const SEMICOLON_CODE = 59;
|
|
458
343
|
const REMOVED_BINARY_OPERATORS = [
|
|
@@ -701,6 +586,121 @@ function parse(source) {
|
|
|
701
586
|
}
|
|
702
587
|
}
|
|
703
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
|
|
704
704
|
//#region src/estimate/run-formula.ts
|
|
705
705
|
function message(code, params) {
|
|
706
706
|
return params === void 0 ? { code } : {
|
|
@@ -810,11 +810,108 @@ function applySurfacePrep(data, room, lines, objectScope) {
|
|
|
810
810
|
}
|
|
811
811
|
//#endregion
|
|
812
812
|
//#region src/estimate/materials.ts
|
|
813
|
+
const ADDITIVITY_TOLERANCE = 1e-9;
|
|
813
814
|
const isMaterialRule = (rule) => rule.quantityFormula !== null;
|
|
814
815
|
function selectRule(data, categoryId, materialTypeId) {
|
|
815
816
|
const rules = data.rules.filter(isMaterialRule);
|
|
816
817
|
return rules.find((rule) => rule.level === "MATERIAL_TYPE" && rule.categoryId === categoryId && rule.materialTypeId === materialTypeId) ?? rules.find((rule) => rule.level === "CATEGORY" && rule.categoryId === categoryId);
|
|
817
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
|
+
}
|
|
818
915
|
function materialLine(configuration, data, room, selection, objectScope) {
|
|
819
916
|
const { categoryId } = selection;
|
|
820
917
|
const productId = selection.productId;
|
|
@@ -878,11 +975,10 @@ function materialLine(configuration, data, room, selection, objectScope) {
|
|
|
878
975
|
if (!cost.ok) return failedLine(ruled, "ERROR", cost.error);
|
|
879
976
|
const materialCents = componentCents(cost.value);
|
|
880
977
|
if (materialCents === null) return failedLine(ruled, "ERROR", outOfRange());
|
|
881
|
-
const components =
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
}];
|
|
978
|
+
const components = splitAccessories(rule, data, {
|
|
979
|
+
...variables,
|
|
980
|
+
quantity: quantity.value
|
|
981
|
+
}, cost.value, materialCents);
|
|
886
982
|
const lineWarnings = [...warnings];
|
|
887
983
|
if (configuration.scope === "PARTIAL") {
|
|
888
984
|
let laborCents = 0;
|
|
@@ -1002,6 +1098,7 @@ const SectionSchema = z.enum([
|
|
|
1002
1098
|
]);
|
|
1003
1099
|
const LineComponentKindSchema = z.enum([
|
|
1004
1100
|
"MATERIAL",
|
|
1101
|
+
"ACCESSORY",
|
|
1005
1102
|
"LABOR",
|
|
1006
1103
|
"PREP"
|
|
1007
1104
|
]);
|
|
@@ -1187,6 +1284,7 @@ const PricingDataSchema = z.object({
|
|
|
1187
1284
|
exchangeRate: ExchangeRateSchema.nullable(),
|
|
1188
1285
|
limits: PricingLimitsSchema,
|
|
1189
1286
|
coefficients: z.record(z.string(), z.number()),
|
|
1287
|
+
accessories: z.record(z.string(), LocalizedTextSchema).optional(),
|
|
1190
1288
|
rules: z.array(PricingRuleSchema),
|
|
1191
1289
|
roomTypes: z.array(PricingRoomTypeSchema),
|
|
1192
1290
|
categories: z.array(PricingCategorySchema),
|
|
@@ -1205,7 +1303,11 @@ const LineComponentSchema = z.object({
|
|
|
1205
1303
|
kind: LineComponentKindSchema,
|
|
1206
1304
|
ruleId: z.string(),
|
|
1207
1305
|
amountCents: z.number().int().min(0),
|
|
1208
|
-
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"
|
|
1209
1311
|
});
|
|
1210
1312
|
const EstimateLineSchema = z.object({
|
|
1211
1313
|
id: z.string(),
|