@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.cjs CHANGED
@@ -51,6 +51,73 @@ function toCents(dollars) {
51
51
  return cents;
52
52
  }
53
53
  //#endregion
54
+ //#region src/variables/putty.ts
55
+ function puttyRequiredOn(room, surface, categories) {
56
+ const selected = categories.filter((category) => category.surface === surface && room.selections.some((selection) => selection.categoryId === category.id));
57
+ return selected.length === 0 || selected.some((category) => category.requiresPutty);
58
+ }
59
+ //#endregion
60
+ //#region src/variables/room.ts
61
+ function roomVariables(room) {
62
+ const perimeter = 2 * (room.lengthM + room.widthM);
63
+ return {
64
+ length: room.lengthM,
65
+ width: room.widthM,
66
+ height: room.heightM,
67
+ floorArea: room.lengthM * room.widthM,
68
+ perimeter,
69
+ wallArea: perimeter * room.heightM,
70
+ heatedFloor: room.heatedFloor,
71
+ roomType: room.roomTypeCode
72
+ };
73
+ }
74
+ //#endregion
75
+ //#region src/variables/object.ts
76
+ const COUNTED_AS_ROOMS = [
77
+ "LIVING_ROOM",
78
+ "BEDROOM",
79
+ "KITCHEN_LIVING"
80
+ ];
81
+ const WET_ROOMS = [
82
+ "BATHROOM",
83
+ "KITCHEN",
84
+ "KITCHEN_LIVING"
85
+ ];
86
+ function totalAreaOf(scope, totalAreaSqm, roomsFloorArea) {
87
+ return scope === "PARTIAL" || totalAreaSqm === null ? roomsFloorArea : totalAreaSqm;
88
+ }
89
+ function objectVariables(configuration, categories) {
90
+ const rooms = configuration.rooms.map(roomVariables);
91
+ const sum = (pick) => rooms.reduce((total, room) => total + pick(room), 0);
92
+ const roomsFloorArea = sum((room) => room.floorArea);
93
+ return {
94
+ scope: configuration.scope,
95
+ totalArea: totalAreaOf(configuration.scope, configuration.totalAreaSqm, roomsFloorArea),
96
+ balconyArea: configuration.balconyAreaSqm,
97
+ balconyTiles: configuration.balconyTiles,
98
+ finishLevel: configuration.finishLevel,
99
+ roomsCount: configuration.rooms.filter((room) => COUNTED_AS_ROOMS.includes(room.roomTypeCode)).length,
100
+ bathroomsCount: configuration.rooms.filter((room) => room.roomTypeCode === "BATHROOM").length,
101
+ roomsFloorArea,
102
+ roomsWallArea: sum((room) => room.wallArea),
103
+ roomsPuttyWallArea: configuration.rooms.reduce((total, room) => puttyRequiredOn(room, "WALLS", categories) ? total + roomVariables(room).wallArea : total, 0),
104
+ roomsPerimeter: sum((room) => room.perimeter),
105
+ wetRoomsCount: rooms.filter((room) => WET_ROOMS.includes(room.roomType)).length
106
+ };
107
+ }
108
+ //#endregion
109
+ //#region src/variables/product.ts
110
+ function productVariablesOf(product, wastePercent) {
111
+ return {
112
+ price: product.priceCents / 100,
113
+ waste: wastePercent / 100,
114
+ unit: product.unit
115
+ };
116
+ }
117
+ function productVariables(product, category) {
118
+ return productVariablesOf(product, product.wastePercentOverride ?? category.wastePercent);
119
+ }
120
+ //#endregion
54
121
  //#region src/estimate/line.ts
55
122
  const MAX_LINE_CENTS = 1e10;
56
123
  function createLine(init) {
@@ -80,18 +147,25 @@ function failedLine(init, status, error) {
80
147
  });
81
148
  }
