@roomwise/calc-engine 0.3.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,52 +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
- kitchenType: configuration.kitchenType,
212
- finishLevel: configuration.finishLevel,
213
- roomsCount: configuration.rooms.filter((room) => COUNTED_AS_ROOMS.includes(room.roomTypeCode)).length,
214
- bathroomsCount: configuration.rooms.filter((room) => room.roomTypeCode === "BATHROOM").length
215
- };
216
- }
217
- //#endregion
218
- //#region src/variables/product.ts
219
- function productVariablesOf(product, wastePercent) {
220
- return {
221
- price: product.priceCents / 100,
222
- waste: wastePercent / 100,
223
- unit: product.unit
224
- };
225
- }
226
- function productVariables(product, category) {
227
- return productVariablesOf(product, product.wastePercentOverride ?? category.wastePercent);
228
- }
229
- //#endregion
230
- //#region src/variables/room.ts
231
- function roomVariables(room) {
232
- const perimeter = 2 * (room.lengthM + room.widthM);
233
- return {
234
- length: room.lengthM,
235
- width: room.widthM,
236
- height: room.heightM,
237
- floorArea: room.lengthM * room.widthM,
238
- perimeter,
239
- wallArea: perimeter * room.heightM,
240
- heatedFloor: room.heatedFloor,
241
- roomType: room.roomTypeCode
242
- };
264
+ return [...(configuration.scope === "PARTIAL" ? data.packageItems.filter((item) => !item.includedInBase) : data.packageItems).map((item) => packageLine(configuration, item)), ...optionLines(configuration, data, "ENGINEERING")];
243
265
  }
244
266
  //#endregion
245
267
  //#region src/formula/language.ts
@@ -673,13 +695,87 @@ function runNumericFormula(source, scope) {
673
695
  };
674
696
  }
675
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
676
772
  //#region src/estimate/materials.ts
677
773
  const isMaterialRule = (rule) => rule.quantityFormula !== null;
678
774
  function selectRule(data, categoryId, materialTypeId) {
679
775
  const rules = data.rules.filter(isMaterialRule);
680
776
  return rules.find((rule) => rule.level === "MATERIAL_TYPE" && rule.categoryId === categoryId && rule.materialTypeId === materialTypeId) ?? rules.find((rule) => rule.level === "CATEGORY" && rule.categoryId === categoryId);
681
777
  }
682
- function materialLine(configuration, data, room, categoryId, productId) {
778
+ function materialLine(configuration, data, room, categoryId, productId, objectScope) {
683
779
  const init = {
684
780
  id: lineId("MATERIALS", room.clientId, categoryId),
685
781
  section: "MATERIALS",
@@ -711,7 +807,7 @@ function materialLine(configuration, data, room, categoryId, productId) {
711
807
  const variables = {
712
808
  ...roomVariables(room),
713
809
  ...productVariables(product, category),
714
- ...objectVariables(configuration)
810
+ ...objectScope
715
811
  };
716
812
  const { coefficients } = data;
717
813
  const ruled = {
@@ -732,32 +828,74 @@ function materialLine(configuration, data, room, categoryId, productId) {
732
828
  coefficients
733
829
  });
734
830
  if (!cost.ok) return failedLine(ruled, "ERROR", cost.error);
735
- 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({
736
867
  ...ruled,
868
+ warnings: lineWarnings,
737
869
  quantity: roundQuantity(quantity.value),
738
870
  unit: product.unit,
739
- unitPriceCents: product.priceCents
740
- }, cost.value);
871
+ unitPriceCents: product.priceCents,
872
+ amountCents,
873
+ components
874
+ });
741
875
  }
742
- function materialLines(configuration, data) {
743
- 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
+ });
744
881
  }
745
882
  //#endregion
746
883
  //#region src/estimate/object-rules.ts
