@roomwise/calc-engine 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -26,6 +26,73 @@ function toCents(dollars) {
26
26
  return cents;
27
27
  }
28
28
  //#endregion
29
+ //#region src/variables/putty.ts
30
+ function puttyRequiredOn(room, surface, categories) {
31
+ const selected = categories.filter((category) => category.surface === surface && room.selections.some((selection) => selection.categoryId === category.id));
32
+ return selected.length === 0 || selected.some((category) => category.requiresPutty);
33
+ }
34
+ //#endregion
35
+ //#region src/variables/room.ts
36
+ function roomVariables(room) {
37
+ const perimeter = 2 * (room.lengthM + room.widthM);
38
+ return {
39
+ length: room.lengthM,
40
+ width: room.widthM,
41
+ height: room.heightM,
42
+ floorArea: room.lengthM * room.widthM,
43
+ perimeter,
44
+ wallArea: perimeter * room.heightM,
45
+ heatedFloor: room.heatedFloor,
46
+ roomType: room.roomTypeCode
47
+ };
48
+ }
49
+ //#endregion
50
+ //#region src/variables/object.ts
51
+ const COUNTED_AS_ROOMS = [
52
+ "LIVING_ROOM",
53
+ "BEDROOM",
54
+ "KITCHEN_LIVING"
55
+ ];
56
+ const WET_ROOMS = [
57
+ "BATHROOM",
58
+ "KITCHEN",
59
+ "KITCHEN_LIVING"
60
+ ];
61
+ function totalAreaOf(scope, totalAreaSqm, roomsFloorArea) {
62
+ return scope === "PARTIAL" || totalAreaSqm === null ? roomsFloorArea : totalAreaSqm;
63
+ }
64
+ function objectVariables(configuration, categories) {
65
+ const rooms = configuration.rooms.map(roomVariables);
66
+ const sum = (pick) => rooms.reduce((total, room) => total + pick(room), 0);
67
+ const roomsFloorArea = sum((room) => room.floorArea);
68
+ return {
69
+ scope: configuration.scope,
70
+ totalArea: totalAreaOf(configuration.scope, configuration.totalAreaSqm, roomsFloorArea),
71
+ balconyArea: configuration.balconyAreaSqm,
72
+ balconyTiles: configuration.balconyTiles,
73
+ finishLevel: configuration.finishLevel,
74
+ roomsCount: configuration.rooms.filter((room) => COUNTED_AS_ROOMS.includes(room.roomTypeCode)).length,
75
+ bathroomsCount: configuration.rooms.filter((room) => room.roomTypeCode === "BATHROOM").length,
76
+ roomsFloorArea,
77
+ roomsWallArea: sum((room) => room.wallArea),
78
+ roomsPuttyWallArea: configuration.rooms.reduce((total, room) => puttyRequiredOn(room, "WALLS", categories) ? total + roomVariables(room).wallArea : total, 0),
79
+ roomsPerimeter: sum((room) => room.perimeter),
80
+ wetRoomsCount: rooms.filter((room) => WET_ROOMS.includes(room.roomType)).length
81
+ };
82
+ }
83
+ //#endregion
84
+ //#region src/variables/product.ts
85
+ function productVariablesOf(product, wastePercent) {
86
+ return {
87
+ price: product.priceCents / 100,
88
+ waste: wastePercent / 100,
89
+ unit: product.unit
90
+ };
91
+ }
92
+ function productVariables(product, category) {
93
+ return productVariablesOf(product, product.wastePercentOverride ?? category.wastePercent);
94
+ }
95
+ //#endregion
29
96
  //#region src/estimate/line.ts
30
97
  const MAX_LINE_CENTS = 1e10;
31
98
  function createLine(init) {
@@ -55,18 +122,25 @@ function failedLine(init, status, error) {
55
122
  });
56
123
  }
