@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 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, and are skipped in `PARTIAL`. 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`.
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
- | Package items with `includedInBase` | `INCLUDED` lines | skipped |
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 single `MATERIAL` component. Each component carries the `ruleId` of the rule that produced it; ids are plain strings.
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
- return [...(configuration.scope === "PARTIAL" ? data.packageItems.filter((item) => !item.includedInBase) : data.packageItems).map((item) => packageLine(configuration, item)), ...optionLines(configuration, data, "ENGINEERING")];
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
- kind: "MATERIAL",
906
- ruleId: rule.id,
907
- amountCents: materialCents
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
- return [...(configuration.scope === "PARTIAL" ? data.packageItems.filter((item) => !item.includedInBase) : data.packageItems).map((item) => packageLine(configuration, item)), ...optionLines(configuration, data, "ENGINEERING")];
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
- kind: "MATERIAL",
881
- ruleId: rule.id,
882
- amountCents: materialCents
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(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@roomwise/calc-engine",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.cjs",
6
6
  "module": "./dist/index.js",