@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.js CHANGED
@@ -26,6 +26,66 @@ function toCents(dollars) {
26
26
  return cents;
27
27
  }
28
28
  //#endregion
29
+ //#region src/variables/room.ts
30
+ function roomVariables(room) {
31
+ const perimeter = 2 * (room.lengthM + room.widthM);
32
+ return {
33
+ length: room.lengthM,
34
+ width: room.widthM,
35
+ height: room.heightM,
36
+ floorArea: room.lengthM * room.widthM,
37
+ perimeter,
38
+ wallArea: perimeter * room.heightM,
39
+ heatedFloor: room.heatedFloor,
40
+ roomType: room.roomTypeCode
41
+ };
42
+ }
43
+ //#endregion
44
+ //#region src/variables/object.ts
45
+ const COUNTED_AS_ROOMS = [
46
+ "LIVING_ROOM",
47
+ "BEDROOM",
48
+ "KITCHEN_LIVING"
49
+ ];
50
+ const WET_ROOMS = [
51
+ "BATHROOM",
52
+ "KITCHEN",
53
+ "KITCHEN_LIVING"
54
+ ];
55
+ function totalAreaOf(scope, totalAreaSqm, roomsFloorArea) {
56
+ return scope === "PARTIAL" || totalAreaSqm === null ? roomsFloorArea : totalAreaSqm;
57
+ }
58
+ function objectVariables(configuration) {
59
+ const rooms = configuration.rooms.map(roomVariables);
60
+ const sum = (pick) => rooms.reduce((total, room) => total + pick(room), 0);
61
+ const roomsFloorArea = sum((room) => room.floorArea);
62
+ return {
63
+ scope: configuration.scope,
64
+ totalArea: totalAreaOf(configuration.scope, configuration.totalAreaSqm, roomsFloorArea),
65
+ balconyArea: configuration.balconyAreaSqm,
66
+ balconyTiles: configuration.balconyTiles,
67
+ finishLevel: configuration.finishLevel,
68
+ roomsCount: configuration.rooms.filter((room) => COUNTED_AS_ROOMS.includes(room.roomTypeCode)).length,
69
+ bathroomsCount: configuration.rooms.filter((room) => room.roomTypeCode === "BATHROOM").length,
70
+ roomsFloorArea,
71
+ roomsWallArea: sum((room) => room.wallArea),
72
+ roomsPerimeter: sum((room) => room.perimeter),
73
+ wetRoomsCount: rooms.filter((room) => WET_ROOMS.includes(room.roomType)).length
74
+ };
75
+ }
76
+ //#endregion
77
+ //#region src/variables/product.ts
78
+ function productVariablesOf(product, wastePercent) {
79
+ return {
80
+ price: product.priceCents / 100,
81
+ waste: wastePercent / 100,
82
+ unit: product.unit
83
+ };
84
+ }
85
+ function productVariables(product, category) {
86
+ return productVariablesOf(product, product.wastePercentOverride ?? category.wastePercent);
87
+ }
88
+ //#endregion
29
89
  //#region src/estimate/line.ts
30
90
  const MAX_LINE_CENTS = 1e10;
31
91
  function createLine(init) {
@@ -55,18 +115,25 @@ function failedLine(init, status, error) {
55
115
  });
56
116
  }