747
- function objectRuleLines(configuration, data) {
884
+ function objectRuleLines(configuration, data, section, variables) {
885
+ if (section === "BASE" && configuration.scope !== "FULL") return [];
748
886
  const scope = {
749
- variables: objectVariables(configuration),
887
+ variables,
750
888
  coefficients: data.coefficients
751
889
  };
752
- 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) => {
753
891
  const init = {
754
- id: lineId("OBJECT", rule.id),
755
- section: "OBJECT",
892
+ id: lineId(section, rule.id),
893
+ section,
756
894
  ruleId: rule.id
757
895
  };
758
896
  const outcome = runNumericFormula(rule.costFormula, scope);
759
897
  return outcome.ok ? pricedLine(init, outcome.value) : failedLine(init, "ERROR", outcome.error);
760
- });
898
+ }).filter((line) => section !== "BASE" || line.status !== "OK" || line.amountCents !== 0);
761
899
  }
762
900
  //#endregion
763
901
  //#region src/schemas/common.ts
@@ -765,11 +903,17 @@ const LocalizedTextSchema = zod.z.object({
765
903
  en: zod.z.string(),
766
904
  uk: zod.z.string()
767
905
  });
768
- 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
+ ]);
769
912
  const RuleLevelSchema = zod.z.enum([
770
913
  "OBJECT",
771
914
  "CATEGORY",
772
- "MATERIAL_TYPE"
915
+ "MATERIAL_TYPE",
916
+ "SURFACE"
773
917
  ]);
774
918
  const RateSourceSchema = zod.z.enum(["NBU", "MANUAL"]);
