@roomwise/calc-engine 0.4.0 → 0.5.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,66 @@ function toCents(dollars) {
51
51
  return cents;
52
52
  }
53
53
  //#endregion
54
+ //#region src/variables/room.ts
55
+ function roomVariables(room) {
56
+ const perimeter = 2 * (room.lengthM + room.widthM);
57
+ return {
58
+ length: room.lengthM,
59
+ width: room.widthM,
60
+ height: room.heightM,
61
+ floorArea: room.lengthM * room.widthM,
62
+ perimeter,
63
+ wallArea: perimeter * room.heightM,
64
+ heatedFloor: room.heatedFloor,
65
+ roomType: room.roomTypeCode
66
+ };
67
+ }
68
+ //#endregion
69
+ //#region src/variables/object.ts
70
+ const COUNTED_AS_ROOMS = [
71
+ "LIVING_ROOM",
72
+ "BEDROOM",
73
+ "KITCHEN_LIVING"
74
+ ];
75
+ const WET_ROOMS = [
76
+ "BATHROOM",
77
+ "KITCHEN",
78
+ "KITCHEN_LIVING"
79
+ ];
80
+ function totalAreaOf(scope, totalAreaSqm, roomsFloorArea) {
81
+ return scope === "PARTIAL" || totalAreaSqm === null ? roomsFloorArea : totalAreaSqm;
82
+ }
83
+ function objectVariables(configuration) {
84
+ const rooms = configuration.rooms.map(roomVariables);
85
+ const sum = (pick) => rooms.reduce((total, room) => total + pick(room), 0);
86
+ const roomsFloorArea = sum((room) => room.floorArea);
87
+ return {
88
+ scope: configuration.scope,
89
+ totalArea: totalAreaOf(configuration.scope, configuration.totalAreaSqm, roomsFloorArea),
90
+ balconyArea: configuration.balconyAreaSqm,
91
+ balconyTiles: configuration.balconyTiles,
92
+ finishLevel: configuration.finishLevel,
93
+ roomsCount: configuration.rooms.filter((room) => COUNTED_AS_ROOMS.includes(room.roomTypeCode)).length,
94
+ bathroomsCount: configuration.rooms.filter((room) => room.roomTypeCode === "BATHROOM").length,
95
+ roomsFloorArea,
96
+ roomsWallArea: sum((room) => room.wallArea),
97
+ roomsPerimeter: sum((room) => room.perimeter),
98
+ wetRoomsCount: rooms.filter((room) => WET_ROOMS.includes(room.roomType)).length
99
+ };
100
+ }
101
+ //#endregion
102
+ //#region src/variables/product.ts
103
+ function productVariablesOf(product, wastePercent) {
104
+ return {
105
+ price: product.priceCents / 100,
106
+ waste: wastePercent / 100,
107
+ unit: product.unit
108
+ };
109
+ }
110
+ function productVariables(product, category) {
111
+ return productVariablesOf(product, product.wastePercentOverride ?? category.wastePercent);
112
+ }
113
+ //#endregion
54
114
  //#region src/estimate/line.ts
55
115
  const MAX_LINE_CENTS = 1e10;
56
116
  function createLine(init) {
@@ -80,18 +140,25 @@ function failedLine(init, status, error) {
80
140
  });
81
141
  }