82
149
  function pricedLine(init, dollars) {
83
- if (!Number.isFinite(dollars) || dollars < 0 || dollars * 100 > 1e10) return failedLine(init, "ERROR", {
84
- code: "AMOUNT_OUT_OF_RANGE",
85
- params: { maxCents: MAX_LINE_CENTS }
86
- });
87
- return createLine({
150
+ const amountCents = componentCents(dollars);
151
+ return amountCents === null ? failedLine(init, "ERROR", outOfRange()) : createLine({
88
152
  ...init,
89
- amountCents: toCents(dollars)
153
+ amountCents
90
154
  });
91
155
  }
92
156
  function roundQuantity(quantity) {
93
157
  return Math.round(quantity * 1e3) / 1e3;
94
158
  }
159
+ function componentCents(dollars) {
160
+ if (!Number.isFinite(dollars) || dollars < 0 || dollars * 100 > 1e10) return null;
161
+ return toCents(dollars);
162
+ }
163
+ function outOfRange() {
164
+ return {
165
+ code: "AMOUNT_OUT_OF_RANGE",
166
+ params: { maxCents: MAX_LINE_CENTS }
167
+ };
168
+ }
95
169
  //#endregion
96
170
  //#region src/estimate/line-id.ts
97
171
  function lineId(section, ...parts) {
@@ -107,16 +181,39 @@ function unitQuantity(unit, rooms, pieces) {
107
181
  case "ROOM_SQM": return rooms.reduce((sum, room) => sum + room.lengthM * room.widthM, 0);
108
182
  }
109
183
  }
110
- function optionLine(configuration, option, selection) {
184
+ function optionUnitPriceCents(option, coefficients) {
185
+ if (option.priceCoefficientKeys.length === 0) return option.priceCents;
186
+ let dollars = 0;
187
+ for (const key of option.priceCoefficientKeys) {
188
+ const value = Object.hasOwn(coefficients, key) ? coefficients[key] : void 0;
189
+ if (value === void 0) return { missingKey: key };
190
+ dollars += value;
191
+ }
192
+ return componentCents(dollars);
193
+ }
194
+ function optionLine(configuration, option, selection, coefficients = {}) {
111
195
  const init = {
112
196
  id: lineId(option.kind, "option", option.id),
113
197
  section: option.kind,
114
198
  optionId: option.id
115
199
  };
200
+ if (!option.scopes.includes(configuration.scope)) return failedLine(init, "UNAVAILABLE", {
201
+ code: "OPTION_SCOPE_NOT_ALLOWED",
202
+ params: {
203
+ optionId: option.id,
204
+ scope: configuration.scope
205
+ }
206
+ });
116
207
  if (!option.available) return failedLine(init, "UNAVAILABLE", {
117
208
  code: "PRODUCT_UNAVAILABLE",
118
209
  params: { optionId: option.id }
119
210
  });
211
+ const unitPriceCents = optionUnitPriceCents(option, coefficients);
212
+ if (unitPriceCents === null) return failedLine(init, "ERROR", outOfRange());
213
+ if (typeof unitPriceCents !== "number") return failedLine(init, "ERROR", {
214
+ code: "UNKNOWN_COEFFICIENT",
215
+ params: { key: unitPriceCents.missingKey }
216
+ });
120
217
  const selected = configuration.rooms.filter((room) => selection.roomClientIds?.includes(room.clientId));
121
218
  const allowed = option.roomTypeCodes.length === 0 ? selected : selected.filter((room) => option.roomTypeCodes.includes(room.roomTypeCode));
122
219
  const rejected = selected.filter((room) => !allowed.includes(room));
@@ -125,7 +222,7 @@ function optionLine(configuration, option, selection) {
125
222
  ...init,
126
223
  quantity: roundQuantity(quantity),
127
224
  unit: option.unit,
128
- unitPriceCents: option.priceCents,
225
+ unitPriceCents,
129
226
  warnings: rejected.map((room) => ({
130
227
  code: "OPTION_ROOM_TYPE_NOT_ALLOWED",
131
228
  params: {
@@ -133,12 +230,12 @@ function optionLine(configuration, option, selection) {
133
230
  roomTypeCode: room.roomTypeCode
134
231
  }
135
232
  }))
136
- }, option.priceCents / 100 * quantity);
233
+ }, unitPriceCents / 100 * quantity);
137
234
  }
138
235
  function optionLines(configuration, data, kind) {
139
236
  return configuration.options.flatMap((selection) => {
140
237
  const option = data.options.find((item) => item.id === selection.optionId);
141
- return option?.kind === kind ? [optionLine(configuration, option, selection)] : [];
238
+ return option?.kind === kind ? [optionLine(configuration, option, selection, data.coefficients)] : [];
142
239
  });
143
240
  }
144
241
  //#endregion
@@ -147,29 +244,6 @@ function additionalLines(configuration, data) {
147
244
  return optionLines(configuration, data, "ADDITIONAL");
148
245
  }
149
246
  //#endregion
150
- //#region src/estimate/base.ts
151
- function baseLine(configuration, data) {
152
- const init = {
153
- id: lineId("BASE"),
154
- section: "BASE"
155
- };
156
- const area = configuration.totalAreaSqm;
157
- const rate = data.baseRates.find((candidate) => candidate.finishLevel === configuration.finishLevel && candidate.areaFromSqm <= area && area < candidate.areaToSqm);
158
- if (rate === void 0) return failedLine(init, "NOT_CALCULATED", {
159
- code: "BASE_RATE_NOT_FOUND",
160
- params: {
161
- totalAreaSqm: area,
162
- finishLevel: configuration.finishLevel
163
- }
164
- });
165
- return pricedLine({
166
- ...init,
167
- quantity: roundQuantity(area),
168
- unit: "SQM",
169
- unitPriceCents: rate.rateCentsPerSqm
170
- }, rate.rateCentsPerSqm / 100 * area);
171
- }
172
- //#endregion
173
247
  //#region src/estimate/engineering.ts
174
248
  function packageLine(configuration, item) {
175
249
  const init = {
@@ -194,51 +268,7 @@ function packageLine(configuration, item) {
194
268
  }, item.priceCents / 100 * quantity);
195
269
  }
196
270
  function engineeringLines(configuration, data) {
197
- return [...data.packageItems.map((item) => packageLine(configuration, item)), ...optionLines(configuration, data, "ENGINEERING")];
198
- }
199
- //#endregion
200
- //#region src/variables/object.ts
201
- const COUNTED_AS_ROOMS = [
202
- "LIVING_ROOM",
203
- "BEDROOM",
204
- "KITCHEN_LIVING"
205
- ];
206
- function objectVariables(configuration) {
207
- return {
208
- totalArea: configuration.totalAreaSqm,
209
- balconyArea: configuration.balconyAreaSqm,
210
- balconyTiles: configuration.balconyTiles,
211
- finishLevel: configuration.finishLevel,
212
- roomsCount: configuration.rooms.filter((room) => COUNTED_AS_ROOMS.includes(room.roomTypeCode)).length,
213
- bathroomsCount: configuration.rooms.filter((room) => room.roomTypeCode === "BATHROOM").length
214
- };
215
- }
216
- //#endregion
217
- //#region src/variables/product.ts
218
- function productVariablesOf(product, wastePercent) {
219
- return {
220
- price: product.priceCents / 100,
221
- waste: wastePercent / 100,
222
- unit: product.unit
223
- };
224
- }
225
- function productVariables(product, category) {
226
- return productVariablesOf(product, product.wastePercentOverride ?? category.wastePercent);
227
- }
228
- //#endregion
229
- //#region src/variables/room.ts
230
- function roomVariables(room) {
231
- const perimeter = 2 * (room.lengthM + room.widthM);
232
- return {
233
- length: room.lengthM,
234
- width: room.widthM,
235
- height: room.heightM,
236
- floorArea: room.lengthM * room.widthM,
237
- perimeter,
238
- wallArea: perimeter * room.heightM,
239
- heatedFloor: room.heatedFloor,
240
- roomType: room.roomTypeCode
241
- };
271
+ return [...(configuration.scope === "PARTIAL" ? data.packageItems.filter((item) => !item.includedInBase) : data.packageItems).map((item) => packageLine(configuration, item)), ...optionLines(configuration, data, "ENGINEERING")];
242
272
  }
243
273
  //#endregion
244
274
  //#region src/formula/language.ts
@@ -672,13 +702,91 @@ function runNumericFormula(source, scope) {
672
702
  };
673
703
  }