57
117
  function pricedLine(init, dollars) {
58
- if (!Number.isFinite(dollars) || dollars < 0 || dollars * 100 > 1e10) return failedLine(init, "ERROR", {
59
- code: "AMOUNT_OUT_OF_RANGE",
60
- params: { maxCents: MAX_LINE_CENTS }
61
- });
62
- return createLine({
118
+ const amountCents = componentCents(dollars);
119
+ return amountCents === null ? failedLine(init, "ERROR", outOfRange()) : createLine({
63
120
  ...init,
64
- amountCents: toCents(dollars)
121
+ amountCents
65
122
  });
66
123
  }
67
124
  function roundQuantity(quantity) {
68
125
  return Math.round(quantity * 1e3) / 1e3;
69
126
  }
127
+ function componentCents(dollars) {
128
+ if (!Number.isFinite(dollars) || dollars < 0 || dollars * 100 > 1e10) return null;
129
+ return toCents(dollars);
130
+ }
131
+ function outOfRange() {
132
+ return {
133
+ code: "AMOUNT_OUT_OF_RANGE",
134
+ params: { maxCents: MAX_LINE_CENTS }
135
+ };
136
+ }
70
137
  //#endregion
71
138
  //#region src/estimate/line-id.ts
72
139
  function lineId(section, ...parts) {
@@ -82,16 +149,39 @@ function unitQuantity(unit, rooms, pieces) {
82
149
  case "ROOM_SQM": return rooms.reduce((sum, room) => sum + room.lengthM * room.widthM, 0);
83
150
  }
84
151
  }
85
- function optionLine(configuration, option, selection) {
152
+ function optionUnitPriceCents(option, coefficients) {
153
+ if (option.priceCoefficientKeys.length === 0) return option.priceCents;
154
+ let dollars = 0;
155
+ for (const key of option.priceCoefficientKeys) {
156
+ const value = Object.hasOwn(coefficients, key) ? coefficients[key] : void 0;
157
+ if (value === void 0) return { missingKey: key };
158
+ dollars += value;
159
+ }
160
+ return componentCents(dollars);
161
+ }
162
+ function optionLine(configuration, option, selection, coefficients = {}) {
86
163
  const init = {
87
164
  id: lineId(option.kind, "option", option.id),
88
165
  section: option.kind,
89
166
  optionId: option.id
90
167
  };
168
+ if (!option.scopes.includes(configuration.scope)) return failedLine(init, "UNAVAILABLE", {
169
+ code: "OPTION_SCOPE_NOT_ALLOWED",
170
+ params: {
171
+ optionId: option.id,
172
+ scope: configuration.scope
173
+ }
174
+ });
91
175
  if (!option.available) return failedLine(init, "UNAVAILABLE", {
92
176
  code: "PRODUCT_UNAVAILABLE",
93
177
  params: { optionId: option.id }
94
178
  });
179
+ const unitPriceCents = optionUnitPriceCents(option, coefficients);
180
+ if (unitPriceCents === null) return failedLine(init, "ERROR", outOfRange());
181
+ if (typeof unitPriceCents !== "number") return failedLine(init, "ERROR", {
182
+ code: "UNKNOWN_COEFFICIENT",
183
+ params: { key: unitPriceCents.missingKey }
184
+ });
95
185
  const selected = configuration.rooms.filter((room) => selection.roomClientIds?.includes(room.clientId));
96
186
  const allowed = option.roomTypeCodes.length === 0 ? selected : selected.filter((room) => option.roomTypeCodes.includes(room.roomTypeCode));
97
187
  const rejected = selected.filter((room) => !allowed.includes(room));
@@ -100,7 +190,7 @@ function optionLine(configuration, option, selection) {
100
190
  ...init,
101
191
  quantity: roundQuantity(quantity),
102
192
  unit: option.unit,
103
- unitPriceCents: option.priceCents,
193
+ unitPriceCents,
104
194
  warnings: rejected.map((room) => ({
105
195
  code: "OPTION_ROOM_TYPE_NOT_ALLOWED",
106
196
  params: {
@@ -108,12 +198,12 @@ function optionLine(configuration, option, selection) {
108
198
  roomTypeCode: room.roomTypeCode
109
199
  }
110
200
  }))
111
- }, option.priceCents / 100 * quantity);
201
+ }, unitPriceCents / 100 * quantity);
112
202
  }
113
203
  function optionLines(configuration, data, kind) {
114
204
  return configuration.options.flatMap((selection) => {
115
205
  const option = data.options.find((item) => item.id === selection.optionId);
116
- return option?.kind === kind ? [optionLine(configuration, option, selection)] : [];
206
+ return option?.kind === kind ? [optionLine(configuration, option, selection, data.coefficients)] : [];
117
207
  });
118
208
  }
119
209
  //#endregion
@@ -122,29 +212,6 @@ function additionalLines(configuration, data) {
122
212
  return optionLines(configuration, data, "ADDITIONAL");
123
213
  }
124
214
  //#endregion
125
- //#region src/estimate/base.ts
126
- function baseLine(configuration, data) {
127
- const init = {
128
- id: lineId("BASE"),
129
- section: "BASE"
130
- };
131
- const area = configuration.totalAreaSqm;
132
- const rate = data.baseRates.find((candidate) => candidate.finishLevel === configuration.finishLevel && candidate.areaFromSqm <= area && area < candidate.areaToSqm);
133
- if (rate === void 0) return failedLine(init, "NOT_CALCULATED", {
134
- code: "BASE_RATE_NOT_FOUND",
135
- params: {
136
- totalAreaSqm: area,
137
- finishLevel: configuration.finishLevel
138
- }
139
- });
140
- return pricedLine({
141
- ...init,
142
- quantity: roundQuantity(area),
143
- unit: "SQM",
144
- unitPriceCents: rate.rateCentsPerSqm
145
- }, rate.rateCentsPerSqm / 100 * area);
146
- }
147
- //#endregion
148
215
  //#region src/estimate/engineering.ts
149
216
  function packageLine(configuration, item) {
150
217
  const init = {
@@ -169,52 +236,7 @@ function packageLine(configuration, item) {
169
236
  }, item.priceCents / 100 * quantity);
170
237
  }
171
238
  function engineeringLines(configuration, data) {
172
- return [...data.packageItems.map((item) => packageLine(configuration, item)), ...optionLines(configuration, data, "ENGINEERING")];
173
- }
174
- //#endregion
175
- //#region src/variables/object.ts
176
- const COUNTED_AS_ROOMS = [
177
- "LIVING_ROOM",
178
- "BEDROOM",
179
- "KITCHEN_LIVING"
180
- ];
181
- function objectVariables(configuration) {
182
- return {
183
- totalArea: configuration.totalAreaSqm,
184
- balconyArea: configuration.balconyAreaSqm,
185
- balconyTiles: configuration.balconyTiles,
186
- kitchenType: configuration.kitchenType,
187
- finishLevel: configuration.finishLevel,
188
- roomsCount: configuration.rooms.filter((room) => COUNTED_AS_ROOMS.includes(room.roomTypeCode)).length,
189
- bathroomsCount: configuration.rooms.filter((room) => room.roomTypeCode === "BATHROOM").length
190
- };
191
- }
192
- //#endregion
193
- //#region src/variables/product.ts
194
- function productVariablesOf(product, wastePercent) {
195
- return {
196
- price: product.priceCents / 100,
197
- waste: wastePercent / 100,
198
- unit: product.unit
199
- };
200
- }
201
- function productVariables(product, category) {
202
- return productVariablesOf(product, product.wastePercentOverride ?? category.wastePercent);
203
- }
204
- //#endregion
205
- //#region src/variables/room.ts
206
- function roomVariables(room) {
207
- const perimeter = 2 * (room.lengthM + room.widthM);
208
- return {
209
- length: room.lengthM,
210
- width: room.widthM,
211
- height: room.heightM,
212
- floorArea: room.lengthM * room.widthM,
213
- perimeter,
214
- wallArea: perimeter * room.heightM,
215
- heatedFloor: room.heatedFloor,
216
- roomType: room.roomTypeCode
217
- };
239
+ return [...(configuration.scope === "PARTIAL" ? data.packageItems.filter((item) => !item.includedInBase) : data.packageItems).map((item) => packageLine(configuration, item)), ...optionLines(configuration, data, "ENGINEERING")];
218
240
  }
219
241
  //#endregion
220
242
  //#region src/formula/language.ts
@@ -648,13 +670,87 @@ function runNumericFormula(source, scope) {
648
670
  };
649
671
  }