82
142
  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({
143
+ const amountCents = componentCents(dollars);
144
+ return amountCents === null ? failedLine(init, "ERROR", outOfRange()) : createLine({
88
145
  ...init,
89
- amountCents: toCents(dollars)
146
+ amountCents
90
147
  });
91
148
  }
92
149
  function roundQuantity(quantity) {
93
150
  return Math.round(quantity * 1e3) / 1e3;
94
151
  }
152
+ function componentCents(dollars) {
153
+ if (!Number.isFinite(dollars) || dollars < 0 || dollars * 100 > 1e10) return null;
154
+ return toCents(dollars);
155
+ }
156
+ function outOfRange() {
157
+ return {
158
+ code: "AMOUNT_OUT_OF_RANGE",
159
+ params: { maxCents: MAX_LINE_CENTS }
160
+ };
161
+ }
95
162
  //#endregion
96
163
  //#region src/estimate/line-id.ts
97
164
  function lineId(section, ...parts) {
@@ -107,16 +174,39 @@ function unitQuantity(unit, rooms, pieces) {
107
174
  case "ROOM_SQM": return rooms.reduce((sum, room) => sum + room.lengthM * room.widthM, 0);
108
175
  }
109
176
  }
110
- function optionLine(configuration, option, selection) {
177
+ function optionUnitPriceCents(option, coefficients) {
178
+ if (option.priceCoefficientKeys.length === 0) return option.priceCents;
179
+ let dollars = 0;
180
+ for (const key of option.priceCoefficientKeys) {
181
+ const value = Object.hasOwn(coefficients, key) ? coefficients[key] : void 0;
182
+ if (value === void 0) return { missingKey: key };
183
+ dollars += value;
184
+ }
185
+ return componentCents(dollars);
186
+ }
187
+ function optionLine(configuration, option, selection, coefficients = {}) {
111
188
  const init = {
112
189
  id: lineId(option.kind, "option", option.id),
113
190
  section: option.kind,
114
191
  optionId: option.id
115
192
  };
193
+ if (!option.scopes.includes(configuration.scope)) return failedLine(init, "UNAVAILABLE", {
194
+ code: "OPTION_SCOPE_NOT_ALLOWED",
195
+ params: {
196
+ optionId: option.id,
197
+ scope: configuration.scope
198
+ }
199
+ });
116
200
  if (!option.available) return failedLine(init, "UNAVAILABLE", {
117
201
  code: "PRODUCT_UNAVAILABLE",
118
202
  params: { optionId: option.id }
119
203
  });
204
+ const unitPriceCents = optionUnitPriceCents(option, coefficients);
205
+ if (unitPriceCents === null) return failedLine(init, "ERROR", outOfRange());
206
+ if (typeof unitPriceCents !== "number") return failedLine(init, "ERROR", {
207
+ code: "UNKNOWN_COEFFICIENT",
208
+ params: { key: unitPriceCents.missingKey }
209
+ });
120
210
  const selected = configuration.rooms.filter((room) => selection.roomClientIds?.includes(room.clientId));
121
211
  const allowed = option.roomTypeCodes.length === 0 ? selected : selected.filter((room) => option.roomTypeCodes.includes(room.roomTypeCode));
122
212
  const rejected = selected.filter((room) => !allowed.includes(room));
@@ -125,7 +215,7 @@ function optionLine(configuration, option, selection) {
125
215
  ...init,
126
216
  quantity: roundQuantity(quantity),
127
217
  unit: option.unit,
128
- unitPriceCents: option.priceCents,
218
+ unitPriceCents,
129
219
  warnings: rejected.map((room) => ({
130
220
  code: "OPTION_ROOM_TYPE_NOT_ALLOWED",
131
221
  params: {
@@ -133,12 +223,12 @@ function optionLine(configuration, option, selection) {
133
223
  roomTypeCode: room.roomTypeCode
134
224
  }
135
225
  }))
136
- }, option.priceCents / 100 * quantity);
226
+ }, unitPriceCents / 100 * quantity);
137
227
  }
138
228
  function optionLines(configuration, data, kind) {
139
229
  return configuration.options.flatMap((selection) => {
140
230
  const option = data.options.find((item) => item.id === selection.optionId);
141
- return option?.kind === kind ? [optionLine(configuration, option, selection)] : [];
231
+ return option?.kind === kind ? [optionLine(configuration, option, selection, data.coefficients)] : [];
142
232
  });
143
233
  }
144
234
  //#endregion
@@ -147,29 +237,6 @@ function additionalLines(configuration, data) {
147
237
  return optionLines(configuration, data, "ADDITIONAL");
148
238
  }
149
239
  //#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
240
  //#region src/estimate/engineering.ts
174
241
  function packageLine(configuration, item) {
175
242
  const init = {
@@ -194,51 +261,7 @@ function packageLine(configuration, item) {
194
261
  }, item.priceCents / 100 * quantity);
195
262
  }
196
263
  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
- };
264
+ return [...(configuration.scope === "PARTIAL" ? data.packageItems.filter((item) => !item.includedInBase) : data.packageItems).map((item) => packageLine(configuration, item)), ...optionLines(configuration, data, "ENGINEERING")];
242
265
  }
243
266
  //#endregion
244
267
  //#region src/formula/language.ts
@@ -672,13 +695,87 @@ function runNumericFormula(source, scope) {
672
695
  };
673
696
  }
674
697
  //#endregion