674
704
  //#endregion
705
+ //#region src/estimate/surfaces.ts
706
+ function surfaceRuleOf(data, surface) {
707
+ return data.rules.filter((rule) => rule.level === "SURFACE" && rule.surface === surface).sort((a, b) => a.sortOrder - b.sortOrder)[0];
708
+ }
709
+ function failedWith(line, error) {
710
+ return failedLine({
711
+ id: line.id,
712
+ section: line.section,
713
+ roomClientId: line.roomClientId,
714
+ categoryId: line.categoryId,
715
+ productId: line.productId,
716
+ ruleId: line.ruleId,
717
+ warnings: line.warnings
718
+ }, "ERROR", error);
719
+ }
720
+ function withComponent(line, component) {
721
+ const components = [...line.components ?? [], component];
722
+ const amountCents = components.reduce((sum, item) => sum + item.amountCents, 0);
723
+ if (amountCents > 1e10) return failedWith(line, outOfRange());
724
+ return {
725
+ ...line,
726
+ components,
727
+ amountCents
728
+ };
729
+ }
730
+ function applySurfacePrep(data, room, lines, objectScope) {
731
+ const result = [...lines];
732
+ const variables = {
733
+ ...roomVariables(room),
734
+ ...objectScope
735
+ };
736
+ const areas = {
737
+ FLOOR: variables.floorArea,
738
+ CEILING: variables.floorArea,
739
+ WALLS: variables.wallArea
740
+ };
741
+ const surfaces = /* @__PURE__ */ new Map();
742
+ lines.forEach((line, index) => {
743
+ const category = data.categories.find((item) => item.id === line.categoryId);
744
+ if (line.status === "OK" && category !== void 0 && category.surface !== "NONE") {
745
+ const surface = category.surface;
746
+ surfaces.set(surface, [...surfaces.get(surface) ?? [], index]);
747
+ }
748
+ });
749
+ for (const [surface, indexes] of surfaces) {
750
+ const rule = surfaceRuleOf(data, surface);
751
+ if (rule === void 0) continue;
752
+ const outcome = runNumericFormula(rule.costFormula, {
753
+ variables: {
754
+ ...variables,
755
+ surfaceArea: areas[surface],
756
+ puttyRequired: puttyRequiredOn(room, surface, data.categories)
757
+ },
758
+ coefficients: data.coefficients
759
+ });
760
+ const cents = outcome.ok ? componentCents(outcome.value) : null;
761
+ const carrierCategoryId = lines[indexes[0]]?.categoryId ?? null;
762
+ indexes.forEach((index, position) => {
763
+ const line = lines[index];
764
+ if (!outcome.ok || cents === null) {
765
+ result[index] = failedWith(line, outcome.ok ? outOfRange() : outcome.error);
766
+ return;
767
+ }
768
+ result[index] = withComponent(line, position === 0 ? {
769
+ kind: "PREP",
770
+ ruleId: rule.id,
771
+ amountCents: cents
772
+ } : {
773
+ kind: "PREP",
774
+ ruleId: rule.id,
775
+ amountCents: 0,
776
+ sharedWithCategoryId: carrierCategoryId ?? void 0
777
+ });
778
+ });
779
+ }
780
+ return result;
781
+ }
782
+ //#endregion
675
783
  //#region src/estimate/materials.ts
676
784
  const isMaterialRule = (rule) => rule.quantityFormula !== null;
677
785
  function selectRule(data, categoryId, materialTypeId) {
678
786
  const rules = data.rules.filter(isMaterialRule);
679
787
  return rules.find((rule) => rule.level === "MATERIAL_TYPE" && rule.categoryId === categoryId && rule.materialTypeId === materialTypeId) ?? rules.find((rule) => rule.level === "CATEGORY" && rule.categoryId === categoryId);
680
788
  }