650
672
  //#endregion
673
+ //#region src/estimate/surfaces.ts
674
+ function surfaceRuleOf(data, surface) {
675
+ return data.rules.filter((rule) => rule.level === "SURFACE" && rule.surface === surface).sort((a, b) => a.sortOrder - b.sortOrder)[0];
676
+ }
677
+ function failedWith(line, error) {
678
+ return failedLine({
679
+ id: line.id,
680
+ section: line.section,
681
+ roomClientId: line.roomClientId,
682
+ categoryId: line.categoryId,
683
+ productId: line.productId,
684
+ ruleId: line.ruleId,
685
+ warnings: line.warnings
686
+ }, "ERROR", error);
687
+ }
688
+ function withComponent(line, component) {
689
+ const components = [...line.components ?? [], component];
690
+ const amountCents = components.reduce((sum, item) => sum + item.amountCents, 0);
691
+ if (amountCents > 1e10) return failedWith(line, outOfRange());
692
+ return {
693
+ ...line,
694
+ components,
695
+ amountCents
696
+ };
697
+ }
698
+ function applySurfacePrep(data, room, lines, objectScope) {
699
+ const result = [...lines];
700
+ const variables = {
701
+ ...roomVariables(room),
702
+ ...objectScope
703
+ };
704
+ const areas = {
705
+ FLOOR: variables.floorArea,
706
+ CEILING: variables.floorArea,
707
+ WALLS: variables.wallArea
708
+ };
709
+ const surfaces = /* @__PURE__ */ new Map();
710
+ lines.forEach((line, index) => {
711
+ const surface = data.categories.find((category) => category.id === line.categoryId)?.surface;
712
+ if (line.status === "OK" && surface !== void 0 && surface !== "NONE") surfaces.set(surface, [...surfaces.get(surface) ?? [], index]);
713
+ });
714
+ for (const [surface, indexes] of surfaces) {
715
+ const rule = surfaceRuleOf(data, surface);
716
+ if (rule === void 0) continue;
717
+ const outcome = runNumericFormula(rule.costFormula, {
718
+ variables: {
719
+ ...variables,
720
+ surfaceArea: areas[surface]
721
+ },
722
+ coefficients: data.coefficients
723
+ });
724
+ const cents = outcome.ok ? componentCents(outcome.value) : null;
725
+ const carrierCategoryId = lines[indexes[0]]?.categoryId ?? null;
726
+ indexes.forEach((index, position) => {
727
+ const line = lines[index];
728
+ if (!outcome.ok || cents === null) {
729
+ result[index] = failedWith(line, outcome.ok ? outOfRange() : outcome.error);
730
+ return;
731
+ }
732
+ result[index] = withComponent(line, position === 0 ? {
733
+ kind: "PREP",
734
+ ruleId: rule.id,
735
+ amountCents: cents
736
+ } : {
737
+ kind: "PREP",
738
+ ruleId: rule.id,
739
+ amountCents: 0,
740
+ sharedWithCategoryId: carrierCategoryId ?? void 0
741
+ });
742
+ });
743
+ }
744
+ return result;
745
+ }
746
+ //#endregion
651
747
  //#region src/estimate/materials.ts
652
748
  const isMaterialRule = (rule) => rule.quantityFormula !== null;
653
749
  function selectRule(data, categoryId, materialTypeId) {
654
750
  const rules = data.rules.filter(isMaterialRule);
655
751
  return rules.find((rule) => rule.level === "MATERIAL_TYPE" && rule.categoryId === categoryId && rule.materialTypeId === materialTypeId) ?? rules.find((rule) => rule.level === "CATEGORY" && rule.categoryId === categoryId);
656
752
  }