698
+ //#region src/estimate/surfaces.ts
699
+ function surfaceRuleOf(data, surface) {
700
+ return data.rules.filter((rule) => rule.level === "SURFACE" && rule.surface === surface).sort((a, b) => a.sortOrder - b.sortOrder)[0];
701
+ }
702
+ function failedWith(line, error) {
703
+ return failedLine({
704
+ id: line.id,
705
+ section: line.section,
706
+ roomClientId: line.roomClientId,
707
+ categoryId: line.categoryId,
708
+ productId: line.productId,
709
+ ruleId: line.ruleId,
710
+ warnings: line.warnings
711
+ }, "ERROR", error);
712
+ }
713
+ function withComponent(line, component) {
714
+ const components = [...line.components ?? [], component];
715
+ const amountCents = components.reduce((sum, item) => sum + item.amountCents, 0);
716
+ if (amountCents > 1e10) return failedWith(line, outOfRange());
717
+ return {
718
+ ...line,
719
+ components,
720
+ amountCents
721
+ };
722
+ }
723
+ function applySurfacePrep(data, room, lines, objectScope) {
724
+ const result = [...lines];
725
+ const variables = {
726
+ ...roomVariables(room),
727
+ ...objectScope
728
+ };
729
+ const areas = {
730
+ FLOOR: variables.floorArea,
731
+ CEILING: variables.floorArea,
732
+ WALLS: variables.wallArea
733
+ };
734
+ const surfaces = /* @__PURE__ */ new Map();
735
+ lines.forEach((line, index) => {
736
+ const surface = data.categories.find((category) => category.id === line.categoryId)?.surface;
737
+ if (line.status === "OK" && surface !== void 0 && surface !== "NONE") surfaces.set(surface, [...surfaces.get(surface) ?? [], index]);
738
+ });
739
+ for (const [surface, indexes] of surfaces) {
740
+ const rule = surfaceRuleOf(data, surface);
741
+ if (rule === void 0) continue;
742
+ const outcome = runNumericFormula(rule.costFormula, {
743
+ variables: {
744
+ ...variables,
745
+ surfaceArea: areas[surface]
746
+ },
747
+ coefficients: data.coefficients
748
+ });
749
+ const cents = outcome.ok ? componentCents(outcome.value) : null;
750
+ const carrierCategoryId = lines[indexes[0]]?.categoryId ?? null;
751
+ indexes.forEach((index, position) => {
752
+ const line = lines[index];
753
+ if (!outcome.ok || cents === null) {
754
+ result[index] = failedWith(line, outcome.ok ? outOfRange() : outcome.error);
755
+ return;
756
+ }
757
+ result[index] = withComponent(line, position === 0 ? {
758
+ kind: "PREP",
759
+ ruleId: rule.id,
760
+ amountCents: cents
761
+ } : {
762
+ kind: "PREP",
763
+ ruleId: rule.id,
764
+ amountCents: 0,
765
+ sharedWithCategoryId: carrierCategoryId ?? void 0
766
+ });
767
+ });
768
+ }
769
+ return result;
770
+ }
771
+ //#endregion
675
772
  //#region src/estimate/materials.ts
676
773
  const isMaterialRule = (rule) => rule.quantityFormula !== null;
677
774
  function selectRule(data, categoryId, materialTypeId) {
678
775
  const rules = data.rules.filter(isMaterialRule);
679
776
  return rules.find((rule) => rule.level === "MATERIAL_TYPE" && rule.categoryId === categoryId && rule.materialTypeId === materialTypeId) ?? rules.find((rule) => rule.level === "CATEGORY" && rule.categoryId === categoryId);
680
777
  }