681
- function materialLine(configuration, data, room, categoryId, productId) {
789
+ function materialLine(configuration, data, room, categoryId, productId, objectScope) {
682
790
  const init = {
683
791
  id: lineId("MATERIALS", room.clientId, categoryId),
684
792
  section: "MATERIALS",
@@ -710,7 +818,7 @@ function materialLine(configuration, data, room, categoryId, productId) {
710
818
  const variables = {
711
819
  ...roomVariables(room),
712
820
  ...productVariables(product, category),
713
- ...objectVariables(configuration)
821
+ ...objectScope
714
822
  };
715
823
  const { coefficients } = data;
716
824
  const ruled = {
@@ -731,32 +839,74 @@ function materialLine(configuration, data, room, categoryId, productId) {
731
839
  coefficients
732
840
  });
733
841
  if (!cost.ok) return failedLine(ruled, "ERROR", cost.error);
734
- return pricedLine({
842
+ const materialCents = componentCents(cost.value);
843
+ if (materialCents === null) return failedLine(ruled, "ERROR", outOfRange());
844
+ const components = [{
845
+ kind: "MATERIAL",
846
+ ruleId: rule.id,
847
+ amountCents: materialCents
848
+ }];
849
+ const lineWarnings = [...warnings];
850
+ if (configuration.scope === "PARTIAL") {
851
+ let laborCents = 0;
852
+ if (rule.laborFormula === null) lineWarnings.push({
853
+ code: "LABOR_RATE_MISSING",
854
+ params: { categoryId }
855
+ });
856
+ else {
857
+ const labor = runNumericFormula(rule.laborFormula, {
858
+ variables: {
859
+ ...variables,
860
+ quantity: quantity.value
861
+ },
862
+ coefficients
863
+ });
864
+ if (!labor.ok) return failedLine(ruled, "ERROR", labor.error);
865
+ const cents = componentCents(labor.value);
866
+ if (cents === null) return failedLine(ruled, "ERROR", outOfRange());
867
+ laborCents = cents;
868
+ }
869
+ components.push({
870
+ kind: "LABOR",
871
+ ruleId: rule.id,
872
+ amountCents: laborCents
873
+ });
874
+ }
875
+ const amountCents = components.reduce((sum, component) => sum + component.amountCents, 0);
876
+ if (amountCents > 1e10) return failedLine(ruled, "ERROR", outOfRange());
877
+ return createLine({
735
878
  ...ruled,
879
+ warnings: lineWarnings,
736
880
  quantity: roundQuantity(quantity.value),
737
881
  unit: product.unit,
738
- unitPriceCents: product.priceCents
739
- }, cost.value);
882
+ unitPriceCents: product.priceCents,
883
+ amountCents,
884
+ components
885
+ });
740
886
  }
741
- function materialLines(configuration, data) {
742
- return configuration.rooms.flatMap((room) => room.selections.flatMap(({ categoryId, productId }) => productId === null ? [] : [materialLine(configuration, data, room, categoryId, productId)]));
887
+ function materialLines(configuration, data, objectScope) {
888
+ return configuration.rooms.flatMap((room) => {
889
+ const lines = room.selections.flatMap(({ categoryId, productId }) => productId === null ? [] : [materialLine(configuration, data, room, categoryId, productId, objectScope)]);
890
+ return configuration.scope === "PARTIAL" ? applySurfacePrep(data, room, lines, objectScope) : lines;
891
+ });
743
892
  }
744
893
  //#endregion
745
894
  //#region src/estimate/object-rules.ts
746
- function objectRuleLines(configuration, data) {
895
+ function objectRuleLines(configuration, data, section, variables) {
896
+ if (section === "BASE" && configuration.scope !== "FULL") return [];
747
897
  const scope = {
748
- variables: objectVariables(configuration),
898
+ variables,
749
899
  coefficients: data.coefficients
750
900
  };
751
- return data.rules.filter((rule) => rule.level === "OBJECT").sort((a, b) => a.sortOrder - b.sortOrder).map((rule) => {
901
+ return data.rules.filter((rule) => rule.level === "OBJECT" && rule.baseComponent === (section === "BASE")).sort((a, b) => a.sortOrder - b.sortOrder).map((rule) => {
752
902
  const init = {
753
- id: lineId("OBJECT", rule.id),
754
- section: "OBJECT",
903
+ id: lineId(section, rule.id),
904
+ section,
755
905
  ruleId: rule.id
756
906
  };
757
907
  const outcome = runNumericFormula(rule.costFormula, scope);
758
908
  return outcome.ok ? pricedLine(init, outcome.value) : failedLine(init, "ERROR", outcome.error);
759
- });
909
+ }).filter((line) => section !== "BASE" || line.status !== "OK" || line.amountCents !== 0);
760
910
  }
761
911
  //#endregion
762
912
  //#region src/schemas/common.ts
@@ -764,11 +914,17 @@ const LocalizedTextSchema = zod.z.object({
764
914
  en: zod.z.string(),
765
915
  uk: zod.z.string()
766
916
  });
767
- const FinishLevelSchema = zod.z.enum(["ROUGH", "PRE_FINISH"]);
917
+ const ScopeSchema = zod.z.enum(["FULL", "PARTIAL"]);
918
+ const FinishLevelSchema = zod.z.enum([
919
+ "ROUGH",
920
+ "PRE_FINISH",
921
+ "LIVED_IN"
922
+ ]);
768
923
  const RuleLevelSchema = zod.z.enum([
769
924
  "OBJECT",
770
925
  "CATEGORY",
771
- "MATERIAL_TYPE"
926
+ "MATERIAL_TYPE",
927
+ "SURFACE"
772
928
  ]);
773
929
  const RateSourceSchema = zod.z.enum(["NBU", "MANUAL"]);
774
930
  const SurfaceKindSchema = zod.z.enum([
@@ -795,7 +951,9 @@ const RoomTypeCodeSchema = zod.z.enum([
795
951
  "BEDROOM",
796
952
  "KITCHEN",
797
953
  "KITCHEN_LIVING",
798
- "BATHROOM"
954
+ "BATHROOM",
955
+ "HALLWAY",
956
+ "STORAGE"
799
957
  ]);
800
958
  const CurrencySchema = zod.z.enum(["USD", "UAH"]);
801
959
  const SectionSchema = zod.z.enum([
@@ -805,6 +963,11 @@ const SectionSchema = zod.z.enum([
805
963
  "ENGINEERING",
806
964
  "ADDITIONAL"
807
965
  ]);
966
+ const LineComponentKindSchema = zod.z.enum([
967
+ "MATERIAL",
968
+ "LABOR",
969
+ "PREP"
970
+ ]);
808
971
  const LineStatusSchema = zod.z.enum([
809
972
  "OK",
810
973
  "INCLUDED",
@@ -848,8 +1011,9 @@ function duplicateIndexes(items, key) {
848
1011
  });
849
1012
  }
850
1013
  const ConfigurationSchema = zod.z.object({
1014
+ scope: ScopeSchema.default("FULL"),
851
1015
  finishLevel: FinishLevelSchema,
852
- totalAreaSqm: zod.z.number().positive(),
1016
+ totalAreaSqm: zod.z.number().positive().nullable(),
853
1017
  balconyAreaSqm: zod.z.number().min(0),
854
1018
  balconyTiles: zod.z.boolean(),
855
1019
  rooms: zod.z.array(RoomConfigurationSchema),
@@ -861,6 +1025,11 @@ const ConfigurationSchema = zod.z.object({
861
1025
  path,
862
1026
  message: "Duplicate"
863
1027
  });
1028
+ if (configuration.scope === "FULL" !== (configuration.totalAreaSqm !== null)) ctx.addIssue({
1029
+ code: "custom",
1030
+ path: ["totalAreaSqm"],
1031
+ message: configuration.scope === "FULL" ? "Required" : "Not allowed"
1032
+ });
864
1033
  for (const index of duplicateIndexes(configuration.rooms, (room) => room.clientId)) addDuplicate([
865
1034
  "rooms",
866
1035
  index,
@@ -892,13 +1061,8 @@ const PricingLimitsSchema = zod.z.object({
892
1061
  totalAreaSqm: RangeSchema,
893
1062
  rooms: RangeSchema,
894
1063
  roomSideM: RangeSchema,
895
- roomHeightM: RangeSchema
896
- });
897
- const BaseRateSchema = zod.z.object({
898
- finishLevel: FinishLevelSchema,
899
- areaFromSqm: zod.z.number().min(0),
900
- areaToSqm: zod.z.number(),
901
- rateCentsPerSqm: zod.z.int().positive()
1064
+ roomHeightM: RangeSchema,
1065
+ missingAreaHintSqm: zod.z.number().min(0)
902
1066
  });
903
1067
  const PricingRuleSchema = zod.z.object({
904
1068
  id: zod.z.string(),
@@ -907,9 +1071,16 @@ const PricingRuleSchema = zod.z.object({
907
1071
  materialTypeId: zod.z.string().nullable(),
908
1072
  name: LocalizedTextSchema,
909
1073
  quantityFormula: zod.z.string().nullable(),
1074
+ surface: zod.z.enum([
1075
+ "FLOOR",
1076
+ "WALLS",
1077
+ "CEILING"
1078
+ ]).nullable(),
1079
+ baseComponent: zod.z.boolean(),
910
1080
  costFormula: zod.z.string().max(500),
1081
+ laborFormula: zod.z.string().max(500).nullable(),
911
1082
  sortOrder: zod.z.int()
912
- }).refine((rule) => rule.level === "OBJECT" === (rule.quantityFormula === null), { path: ["quantityFormula"] });
1083
+ }).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"] });
913
1084
  const PricingRoomTypeSchema = zod.z.object({
914
1085
  id: zod.z.string(),
915
1086
  code: zod.z.string()
@@ -917,7 +1088,8 @@ const PricingRoomTypeSchema = zod.z.object({
917
1088
  const PricingCategorySchema = zod.z.object({
918
1089
  id: zod.z.string(),
919
1090
  wastePercent: zod.z.number().min(0),
920
- surface: SurfaceKindSchema
1091
+ surface: SurfaceKindSchema,
1092
+ requiresPutty: zod.z.boolean().default(true)
921
1093
  });
922
1094
  const PricingMaterialTypeSchema = zod.z.object({
923
1095
  id: zod.z.string(),
@@ -939,22 +1111,43 @@ const PricingPackageItemSchema = zod.z.object({
939
1111
  priceCents: CentsSchema.nullable(),
940
1112
  unit: OptionUnitSchema.nullable()
941
1113
  });
942
- const PricingOptionSchema = zod.z.object({
1114
+ const PricingOptionObjectSchema = zod.z.object({
943
1115
  id: zod.z.string(),
944
1116
  code: OptionCodeSchema.nullable(),
945
1117
  kind: OptionKindSchema,
946
- priceCents: CentsSchema,
1118
+ priceCents: CentsSchema.nullable(),
947
1119
  unit: OptionUnitSchema,
948
1120
  roomTypeCodes: zod.z.array(zod.z.string()),
1121
+ priceCoefficientKeys: zod.z.array(zod.z.string()),
1122
+ scopes: zod.z.array(ScopeSchema).min(1),
949
1123
  minQuantity: zod.z.int().nullable(),
950
1124
  maxQuantity: zod.z.int().nullable(),
951
1125
  available: zod.z.boolean()
952
1126
  });
1127
+ function findOptionPriceSourceViolation(priceCents, priceCoefficientKeys) {
1128
+ const hasKeys = priceCoefficientKeys.length > 0;
1129
+ if (priceCents !== null && hasKeys) return {
1130
+ path: "priceCents",
1131
+ message: "priceCents must be null when priceCoefficientKeys is set"
1132
+ };
1133
+ if (priceCents === null && !hasKeys) return {
1134
+ path: "priceCoefficientKeys",
1135
+ message: "priceCoefficientKeys is required when priceCents is null"
1136
+ };
1137
+ return null;
1138
+ }
1139
+ const PricingOptionSchema = PricingOptionObjectSchema.superRefine((option, context) => {
1140
+ const violation = findOptionPriceSourceViolation(option.priceCents, option.priceCoefficientKeys);
1141
+ if (violation !== null) context.addIssue({
1142
+ code: "custom",
1143
+ path: [violation.path],
1144
+ message: violation.message
1145
+ });
1146
+ });
953
1147
  const PricingDataSchema = zod.z.object({
954
1148
  version: zod.z.string().regex(/^[0-9a-f]{16}$/),
955
1149
  exchangeRate: ExchangeRateSchema.nullable(),
956
1150
  limits: PricingLimitsSchema,
957
- baseRates: zod.z.array(BaseRateSchema),
958
1151
  coefficients: zod.z.record(zod.z.string(), zod.z.number()),
959
1152
  rules: zod.z.array(PricingRuleSchema),
960
1153
  roomTypes: zod.z.array(PricingRoomTypeSchema),
@@ -970,6 +1163,12 @@ const EstimateMessageSchema = zod.z.object({
970
1163
  code: zod.z.string(),
971
1164
  params: zod.z.record(zod.z.string(), zod.z.unknown()).optional()
972
1165
  });
1166
+ const LineComponentSchema = zod.z.object({
1167
+ kind: LineComponentKindSchema,
1168
+ ruleId: zod.z.string(),
1169
+ amountCents: zod.z.number().int().min(0),
1170
+ sharedWithCategoryId: zod.z.string().optional()
1171
+ });
973
1172
  const EstimateLineSchema = zod.z.object({
974
1173
  id: zod.z.string(),
975
1174
  section: SectionSchema,
@@ -985,6 +1184,7 @@ const EstimateLineSchema = zod.z.object({
985
1184
  unitPriceCents: CentsSchema.nullable(),
986
1185
  amountCents: CentsSchema,
987
1186
  amountUahKopecks: CentsSchema.nullable(),
1187
+ components: zod.z.array(LineComponentSchema).optional(),
988
1188
  warnings: zod.z.array(EstimateMessageSchema),
989
1189
  error: EstimateMessageSchema.nullable()
990
1190
  });
@@ -1022,18 +1222,34 @@ const RuleSampleSchema = zod.z.object({
1022
1222
  unit: ProductUnitSchema,
1023
1223
  wastePercent: zod.z.number().min(0)
1024
1224
  }).optional(),
1225
+ surfaceArea: zod.z.number().min(0).optional(),
1226
+ puttyRequired: zod.z.boolean().optional(),
1025
1227
  object: zod.z.object({
1026
- totalAreaSqm: zod.z.number().positive(),
1228
+ scope: ScopeSchema,
1229
+ totalAreaSqm: zod.z.number().positive().nullable(),
1027
1230
  balconyAreaSqm: zod.z.number().min(0),
1028
1231
  balconyTiles: zod.z.boolean(),
1029
1232
  finishLevel: FinishLevelSchema,
1030
1233
  roomsCount: zod.z.int().min(0),
1031
- bathroomsCount: zod.z.int().min(0)
1234
+ bathroomsCount: zod.z.int().min(0),
1235
+ roomsFloorArea: zod.z.number().min(0),
1236
+ roomsWallArea: zod.z.number().min(0),
1237
+ roomsPuttyWallArea: zod.z.number().min(0).optional(),
1238
+ roomsPerimeter: zod.z.number().min(0),
1239
+ wetRoomsCount: zod.z.int().min(0)
1032
1240
  })
1241
+ }).superRefine((sample, ctx) => {
1242
+ const { scope, totalAreaSqm } = sample.object;
1243
+ if (scope === "FULL" !== (totalAreaSqm !== null)) ctx.addIssue({
1244
+ code: "custom",
1245
+ path: ["object", "totalAreaSqm"],
1246
+ message: scope === "FULL" ? "Required" : "Not allowed"
1247
+ });
1033
1248
  });
1034
1249
  const RulePreviewSchema = zod.z.object({
1035
1250
  quantity: zod.z.number().nullable(),
1036
1251
  amountCents: CentsSchema.nullable(),
1252
+ laborCents: CentsSchema.nullable(),
1037
1253
  variables: zod.z.record(zod.z.string(), zod.z.union([
1038
1254
  zod.z.number(),
1039
1255
  zod.z.boolean(),
@@ -1068,20 +1284,28 @@ function isIncomplete(lines) {
1068
1284
  }
1069
1285
  //#endregion
1070
1286
  //#region src/estimate/warnings.ts
1071
- function roomsAreaWarnings(configuration) {
1072
- const roomsAreaSqm = Math.round(configuration.rooms.reduce((sum, room) => sum + roomVariables(room).floorArea, 0) * 1e4) / 1e4;
1073
- return roomsAreaSqm > configuration.totalAreaSqm ? [{
1287
+ const roundArea = (value) => Math.round(value * 1e4) / 1e4;
1288
+ function roomsAreaWarnings(configuration, limits) {
1289
+ const totalAreaSqm = configuration.totalAreaSqm;
1290
+ if (configuration.scope !== "FULL" || totalAreaSqm === null) return [];
1291
+ const roomsAreaSqm = roundArea(configuration.rooms.reduce((sum, room) => sum + roomVariables(room).floorArea, 0));
1292
+ if (roomsAreaSqm > totalAreaSqm) return [{
1074
1293
  code: "ROOMS_AREA_EXCEEDS_TOTAL",
1075
1294
  params: {
1076
1295
  roomsAreaSqm,
1077
- totalAreaSqm: configuration.totalAreaSqm
1296
+ totalAreaSqm
1078
1297
  }
1298
+ }];
1299
+ const missingSqm = roundArea(totalAreaSqm - roomsAreaSqm);
1300
+ return missingSqm > limits.missingAreaHintSqm ? [{
1301
+ code: "ROOMS_AREA_BELOW_TOTAL",
1302
+ params: { missingSqm }
1079
1303
  }] : [];
1080
1304
  }
1081
- function estimateWarnings(lines, configuration, rate) {
1305
+ function estimateWarnings(lines, configuration, rate, limits) {
1082
1306
  return [
1083
1307
  ...collectWarnings(lines),
1084
- ...roomsAreaWarnings(configuration),
1308
+ ...roomsAreaWarnings(configuration, limits),
1085
1309
  ...configuration.currency === "UAH" && rate === null ? [{ code: "EXCHANGE_RATE_UNAVAILABLE" }] : []
1086
1310
  ];
1087
1311
  }
@@ -1095,10 +1319,11 @@ function withKopecks(line, rate) {
1095
1319
  }
1096
1320
  function estimate(configuration, data) {
1097
1321
  const rate = data.exchangeRate?.rate ?? null;
1322
+ const variables = objectVariables(configuration, data.categories);
1098
1323
  const lines = [
1099
- baseLine(configuration, data),
1100
- ...objectRuleLines(configuration, data),
1101
- ...materialLines(configuration, data),
1324
+ ...objectRuleLines(configuration, data, "BASE", variables),
1325
+ ...objectRuleLines(configuration, data, "OBJECT", variables),
1326
+ ...materialLines(configuration, data, variables),
1102
1327
  ...engineeringLines(configuration, data),
1103
1328
  ...additionalLines(configuration, data)
1104
1329
  ].map((line) => withKopecks(line, rate));
@@ -1108,7 +1333,7 @@ function estimate(configuration, data) {
1108
1333
  exchangeRate: data.exchangeRate,
1109
1334
  lines,
1110
1335
  totals: computeTotals(lines, rate !== null),
1111
- warnings: estimateWarnings(lines, configuration, rate),
1336
+ warnings: estimateWarnings(lines, configuration, rate, data.limits),
1112
1337
  incomplete: isIncomplete(lines)
1113
1338
  };
1114
1339
  }
@@ -1123,8 +1348,9 @@ function checkLimits(configuration, limits, options = []) {
1123
1348
  max
1124
1349
  });
1125
1350
  };
1126
- check("totalAreaSqm", configuration.totalAreaSqm, limits.totalAreaSqm.min, limits.totalAreaSqm.max);
1127
- check("balconyAreaSqm", configuration.balconyAreaSqm, 0, configuration.totalAreaSqm);
1351
+ const isFull = configuration.scope === "FULL";
1352
+ if (isFull && configuration.totalAreaSqm !== null) check("totalAreaSqm", configuration.totalAreaSqm, limits.totalAreaSqm.min, limits.totalAreaSqm.max);
1353
+ check("balconyAreaSqm", configuration.balconyAreaSqm, 0, isFull && configuration.totalAreaSqm !== null ? configuration.totalAreaSqm : limits.totalAreaSqm.max);
1128
1354
  check("rooms", configuration.rooms.length, limits.rooms.min, limits.rooms.max);
1129
1355
  configuration.rooms.forEach((room, index) => {
1130
1356
  const path = `rooms[${index}]`;
@@ -1180,17 +1406,35 @@ function referencesOf(sources) {
1180
1406
  }
1181
1407
  function objectScope({ object }) {
1182
1408
  return {
1183
- totalArea: object.totalAreaSqm,
1409
+ scope: object.scope,
1410
+ totalArea: totalAreaOf(object.scope, object.totalAreaSqm, object.roomsFloorArea),
1184
1411
  balconyArea: object.balconyAreaSqm,
1185
1412
  balconyTiles: object.balconyTiles,
1186
1413
  finishLevel: object.finishLevel,
1187
1414
  roomsCount: object.roomsCount,
1188
- bathroomsCount: object.bathroomsCount
1415
+ bathroomsCount: object.bathroomsCount,
1416
+ roomsFloorArea: object.roomsFloorArea,
1417
+ roomsWallArea: object.roomsWallArea,
1418
+ roomsPuttyWallArea: object.roomsPuttyWallArea ?? object.roomsWallArea,
1419
+ roomsPerimeter: object.roomsPerimeter,
1420
+ wetRoomsCount: object.wetRoomsCount
1189
1421
  };
1190
1422
  }
1191
1423
  function scopeOf(level, sample) {
1192
1424
  if (level === "OBJECT") return { scope: objectScope(sample) };
1193
- const { room, product } = sample;
1425
+ const { room, product, surfaceArea } = sample;
1426
+ if (level === "SURFACE") {
1427
+ if (room === void 0 || surfaceArea === void 0) return { error: {
1428
+ code: "SAMPLE_INCOMPLETE",
1429
+ params: { missing: room === void 0 ? "room" : "surfaceArea" }
1430
+ } };
1431
+ return { scope: {
1432
+ ...roomVariables(room),
1433
+ ...objectScope(sample),
1434
+ surfaceArea,
1435
+ puttyRequired: sample.puttyRequired ?? true
1436
+ } };
1437
+ }
1194
1438
  if (room === void 0 || product === void 0) return { error: {
1195
1439
  code: "SAMPLE_INCOMPLETE",
1196
1440
  params: { missing: room === void 0 ? "room" : "product" }
@@ -1206,15 +1450,21 @@ function pick(source, names) {
1206
1450
  }
1207
1451
  function previewRule(input) {
1208
1452
  const { level, quantityFormula, costFormula, coefficients } = input;
1209
- const references = referencesOf([quantityFormula, costFormula]);
1210
- const report = (variables, quantity, amountCents, error) => ({
1453
+ const laborFormula = quantityFormula === null ? null : input.laborFormula ?? null;
1454
+ const references = referencesOf([
1455
+ quantityFormula,
1456
+ costFormula,
1457
+ laborFormula
1458
+ ]);
1459
+ const report = (variables, quantity, amountCents, error, laborCents = null) => ({
1211
1460
  quantity,
1212
1461
  amountCents,
1462
+ laborCents,
1213
1463
  variables: pick(variables, references.variables),
1214
1464
  coefficients: pick(coefficients, references.coefficients),
1215
1465
  error
1216
1466
  });
1217
- if (level === "OBJECT" !== (quantityFormula === null)) return report({}, null, null, { code: "QUANTITY_FORMULA_MISMATCH" });
1467
+ if ((level === "OBJECT" || level === "SURFACE") !== (quantityFormula === null)) return report({}, null, null, { code: "QUANTITY_FORMULA_MISMATCH" });
1218
1468
  const prepared = scopeOf(level, input.sample);
1219
1469
  if ("error" in prepared) return report({}, null, null, prepared.error);
1220
1470
  let variables = prepared.scope;
@@ -1240,7 +1490,15 @@ function previewRule(input) {
1240
1490
  id: "preview",
1241
1491
  section: "OBJECT"
1242
1492
  }, cost.value);
1243
- return line.error === null ? report(variables, quantity, line.amountCents, null) : report(variables, quantity, null, line.error);
1493
+ if (line.error !== null) return report(variables, quantity, null, line.error);
1494
+ if (laborFormula === null) return report(variables, quantity, line.amountCents, null);
1495
+ const labor = runNumericFormula(laborFormula, {
1496
+ variables,
1497
+ coefficients
1498
+ });
1499
+ if (!labor.ok) return report(variables, quantity, line.amountCents, labor.error);
1500
+ const laborCents = componentCents(labor.value);
1501
+ return laborCents === null ? report(variables, quantity, line.amountCents, outOfRange()) : report(variables, quantity, line.amountCents, null, laborCents);
1244
1502
  }
1245
1503
  //#endregion
1246
1504
  //#region src/formula/suggest.ts
@@ -1312,10 +1570,18 @@ const FORMULA_SCOPE_VARIABLES = {
1312
1570
  boolean("balconyTiles"),
1313
1571
  enumeration("finishLevel", FinishLevelSchema.options),
1314
1572
  number("roomsCount"),
1315
- number("bathroomsCount")
1573
+ number("bathroomsCount"),
1574
+ enumeration("scope", ScopeSchema.options),
1575
+ number("roomsFloorArea"),
1576
+ number("roomsWallArea"),
1577
+ number("roomsPuttyWallArea"),
1578
+ number("roomsPerimeter"),
1579
+ number("wetRoomsCount")
1316
1580
  ]
1317
1581
  };
1318
1582
  const FORMULA_QUANTITY_VARIABLE = number("quantity");
1583
+ const FORMULA_SURFACE_AREA_VARIABLE = number("surfaceArea");
1584
+ const FORMULA_PUTTY_REQUIRED_VARIABLE = boolean("puttyRequired");
1319
1585
  const FORMULA_VARIABLES = {
1320
1586
  OBJECT: FORMULA_SCOPE_VARIABLES.object,
1321
1587
  CATEGORY: [
@@ -1327,9 +1593,15 @@ const FORMULA_VARIABLES = {
1327
1593
  ...FORMULA_SCOPE_VARIABLES.room,
1328
1594
  ...FORMULA_SCOPE_VARIABLES.product,
1329
1595
  ...FORMULA_SCOPE_VARIABLES.object
1596
+ ],
1597
+ SURFACE: [
1598
+ ...FORMULA_SCOPE_VARIABLES.room,
1599
+ ...FORMULA_SCOPE_VARIABLES.object,
1600
+ FORMULA_SURFACE_AREA_VARIABLE,
1601
+ FORMULA_PUTTY_REQUIRED_VARIABLE
1330
1602
  ]
1331
1603
  };
1332
- const ALL_VARIABLE_NAMES = new Set(FORMULA_VARIABLES.CATEGORY.map((variable) => variable.name));
1604
+ const ALL_VARIABLE_NAMES = new Set([...FORMULA_VARIABLES.CATEGORY, ...FORMULA_VARIABLES.SURFACE].map((variable) => variable.name));
1333
1605
  function span(node) {
1334
1606
  return {
1335
1607
  position: node.start,
@@ -1353,7 +1625,7 @@ function validateFormula(input) {
1353
1625
  const knownCoefficients = new Set(input.coefficientKeys);
1354
1626
  const usedCoefficients = /* @__PURE__ */ new Set();
1355
1627
  const errors = [];
1356
- const quantityAllowed = input.kind === "cost" && input.level !== "OBJECT";
1628
+ const quantityAllowed = input.kind !== "quantity" && (input.level === "CATEGORY" || input.level === "MATERIAL_TYPE");
1357
1629
  function mismatch(node, expected) {
1358
1630
  errors.push({
1359
1631
  code: "FORMULA_SYNTAX",
@@ -1541,7 +1813,6 @@ function validateFormula(input) {
1541
1813
  };
1542
1814
  }
1543
1815
  //#endregion
1544
- exports.BaseRateSchema = BaseRateSchema;
1545
1816
  exports.COEFFICIENT_NAMESPACE = COEFFICIENT_NAMESPACE;
1546
1817
  exports.CentsSchema = CentsSchema;
1547
1818
  exports.ConfigurationSchema = ConfigurationSchema;
@@ -1555,10 +1826,14 @@ exports.ExchangeRateSchema = ExchangeRateSchema;
1555
1826
  exports.FORMULA_FUNCTIONS = FORMULA_FUNCTIONS;
1556
1827
  exports.FORMULA_FUNCTION_TABLE = FORMULA_FUNCTION_TABLE;
1557
1828
  exports.FORMULA_LIMITS = FORMULA_LIMITS;
1829
+ exports.FORMULA_PUTTY_REQUIRED_VARIABLE = FORMULA_PUTTY_REQUIRED_VARIABLE;
1558
1830
  exports.FORMULA_QUANTITY_VARIABLE = FORMULA_QUANTITY_VARIABLE;
1559
1831
  exports.FORMULA_SCOPE_VARIABLES = FORMULA_SCOPE_VARIABLES;
1832
+ exports.FORMULA_SURFACE_AREA_VARIABLE = FORMULA_SURFACE_AREA_VARIABLE;
1560
1833
  exports.FORMULA_VARIABLES = FORMULA_VARIABLES;
1561
1834
  exports.FinishLevelSchema = FinishLevelSchema;
1835
+ exports.LineComponentKindSchema = LineComponentKindSchema;
1836
+ exports.LineComponentSchema = LineComponentSchema;
1562
1837
  exports.LineStatusSchema = LineStatusSchema;
1563
1838
  exports.LocalizedTextSchema = LocalizedTextSchema;
1564
1839
  exports.OptionCodeSchema = OptionCodeSchema;
@@ -1583,6 +1858,7 @@ exports.RoomTypeCodeSchema = RoomTypeCodeSchema;
1583
1858
  exports.RuleLevelSchema = RuleLevelSchema;
1584
1859
  exports.RulePreviewSchema = RulePreviewSchema;
1585
1860
  exports.RuleSampleSchema = RuleSampleSchema;
1861
+ exports.ScopeSchema = ScopeSchema;
1586
1862
  exports.SectionSchema = SectionSchema;
1587
1863
  exports.SectionTotalSchema = SectionTotalSchema;
1588
1864
  exports.SurfaceKindSchema = SurfaceKindSchema;
@@ -1590,13 +1866,16 @@ exports.checkLimits = checkLimits;
1590
1866
  exports.convertCentsToKopecks = convertCentsToKopecks;
1591
1867
  exports.estimate = estimate;
1592
1868
  exports.evaluate = evaluate;
1869
+ exports.findOptionPriceSourceViolation = findOptionPriceSourceViolation;
1593
1870
  exports.levenshtein = levenshtein;
1594
1871
  exports.objectVariables = objectVariables;
1595
1872
  exports.parse = parse;
1596
1873
  exports.previewRule = previewRule;
1597
1874
  exports.productVariables = productVariables;
1598
1875
  exports.productVariablesOf = productVariablesOf;
1876
+ exports.puttyRequiredOn = puttyRequiredOn;
1599
1877
  exports.roomVariables = roomVariables;
1600
1878
  exports.suggestNearest = suggestNearest;
1601
1879
  exports.toCents = toCents;
1880
+ exports.totalAreaOf = totalAreaOf;
1602
1881
  exports.validateFormula = validateFormula;