57
124
  function pricedLine(init, dollars) {
58
- if (!Number.isFinite(dollars) || dollars < 0 || dollars * 100 > 1e10) return failedLine(init, "ERROR", {
59
- code: "AMOUNT_OUT_OF_RANGE",
60
- params: { maxCents: MAX_LINE_CENTS }
61
- });
62
- return createLine({
125
+ const amountCents = componentCents(dollars);
126
+ return amountCents === null ? failedLine(init, "ERROR", outOfRange()) : createLine({
63
127
  ...init,
64
- amountCents: toCents(dollars)
128
+ amountCents
65
129
  });
66
130
  }
67
131
  function roundQuantity(quantity) {
68
132
  return Math.round(quantity * 1e3) / 1e3;
69
133
  }
134
+ function componentCents(dollars) {
135
+ if (!Number.isFinite(dollars) || dollars < 0 || dollars * 100 > 1e10) return null;
136
+ return toCents(dollars);
137
+ }
138
+ function outOfRange() {
139
+ return {
140
+ code: "AMOUNT_OUT_OF_RANGE",
141
+ params: { maxCents: MAX_LINE_CENTS }
142
+ };
143
+ }
70
144
  //#endregion
71
145
  //#region src/estimate/line-id.ts
72
146
  function lineId(section, ...parts) {
@@ -82,16 +156,39 @@ function unitQuantity(unit, rooms, pieces) {
82
156
  case "ROOM_SQM": return rooms.reduce((sum, room) => sum + room.lengthM * room.widthM, 0);
83
157
  }
84
158
  }
85
- function optionLine(configuration, option, selection) {
159
+ function optionUnitPriceCents(option, coefficients) {
160
+ if (option.priceCoefficientKeys.length === 0) return option.priceCents;
161
+ let dollars = 0;
162
+ for (const key of option.priceCoefficientKeys) {
163
+ const value = Object.hasOwn(coefficients, key) ? coefficients[key] : void 0;
164
+ if (value === void 0) return { missingKey: key };
165
+ dollars += value;
166
+ }
167
+ return componentCents(dollars);
168
+ }
169
+ function optionLine(configuration, option, selection, coefficients = {}) {
86
170
  const init = {
87
171
  id: lineId(option.kind, "option", option.id),
88
172
  section: option.kind,
89
173
  optionId: option.id
90
174
  };
175
+ if (!option.scopes.includes(configuration.scope)) return failedLine(init, "UNAVAILABLE", {
176
+ code: "OPTION_SCOPE_NOT_ALLOWED",
177
+ params: {
178
+ optionId: option.id,
179
+ scope: configuration.scope
180
+ }
181
+ });
91
182
  if (!option.available) return failedLine(init, "UNAVAILABLE", {
92
183
  code: "PRODUCT_UNAVAILABLE",
93
184
  params: { optionId: option.id }
94
185
  });
186
+ const unitPriceCents = optionUnitPriceCents(option, coefficients);
187
+ if (unitPriceCents === null) return failedLine(init, "ERROR", outOfRange());
188
+ if (typeof unitPriceCents !== "number") return failedLine(init, "ERROR", {
189
+ code: "UNKNOWN_COEFFICIENT",
190
+ params: { key: unitPriceCents.missingKey }
191
+ });
95
192
  const selected = configuration.rooms.filter((room) => selection.roomClientIds?.includes(room.clientId));
96
193
  const allowed = option.roomTypeCodes.length === 0 ? selected : selected.filter((room) => option.roomTypeCodes.includes(room.roomTypeCode));
97
194
  const rejected = selected.filter((room) => !allowed.includes(room));
@@ -100,7 +197,7 @@ function optionLine(configuration, option, selection) {
100
197
  ...init,
101
198
  quantity: roundQuantity(quantity),
102
199
  unit: option.unit,
103
- unitPriceCents: option.priceCents,
200
+ unitPriceCents,
104
201
  warnings: rejected.map((room) => ({
105
202
  code: "OPTION_ROOM_TYPE_NOT_ALLOWED",
106
203
  params: {
@@ -108,12 +205,12 @@ function optionLine(configuration, option, selection) {
108
205
  roomTypeCode: room.roomTypeCode
109
206
  }
110
207
  }))
111
- }, option.priceCents / 100 * quantity);
208
+ }, unitPriceCents / 100 * quantity);
112
209
  }
113
210
  function optionLines(configuration, data, kind) {
114
211
  return configuration.options.flatMap((selection) => {
115
212
  const option = data.options.find((item) => item.id === selection.optionId);
116
- return option?.kind === kind ? [optionLine(configuration, option, selection)] : [];
213
+ return option?.kind === kind ? [optionLine(configuration, option, selection, data.coefficients)] : [];
117
214
  });
118
215
  }
119
216
  //#endregion
@@ -122,29 +219,6 @@ function additionalLines(configuration, data) {
122
219
  return optionLines(configuration, data, "ADDITIONAL");
123
220
  }
124
221
  //#endregion
125
- //#region src/estimate/base.ts
126
- function baseLine(configuration, data) {
127
- const init = {
128
- id: lineId("BASE"),
129
- section: "BASE"
130
- };
131
- const area = configuration.totalAreaSqm;
132
- const rate = data.baseRates.find((candidate) => candidate.finishLevel === configuration.finishLevel && candidate.areaFromSqm <= area && area < candidate.areaToSqm);
133
- if (rate === void 0) return failedLine(init, "NOT_CALCULATED", {
134
- code: "BASE_RATE_NOT_FOUND",
135
- params: {
136
- totalAreaSqm: area,
137
- finishLevel: configuration.finishLevel
138
- }
139
- });
140
- return pricedLine({
141
- ...init,
142
- quantity: roundQuantity(area),
143
- unit: "SQM",
144
- unitPriceCents: rate.rateCentsPerSqm
145
- }, rate.rateCentsPerSqm / 100 * area);
146
- }
147
- //#endregion
148
222
  //#region src/estimate/engineering.ts
149
223
  function packageLine(configuration, item) {
150
224
  const init = {
@@ -169,51 +243,7 @@ function packageLine(configuration, item) {
169
243
  }, item.priceCents / 100 * quantity);
170
244
  }
171
245
  function engineeringLines(configuration, data) {
172
- return [...data.packageItems.map((item) => packageLine(configuration, item)), ...optionLines(configuration, data, "ENGINEERING")];
173
- }
174
- //#endregion
175
- //#region src/variables/object.ts
176
- const COUNTED_AS_ROOMS = [
177
- "LIVING_ROOM",
178
- "BEDROOM",
179
- "KITCHEN_LIVING"
180
- ];
181
- function objectVariables(configuration) {
182
- return {
183
- totalArea: configuration.totalAreaSqm,
184
- balconyArea: configuration.balconyAreaSqm,
185
- balconyTiles: configuration.balconyTiles,
186
- finishLevel: configuration.finishLevel,
187
- roomsCount: configuration.rooms.filter((room) => COUNTED_AS_ROOMS.includes(room.roomTypeCode)).length,
188
- bathroomsCount: configuration.rooms.filter((room) => room.roomTypeCode === "BATHROOM").length
189
- };
190
- }
191
- //#endregion
192
- //#region src/variables/product.ts
193
- function productVariablesOf(product, wastePercent) {
194
- return {
195
- price: product.priceCents / 100,
196
- waste: wastePercent / 100,
197
- unit: product.unit
198
- };
199
- }
200
- function productVariables(product, category) {
201
- return productVariablesOf(product, product.wastePercentOverride ?? category.wastePercent);
202
- }
203
- //#endregion
204
- //#region src/variables/room.ts
205
- function roomVariables(room) {
206
- const perimeter = 2 * (room.lengthM + room.widthM);
207
- return {
208
- length: room.lengthM,
209
- width: room.widthM,
210
- height: room.heightM,
211
- floorArea: room.lengthM * room.widthM,
212
- perimeter,
213
- wallArea: perimeter * room.heightM,
214
- heatedFloor: room.heatedFloor,
215
- roomType: room.roomTypeCode
216
- };
246
+ return [...(configuration.scope === "PARTIAL" ? data.packageItems.filter((item) => !item.includedInBase) : data.packageItems).map((item) => packageLine(configuration, item)), ...optionLines(configuration, data, "ENGINEERING")];
217
247
  }
218
248
  //#endregion
219
249
  //#region src/formula/language.ts
@@ -647,13 +677,91 @@ function runNumericFormula(source, scope) {
647
677
  };
648
678
  }