657
- function materialLine(configuration, data, room, categoryId, productId) {
753
+ function materialLine(configuration, data, room, categoryId, productId, objectScope) {
658
754
  const init = {
659
755
  id: lineId("MATERIALS", room.clientId, categoryId),
660
756
  section: "MATERIALS",
@@ -686,7 +782,7 @@ function materialLine(configuration, data, room, categoryId, productId) {
686
782
  const variables = {
687
783
  ...roomVariables(room),
688
784
  ...productVariables(product, category),
689
- ...objectVariables(configuration)
785
+ ...objectScope
690
786
  };
691
787
  const { coefficients } = data;
692
788
  const ruled = {
@@ -707,32 +803,74 @@ function materialLine(configuration, data, room, categoryId, productId) {
707
803
  coefficients
708
804
  });
709
805
  if (!cost.ok) return failedLine(ruled, "ERROR", cost.error);
710
- return pricedLine({
806
+ const materialCents = componentCents(cost.value);
807
+ if (materialCents === null) return failedLine(ruled, "ERROR", outOfRange());
808
+ const components = [{
809
+ kind: "MATERIAL",
810
+ ruleId: rule.id,
811
+ amountCents: materialCents
812
+ }];
813
+ const lineWarnings = [...warnings];
814
+ if (configuration.scope === "PARTIAL") {
815
+ let laborCents = 0;
816
+ if (rule.laborFormula === null) lineWarnings.push({
817
+ code: "LABOR_RATE_MISSING",
818
+ params: { categoryId }
819
+ });
820
+ else {
821
+ const labor = runNumericFormula(rule.laborFormula, {
822
+ variables: {
823
+ ...variables,
824
+ quantity: quantity.value
825
+ },
826
+ coefficients
827
+ });
828
+ if (!labor.ok) return failedLine(ruled, "ERROR", labor.error);
829
+ const cents = componentCents(labor.value);
830
+ if (cents === null) return failedLine(ruled, "ERROR", outOfRange());
831
+ laborCents = cents;
832
+ }
833
+ components.push({
834
+ kind: "LABOR",
835
+ ruleId: rule.id,
836
+ amountCents: laborCents
837
+ });
838
+ }
839
+ const amountCents = components.reduce((sum, component) => sum + component.amountCents, 0);
840
+ if (amountCents > 1e10) return failedLine(ruled, "ERROR", outOfRange());
841
+ return createLine({
711
842
  ...ruled,
843
+ warnings: lineWarnings,
712
844
  quantity: roundQuantity(quantity.value),
713
845
  unit: product.unit,
714
- unitPriceCents: product.priceCents
715
- }, cost.value);
846
+ unitPriceCents: product.priceCents,
847
+ amountCents,
848
+ components
849
+ });
716
850
  }
717
- function materialLines(configuration, data) {
718
- return configuration.rooms.flatMap((room) => room.selections.flatMap(({ categoryId, productId }) => productId === null ? [] : [materialLine(configuration, data, room, categoryId, productId)]));
851
+ function materialLines(configuration, data, objectScope) {
852
+ return configuration.rooms.flatMap((room) => {
853
+ const lines = room.selections.flatMap(({ categoryId, productId }) => productId === null ? [] : [materialLine(configuration, data, room, categoryId, productId, objectScope)]);
854
+ return configuration.scope === "PARTIAL" ? applySurfacePrep(data, room, lines, objectScope) : lines;
855
+ });
719
856
  }
720
857
  //#endregion
721
858
  //#region src/estimate/object-rules.ts
722
- function objectRuleLines(configuration, data) {
859
+ function objectRuleLines(configuration, data, section, variables) {
860
+ if (section === "BASE" && configuration.scope !== "FULL") return [];
723
861
  const scope = {
724
- variables: objectVariables(configuration),
862
+ variables,
725
863
  coefficients: data.coefficients
726
864
  };
727
- return data.rules.filter((rule) => rule.level === "OBJECT").sort((a, b) => a.sortOrder - b.sortOrder).map((rule) => {
865
+ return data.rules.filter((rule) => rule.level === "OBJECT" && rule.baseComponent === (section === "BASE")).sort((a, b) => a.sortOrder - b.sortOrder).map((rule) => {
728
866
  const init = {
729
- id: lineId("OBJECT", rule.id),
730
- section: "OBJECT",
867
+ id: lineId(section, rule.id),
868
+ section,
731
869
  ruleId: rule.id
732
870
  };
733
871
  const outcome = runNumericFormula(rule.costFormula, scope);
734
872
  return outcome.ok ? pricedLine(init, outcome.value) : failedLine(init, "ERROR", outcome.error);
735
- });
873
+ }).filter((line) => section !== "BASE" || line.status !== "OK" || line.amountCents !== 0);
736
874
  }
737
875
  //#endregion
738
876
  //#region src/schemas/common.ts
@@ -740,11 +878,17 @@ const LocalizedTextSchema = z.object({
740
878
  en: z.string(),
741
879
  uk: z.string()
742
880
  });
743
- const FinishLevelSchema = z.enum(["ROUGH", "PRE_FINISH"]);
881
+ const ScopeSchema = z.enum(["FULL", "PARTIAL"]);
882
+ const FinishLevelSchema = z.enum([
883
+ "ROUGH",
884
+ "PRE_FINISH",
885
+ "LIVED_IN"
886
+ ]);
744
887
  const RuleLevelSchema = z.enum([
745
888
  "OBJECT",
746
889
  "CATEGORY",
747
- "MATERIAL_TYPE"
890
+ "MATERIAL_TYPE",
891
+ "SURFACE"
748
892
  ]);
749
893
  const RateSourceSchema = z.enum(["NBU", "MANUAL"]);
750
894
  const SurfaceKindSchema = z.enum([
@@ -771,9 +915,10 @@ const RoomTypeCodeSchema = z.enum([
771
915
  "BEDROOM",
772
916
  "KITCHEN",
773
917
  "KITCHEN_LIVING",
774
- "BATHROOM"
918
+ "BATHROOM",
919
+ "HALLWAY",
920
+ "STORAGE"
775
921
  ]);
776
- const KitchenTypeSchema = z.enum(["SEPARATE", "KITCHEN_LIVING"]);
777
922
  const CurrencySchema = z.enum(["USD", "UAH"]);
778
923
  const SectionSchema = z.enum([
779
924
  "BASE",
@@ -782,6 +927,11 @@ const SectionSchema = z.enum([
782
927
  "ENGINEERING",
783
928
  "ADDITIONAL"
784
929
  ]);
930
+ const LineComponentKindSchema = z.enum([
931
+ "MATERIAL",
932
+ "LABOR",
933
+ "PREP"
934
+ ]);
785
935
  const LineStatusSchema = z.enum([
786
936
  "OK",
787
937
  "INCLUDED",
@@ -825,11 +975,11 @@ function duplicateIndexes(items, key) {
825
975
  });
826
976
  }
827
977
  const ConfigurationSchema = z.object({
978
+ scope: ScopeSchema.default("FULL"),
828
979
  finishLevel: FinishLevelSchema,
829
- totalAreaSqm: z.number().positive(),
980
+ totalAreaSqm: z.number().positive().nullable(),
830
981
  balconyAreaSqm: z.number().min(0),
831
982
  balconyTiles: z.boolean(),
832
- kitchenType: KitchenTypeSchema,
833
983
  rooms: z.array(RoomConfigurationSchema),
834
984
  options: z.array(OptionSelectionSchema),
835
985
  currency: CurrencySchema
@@ -839,6 +989,11 @@ const ConfigurationSchema = z.object({
839
989
  path,
840
990
  message: "Duplicate"
841
991
  });
992
+ if (configuration.scope === "FULL" !== (configuration.totalAreaSqm !== null)) ctx.addIssue({
993
+ code: "custom",
994
+ path: ["totalAreaSqm"],
995
+ message: configuration.scope === "FULL" ? "Required" : "Not allowed"
996
+ });
842
997
  for (const index of duplicateIndexes(configuration.rooms, (room) => room.clientId)) addDuplicate([
843
998
  "rooms",
844
999
  index,
@@ -870,13 +1025,8 @@ const PricingLimitsSchema = z.object({
870
1025
  totalAreaSqm: RangeSchema,
871
1026
  rooms: RangeSchema,
872
1027
  roomSideM: RangeSchema,
873
- roomHeightM: RangeSchema
874
- });
875
- const BaseRateSchema = z.object({
876
- finishLevel: FinishLevelSchema,
877
- areaFromSqm: z.number().min(0),
878
- areaToSqm: z.number(),
879
- rateCentsPerSqm: z.int().positive()
1028
+ roomHeightM: RangeSchema,
1029
+ missingAreaHintSqm: z.number().min(0)
880
1030
  });
881
1031
  const PricingRuleSchema = z.object({
882
1032
  id: z.string(),
@@ -885,9 +1035,16 @@ const PricingRuleSchema = z.object({
885
1035
  materialTypeId: z.string().nullable(),
886
1036
  name: LocalizedTextSchema,
887
1037
  quantityFormula: z.string().nullable(),
1038
+ surface: z.enum([
1039
+ "FLOOR",
1040
+ "WALLS",
1041
+ "CEILING"
1042
+ ]).nullable(),
1043
+ baseComponent: z.boolean(),
888
1044
  costFormula: z.string().max(500),
1045
+ laborFormula: z.string().max(500).nullable(),
889
1046
  sortOrder: z.int()
890
- }).refine((rule) => rule.level === "OBJECT" === (rule.quantityFormula === null), { path: ["quantityFormula"] });
1047
+ }).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"] });
891
1048
  const PricingRoomTypeSchema = z.object({
892
1049
  id: z.string(),
893
1050
  code: z.string()
@@ -924,6 +1081,8 @@ const PricingOptionSchema = z.object({
924
1081
  priceCents: CentsSchema,
925
1082
  unit: OptionUnitSchema,
926
1083
  roomTypeCodes: z.array(z.string()),
1084
+ priceCoefficientKeys: z.array(z.string()),
1085
+ scopes: z.array(ScopeSchema).min(1),
927
1086
  minQuantity: z.int().nullable(),
928
1087
  maxQuantity: z.int().nullable(),
929
1088
  available: z.boolean()
@@ -932,7 +1091,6 @@ const PricingDataSchema = z.object({
932
1091
  version: z.string().regex(/^[0-9a-f]{16}$/),
933
1092
  exchangeRate: ExchangeRateSchema.nullable(),
934
1093
  limits: PricingLimitsSchema,
935
- baseRates: z.array(BaseRateSchema),
936
1094
  coefficients: z.record(z.string(), z.number()),
937
1095
  rules: z.array(PricingRuleSchema),
938
1096
  roomTypes: z.array(PricingRoomTypeSchema),
@@ -948,6 +1106,12 @@ const EstimateMessageSchema = z.object({
948
1106
  code: z.string(),
949
1107
  params: z.record(z.string(), z.unknown()).optional()
950
1108
  });
1109
+ const LineComponentSchema = z.object({
1110
+ kind: LineComponentKindSchema,
1111
+ ruleId: z.string(),
1112
+ amountCents: z.number().int().min(0),
1113
+ sharedWithCategoryId: z.string().optional()
1114
+ });
951
1115
  const EstimateLineSchema = z.object({
952
1116
  id: z.string(),
953
1117
  section: SectionSchema,
@@ -963,6 +1127,7 @@ const EstimateLineSchema = z.object({
963
1127
  unitPriceCents: CentsSchema.nullable(),
964
1128
  amountCents: CentsSchema,
965
1129
  amountUahKopecks: CentsSchema.nullable(),
1130
+ components: z.array(LineComponentSchema).optional(),
966
1131
  warnings: z.array(EstimateMessageSchema),
967
1132
  error: EstimateMessageSchema.nullable()
968
1133
  });
@@ -1000,19 +1165,32 @@ const RuleSampleSchema = z.object({
1000
1165
  unit: ProductUnitSchema,
1001
1166
  wastePercent: z.number().min(0)
1002
1167
  }).optional(),
1168
+ surfaceArea: z.number().min(0).optional(),
1003
1169
  object: z.object({
1004
- totalAreaSqm: z.number().positive(),
1170
+ scope: ScopeSchema,
1171
+ totalAreaSqm: z.number().positive().nullable(),
1005
1172
  balconyAreaSqm: z.number().min(0),
1006
1173
  balconyTiles: z.boolean(),
1007
- kitchenType: KitchenTypeSchema,
1008
1174
  finishLevel: FinishLevelSchema,
1009
1175
  roomsCount: z.int().min(0),
1010
- bathroomsCount: z.int().min(0)
1176
+ bathroomsCount: z.int().min(0),
1177
+ roomsFloorArea: z.number().min(0),
1178
+ roomsWallArea: z.number().min(0),
1179
+ roomsPerimeter: z.number().min(0),
1180
+ wetRoomsCount: z.int().min(0)
1011
1181
  })
1182
+ }).superRefine((sample, ctx) => {
1183
+ const { scope, totalAreaSqm } = sample.object;
1184
+ if (scope === "FULL" !== (totalAreaSqm !== null)) ctx.addIssue({
1185
+ code: "custom",
1186
+ path: ["object", "totalAreaSqm"],
1187
+ message: scope === "FULL" ? "Required" : "Not allowed"
1188
+ });
1012
1189
  });
1013
1190
  const RulePreviewSchema = z.object({
1014
1191
  quantity: z.number().nullable(),
1015
1192
  amountCents: CentsSchema.nullable(),
1193
+ laborCents: CentsSchema.nullable(),
1016
1194
  variables: z.record(z.string(), z.union([
1017
1195
  z.number(),
1018
1196
  z.boolean(),
@@ -1047,20 +1225,28 @@ function isIncomplete(lines) {
1047
1225
  }
1048
1226
  //#endregion
1049
1227
  //#region src/estimate/warnings.ts
1050
- function roomsAreaWarnings(configuration) {
1051
- const roomsAreaSqm = Math.round(configuration.rooms.reduce((sum, room) => sum + roomVariables(room).floorArea, 0) * 1e4) / 1e4;
1052
- return roomsAreaSqm > configuration.totalAreaSqm ? [{
1228
+ const roundArea = (value) => Math.round(value * 1e4) / 1e4;
1229
+ function roomsAreaWarnings(configuration, limits) {
1230
+ const totalAreaSqm = configuration.totalAreaSqm;
1231
+ if (configuration.scope !== "FULL" || totalAreaSqm === null) return [];
1232
+ const roomsAreaSqm = roundArea(configuration.rooms.reduce((sum, room) => sum + roomVariables(room).floorArea, 0));
1233
+ if (roomsAreaSqm > totalAreaSqm) return [{
1053
1234
  code: "ROOMS_AREA_EXCEEDS_TOTAL",
1054
1235
  params: {
1055
1236
  roomsAreaSqm,
1056
- totalAreaSqm: configuration.totalAreaSqm
1237
+ totalAreaSqm
1057
1238
  }
1239
+ }];
1240
+ const missingSqm = roundArea(totalAreaSqm - roomsAreaSqm);
1241
+ return missingSqm > limits.missingAreaHintSqm ? [{
1242
+ code: "ROOMS_AREA_BELOW_TOTAL",
1243
+ params: { missingSqm }
1058
1244
  }] : [];
1059
1245
  }
1060
- function estimateWarnings(lines, configuration, rate) {
1246
+ function estimateWarnings(lines, configuration, rate, limits) {
1061
1247
  return [
1062
1248
  ...collectWarnings(lines),
1063
- ...roomsAreaWarnings(configuration),
1249
+ ...roomsAreaWarnings(configuration, limits),
1064
1250
  ...configuration.currency === "UAH" && rate === null ? [{ code: "EXCHANGE_RATE_UNAVAILABLE" }] : []
1065
1251
  ];
1066
1252
  }
@@ -1074,10 +1260,11 @@ function withKopecks(line, rate) {
1074
1260
  }
1075
1261
  function estimate(configuration, data) {
1076
1262
  const rate = data.exchangeRate?.rate ?? null;
1263
+ const variables = objectVariables(configuration);
1077
1264
  const lines = [
1078
- baseLine(configuration, data),
1079
- ...objectRuleLines(configuration, data),
1080
- ...materialLines(configuration, data),
1265
+ ...objectRuleLines(configuration, data, "BASE", variables),
1266
+ ...objectRuleLines(configuration, data, "OBJECT", variables),
1267
+ ...materialLines(configuration, data, variables),
1081
1268
  ...engineeringLines(configuration, data),
1082
1269
  ...additionalLines(configuration, data)
1083
1270
  ].map((line) => withKopecks(line, rate));
@@ -1087,7 +1274,7 @@ function estimate(configuration, data) {
1087
1274
  exchangeRate: data.exchangeRate,
1088
1275
  lines,
1089
1276
  totals: computeTotals(lines, rate !== null),
1090
- warnings: estimateWarnings(lines, configuration, rate),
1277
+ warnings: estimateWarnings(lines, configuration, rate, data.limits),
1091
1278
  incomplete: isIncomplete(lines)
1092
1279
  };
1093
1280
  }
@@ -1102,8 +1289,9 @@ function checkLimits(configuration, limits, options = []) {
1102
1289
  max
1103
1290
  });
1104
1291
  };
1105
- check("totalAreaSqm", configuration.totalAreaSqm, limits.totalAreaSqm.min, limits.totalAreaSqm.max);
1106
- check("balconyAreaSqm", configuration.balconyAreaSqm, 0, configuration.totalAreaSqm);
1292
+ const isFull = configuration.scope === "FULL";
1293
+ if (isFull && configuration.totalAreaSqm !== null) check("totalAreaSqm", configuration.totalAreaSqm, limits.totalAreaSqm.min, limits.totalAreaSqm.max);
1294
+ check("balconyAreaSqm", configuration.balconyAreaSqm, 0, isFull && configuration.totalAreaSqm !== null ? configuration.totalAreaSqm : limits.totalAreaSqm.max);
1107
1295
  check("rooms", configuration.rooms.length, limits.rooms.min, limits.rooms.max);
1108
1296
  configuration.rooms.forEach((room, index) => {
1109
1297
  const path = `rooms[${index}]`;
@@ -1159,18 +1347,33 @@ function referencesOf(sources) {
1159
1347
  }
1160
1348
  function objectScope({ object }) {
1161
1349
  return {
1162
- totalArea: object.totalAreaSqm,
1350
+ scope: object.scope,
1351
+ totalArea: totalAreaOf(object.scope, object.totalAreaSqm, object.roomsFloorArea),
1163
1352
  balconyArea: object.balconyAreaSqm,
1164
1353
  balconyTiles: object.balconyTiles,
1165
- kitchenType: object.kitchenType,
1166
1354
  finishLevel: object.finishLevel,
1167
1355
  roomsCount: object.roomsCount,
1168
- bathroomsCount: object.bathroomsCount
1356
+ bathroomsCount: object.bathroomsCount,
1357
+ roomsFloorArea: object.roomsFloorArea,
1358
+ roomsWallArea: object.roomsWallArea,
1359
+ roomsPerimeter: object.roomsPerimeter,
1360
+ wetRoomsCount: object.wetRoomsCount
1169
1361
  };
1170
1362
  }
1171
1363
  function scopeOf(level, sample) {
1172
1364
  if (level === "OBJECT") return { scope: objectScope(sample) };
1173
- const { room, product } = sample;
1365
+ const { room, product, surfaceArea } = sample;
1366
+ if (level === "SURFACE") {
1367
+ if (room === void 0 || surfaceArea === void 0) return { error: {
1368
+ code: "SAMPLE_INCOMPLETE",
1369
+ params: { missing: room === void 0 ? "room" : "surfaceArea" }
1370
+ } };
1371
+ return { scope: {
1372
+ ...roomVariables(room),
1373
+ ...objectScope(sample),
1374
+ surfaceArea
1375
+ } };
1376
+ }
1174
1377
  if (room === void 0 || product === void 0) return { error: {
1175
1378
  code: "SAMPLE_INCOMPLETE",
1176
1379
  params: { missing: room === void 0 ? "room" : "product" }
@@ -1186,15 +1389,21 @@ function pick(source, names) {
1186
1389
  }
1187
1390
  function previewRule(input) {
1188
1391
  const { level, quantityFormula, costFormula, coefficients } = input;
1189
- const references = referencesOf([quantityFormula, costFormula]);
1190
- const report = (variables, quantity, amountCents, error) => ({
1392
+ const laborFormula = quantityFormula === null ? null : input.laborFormula ?? null;
1393
+ const references = referencesOf([
1394
+ quantityFormula,
1395
+ costFormula,
1396
+ laborFormula
1397
+ ]);
1398
+ const report = (variables, quantity, amountCents, error, laborCents = null) => ({
1191
1399
  quantity,
1192
1400
  amountCents,
1401
+ laborCents,
1193
1402
  variables: pick(variables, references.variables),
1194
1403
  coefficients: pick(coefficients, references.coefficients),
1195
1404
  error
1196
1405
  });
1197
- if (level === "OBJECT" !== (quantityFormula === null)) return report({}, null, null, { code: "QUANTITY_FORMULA_MISMATCH" });
1406
+ if ((level === "OBJECT" || level === "SURFACE") !== (quantityFormula === null)) return report({}, null, null, { code: "QUANTITY_FORMULA_MISMATCH" });
1198
1407
  const prepared = scopeOf(level, input.sample);
1199
1408
  if ("error" in prepared) return report({}, null, null, prepared.error);
1200
1409
  let variables = prepared.scope;
@@ -1220,7 +1429,15 @@ function previewRule(input) {
1220
1429
  id: "preview",
1221
1430
  section: "OBJECT"
1222
1431
  }, cost.value);
1223
- return line.error === null ? report(variables, quantity, line.amountCents, null) : report(variables, quantity, null, line.error);
1432
+ if (line.error !== null) return report(variables, quantity, null, line.error);
1433
+ if (laborFormula === null) return report(variables, quantity, line.amountCents, null);
1434
+ const labor = runNumericFormula(laborFormula, {
1435
+ variables,
1436
+ coefficients
1437
+ });
1438
+ if (!labor.ok) return report(variables, quantity, line.amountCents, labor.error);
1439
+ const laborCents = componentCents(labor.value);
1440
+ return laborCents === null ? report(variables, quantity, line.amountCents, outOfRange()) : report(variables, quantity, line.amountCents, null, laborCents);
1224
1441
  }
1225
1442
  //#endregion
1226
1443
  //#region src/formula/suggest.ts
@@ -1290,13 +1507,18 @@ const FORMULA_SCOPE_VARIABLES = {
1290
1507
  number("totalArea"),
1291
1508
  number("balconyArea"),
1292
1509
  boolean("balconyTiles"),
1293
- enumeration("kitchenType", KitchenTypeSchema.options),
1294
1510
  enumeration("finishLevel", FinishLevelSchema.options),
1295
1511
  number("roomsCount"),
1296
- number("bathroomsCount")
1512
+ number("bathroomsCount"),
1513
+ enumeration("scope", ScopeSchema.options),
1514
+ number("roomsFloorArea"),
1515
+ number("roomsWallArea"),
1516
+ number("roomsPerimeter"),
1517
+ number("wetRoomsCount")
1297
1518
  ]
1298
1519
  };
1299
1520
  const FORMULA_QUANTITY_VARIABLE = number("quantity");
1521
+ const FORMULA_SURFACE_AREA_VARIABLE = number("surfaceArea");
1300
1522
  const FORMULA_VARIABLES = {
1301
1523
  OBJECT: FORMULA_SCOPE_VARIABLES.object,
1302
1524
  CATEGORY: [
@@ -1308,9 +1530,14 @@ const FORMULA_VARIABLES = {
1308
1530
  ...FORMULA_SCOPE_VARIABLES.room,
1309
1531
  ...FORMULA_SCOPE_VARIABLES.product,
1310
1532
  ...FORMULA_SCOPE_VARIABLES.object
1533
+ ],
1534
+ SURFACE: [
1535
+ ...FORMULA_SCOPE_VARIABLES.room,
1536
+ ...FORMULA_SCOPE_VARIABLES.object,
1537
+ FORMULA_SURFACE_AREA_VARIABLE
1311
1538
  ]
1312
1539
  };
1313
- const ALL_VARIABLE_NAMES = new Set(FORMULA_VARIABLES.CATEGORY.map((variable) => variable.name));
1540
+ const ALL_VARIABLE_NAMES = new Set([...FORMULA_VARIABLES.CATEGORY, ...FORMULA_VARIABLES.SURFACE].map((variable) => variable.name));
1314
1541
  function span(node) {
1315
1542
  return {
1316
1543
  position: node.start,
@@ -1334,7 +1561,7 @@ function validateFormula(input) {
1334
1561
  const knownCoefficients = new Set(input.coefficientKeys);
1335
1562
  const usedCoefficients = /* @__PURE__ */ new Set();
1336
1563
  const errors = [];
1337
- const quantityAllowed = input.kind === "cost" && input.level !== "OBJECT";
1564
+ const quantityAllowed = input.kind !== "quantity" && (input.level === "CATEGORY" || input.level === "MATERIAL_TYPE");
1338
1565
  function mismatch(node, expected) {
1339
1566
  errors.push({
1340
1567
  code: "FORMULA_SYNTAX",
@@ -1522,4 +1749,4 @@ function validateFormula(input) {
1522
1749
  };
1523
1750
  }
1524
1751
  //#endregion
1525
- export { BaseRateSchema, COEFFICIENT_NAMESPACE, CentsSchema, ConfigurationSchema, CurrencySchema, EstimateLineSchema, EstimateMessageSchema, EstimateSchema, EstimateTotalsSchema, EstimateWarningSchema, ExchangeRateSchema, FORMULA_FUNCTIONS, FORMULA_FUNCTION_TABLE, FORMULA_LIMITS, FORMULA_QUANTITY_VARIABLE, FORMULA_SCOPE_VARIABLES, FORMULA_VARIABLES, FinishLevelSchema, KitchenTypeSchema, LineStatusSchema, LocalizedTextSchema, OptionCodeSchema, OptionKindSchema, OptionSelectionSchema, OptionUnitSchema, PricingCategorySchema, PricingDataSchema, PricingLimitsSchema, PricingMaterialTypeSchema, PricingOptionSchema, PricingPackageItemSchema, PricingProductSchema, PricingRoomTypeSchema, PricingRuleSchema, ProductUnitSchema, RangeSchema, RateSourceSchema, RoomConfigurationSchema, RoomSelectionSchema, RoomTypeCodeSchema, RuleLevelSchema, RulePreviewSchema, RuleSampleSchema, SectionSchema, SectionTotalSchema, SurfaceKindSchema, checkLimits, convertCentsToKopecks, estimate, evaluate, levenshtein, objectVariables, parse, previewRule, productVariables, productVariablesOf, roomVariables, suggestNearest, toCents, validateFormula };
1752
+ export { COEFFICIENT_NAMESPACE, CentsSchema, ConfigurationSchema, CurrencySchema, EstimateLineSchema, EstimateMessageSchema, EstimateSchema, EstimateTotalsSchema, EstimateWarningSchema, ExchangeRateSchema, FORMULA_FUNCTIONS, FORMULA_FUNCTION_TABLE, FORMULA_LIMITS, FORMULA_QUANTITY_VARIABLE, FORMULA_SCOPE_VARIABLES, FORMULA_SURFACE_AREA_VARIABLE, FORMULA_VARIABLES, FinishLevelSchema, LineComponentKindSchema, LineComponentSchema, LineStatusSchema, LocalizedTextSchema, OptionCodeSchema, OptionKindSchema, OptionSelectionSchema, OptionUnitSchema, PricingCategorySchema, PricingDataSchema, PricingLimitsSchema, PricingMaterialTypeSchema, PricingOptionSchema, PricingPackageItemSchema, PricingProductSchema, PricingRoomTypeSchema, PricingRuleSchema, ProductUnitSchema, RangeSchema, RateSourceSchema, RoomConfigurationSchema, RoomSelectionSchema, RoomTypeCodeSchema, RuleLevelSchema, RulePreviewSchema, RuleSampleSchema, ScopeSchema, SectionSchema, SectionTotalSchema, SurfaceKindSchema, checkLimits, convertCentsToKopecks, estimate, evaluate, levenshtein, objectVariables, parse, previewRule, productVariables, productVariablesOf, roomVariables, suggestNearest, toCents, totalAreaOf, validateFormula };