681
- function materialLine(configuration, data, room, categoryId, productId) {
778
+ function materialLine(configuration, data, room, categoryId, productId, objectScope) {
682
779
  const init = {
683
780
  id: lineId("MATERIALS", room.clientId, categoryId),
684
781
  section: "MATERIALS",
@@ -710,7 +807,7 @@ function materialLine(configuration, data, room, categoryId, productId) {
710
807
  const variables = {
711
808
  ...roomVariables(room),
712
809
  ...productVariables(product, category),
713
- ...objectVariables(configuration)
810
+ ...objectScope
714
811
  };
715
812
  const { coefficients } = data;
716
813
  const ruled = {
@@ -731,32 +828,74 @@ function materialLine(configuration, data, room, categoryId, productId) {
731
828
  coefficients
732
829
  });
733
830
  if (!cost.ok) return failedLine(ruled, "ERROR", cost.error);
734
- return pricedLine({
831
+ const materialCents = componentCents(cost.value);
832
+ if (materialCents === null) return failedLine(ruled, "ERROR", outOfRange());
833
+ const components = [{
834
+ kind: "MATERIAL",
835
+ ruleId: rule.id,
836
+ amountCents: materialCents
837
+ }];
838
+ const lineWarnings = [...warnings];
839
+ if (configuration.scope === "PARTIAL") {
840
+ let laborCents = 0;
841
+ if (rule.laborFormula === null) lineWarnings.push({
842
+ code: "LABOR_RATE_MISSING",
843
+ params: { categoryId }
844
+ });
845
+ else {
846
+ const labor = runNumericFormula(rule.laborFormula, {
847
+ variables: {
848
+ ...variables,
849
+ quantity: quantity.value
850
+ },
851
+ coefficients
852
+ });
853
+ if (!labor.ok) return failedLine(ruled, "ERROR", labor.error);
854
+ const cents = componentCents(labor.value);
855
+ if (cents === null) return failedLine(ruled, "ERROR", outOfRange());
856
+ laborCents = cents;
857
+ }
858
+ components.push({
859
+ kind: "LABOR",
860
+ ruleId: rule.id,
861
+ amountCents: laborCents
862
+ });
863
+ }
864
+ const amountCents = components.reduce((sum, component) => sum + component.amountCents, 0);
865
+ if (amountCents > 1e10) return failedLine(ruled, "ERROR", outOfRange());
866
+ return createLine({
735
867
  ...ruled,
868
+ warnings: lineWarnings,
736
869
  quantity: roundQuantity(quantity.value),
737
870
  unit: product.unit,
738
- unitPriceCents: product.priceCents
739
- }, cost.value);
871
+ unitPriceCents: product.priceCents,
872
+ amountCents,
873
+ components
874
+ });
740
875
  }
741
- function materialLines(configuration, data) {
742
- return configuration.rooms.flatMap((room) => room.selections.flatMap(({ categoryId, productId }) => productId === null ? [] : [materialLine(configuration, data, room, categoryId, productId)]));
876
+ function materialLines(configuration, data, objectScope) {
877
+ return configuration.rooms.flatMap((room) => {
878
+ const lines = room.selections.flatMap(({ categoryId, productId }) => productId === null ? [] : [materialLine(configuration, data, room, categoryId, productId, objectScope)]);
879
+ return configuration.scope === "PARTIAL" ? applySurfacePrep(data, room, lines, objectScope) : lines;
880
+ });
743
881
  }
744
882
  //#endregion
745
883
  //#region src/estimate/object-rules.ts
746
- function objectRuleLines(configuration, data) {
884
+ function objectRuleLines(configuration, data, section, variables) {
885
+ if (section === "BASE" && configuration.scope !== "FULL") return [];
747
886
  const scope = {
748
- variables: objectVariables(configuration),
887
+ variables,
749
888
  coefficients: data.coefficients
750
889
  };
751
- return data.rules.filter((rule) => rule.level === "OBJECT").sort((a, b) => a.sortOrder - b.sortOrder).map((rule) => {
890
+ return data.rules.filter((rule) => rule.level === "OBJECT" && rule.baseComponent === (section === "BASE")).sort((a, b) => a.sortOrder - b.sortOrder).map((rule) => {
752
891
  const init = {
753
- id: lineId("OBJECT", rule.id),
754
- section: "OBJECT",
892
+ id: lineId(section, rule.id),
893
+ section,
755
894
  ruleId: rule.id
756
895
  };
757
896
  const outcome = runNumericFormula(rule.costFormula, scope);
758
897
  return outcome.ok ? pricedLine(init, outcome.value) : failedLine(init, "ERROR", outcome.error);
759
- });
898
+ }).filter((line) => section !== "BASE" || line.status !== "OK" || line.amountCents !== 0);
760
899
  }
761
900
  //#endregion
762
901
  //#region src/schemas/common.ts
@@ -764,11 +903,17 @@ const LocalizedTextSchema = zod.z.object({
764
903
  en: zod.z.string(),
765
904
  uk: zod.z.string()
766
905
  });
767
- const FinishLevelSchema = zod.z.enum(["ROUGH", "PRE_FINISH"]);
906
+ const ScopeSchema = zod.z.enum(["FULL", "PARTIAL"]);
907
+ const FinishLevelSchema = zod.z.enum([
908
+ "ROUGH",
909
+ "PRE_FINISH",
910
+ "LIVED_IN"
911
+ ]);
768
912
  const RuleLevelSchema = zod.z.enum([
769
913
  "OBJECT",
770
914
  "CATEGORY",
771
- "MATERIAL_TYPE"
915
+ "MATERIAL_TYPE",
916
+ "SURFACE"
772
917
  ]);
773
918
  const RateSourceSchema = zod.z.enum(["NBU", "MANUAL"]);
774
919
  const SurfaceKindSchema = zod.z.enum([
@@ -795,7 +940,9 @@ const RoomTypeCodeSchema = zod.z.enum([
795
940
  "BEDROOM",
796
941
  "KITCHEN",
797
942
  "KITCHEN_LIVING",
798
- "BATHROOM"
943
+ "BATHROOM",
944
+ "HALLWAY",
945
+ "STORAGE"
799
946
  ]);
800
947
  const CurrencySchema = zod.z.enum(["USD", "UAH"]);
801
948
  const SectionSchema = zod.z.enum([
@@ -805,6 +952,11 @@ const SectionSchema = zod.z.enum([
805
952
  "ENGINEERING",
806
953
  "ADDITIONAL"
807
954
  ]);
955
+ const LineComponentKindSchema = zod.z.enum([
956
+ "MATERIAL",
957
+ "LABOR",
958
+ "PREP"
959
+ ]);
808
960
  const LineStatusSchema = zod.z.enum([
809
961
  "OK",
810
962
  "INCLUDED",
@@ -848,8 +1000,9 @@ function duplicateIndexes(items, key) {
848
1000
  });
849
1001
  }
850
1002
  const ConfigurationSchema = zod.z.object({
1003
+ scope: ScopeSchema.default("FULL"),
851
1004
  finishLevel: FinishLevelSchema,
852
- totalAreaSqm: zod.z.number().positive(),
1005
+ totalAreaSqm: zod.z.number().positive().nullable(),
853
1006
  balconyAreaSqm: zod.z.number().min(0),
854
1007
  balconyTiles: zod.z.boolean(),
855
1008
  rooms: zod.z.array(RoomConfigurationSchema),
@@ -861,6 +1014,11 @@ const ConfigurationSchema = zod.z.object({
861
1014
  path,
862
1015
  message: "Duplicate"
863
1016
  });
1017
+ if (configuration.scope === "FULL" !== (configuration.totalAreaSqm !== null)) ctx.addIssue({
1018
+ code: "custom",
1019
+ path: ["totalAreaSqm"],
1020
+ message: configuration.scope === "FULL" ? "Required" : "Not allowed"
1021
+ });
864
1022
  for (const index of duplicateIndexes(configuration.rooms, (room) => room.clientId)) addDuplicate([
865
1023
  "rooms",
866
1024
  index,
@@ -892,13 +1050,8 @@ const PricingLimitsSchema = zod.z.object({
892
1050
  totalAreaSqm: RangeSchema,
893
1051
  rooms: RangeSchema,
894
1052
  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()
1053
+ roomHeightM: RangeSchema,
1054
+ missingAreaHintSqm: zod.z.number().min(0)
902
1055
  });
903
1056
  const PricingRuleSchema = zod.z.object({
904
1057
  id: zod.z.string(),
@@ -907,9 +1060,16 @@ const PricingRuleSchema = zod.z.object({
907
1060
  materialTypeId: zod.z.string().nullable(),
908
1061
  name: LocalizedTextSchema,
909
1062
  quantityFormula: zod.z.string().nullable(),
1063
+ surface: zod.z.enum([
1064
+ "FLOOR",
1065
+ "WALLS",
1066
+ "CEILING"
1067
+ ]).nullable(),
1068
+ baseComponent: zod.z.boolean(),
910
1069
  costFormula: zod.z.string().max(500),
1070
+ laborFormula: zod.z.string().max(500).nullable(),
911
1071
  sortOrder: zod.z.int()
912
- }).refine((rule) => rule.level === "OBJECT" === (rule.quantityFormula === null), { path: ["quantityFormula"] });
1072
+ }).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
1073
  const PricingRoomTypeSchema = zod.z.object({
914
1074
  id: zod.z.string(),
915
1075
  code: zod.z.string()
@@ -946,6 +1106,8 @@ const PricingOptionSchema = zod.z.object({
946
1106
  priceCents: CentsSchema,
947
1107
  unit: OptionUnitSchema,
948
1108
  roomTypeCodes: zod.z.array(zod.z.string()),
1109
+ priceCoefficientKeys: zod.z.array(zod.z.string()),
1110
+ scopes: zod.z.array(ScopeSchema).min(1),
949
1111
  minQuantity: zod.z.int().nullable(),
950
1112
  maxQuantity: zod.z.int().nullable(),
951
1113
  available: zod.z.boolean()
@@ -954,7 +1116,6 @@ const PricingDataSchema = zod.z.object({
954
1116
  version: zod.z.string().regex(/^[0-9a-f]{16}$/),
955
1117
  exchangeRate: ExchangeRateSchema.nullable(),
956
1118
  limits: PricingLimitsSchema,
957
- baseRates: zod.z.array(BaseRateSchema),
958
1119
  coefficients: zod.z.record(zod.z.string(), zod.z.number()),
959
1120
  rules: zod.z.array(PricingRuleSchema),
960
1121
  roomTypes: zod.z.array(PricingRoomTypeSchema),
@@ -970,6 +1131,12 @@ const EstimateMessageSchema = zod.z.object({
970
1131
  code: zod.z.string(),
971
1132
  params: zod.z.record(zod.z.string(), zod.z.unknown()).optional()
972
1133
  });
1134
+ const LineComponentSchema = zod.z.object({
1135
+ kind: LineComponentKindSchema,
1136
+ ruleId: zod.z.string(),
1137
+ amountCents: zod.z.number().int().min(0),
1138
+ sharedWithCategoryId: zod.z.string().optional()
1139
+ });
973
1140
  const EstimateLineSchema = zod.z.object({
974
1141
  id: zod.z.string(),
975
1142
  section: SectionSchema,
@@ -985,6 +1152,7 @@ const EstimateLineSchema = zod.z.object({
985
1152
  unitPriceCents: CentsSchema.nullable(),
986
1153
  amountCents: CentsSchema,
987
1154
  amountUahKopecks: CentsSchema.nullable(),
1155
+ components: zod.z.array(LineComponentSchema).optional(),
988
1156
  warnings: zod.z.array(EstimateMessageSchema),
989
1157
  error: EstimateMessageSchema.nullable()
990
1158
  });
@@ -1022,18 +1190,32 @@ const RuleSampleSchema = zod.z.object({
1022
1190
  unit: ProductUnitSchema,
1023
1191
  wastePercent: zod.z.number().min(0)
1024
1192
  }).optional(),
1193
+ surfaceArea: zod.z.number().min(0).optional(),
1025
1194
  object: zod.z.object({
1026
- totalAreaSqm: zod.z.number().positive(),
1195
+ scope: ScopeSchema,
1196
+ totalAreaSqm: zod.z.number().positive().nullable(),
1027
1197
  balconyAreaSqm: zod.z.number().min(0),
1028
1198
  balconyTiles: zod.z.boolean(),
1029
1199
  finishLevel: FinishLevelSchema,
1030
1200
  roomsCount: zod.z.int().min(0),
1031
- bathroomsCount: zod.z.int().min(0)
1201
+ bathroomsCount: zod.z.int().min(0),
1202
+ roomsFloorArea: zod.z.number().min(0),
1203
+ roomsWallArea: zod.z.number().min(0),
1204
+ roomsPerimeter: zod.z.number().min(0),
1205
+ wetRoomsCount: zod.z.int().min(0)
1032
1206
  })
1207
+ }).superRefine((sample, ctx) => {
1208
+ const { scope, totalAreaSqm } = sample.object;
1209
+ if (scope === "FULL" !== (totalAreaSqm !== null)) ctx.addIssue({
1210
+ code: "custom",
1211
+ path: ["object", "totalAreaSqm"],
1212
+ message: scope === "FULL" ? "Required" : "Not allowed"
1213
+ });
1033
1214
  });
1034
1215
  const RulePreviewSchema = zod.z.object({
1035
1216
  quantity: zod.z.number().nullable(),
1036
1217
  amountCents: CentsSchema.nullable(),
1218
+ laborCents: CentsSchema.nullable(),
1037
1219
  variables: zod.z.record(zod.z.string(), zod.z.union([
1038
1220
  zod.z.number(),
1039
1221
  zod.z.boolean(),
@@ -1068,20 +1250,28 @@ function isIncomplete(lines) {
1068
1250
  }
1069
1251
  //#endregion
1070
1252
  //#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 ? [{
1253
+ const roundArea = (value) => Math.round(value * 1e4) / 1e4;
1254
+ function roomsAreaWarnings(configuration, limits) {
1255
+ const totalAreaSqm = configuration.totalAreaSqm;
1256
+ if (configuration.scope !== "FULL" || totalAreaSqm === null) return [];
1257
+ const roomsAreaSqm = roundArea(configuration.rooms.reduce((sum, room) => sum + roomVariables(room).floorArea, 0));
1258
+ if (roomsAreaSqm > totalAreaSqm) return [{
1074
1259
  code: "ROOMS_AREA_EXCEEDS_TOTAL",
1075
1260
  params: {
1076
1261
  roomsAreaSqm,
1077
- totalAreaSqm: configuration.totalAreaSqm
1262
+ totalAreaSqm
1078
1263
  }
1264
+ }];
1265
+ const missingSqm = roundArea(totalAreaSqm - roomsAreaSqm);
1266
+ return missingSqm > limits.missingAreaHintSqm ? [{
1267
+ code: "ROOMS_AREA_BELOW_TOTAL",
1268
+ params: { missingSqm }
1079
1269
  }] : [];
1080
1270
  }
1081
- function estimateWarnings(lines, configuration, rate) {
1271
+ function estimateWarnings(lines, configuration, rate, limits) {
1082
1272
  return [
1083
1273
  ...collectWarnings(lines),
1084
- ...roomsAreaWarnings(configuration),
1274
+ ...roomsAreaWarnings(configuration, limits),
1085
1275
  ...configuration.currency === "UAH" && rate === null ? [{ code: "EXCHANGE_RATE_UNAVAILABLE" }] : []
1086
1276
  ];
1087
1277
  }
@@ -1095,10 +1285,11 @@ function withKopecks(line, rate) {
1095
1285
  }
1096
1286
  function estimate(configuration, data) {
1097
1287
  const rate = data.exchangeRate?.rate ?? null;
1288
+ const variables = objectVariables(configuration);
1098
1289
  const lines = [
1099
- baseLine(configuration, data),
1100
- ...objectRuleLines(configuration, data),
1101
- ...materialLines(configuration, data),
1290
+ ...objectRuleLines(configuration, data, "BASE", variables),
1291
+ ...objectRuleLines(configuration, data, "OBJECT", variables),
1292
+ ...materialLines(configuration, data, variables),
1102
1293
  ...engineeringLines(configuration, data),
1103
1294
  ...additionalLines(configuration, data)
1104
1295
  ].map((line) => withKopecks(line, rate));
@@ -1108,7 +1299,7 @@ function estimate(configuration, data) {
1108
1299
  exchangeRate: data.exchangeRate,
1109
1300
  lines,
1110
1301
  totals: computeTotals(lines, rate !== null),
1111
- warnings: estimateWarnings(lines, configuration, rate),
1302
+ warnings: estimateWarnings(lines, configuration, rate, data.limits),
1112
1303
  incomplete: isIncomplete(lines)
1113
1304
  };
1114
1305
  }
@@ -1123,8 +1314,9 @@ function checkLimits(configuration, limits, options = []) {
1123
1314
  max
1124
1315
  });
1125
1316
  };
1126
- check("totalAreaSqm", configuration.totalAreaSqm, limits.totalAreaSqm.min, limits.totalAreaSqm.max);
1127
- check("balconyAreaSqm", configuration.balconyAreaSqm, 0, configuration.totalAreaSqm);
1317
+ const isFull = configuration.scope === "FULL";
1318
+ if (isFull && configuration.totalAreaSqm !== null) check("totalAreaSqm", configuration.totalAreaSqm, limits.totalAreaSqm.min, limits.totalAreaSqm.max);
1319
+ check("balconyAreaSqm", configuration.balconyAreaSqm, 0, isFull && configuration.totalAreaSqm !== null ? configuration.totalAreaSqm : limits.totalAreaSqm.max);
1128
1320
  check("rooms", configuration.rooms.length, limits.rooms.min, limits.rooms.max);
1129
1321
  configuration.rooms.forEach((room, index) => {
1130
1322
  const path = `rooms[${index}]`;
@@ -1180,17 +1372,33 @@ function referencesOf(sources) {
1180
1372
  }
1181
1373
  function objectScope({ object }) {
1182
1374
  return {
1183
- totalArea: object.totalAreaSqm,
1375
+ scope: object.scope,
1376
+ totalArea: totalAreaOf(object.scope, object.totalAreaSqm, object.roomsFloorArea),
1184
1377
  balconyArea: object.balconyAreaSqm,
1185
1378
  balconyTiles: object.balconyTiles,
1186
1379
  finishLevel: object.finishLevel,
1187
1380
  roomsCount: object.roomsCount,
1188
- bathroomsCount: object.bathroomsCount
1381
+ bathroomsCount: object.bathroomsCount,
1382
+ roomsFloorArea: object.roomsFloorArea,
1383
+ roomsWallArea: object.roomsWallArea,
1384
+ roomsPerimeter: object.roomsPerimeter,
1385
+ wetRoomsCount: object.wetRoomsCount
1189
1386
  };
1190
1387
  }
1191
1388
  function scopeOf(level, sample) {
1192
1389
  if (level === "OBJECT") return { scope: objectScope(sample) };
1193
- const { room, product } = sample;
1390
+ const { room, product, surfaceArea } = sample;
1391
+ if (level === "SURFACE") {
1392
+ if (room === void 0 || surfaceArea === void 0) return { error: {
1393
+ code: "SAMPLE_INCOMPLETE",
1394
+ params: { missing: room === void 0 ? "room" : "surfaceArea" }
1395
+ } };
1396
+ return { scope: {
1397
+ ...roomVariables(room),
1398
+ ...objectScope(sample),
1399
+ surfaceArea
1400
+ } };
1401
+ }
1194
1402
  if (room === void 0 || product === void 0) return { error: {
1195
1403
  code: "SAMPLE_INCOMPLETE",
1196
1404
  params: { missing: room === void 0 ? "room" : "product" }
@@ -1206,15 +1414,21 @@ function pick(source, names) {
1206
1414
  }
1207
1415
  function previewRule(input) {
1208
1416
  const { level, quantityFormula, costFormula, coefficients } = input;
1209
- const references = referencesOf([quantityFormula, costFormula]);
1210
- const report = (variables, quantity, amountCents, error) => ({
1417
+ const laborFormula = quantityFormula === null ? null : input.laborFormula ?? null;
1418
+ const references = referencesOf([
1419
+ quantityFormula,
1420
+ costFormula,
1421
+ laborFormula
1422
+ ]);
1423
+ const report = (variables, quantity, amountCents, error, laborCents = null) => ({
1211
1424
  quantity,
1212
1425
  amountCents,
1426
+ laborCents,
1213
1427
  variables: pick(variables, references.variables),
1214
1428
  coefficients: pick(coefficients, references.coefficients),
1215
1429
  error
1216
1430
  });
1217
- if (level === "OBJECT" !== (quantityFormula === null)) return report({}, null, null, { code: "QUANTITY_FORMULA_MISMATCH" });
1431
+ if ((level === "OBJECT" || level === "SURFACE") !== (quantityFormula === null)) return report({}, null, null, { code: "QUANTITY_FORMULA_MISMATCH" });
1218
1432
  const prepared = scopeOf(level, input.sample);
1219
1433
  if ("error" in prepared) return report({}, null, null, prepared.error);
1220
1434
  let variables = prepared.scope;
@@ -1240,7 +1454,15 @@ function previewRule(input) {
1240
1454
  id: "preview",
1241
1455
  section: "OBJECT"
1242
1456
  }, cost.value);
1243
- return line.error === null ? report(variables, quantity, line.amountCents, null) : report(variables, quantity, null, line.error);
1457
+ if (line.error !== null) return report(variables, quantity, null, line.error);
1458
+ if (laborFormula === null) return report(variables, quantity, line.amountCents, null);
1459
+ const labor = runNumericFormula(laborFormula, {
1460
+ variables,
1461
+ coefficients
1462
+ });
1463
+ if (!labor.ok) return report(variables, quantity, line.amountCents, labor.error);
1464
+ const laborCents = componentCents(labor.value);
1465
+ return laborCents === null ? report(variables, quantity, line.amountCents, outOfRange()) : report(variables, quantity, line.amountCents, null, laborCents);
1244
1466
  }
1245
1467
  //#endregion
1246
1468
  //#region src/formula/suggest.ts
@@ -1312,10 +1534,16 @@ const FORMULA_SCOPE_VARIABLES = {
1312
1534
  boolean("balconyTiles"),
1313
1535
  enumeration("finishLevel", FinishLevelSchema.options),
1314
1536
  number("roomsCount"),
1315
- number("bathroomsCount")
1537
+ number("bathroomsCount"),
1538
+ enumeration("scope", ScopeSchema.options),
1539
+ number("roomsFloorArea"),
1540
+ number("roomsWallArea"),
1541
+ number("roomsPerimeter"),
1542
+ number("wetRoomsCount")
1316
1543
  ]
1317
1544
  };
1318
1545
  const FORMULA_QUANTITY_VARIABLE = number("quantity");
1546
+ const FORMULA_SURFACE_AREA_VARIABLE = number("surfaceArea");
1319
1547
  const FORMULA_VARIABLES = {
1320
1548
  OBJECT: FORMULA_SCOPE_VARIABLES.object,
1321
1549
  CATEGORY: [
@@ -1327,9 +1555,14 @@ const FORMULA_VARIABLES = {
1327
1555
  ...FORMULA_SCOPE_VARIABLES.room,
1328
1556
  ...FORMULA_SCOPE_VARIABLES.product,
1329
1557
  ...FORMULA_SCOPE_VARIABLES.object
1558
+ ],
1559
+ SURFACE: [
1560
+ ...FORMULA_SCOPE_VARIABLES.room,
1561
+ ...FORMULA_SCOPE_VARIABLES.object,
1562
+ FORMULA_SURFACE_AREA_VARIABLE
1330
1563
  ]
1331
1564
  };
1332
- const ALL_VARIABLE_NAMES = new Set(FORMULA_VARIABLES.CATEGORY.map((variable) => variable.name));
1565
+ const ALL_VARIABLE_NAMES = new Set([...FORMULA_VARIABLES.CATEGORY, ...FORMULA_VARIABLES.SURFACE].map((variable) => variable.name));
1333
1566
  function span(node) {
1334
1567
  return {
1335
1568
  position: node.start,
@@ -1353,7 +1586,7 @@ function validateFormula(input) {
1353
1586
  const knownCoefficients = new Set(input.coefficientKeys);
1354
1587
  const usedCoefficients = /* @__PURE__ */ new Set();
1355
1588
  const errors = [];
1356
- const quantityAllowed = input.kind === "cost" && input.level !== "OBJECT";
1589
+ const quantityAllowed = input.kind !== "quantity" && (input.level === "CATEGORY" || input.level === "MATERIAL_TYPE");
1357
1590
  function mismatch(node, expected) {
1358
1591
  errors.push({
1359
1592
  code: "FORMULA_SYNTAX",
@@ -1541,7 +1774,6 @@ function validateFormula(input) {
1541
1774
  };
1542
1775
  }
1543
1776
  //#endregion
1544
- exports.BaseRateSchema = BaseRateSchema;
1545
1777
  exports.COEFFICIENT_NAMESPACE = COEFFICIENT_NAMESPACE;
1546
1778
  exports.CentsSchema = CentsSchema;
1547
1779
  exports.ConfigurationSchema = ConfigurationSchema;
@@ -1557,8 +1789,11 @@ exports.FORMULA_FUNCTION_TABLE = FORMULA_FUNCTION_TABLE;
1557
1789
  exports.FORMULA_LIMITS = FORMULA_LIMITS;
1558
1790
  exports.FORMULA_QUANTITY_VARIABLE = FORMULA_QUANTITY_VARIABLE;
1559
1791
  exports.FORMULA_SCOPE_VARIABLES = FORMULA_SCOPE_VARIABLES;
1792
+ exports.FORMULA_SURFACE_AREA_VARIABLE = FORMULA_SURFACE_AREA_VARIABLE;
1560
1793
  exports.FORMULA_VARIABLES = FORMULA_VARIABLES;
1561
1794
  exports.FinishLevelSchema = FinishLevelSchema;
1795
+ exports.LineComponentKindSchema = LineComponentKindSchema;
1796
+ exports.LineComponentSchema = LineComponentSchema;
1562
1797
  exports.LineStatusSchema = LineStatusSchema;
1563
1798
  exports.LocalizedTextSchema = LocalizedTextSchema;
1564
1799
  exports.OptionCodeSchema = OptionCodeSchema;
@@ -1583,6 +1818,7 @@ exports.RoomTypeCodeSchema = RoomTypeCodeSchema;
1583
1818
  exports.RuleLevelSchema = RuleLevelSchema;
1584
1819
  exports.RulePreviewSchema = RulePreviewSchema;
1585
1820
  exports.RuleSampleSchema = RuleSampleSchema;
1821
+ exports.ScopeSchema = ScopeSchema;
1586
1822
  exports.SectionSchema = SectionSchema;
1587
1823
  exports.SectionTotalSchema = SectionTotalSchema;
1588
1824
  exports.SurfaceKindSchema = SurfaceKindSchema;
@@ -1599,4 +1835,5 @@ exports.productVariablesOf = productVariablesOf;
1599
1835
  exports.roomVariables = roomVariables;
1600
1836
  exports.suggestNearest = suggestNearest;
1601
1837
  exports.toCents = toCents;
1838
+ exports.totalAreaOf = totalAreaOf;
1602
1839
  exports.validateFormula = validateFormula;