649
679
  //#endregion
680
+ //#region src/estimate/surfaces.ts
681
+ function surfaceRuleOf(data, surface) {
682
+ return data.rules.filter((rule) => rule.level === "SURFACE" && rule.surface === surface).sort((a, b) => a.sortOrder - b.sortOrder)[0];
683
+ }
684
+ function failedWith(line, error) {
685
+ return failedLine({
686
+ id: line.id,
687
+ section: line.section,
688
+ roomClientId: line.roomClientId,
689
+ categoryId: line.categoryId,
690
+ productId: line.productId,
691
+ ruleId: line.ruleId,
692
+ warnings: line.warnings
693
+ }, "ERROR", error);
694
+ }
695
+ function withComponent(line, component) {
696
+ const components = [...line.components ?? [], component];
697
+ const amountCents = components.reduce((sum, item) => sum + item.amountCents, 0);
698
+ if (amountCents > 1e10) return failedWith(line, outOfRange());
699
+ return {
700
+ ...line,
701
+ components,
702
+ amountCents
703
+ };
704
+ }
705
+ function applySurfacePrep(data, room, lines, objectScope) {
706
+ const result = [...lines];
707
+ const variables = {
708
+ ...roomVariables(room),
709
+ ...objectScope
710
+ };
711
+ const areas = {
712
+ FLOOR: variables.floorArea,
713
+ CEILING: variables.floorArea,
714
+ WALLS: variables.wallArea
715
+ };
716
+ const surfaces = /* @__PURE__ */ new Map();
717
+ lines.forEach((line, index) => {
718
+ const category = data.categories.find((item) => item.id === line.categoryId);
719
+ if (line.status === "OK" && category !== void 0 && category.surface !== "NONE") {
720
+ const surface = category.surface;
721
+ surfaces.set(surface, [...surfaces.get(surface) ?? [], index]);
722
+ }
723
+ });
724
+ for (const [surface, indexes] of surfaces) {
725
+ const rule = surfaceRuleOf(data, surface);
726
+ if (rule === void 0) continue;
727
+ const outcome = runNumericFormula(rule.costFormula, {
728
+ variables: {
729
+ ...variables,
730
+ surfaceArea: areas[surface],
731
+ puttyRequired: puttyRequiredOn(room, surface, data.categories)
732
+ },
733
+ coefficients: data.coefficients
734
+ });
735
+ const cents = outcome.ok ? componentCents(outcome.value) : null;
736
+ const carrierCategoryId = lines[indexes[0]]?.categoryId ?? null;
737
+ indexes.forEach((index, position) => {
738
+ const line = lines[index];
739
+ if (!outcome.ok || cents === null) {
740
+ result[index] = failedWith(line, outcome.ok ? outOfRange() : outcome.error);
741
+ return;
742
+ }
743
+ result[index] = withComponent(line, position === 0 ? {
744
+ kind: "PREP",
745
+ ruleId: rule.id,
746
+ amountCents: cents
747
+ } : {
748
+ kind: "PREP",
749
+ ruleId: rule.id,
750
+ amountCents: 0,
751
+ sharedWithCategoryId: carrierCategoryId ?? void 0
752
+ });
753
+ });
754
+ }
755
+ return result;
756
+ }
757
+ //#endregion
650
758
  //#region src/estimate/materials.ts
651
759
  const isMaterialRule = (rule) => rule.quantityFormula !== null;
652
760
  function selectRule(data, categoryId, materialTypeId) {
653
761
  const rules = data.rules.filter(isMaterialRule);
654
762
  return rules.find((rule) => rule.level === "MATERIAL_TYPE" && rule.categoryId === categoryId && rule.materialTypeId === materialTypeId) ?? rules.find((rule) => rule.level === "CATEGORY" && rule.categoryId === categoryId);
655
763
  }