775
919
  const SurfaceKindSchema = zod.z.enum([
@@ -796,9 +940,10 @@ const RoomTypeCodeSchema = zod.z.enum([
796
940
  "BEDROOM",
797
941
  "KITCHEN",
798
942
  "KITCHEN_LIVING",
799
- "BATHROOM"
943
+ "BATHROOM",
944
+ "HALLWAY",
945
+ "STORAGE"
800
946
  ]);
801
- const KitchenTypeSchema = zod.z.enum(["SEPARATE", "KITCHEN_LIVING"]);
802
947
  const CurrencySchema = zod.z.enum(["USD", "UAH"]);
803
948
  const SectionSchema = zod.z.enum([
804
949
  "BASE",
@@ -807,6 +952,11 @@ const SectionSchema = zod.z.enum([
807
952
  "ENGINEERING",
808
953
  "ADDITIONAL"
809
954
  ]);
955
+ const LineComponentKindSchema = zod.z.enum([
956
+ "MATERIAL",
957
+ "LABOR",
958
+ "PREP"
959
+ ]);
810
960
  const LineStatusSchema = zod.z.enum([
811
961
  "OK",
812
962
  "INCLUDED",
@@ -850,11 +1000,11 @@ function duplicateIndexes(items, key) {
850
1000
  });
851
1001
  }
852
1002
  const ConfigurationSchema = zod.z.object({
1003
+ scope: ScopeSchema.default("FULL"),
853
1004
  finishLevel: FinishLevelSchema,
854
- totalAreaSqm: zod.z.number().positive(),
1005
+ totalAreaSqm: zod.z.number().positive().nullable(),
855
1006
  balconyAreaSqm: zod.z.number().min(0),
856
1007
  balconyTiles: zod.z.boolean(),
857
- kitchenType: KitchenTypeSchema,
858
1008
  rooms: zod.z.array(RoomConfigurationSchema),
859
1009
  options: zod.z.array(OptionSelectionSchema),
860
1010
  currency: CurrencySchema
@@ -864,6 +1014,11 @@ const ConfigurationSchema = zod.z.object({
864
1014
  path,
865
1015
  message: "Duplicate"
866
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
+ });
867
1022
  for (const index of duplicateIndexes(configuration.rooms, (room) => room.clientId)) addDuplicate([
868
1023
  "rooms",
869
1024
  index,
@@ -895,13 +1050,8 @@ const PricingLimitsSchema = zod.z.object({
895
1050
  totalAreaSqm: RangeSchema,
896
1051
  rooms: RangeSchema,
897
1052
  roomSideM: RangeSchema,
898
- roomHeightM: RangeSchema
899
- });
900
- const BaseRateSchema = zod.z.object({
901
- finishLevel: FinishLevelSchema,
902
- areaFromSqm: zod.z.number().min(0),
903
- areaToSqm: zod.z.number(),
904
- rateCentsPerSqm: zod.z.int().positive()
1053
+ roomHeightM: RangeSchema,
1054
+ missingAreaHintSqm: zod.z.number().min(0)
905
1055
  });
906
1056
  const PricingRuleSchema = zod.z.object({
907
1057
  id: zod.z.string(),
@@ -910,9 +1060,16 @@ const PricingRuleSchema = zod.z.object({
910
1060
  materialTypeId: zod.z.string().nullable(),
911
1061
  name: LocalizedTextSchema,
912
1062
  quantityFormula: zod.z.string().nullable(),
1063
+ surface: zod.z.enum([
1064
+ "FLOOR",
1065
+ "WALLS",
1066
+ "CEILING"
1067
+ ]).nullable(),
1068
+ baseComponent: zod.z.boolean(),
913
1069
  costFormula: zod.z.string().max(500),
1070
+ laborFormula: zod.z.string().max(500).nullable(),
914
1071
  sortOrder: zod.z.int()
915
- }).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"] });
916
1073
  const PricingRoomTypeSchema = zod.z.object({
917
1074
  id: zod.z.string(),
918
1075
  code: zod.z.string()
@@ -949,6 +1106,8 @@ const PricingOptionSchema = zod.z.object({
949
1106
  priceCents: CentsSchema,
950
1107
  unit: OptionUnitSchema,
951
1108
  roomTypeCodes: zod.z.array(zod.z.string()),
1109
+ priceCoefficientKeys: zod.z.array(zod.z.string()),
1110
+ scopes: zod.z.array(ScopeSchema).min(1),
952
1111
  minQuantity: zod.z.int().nullable(),
953
1112
  maxQuantity: zod.z.int().nullable(),
954
1113
  available: zod.z.boolean()
@@ -957,7 +1116,6 @@ const PricingDataSchema = zod.z.object({
957
1116
  version: zod.z.string().regex(/^[0-9a-f]{16}$/),
958
1117
  exchangeRate: ExchangeRateSchema.nullable(),
959
1118
  limits: PricingLimitsSchema,
960
- baseRates: zod.z.array(BaseRateSchema),
961
1119
  coefficients: zod.z.record(zod.z.string(), zod.z.number()),
962
1120
  rules: zod.z.array(PricingRuleSchema),
963
1121
  roomTypes: zod.z.array(PricingRoomTypeSchema),
@@ -973,6 +1131,12 @@ const EstimateMessageSchema = zod.z.object({
973
1131
  code: zod.z.string(),
974
1132
  params: zod.z.record(zod.z.string(), zod.z.unknown()).optional()
975
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
+ });
976
1140
  const EstimateLineSchema = zod.z.object({
977
1141
  id: zod.z.string(),
978
1142
  section: SectionSchema,
@@ -988,6 +1152,7 @@ const EstimateLineSchema = zod.z.object({
988
1152
  unitPriceCents: CentsSchema.nullable(),
989
1153
  amountCents: CentsSchema,
990
1154
  amountUahKopecks: CentsSchema.nullable(),
1155
+ components: zod.z.array(LineComponentSchema).optional(),
991
1156
  warnings: zod.z.array(EstimateMessageSchema),
992
1157
  error: EstimateMessageSchema.nullable()
993
1158
  });
@@ -1025,19 +1190,32 @@ const RuleSampleSchema = zod.z.object({
1025
1190
  unit: ProductUnitSchema,
1026
1191
  wastePercent: zod.z.number().min(0)
1027
1192
  }).optional(),
1193
+ surfaceArea: zod.z.number().min(0).optional(),
1028
1194
  object: zod.z.object({
1029
- totalAreaSqm: zod.z.number().positive(),
1195
+ scope: ScopeSchema,
1196
+ totalAreaSqm: zod.z.number().positive().nullable(),
1030
1197
  balconyAreaSqm: zod.z.number().min(0),
1031
1198
  balconyTiles: zod.z.boolean(),
1032
- kitchenType: KitchenTypeSchema,
1033
1199
  finishLevel: FinishLevelSchema,
1034
1200
  roomsCount: zod.z.int().min(0),
1035
- 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)
1036
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
+ });
1037
1214
  });
1038
1215
  const RulePreviewSchema = zod.z.object({
1039
1216
  quantity: zod.z.number().nullable(),
1040
1217
  amountCents: CentsSchema.nullable(),
1218
+ laborCents: CentsSchema.nullable(),
1041
1219
  variables: zod.z.record(zod.z.string(), zod.z.union([
1042
1220
  zod.z.number(),
1043
1221
  zod.z.boolean(),
@@ -1072,20 +1250,28 @@ function isIncomplete(lines) {
1072
1250
  }
1073
1251
  //#endregion
1074
1252
  //#region src/estimate/warnings.ts
1075
- function roomsAreaWarnings(configuration) {
1076
- const roomsAreaSqm = Math.round(configuration.rooms.reduce((sum, room) => sum + roomVariables(room).floorArea, 0) * 1e4) / 1e4;
1077
- 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 [{
1078
1259
  code: "ROOMS_AREA_EXCEEDS_TOTAL",
1079
1260
  params: {
1080
1261
  roomsAreaSqm,
1081
- totalAreaSqm: configuration.totalAreaSqm
1262
+ totalAreaSqm
1082
1263
  }
1264
+ }];
1265
+ const missingSqm = roundArea(totalAreaSqm - roomsAreaSqm);
1266
+ return missingSqm > limits.missingAreaHintSqm ? [{
1267
+ code: "ROOMS_AREA_BELOW_TOTAL",
1268
+ params: { missingSqm }
1083
1269
  }] : [];
1084
1270
  }
1085
- function estimateWarnings(lines, configuration, rate) {
1271
+ function estimateWarnings(lines, configuration, rate, limits) {
1086
1272
  return [
1087
1273
  ...collectWarnings(lines),
1088
- ...roomsAreaWarnings(configuration),
1274
+ ...roomsAreaWarnings(configuration, limits),
1089
1275
  ...configuration.currency === "UAH" && rate === null ? [{ code: "EXCHANGE_RATE_UNAVAILABLE" }] : []
1090
1276
  ];
1091
1277
  }
@@ -1099,10 +1285,11 @@ function withKopecks(line, rate) {
1099
1285
  }
1100
1286
  function estimate(configuration, data) {
1101
1287
  const rate = data.exchangeRate?.rate ?? null;
1288
+ const variables = objectVariables(configuration);
1102
1289
  const lines = [
1103
- baseLine(configuration, data),
1104
- ...objectRuleLines(configuration, data),
1105
- ...materialLines(configuration, data),
1290
+ ...objectRuleLines(configuration, data, "BASE", variables),
1291
+ ...objectRuleLines(configuration, data, "OBJECT", variables),
1292
+ ...materialLines(configuration, data, variables),
1106
1293
  ...engineeringLines(configuration, data),
1107
1294
  ...additionalLines(configuration, data)
1108
1295
  ].map((line) => withKopecks(line, rate));
@@ -1112,7 +1299,7 @@ function estimate(configuration, data) {
1112
1299
  exchangeRate: data.exchangeRate,
1113
1300
  lines,
1114
1301
  totals: computeTotals(lines, rate !== null),
1115
- warnings: estimateWarnings(lines, configuration, rate),
1302
+ warnings: estimateWarnings(lines, configuration, rate, data.limits),
1116
1303
  incomplete: isIncomplete(lines)
1117
1304
  };
1118
1305
  }
@@ -1127,8 +1314,9 @@ function checkLimits(configuration, limits, options = []) {
1127
1314
  max
1128
1315
  });
1129
1316
  };
1130
- check("totalAreaSqm", configuration.totalAreaSqm, limits.totalAreaSqm.min, limits.totalAreaSqm.max);
1131
- 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);
1132
1320
  check("rooms", configuration.rooms.length, limits.rooms.min, limits.rooms.max);
1133
1321
  configuration.rooms.forEach((room, index) => {
1134
1322
  const path = `rooms[${index}]`;
@@ -1184,18 +1372,33 @@ function referencesOf(sources) {
1184
1372
  }
1185
1373
  function objectScope({ object }) {
1186
1374
  return {
1187
- totalArea: object.totalAreaSqm,
1375
+ scope: object.scope,
1376
+ totalArea: totalAreaOf(object.scope, object.totalAreaSqm, object.roomsFloorArea),
1188
1377
  balconyArea: object.balconyAreaSqm,
1189
1378
  balconyTiles: object.balconyTiles,
1190
- kitchenType: object.kitchenType,
1191
1379
  finishLevel: object.finishLevel,
1192
1380
  roomsCount: object.roomsCount,
1193
- bathroomsCount: object.bathroomsCount
1381
+ bathroomsCount: object.bathroomsCount,
1382
+ roomsFloorArea: object.roomsFloorArea,
1383
+ roomsWallArea: object.roomsWallArea,
1384
+ roomsPerimeter: object.roomsPerimeter,
1385
+ wetRoomsCount: object.wetRoomsCount
1194
1386
  };
1195
1387
  }
1196
1388
  function scopeOf(level, sample) {
1197
1389
  if (level === "OBJECT") return { scope: objectScope(sample) };
1198
- 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
+ }
1199
1402
  if (room === void 0 || product === void 0) return { error: {
1200
1403
  code: "SAMPLE_INCOMPLETE",
1201
1404
  params: { missing: room === void 0 ? "room" : "product" }
@@ -1211,15 +1414,21 @@ function pick(source, names) {
1211
1414
  }
1212
1415
  function previewRule(input) {
1213
1416
  const { level, quantityFormula, costFormula, coefficients } = input;
1214
- const references = referencesOf([quantityFormula, costFormula]);
1215
- 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) => ({
1216
1424
  quantity,
1217
1425
  amountCents,
1426
+ laborCents,
1218
1427
  variables: pick(variables, references.variables),
1219
1428
  coefficients: pick(coefficients, references.coefficients),
1220
1429
  error
1221
1430
  });
1222
- 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" });
1223
1432
  const prepared = scopeOf(level, input.sample);
1224
1433
  if ("error" in prepared) return report({}, null, null, prepared.error);
1225
1434
  let variables = prepared.scope;
@@ -1245,7 +1454,15 @@ function previewRule(input) {
1245
1454
  id: "preview",
1246
1455
  section: "OBJECT"
1247
1456
  }, cost.value);
1248
- 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);
1249
1466
  }
1250
1467
  //#endregion
1251
1468
  //#region src/formula/suggest.ts
@@ -1315,13 +1532,18 @@ const FORMULA_SCOPE_VARIABLES = {
1315
1532
  number("totalArea"),
1316
1533
  number("balconyArea"),
1317
1534
  boolean("balconyTiles"),
1318
- enumeration("kitchenType", KitchenTypeSchema.options),
1319
1535
  enumeration("finishLevel", FinishLevelSchema.options),
1320
1536
  number("roomsCount"),
1321
- number("bathroomsCount")
1537
+ number("bathroomsCount"),
1538
+ enumeration("scope", ScopeSchema.options),
1539
+ number("roomsFloorArea"),
1540
+ number("roomsWallArea"),
1541
+ number("roomsPerimeter"),
1542
+ number("wetRoomsCount")
1322
1543
  ]
1323
1544
  };
1324
1545
  const FORMULA_QUANTITY_VARIABLE = number("quantity");
1546
+ const FORMULA_SURFACE_AREA_VARIABLE = number("surfaceArea");
1325
1547
  const FORMULA_VARIABLES = {
1326
1548
  OBJECT: FORMULA_SCOPE_VARIABLES.object,
1327
1549
  CATEGORY: [
@@ -1333,9 +1555,14 @@ const FORMULA_VARIABLES = {
1333
1555
  ...FORMULA_SCOPE_VARIABLES.room,
1334
1556
  ...FORMULA_SCOPE_VARIABLES.product,
1335
1557
  ...FORMULA_SCOPE_VARIABLES.object
1558
+ ],
1559
+ SURFACE: [
1560
+ ...FORMULA_SCOPE_VARIABLES.room,
1561
+ ...FORMULA_SCOPE_VARIABLES.object,
1562
+ FORMULA_SURFACE_AREA_VARIABLE
1336
1563
  ]
1337
1564
  };
1338
- 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));
1339
1566
  function span(node) {
1340
1567
  return {
1341
1568
  position: node.start,
@@ -1359,7 +1586,7 @@ function validateFormula(input) {
1359
1586
  const knownCoefficients = new Set(input.coefficientKeys);
1360
1587
  const usedCoefficients = /* @__PURE__ */ new Set();
1361
1588
  const errors = [];
1362
- const quantityAllowed = input.kind === "cost" && input.level !== "OBJECT";
1589
+ const quantityAllowed = input.kind !== "quantity" && (input.level === "CATEGORY" || input.level === "MATERIAL_TYPE");
1363
1590
  function mismatch(node, expected) {
1364
1591
  errors.push({
1365
1592
  code: "FORMULA_SYNTAX",
@@ -1547,7 +1774,6 @@ function validateFormula(input) {
1547
1774
  };
1548
1775
  }
1549
1776
  //#endregion
1550
- exports.BaseRateSchema = BaseRateSchema;
1551
1777
  exports.COEFFICIENT_NAMESPACE = COEFFICIENT_NAMESPACE;
1552
1778
  exports.CentsSchema = CentsSchema;
1553
1779
  exports.ConfigurationSchema = ConfigurationSchema;
@@ -1563,9 +1789,11 @@ exports.FORMULA_FUNCTION_TABLE = FORMULA_FUNCTION_TABLE;
1563
1789
  exports.FORMULA_LIMITS = FORMULA_LIMITS;
1564
1790
  exports.FORMULA_QUANTITY_VARIABLE = FORMULA_QUANTITY_VARIABLE;
1565
1791
  exports.FORMULA_SCOPE_VARIABLES = FORMULA_SCOPE_VARIABLES;
1792
+ exports.FORMULA_SURFACE_AREA_VARIABLE = FORMULA_SURFACE_AREA_VARIABLE;
1566
1793
  exports.FORMULA_VARIABLES = FORMULA_VARIABLES;
1567
1794
  exports.FinishLevelSchema = FinishLevelSchema;
1568
- exports.KitchenTypeSchema = KitchenTypeSchema;
1795
+ exports.LineComponentKindSchema = LineComponentKindSchema;
1796
+ exports.LineComponentSchema = LineComponentSchema;
1569
1797
  exports.LineStatusSchema = LineStatusSchema;
1570
1798
  exports.LocalizedTextSchema = LocalizedTextSchema;
1571
1799
  exports.OptionCodeSchema = OptionCodeSchema;
@@ -1590,6 +1818,7 @@ exports.RoomTypeCodeSchema = RoomTypeCodeSchema;
1590
1818
  exports.RuleLevelSchema = RuleLevelSchema;
1591
1819
  exports.RulePreviewSchema = RulePreviewSchema;
1592
1820
  exports.RuleSampleSchema = RuleSampleSchema;
1821
+ exports.ScopeSchema = ScopeSchema;
1593
1822
  exports.SectionSchema = SectionSchema;
1594
1823
  exports.SectionTotalSchema = SectionTotalSchema;
1595
1824
  exports.SurfaceKindSchema = SurfaceKindSchema;
@@ -1606,4 +1835,5 @@ exports.productVariablesOf = productVariablesOf;
1606
1835
  exports.roomVariables = roomVariables;
1607
1836
  exports.suggestNearest = suggestNearest;
1608
1837
  exports.toCents = toCents;
1838
+ exports.totalAreaOf = totalAreaOf;
1609
1839
  exports.validateFormula = validateFormula;