656
- function materialLine(configuration, data, room, categoryId, productId) {
764
+ function materialLine(configuration, data, room, categoryId, productId, objectScope) {
657
765
  const init = {
658
766
  id: lineId("MATERIALS", room.clientId, categoryId),
659
767
  section: "MATERIALS",
@@ -685,7 +793,7 @@ function materialLine(configuration, data, room, categoryId, productId) {
685
793
  const variables = {
686
794
  ...roomVariables(room),
687
795
  ...productVariables(product, category),
688
- ...objectVariables(configuration)
796
+ ...objectScope
689
797
  };
690
798
  const { coefficients } = data;
691
799
  const ruled = {
@@ -706,32 +814,74 @@ function materialLine(configuration, data, room, categoryId, productId) {
706
814
  coefficients
707
815
  });
708
816
  if (!cost.ok) return failedLine(ruled, "ERROR", cost.error);
709
- return pricedLine({
817
+ const materialCents = componentCents(cost.value);
818
+ if (materialCents === null) return failedLine(ruled, "ERROR", outOfRange());
819
+ const components = [{
820
+ kind: "MATERIAL",
821
+ ruleId: rule.id,
822
+ amountCents: materialCents
823
+ }];
824
+ const lineWarnings = [...warnings];
825
+ if (configuration.scope === "PARTIAL") {
826
+ let laborCents = 0;
827
+ if (rule.laborFormula === null) lineWarnings.push({
828
+ code: "LABOR_RATE_MISSING",
829
+ params: { categoryId }
830
+ });
831
+ else {
832
+ const labor = runNumericFormula(rule.laborFormula, {
833
+ variables: {
834
+ ...variables,
835
+ quantity: quantity.value
836
+ },
837
+ coefficients
838
+ });
839
+ if (!labor.ok) return failedLine(ruled, "ERROR", labor.error);
840
+ const cents = componentCents(labor.value);
841
+ if (cents === null) return failedLine(ruled, "ERROR", outOfRange());
842
+ laborCents = cents;
843
+ }
844
+ components.push({
845
+ kind: "LABOR",
846
+ ruleId: rule.id,
847
+ amountCents: laborCents
848
+ });
849
+ }
850
+ const amountCents = components.reduce((sum, component) => sum + component.amountCents, 0);
851
+ if (amountCents > 1e10) return failedLine(ruled, "ERROR", outOfRange());
852
+ return createLine({
710
853
  ...ruled,
854
+ warnings: lineWarnings,
711
855
  quantity: roundQuantity(quantity.value),
712
856
  unit: product.unit,
713
- unitPriceCents: product.priceCents
714
- }, cost.value);
857
+ unitPriceCents: product.priceCents,
858
+ amountCents,
859
+ components
860
+ });
715
861
  }
716
- function materialLines(configuration, data) {
717
- return configuration.rooms.flatMap((room) => room.selections.flatMap(({ categoryId, productId }) => productId === null ? [] : [materialLine(configuration, data, room, categoryId, productId)]));
862
+ function materialLines(configuration, data, objectScope) {
863
+ return configuration.rooms.flatMap((room) => {
864
+ const lines = room.selections.flatMap(({ categoryId, productId }) => productId === null ? [] : [materialLine(configuration, data, room, categoryId, productId, objectScope)]);
865
+ return configuration.scope === "PARTIAL" ? applySurfacePrep(data, room, lines, objectScope) : lines;
866
+ });
718
867
  }
719
868
  //#endregion
720
869
  //#region src/estimate/object-rules.ts
721
- function objectRuleLines(configuration, data) {
870
+ function objectRuleLines(configuration, data, section, variables) {
871
+ if (section === "BASE" && configuration.scope !== "FULL") return [];
722
872
  const scope = {
723
- variables: objectVariables(configuration),
873
+ variables,
724
874
  coefficients: data.coefficients
725
875
  };
726
- return data.rules.filter((rule) => rule.level === "OBJECT").sort((a, b) => a.sortOrder - b.sortOrder).map((rule) => {
876
+ return data.rules.filter((rule) => rule.level === "OBJECT" && rule.baseComponent === (section === "BASE")).sort((a, b) => a.sortOrder - b.sortOrder).map((rule) => {
727
877
  const init = {
728
- id: lineId("OBJECT", rule.id),
729
- section: "OBJECT",
878
+ id: lineId(section, rule.id),
879
+ section,
730
880
  ruleId: rule.id
731
881
  };
732
882
  const outcome = runNumericFormula(rule.costFormula, scope);
733
883
  return outcome.ok ? pricedLine(init, outcome.value) : failedLine(init, "ERROR", outcome.error);
734
- });
884
+ }).filter((line) => section !== "BASE" || line.status !== "OK" || line.amountCents !== 0);
735
885
  }
736
886
  //#endregion
737
887
  //#region src/schemas/common.ts
@@ -739,11 +889,17 @@ const LocalizedTextSchema = z.object({
739
889
  en: z.string(),
740
890
  uk: z.string()
741
891
  });
742
- const FinishLevelSchema = z.enum(["ROUGH", "PRE_FINISH"]);
892
+ const ScopeSchema = z.enum(["FULL", "PARTIAL"]);
893
+ const FinishLevelSchema = z.enum([
894
+ "ROUGH",
895
+ "PRE_FINISH",
896
+ "LIVED_IN"
897
+ ]);
743
898
  const RuleLevelSchema = z.enum([
744
899
  "OBJECT",
745
900
  "CATEGORY",
746
- "MATERIAL_TYPE"
901
+ "MATERIAL_TYPE",
902
+ "SURFACE"
747
903
  ]);
748
904
  const RateSourceSchema = z.enum(["NBU", "MANUAL"]);
749
905
  const SurfaceKindSchema = z.enum([
@@ -770,7 +926,9 @@ const RoomTypeCodeSchema = z.enum([
770
926
  "BEDROOM",
771
927
  "KITCHEN",
772
928
  "KITCHEN_LIVING",
773
- "BATHROOM"
929
+ "BATHROOM",
930
+ "HALLWAY",
931
+ "STORAGE"
774
932
  ]);
775
933
  const CurrencySchema = z.enum(["USD", "UAH"]);
776
934
  const SectionSchema = z.enum([
@@ -780,6 +938,11 @@ const SectionSchema = z.enum([
780
938
  "ENGINEERING",
781
939
  "ADDITIONAL"
782
940
  ]);
941
+ const LineComponentKindSchema = z.enum([
942
+ "MATERIAL",
943
+ "LABOR",
944
+ "PREP"
945
+ ]);
783
946
  const LineStatusSchema = z.enum([
784
947
  "OK",
785
948
  "INCLUDED",
@@ -823,8 +986,9 @@ function duplicateIndexes(items, key) {
823
986
  });
824
987
  }
825
988
  const ConfigurationSchema = z.object({
989
+ scope: ScopeSchema.default("FULL"),
826
990
  finishLevel: FinishLevelSchema,
827
- totalAreaSqm: z.number().positive(),
991
+ totalAreaSqm: z.number().positive().nullable(),
828
992
  balconyAreaSqm: z.number().min(0),
829
993
  balconyTiles: z.boolean(),
830
994
  rooms: z.array(RoomConfigurationSchema),
@@ -836,6 +1000,11 @@ const ConfigurationSchema = z.object({
836
1000
  path,
837
1001
  message: "Duplicate"
838
1002
  });
1003
+ if (configuration.scope === "FULL" !== (configuration.totalAreaSqm !== null)) ctx.addIssue({
1004
+ code: "custom",
1005
+ path: ["totalAreaSqm"],
1006
+ message: configuration.scope === "FULL" ? "Required" : "Not allowed"
1007
+ });
839
1008
  for (const index of duplicateIndexes(configuration.rooms, (room) => room.clientId)) addDuplicate([
840
1009
  "rooms",
841
1010
  index,
@@ -867,13 +1036,8 @@ const PricingLimitsSchema = z.object({
867
1036
  totalAreaSqm: RangeSchema,
868
1037
  rooms: RangeSchema,
869
1038
  roomSideM: RangeSchema,
870
- roomHeightM: RangeSchema
871
- });
872
- const BaseRateSchema = z.object({
873
- finishLevel: FinishLevelSchema,
874
- areaFromSqm: z.number().min(0),
875
- areaToSqm: z.number(),
876
- rateCentsPerSqm: z.int().positive()
1039
+ roomHeightM: RangeSchema,
1040
+ missingAreaHintSqm: z.number().min(0)
877
1041
  });
878
1042
  const PricingRuleSchema = z.object({
879
1043
  id: z.string(),
@@ -882,9 +1046,16 @@ const PricingRuleSchema = z.object({
882
1046
  materialTypeId: z.string().nullable(),
883
1047
  name: LocalizedTextSchema,
884
1048
  quantityFormula: z.string().nullable(),
1049
+ surface: z.enum([
1050
+ "FLOOR",
1051
+ "WALLS",
1052
+ "CEILING"
1053
+ ]).nullable(),
1054
+ baseComponent: z.boolean(),
885
1055
  costFormula: z.string().max(500),
1056
+ laborFormula: z.string().max(500).nullable(),
886
1057
  sortOrder: z.int()
887
- }).refine((rule) => rule.level === "OBJECT" === (rule.quantityFormula === null), { path: ["quantityFormula"] });
1058
+ }).refine((rule) => (rule.level === "OBJECT" || rule.level === "SURFACE") === (rule.quantityFormula === null), { path: ["quantityFormula"] }).refine((rule) => rule.level === "SURFACE" === (rule.surface !== null), { path: ["surface"] }).refine((rule) => rule.level !== "SURFACE" || rule.categoryId === null, { path: ["categoryId"] }).refine((rule) => !rule.baseComponent || rule.level === "OBJECT", { path: ["baseComponent"] }).refine((rule) => rule.laborFormula === null || rule.level === "CATEGORY" || rule.level === "MATERIAL_TYPE", { path: ["laborFormula"] });
888
1059
  const PricingRoomTypeSchema = z.object({
889
1060
  id: z.string(),
890
1061
  code: z.string()
@@ -892,7 +1063,8 @@ const PricingRoomTypeSchema = z.object({
892
1063
  const PricingCategorySchema = z.object({
893
1064
  id: z.string(),
894
1065
  wastePercent: z.number().min(0),
895
- surface: SurfaceKindSchema
1066
+ surface: SurfaceKindSchema,
1067
+ requiresPutty: z.boolean().default(true)
896
1068
  });
897
1069
  const PricingMaterialTypeSchema = z.object({
898
1070
  id: z.string(),
@@ -914,22 +1086,43 @@ const PricingPackageItemSchema = z.object({
914
1086
  priceCents: CentsSchema.nullable(),
915
1087
  unit: OptionUnitSchema.nullable()
916
1088
  });
917
- const PricingOptionSchema = z.object({
1089
+ const PricingOptionObjectSchema = z.object({
918
1090
  id: z.string(),
919
1091
  code: OptionCodeSchema.nullable(),
920
1092
  kind: OptionKindSchema,
921
- priceCents: CentsSchema,
1093
+ priceCents: CentsSchema.nullable(),
922
1094
  unit: OptionUnitSchema,
923
1095
  roomTypeCodes: z.array(z.string()),
1096
+ priceCoefficientKeys: z.array(z.string()),
1097
+ scopes: z.array(ScopeSchema).min(1),
924
1098
  minQuantity: z.int().nullable(),
925
1099
  maxQuantity: z.int().nullable(),
926
1100
  available: z.boolean()
927
1101
  });
1102
+ function findOptionPriceSourceViolation(priceCents, priceCoefficientKeys) {
1103
+ const hasKeys = priceCoefficientKeys.length > 0;
1104
+ if (priceCents !== null && hasKeys) return {
1105
+ path: "priceCents",
1106
+ message: "priceCents must be null when priceCoefficientKeys is set"
1107
+ };
1108
+ if (priceCents === null && !hasKeys) return {
1109
+ path: "priceCoefficientKeys",
1110
+ message: "priceCoefficientKeys is required when priceCents is null"
1111
+ };
1112
+ return null;
1113
+ }
1114
+ const PricingOptionSchema = PricingOptionObjectSchema.superRefine((option, context) => {
1115
+ const violation = findOptionPriceSourceViolation(option.priceCents, option.priceCoefficientKeys);
1116
+ if (violation !== null) context.addIssue({
1117
+ code: "custom",
1118
+ path: [violation.path],
1119
+ message: violation.message
1120
+ });
1121
+ });
928
1122
  const PricingDataSchema = z.object({
929
1123
  version: z.string().regex(/^[0-9a-f]{16}$/),
930
1124
  exchangeRate: ExchangeRateSchema.nullable(),
931
1125
  limits: PricingLimitsSchema,
932
- baseRates: z.array(BaseRateSchema),
933
1126
  coefficients: z.record(z.string(), z.number()),
934
1127
  rules: z.array(PricingRuleSchema),
935
1128
  roomTypes: z.array(PricingRoomTypeSchema),
@@ -945,6 +1138,12 @@ const EstimateMessageSchema = z.object({
945
1138
  code: z.string(),
946
1139
  params: z.record(z.string(), z.unknown()).optional()
947
1140
  });
1141
+ const LineComponentSchema = z.object({
1142
+ kind: LineComponentKindSchema,
1143
+ ruleId: z.string(),
1144
+ amountCents: z.number().int().min(0),
1145
+ sharedWithCategoryId: z.string().optional()
1146
+ });
948
1147
  const EstimateLineSchema = z.object({
949
1148
  id: z.string(),
950
1149
  section: SectionSchema,
@@ -960,6 +1159,7 @@ const EstimateLineSchema = z.object({
960
1159
  unitPriceCents: CentsSchema.nullable(),
961
1160
  amountCents: CentsSchema,
962
1161
  amountUahKopecks: CentsSchema.nullable(),
1162
+ components: z.array(LineComponentSchema).optional(),
963
1163
  warnings: z.array(EstimateMessageSchema),
964
1164
  error: EstimateMessageSchema.nullable()
965
1165
  });
@@ -997,18 +1197,34 @@ const RuleSampleSchema = z.object({
997
1197
  unit: ProductUnitSchema,
998
1198
  wastePercent: z.number().min(0)
999
1199
  }).optional(),
1200
+ surfaceArea: z.number().min(0).optional(),
1201
+ puttyRequired: z.boolean().optional(),
1000
1202
  object: z.object({
1001
- totalAreaSqm: z.number().positive(),
1203
+ scope: ScopeSchema,
1204
+ totalAreaSqm: z.number().positive().nullable(),
1002
1205
  balconyAreaSqm: z.number().min(0),
1003
1206
  balconyTiles: z.boolean(),
1004
1207
  finishLevel: FinishLevelSchema,
1005
1208
  roomsCount: z.int().min(0),
1006
- bathroomsCount: z.int().min(0)
1209
+ bathroomsCount: z.int().min(0),
1210
+ roomsFloorArea: z.number().min(0),
1211
+ roomsWallArea: z.number().min(0),
1212
+ roomsPuttyWallArea: z.number().min(0).optional(),
1213
+ roomsPerimeter: z.number().min(0),
1214
+ wetRoomsCount: z.int().min(0)
1007
1215
  })
1216
+ }).superRefine((sample, ctx) => {
1217
+ const { scope, totalAreaSqm } = sample.object;
1218
+ if (scope === "FULL" !== (totalAreaSqm !== null)) ctx.addIssue({
1219
+ code: "custom",
1220
+ path: ["object", "totalAreaSqm"],
1221
+ message: scope === "FULL" ? "Required" : "Not allowed"
1222
+ });
1008
1223
  });
1009
1224
  const RulePreviewSchema = z.object({
1010
1225
  quantity: z.number().nullable(),
1011
1226
  amountCents: CentsSchema.nullable(),
1227
+ laborCents: CentsSchema.nullable(),
1012
1228
  variables: z.record(z.string(), z.union([
1013
1229
  z.number(),
1014
1230
  z.boolean(),
@@ -1043,20 +1259,28 @@ function isIncomplete(lines) {
1043
1259
  }
1044
1260
  //#endregion
1045
1261
  //#region src/estimate/warnings.ts
1046
- function roomsAreaWarnings(configuration) {
1047
- const roomsAreaSqm = Math.round(configuration.rooms.reduce((sum, room) => sum + roomVariables(room).floorArea, 0) * 1e4) / 1e4;
1048
- return roomsAreaSqm > configuration.totalAreaSqm ? [{
1262
+ const roundArea = (value) => Math.round(value * 1e4) / 1e4;
1263
+ function roomsAreaWarnings(configuration, limits) {
1264
+ const totalAreaSqm = configuration.totalAreaSqm;
1265
+ if (configuration.scope !== "FULL" || totalAreaSqm === null) return [];
1266
+ const roomsAreaSqm = roundArea(configuration.rooms.reduce((sum, room) => sum + roomVariables(room).floorArea, 0));
1267
+ if (roomsAreaSqm > totalAreaSqm) return [{
1049
1268
  code: "ROOMS_AREA_EXCEEDS_TOTAL",
1050
1269
  params: {
1051
1270
  roomsAreaSqm,
1052
- totalAreaSqm: configuration.totalAreaSqm
1271
+ totalAreaSqm
1053
1272
  }
1273
+ }];
1274
+ const missingSqm = roundArea(totalAreaSqm - roomsAreaSqm);
1275
+ return missingSqm > limits.missingAreaHintSqm ? [{
1276
+ code: "ROOMS_AREA_BELOW_TOTAL",
1277
+ params: { missingSqm }
1054
1278
  }] : [];
1055
1279
  }
1056
- function estimateWarnings(lines, configuration, rate) {
1280
+ function estimateWarnings(lines, configuration, rate, limits) {
1057
1281
  return [
1058
1282
  ...collectWarnings(lines),
1059
- ...roomsAreaWarnings(configuration),
1283
+ ...roomsAreaWarnings(configuration, limits),
1060
1284
  ...configuration.currency === "UAH" && rate === null ? [{ code: "EXCHANGE_RATE_UNAVAILABLE" }] : []
1061
1285
  ];
1062
1286
  }
@@ -1070,10 +1294,11 @@ function withKopecks(line, rate) {
1070
1294
  }
1071
1295
  function estimate(configuration, data) {
1072
1296
  const rate = data.exchangeRate?.rate ?? null;
1297
+ const variables = objectVariables(configuration, data.categories);
1073
1298
  const lines = [
1074
- baseLine(configuration, data),
1075
- ...objectRuleLines(configuration, data),
1076
- ...materialLines(configuration, data),
1299
+ ...objectRuleLines(configuration, data, "BASE", variables),
1300
+ ...objectRuleLines(configuration, data, "OBJECT", variables),
1301
+ ...materialLines(configuration, data, variables),
1077
1302
  ...engineeringLines(configuration, data),
1078
1303
  ...additionalLines(configuration, data)
1079
1304
  ].map((line) => withKopecks(line, rate));
@@ -1083,7 +1308,7 @@ function estimate(configuration, data) {
1083
1308
  exchangeRate: data.exchangeRate,
1084
1309
  lines,
1085
1310
  totals: computeTotals(lines, rate !== null),
1086
- warnings: estimateWarnings(lines, configuration, rate),
1311
+ warnings: estimateWarnings(lines, configuration, rate, data.limits),
1087
1312
  incomplete: isIncomplete(lines)
1088
1313
  };
1089
1314
  }
@@ -1098,8 +1323,9 @@ function checkLimits(configuration, limits, options = []) {
1098
1323
  max
1099
1324
  });
1100
1325
  };
1101
- check("totalAreaSqm", configuration.totalAreaSqm, limits.totalAreaSqm.min, limits.totalAreaSqm.max);
1102
- check("balconyAreaSqm", configuration.balconyAreaSqm, 0, configuration.totalAreaSqm);
1326
+ const isFull = configuration.scope === "FULL";
1327
+ if (isFull && configuration.totalAreaSqm !== null) check("totalAreaSqm", configuration.totalAreaSqm, limits.totalAreaSqm.min, limits.totalAreaSqm.max);
1328
+ check("balconyAreaSqm", configuration.balconyAreaSqm, 0, isFull && configuration.totalAreaSqm !== null ? configuration.totalAreaSqm : limits.totalAreaSqm.max);
1103
1329
  check("rooms", configuration.rooms.length, limits.rooms.min, limits.rooms.max);
1104
1330
  configuration.rooms.forEach((room, index) => {
1105
1331
  const path = `rooms[${index}]`;
@@ -1155,17 +1381,35 @@ function referencesOf(sources) {
1155
1381
  }
1156
1382
  function objectScope({ object }) {
1157
1383
  return {
1158
- totalArea: object.totalAreaSqm,
1384
+ scope: object.scope,
1385
+ totalArea: totalAreaOf(object.scope, object.totalAreaSqm, object.roomsFloorArea),
1159
1386
  balconyArea: object.balconyAreaSqm,
1160
1387
  balconyTiles: object.balconyTiles,
1161
1388
  finishLevel: object.finishLevel,
1162
1389
  roomsCount: object.roomsCount,
1163
- bathroomsCount: object.bathroomsCount
1390
+ bathroomsCount: object.bathroomsCount,
1391
+ roomsFloorArea: object.roomsFloorArea,
1392
+ roomsWallArea: object.roomsWallArea,
1393
+ roomsPuttyWallArea: object.roomsPuttyWallArea ?? object.roomsWallArea,
1394
+ roomsPerimeter: object.roomsPerimeter,
1395
+ wetRoomsCount: object.wetRoomsCount
1164
1396
  };
1165
1397
  }
1166
1398
  function scopeOf(level, sample) {
1167
1399
  if (level === "OBJECT") return { scope: objectScope(sample) };
1168
- const { room, product } = sample;
1400
+ const { room, product, surfaceArea } = sample;
1401
+ if (level === "SURFACE") {
1402
+ if (room === void 0 || surfaceArea === void 0) return { error: {
1403
+ code: "SAMPLE_INCOMPLETE",
1404
+ params: { missing: room === void 0 ? "room" : "surfaceArea" }
1405
+ } };
1406
+ return { scope: {
1407
+ ...roomVariables(room),
1408
+ ...objectScope(sample),
1409
+ surfaceArea,
1410
+ puttyRequired: sample.puttyRequired ?? true
1411
+ } };
1412
+ }
1169
1413
  if (room === void 0 || product === void 0) return { error: {
1170
1414
  code: "SAMPLE_INCOMPLETE",
1171
1415
  params: { missing: room === void 0 ? "room" : "product" }
@@ -1181,15 +1425,21 @@ function pick(source, names) {
1181
1425
  }
1182
1426
  function previewRule(input) {
1183
1427
  const { level, quantityFormula, costFormula, coefficients } = input;
1184
- const references = referencesOf([quantityFormula, costFormula]);
1185
- const report = (variables, quantity, amountCents, error) => ({
1428
+ const laborFormula = quantityFormula === null ? null : input.laborFormula ?? null;
1429
+ const references = referencesOf([
1430
+ quantityFormula,
1431
+ costFormula,
1432
+ laborFormula
1433
+ ]);
1434
+ const report = (variables, quantity, amountCents, error, laborCents = null) => ({
1186
1435
  quantity,
1187
1436
  amountCents,
1437
+ laborCents,
1188
1438
  variables: pick(variables, references.variables),
1189
1439
  coefficients: pick(coefficients, references.coefficients),
1190
1440
  error
1191
1441
  });
1192
- if (level === "OBJECT" !== (quantityFormula === null)) return report({}, null, null, { code: "QUANTITY_FORMULA_MISMATCH" });
1442
+ if ((level === "OBJECT" || level === "SURFACE") !== (quantityFormula === null)) return report({}, null, null, { code: "QUANTITY_FORMULA_MISMATCH" });
1193
1443
  const prepared = scopeOf(level, input.sample);
1194
1444
  if ("error" in prepared) return report({}, null, null, prepared.error);
1195
1445
  let variables = prepared.scope;
@@ -1215,7 +1465,15 @@ function previewRule(input) {
1215
1465
  id: "preview",
1216
1466
  section: "OBJECT"
1217
1467
  }, cost.value);
1218
- return line.error === null ? report(variables, quantity, line.amountCents, null) : report(variables, quantity, null, line.error);
1468
+ if (line.error !== null) return report(variables, quantity, null, line.error);
1469
+ if (laborFormula === null) return report(variables, quantity, line.amountCents, null);
1470
+ const labor = runNumericFormula(laborFormula, {
1471
+ variables,
1472
+ coefficients
1473
+ });
1474
+ if (!labor.ok) return report(variables, quantity, line.amountCents, labor.error);
1475
+ const laborCents = componentCents(labor.value);
1476
+ return laborCents === null ? report(variables, quantity, line.amountCents, outOfRange()) : report(variables, quantity, line.amountCents, null, laborCents);
1219
1477
  }
1220
1478
  //#endregion
1221
1479
  //#region src/formula/suggest.ts
@@ -1287,10 +1545,18 @@ const FORMULA_SCOPE_VARIABLES = {
1287
1545
  boolean("balconyTiles"),
1288
1546
  enumeration("finishLevel", FinishLevelSchema.options),
1289
1547
  number("roomsCount"),
1290
- number("bathroomsCount")
1548
+ number("bathroomsCount"),
1549
+ enumeration("scope", ScopeSchema.options),
1550
+ number("roomsFloorArea"),
1551
+ number("roomsWallArea"),
1552
+ number("roomsPuttyWallArea"),
1553
+ number("roomsPerimeter"),
1554
+ number("wetRoomsCount")
1291
1555
  ]
1292
1556
  };
1293
1557
  const FORMULA_QUANTITY_VARIABLE = number("quantity");
1558
+ const FORMULA_SURFACE_AREA_VARIABLE = number("surfaceArea");
1559
+ const FORMULA_PUTTY_REQUIRED_VARIABLE = boolean("puttyRequired");
1294
1560
  const FORMULA_VARIABLES = {
1295
1561
  OBJECT: FORMULA_SCOPE_VARIABLES.object,
1296
1562
  CATEGORY: [
@@ -1302,9 +1568,15 @@ const FORMULA_VARIABLES = {
1302
1568
  ...FORMULA_SCOPE_VARIABLES.room,
1303
1569
  ...FORMULA_SCOPE_VARIABLES.product,
1304
1570
  ...FORMULA_SCOPE_VARIABLES.object
1571
+ ],
1572
+ SURFACE: [
1573
+ ...FORMULA_SCOPE_VARIABLES.room,
1574
+ ...FORMULA_SCOPE_VARIABLES.object,
1575
+ FORMULA_SURFACE_AREA_VARIABLE,
1576
+ FORMULA_PUTTY_REQUIRED_VARIABLE
1305
1577
  ]
1306
1578
  };
1307
- const ALL_VARIABLE_NAMES = new Set(FORMULA_VARIABLES.CATEGORY.map((variable) => variable.name));
1579
+ const ALL_VARIABLE_NAMES = new Set([...FORMULA_VARIABLES.CATEGORY, ...FORMULA_VARIABLES.SURFACE].map((variable) => variable.name));
1308
1580
  function span(node) {
1309
1581
  return {
1310
1582
  position: node.start,
@@ -1328,7 +1600,7 @@ function validateFormula(input) {
1328
1600
  const knownCoefficients = new Set(input.coefficientKeys);
1329
1601
  const usedCoefficients = /* @__PURE__ */ new Set();
1330
1602
  const errors = [];
1331
- const quantityAllowed = input.kind === "cost" && input.level !== "OBJECT";
1603
+ const quantityAllowed = input.kind !== "quantity" && (input.level === "CATEGORY" || input.level === "MATERIAL_TYPE");
1332
1604
  function mismatch(node, expected) {
1333
1605
  errors.push({
1334
1606
  code: "FORMULA_SYNTAX",
@@ -1516,4 +1788,4 @@ function validateFormula(input) {
1516
1788
  };
1517
1789
  }
1518
1790
  //#endregion
1519
- export { BaseRateSchema, COEFFICIENT_NAMESPACE, CentsSchema, ConfigurationSchema, CurrencySchema, EstimateLineSchema, EstimateMessageSchema, EstimateSchema, EstimateTotalsSchema, EstimateWarningSchema, ExchangeRateSchema, FORMULA_FUNCTIONS, FORMULA_FUNCTION_TABLE, FORMULA_LIMITS, FORMULA_QUANTITY_VARIABLE, FORMULA_SCOPE_VARIABLES, FORMULA_VARIABLES, FinishLevelSchema, LineStatusSchema, LocalizedTextSchema, OptionCodeSchema, OptionKindSchema, OptionSelectionSchema, OptionUnitSchema, PricingCategorySchema, PricingDataSchema, PricingLimitsSchema, PricingMaterialTypeSchema, PricingOptionSchema, PricingPackageItemSchema, PricingProductSchema, PricingRoomTypeSchema, PricingRuleSchema, ProductUnitSchema, RangeSchema, RateSourceSchema, RoomConfigurationSchema, RoomSelectionSchema, RoomTypeCodeSchema, RuleLevelSchema, RulePreviewSchema, RuleSampleSchema, SectionSchema, SectionTotalSchema, SurfaceKindSchema, checkLimits, convertCentsToKopecks, estimate, evaluate, levenshtein, objectVariables, parse, previewRule, productVariables, productVariablesOf, roomVariables, suggestNearest, toCents, validateFormula };
1791
+ export { COEFFICIENT_NAMESPACE, CentsSchema, ConfigurationSchema, CurrencySchema, EstimateLineSchema, EstimateMessageSchema, EstimateSchema, EstimateTotalsSchema, EstimateWarningSchema, ExchangeRateSchema, FORMULA_FUNCTIONS, FORMULA_FUNCTION_TABLE, FORMULA_LIMITS, FORMULA_PUTTY_REQUIRED_VARIABLE, FORMULA_QUANTITY_VARIABLE, FORMULA_SCOPE_VARIABLES, FORMULA_SURFACE_AREA_VARIABLE, FORMULA_VARIABLES, FinishLevelSchema, LineComponentKindSchema, LineComponentSchema, LineStatusSchema, LocalizedTextSchema, OptionCodeSchema, OptionKindSchema, OptionSelectionSchema, OptionUnitSchema, PricingCategorySchema, PricingDataSchema, PricingLimitsSchema, PricingMaterialTypeSchema, PricingOptionSchema, PricingPackageItemSchema, PricingProductSchema, PricingRoomTypeSchema, PricingRuleSchema, ProductUnitSchema, RangeSchema, RateSourceSchema, RoomConfigurationSchema, RoomSelectionSchema, RoomTypeCodeSchema, RuleLevelSchema, RulePreviewSchema, RuleSampleSchema, ScopeSchema, SectionSchema, SectionTotalSchema, SurfaceKindSchema, checkLimits, convertCentsToKopecks, estimate, evaluate, findOptionPriceSourceViolation, levenshtein, objectVariables, parse, previewRule, productVariables, productVariablesOf, puttyRequiredOn, roomVariables, suggestNearest, toCents, totalAreaOf, validateFormula };