@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.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,51 +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
- finishLevel: configuration.finishLevel,
187
- roomsCount: configuration.rooms.filter((room) => COUNTED_AS_ROOMS.includes(room.roomTypeCode)).length,
188
- bathroomsCount: configuration.rooms.filter((room) => room.roomTypeCode === "BATHROOM").length
189
- };
190
- }
191
- //#endregion
192
- //#region src/variables/product.ts
193
- function productVariablesOf(product, wastePercent) {
194
- return {
195
- price: product.priceCents / 100,
196
- waste: wastePercent / 100,
197
- unit: product.unit
198
- };
199
- }
200
- function productVariables(product, category) {
201
- return productVariablesOf(product, product.wastePercentOverride ?? category.wastePercent);
202
- }
203
- //#endregion
204
- //#region src/variables/room.ts
205
- function roomVariables(room) {
206
- const perimeter = 2 * (room.lengthM + room.widthM);
207
- return {
208
- length: room.lengthM,
209
- width: room.widthM,
210
- height: room.heightM,
211
- floorArea: room.lengthM * room.widthM,
212
- perimeter,
213
- wallArea: perimeter * room.heightM,
214
- heatedFloor: room.heatedFloor,
215
- roomType: room.roomTypeCode
216
- };
239
+ return [...(configuration.scope === "PARTIAL" ? data.packageItems.filter((item) => !item.includedInBase) : data.packageItems).map((item) => packageLine(configuration, item)), ...optionLines(configuration, data, "ENGINEERING")];
217
240
  }
218
241
  //#endregion
219
242
  //#region src/formula/language.ts
@@ -647,13 +670,87 @@ function runNumericFormula(source, scope) {
647
670
  };
648
671
  }
649
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
650
747
  //#region src/estimate/materials.ts
651
748
  const isMaterialRule = (rule) => rule.quantityFormula !== null;
652
749
  function selectRule(data, categoryId, materialTypeId) {
653
750
  const rules = data.rules.filter(isMaterialRule);
654
751
  return rules.find((rule) => rule.level === "MATERIAL_TYPE" && rule.categoryId === categoryId && rule.materialTypeId === materialTypeId) ?? rules.find((rule) => rule.level === "CATEGORY" && rule.categoryId === categoryId);
655
752
  }
656
- function materialLine(configuration, data, room, categoryId, productId) {
753
+ function materialLine(configuration, data, room, categoryId, productId, objectScope) {
657
754
  const init = {
658
755
  id: lineId("MATERIALS", room.clientId, categoryId),
659
756
  section: "MATERIALS",
@@ -685,7 +782,7 @@ function materialLine(configuration, data, room, categoryId, productId) {
685
782
  const variables = {
686
783
  ...roomVariables(room),
687
784
  ...productVariables(product, category),
688
- ...objectVariables(configuration)
785
+ ...objectScope
689
786
  };
690
787
  const { coefficients } = data;
691
788
  const ruled = {
@@ -706,32 +803,74 @@ function materialLine(configuration, data, room, categoryId, productId) {
706
803
  coefficients
707
804
  });
708
805
  if (!cost.ok) return failedLine(ruled, "ERROR", cost.error);
709
- 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({
710
842
  ...ruled,
843
+ warnings: lineWarnings,
711
844
  quantity: roundQuantity(quantity.value),
712
845
  unit: product.unit,
713
- unitPriceCents: product.priceCents
714
- }, cost.value);
846
+ unitPriceCents: product.priceCents,
847
+ amountCents,
848
+ components
849
+ });
715
850
  }
716
- function materialLines(configuration, data) {
717
- 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
+ });
718
856
  }
719
857
  //#endregion
720
858
  //#region src/estimate/object-rules.ts
721
- function objectRuleLines(configuration, data) {
859
+ function objectRuleLines(configuration, data, section, variables) {
860
+ if (section === "BASE" && configuration.scope !== "FULL") return [];
722
861
  const scope = {
723
- variables: objectVariables(configuration),
862
+ variables,
724
863
  coefficients: data.coefficients
725
864
  };
726
- 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) => {
727
866
  const init = {
728
- id: lineId("OBJECT", rule.id),
729
- section: "OBJECT",
867
+ id: lineId(section, rule.id),
868
+ section,
730
869
  ruleId: rule.id
731
870
  };
732
871
  const outcome = runNumericFormula(rule.costFormula, scope);
733
872
  return outcome.ok ? pricedLine(init, outcome.value) : failedLine(init, "ERROR", outcome.error);
734
- });
873
+ }).filter((line) => section !== "BASE" || line.status !== "OK" || line.amountCents !== 0);
735
874
  }
736
875
  //#endregion
737
876
  //#region src/schemas/common.ts
@@ -739,11 +878,17 @@ const LocalizedTextSchema = z.object({
739
878
  en: z.string(),
740
879
  uk: z.string()
741
880
  });
742
- 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
+ ]);
743
887
  const RuleLevelSchema = z.enum([
744
888
  "OBJECT",
745
889
  "CATEGORY",
746
- "MATERIAL_TYPE"
890
+ "MATERIAL_TYPE",
891
+ "SURFACE"
747
892
  ]);
748
893
  const RateSourceSchema = z.enum(["NBU", "MANUAL"]);
749
894
  const SurfaceKindSchema = z.enum([
@@ -770,7 +915,9 @@ const RoomTypeCodeSchema = z.enum([
770
915
  "BEDROOM",
771
916
  "KITCHEN",
772
917
  "KITCHEN_LIVING",
773
- "BATHROOM"
918
+ "BATHROOM",
919
+ "HALLWAY",
920
+ "STORAGE"
774
921
  ]);
775
922
  const CurrencySchema = z.enum(["USD", "UAH"]);
776
923
  const SectionSchema = z.enum([
@@ -780,6 +927,11 @@ const SectionSchema = z.enum([
780
927
  "ENGINEERING",
781
928
  "ADDITIONAL"
782
929
  ]);
930
+ const LineComponentKindSchema = z.enum([
931
+ "MATERIAL",
932
+ "LABOR",
933
+ "PREP"
934
+ ]);
783
935
  const LineStatusSchema = z.enum([
784
936
  "OK",
785
937
  "INCLUDED",
@@ -823,8 +975,9 @@ function duplicateIndexes(items, key) {
823
975
  });
824
976
  }
825
977
  const ConfigurationSchema = z.object({
978
+ scope: ScopeSchema.default("FULL"),
826
979
  finishLevel: FinishLevelSchema,
827
- totalAreaSqm: z.number().positive(),
980
+ totalAreaSqm: z.number().positive().nullable(),
828
981
  balconyAreaSqm: z.number().min(0),
829
982
  balconyTiles: z.boolean(),
830
983
  rooms: z.array(RoomConfigurationSchema),
@@ -836,6 +989,11 @@ const ConfigurationSchema = z.object({
836
989
  path,
837
990
  message: "Duplicate"
838
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
+ });
839
997
  for (const index of duplicateIndexes(configuration.rooms, (room) => room.clientId)) addDuplicate([
840
998
  "rooms",
841
999
  index,
@@ -867,13 +1025,8 @@ const PricingLimitsSchema = z.object({
867
1025
  totalAreaSqm: RangeSchema,
868
1026
  rooms: RangeSchema,
869
1027
  roomSideM: RangeSchema,
870
- roomHeightM: RangeSchema
871
- });
872
- const BaseRateSchema = z.object({
873
- finishLevel: FinishLevelSchema,
874
- areaFromSqm: z.number().min(0),
875
- areaToSqm: z.number(),
876
- rateCentsPerSqm: z.int().positive()
1028
+ roomHeightM: RangeSchema,
1029
+ missingAreaHintSqm: z.number().min(0)
877
1030
  });
878
1031
  const PricingRuleSchema = z.object({
879
1032
  id: z.string(),
@@ -882,9 +1035,16 @@ const PricingRuleSchema = z.object({
882
1035
  materialTypeId: z.string().nullable(),
883
1036
  name: LocalizedTextSchema,
884
1037
  quantityFormula: z.string().nullable(),
1038
+ surface: z.enum([
1039
+ "FLOOR",
1040
+ "WALLS",
1041
+ "CEILING"
1042
+ ]).nullable(),
1043
+ baseComponent: z.boolean(),
885
1044
  costFormula: z.string().max(500),
1045
+ laborFormula: z.string().max(500).nullable(),
886
1046
  sortOrder: z.int()
887
- }).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"] });
888
1048
  const PricingRoomTypeSchema = z.object({
889
1049
  id: z.string(),
890
1050
  code: z.string()
@@ -921,6 +1081,8 @@ const PricingOptionSchema = z.object({
921
1081
  priceCents: CentsSchema,
922
1082
  unit: OptionUnitSchema,
923
1083
  roomTypeCodes: z.array(z.string()),
1084
+ priceCoefficientKeys: z.array(z.string()),
1085
+ scopes: z.array(ScopeSchema).min(1),
924
1086
  minQuantity: z.int().nullable(),
925
1087
  maxQuantity: z.int().nullable(),
926
1088
  available: z.boolean()
@@ -929,7 +1091,6 @@ const PricingDataSchema = z.object({
929
1091
  version: z.string().regex(/^[0-9a-f]{16}$/),
930
1092
  exchangeRate: ExchangeRateSchema.nullable(),
931
1093
  limits: PricingLimitsSchema,
932
- baseRates: z.array(BaseRateSchema),
933
1094
  coefficients: z.record(z.string(), z.number()),
934
1095
  rules: z.array(PricingRuleSchema),
935
1096
  roomTypes: z.array(PricingRoomTypeSchema),
@@ -945,6 +1106,12 @@ const EstimateMessageSchema = z.object({
945
1106
  code: z.string(),
946
1107
  params: z.record(z.string(), z.unknown()).optional()
947
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
+ });
948
1115
  const EstimateLineSchema = z.object({
949
1116
  id: z.string(),
950
1117
  section: SectionSchema,
@@ -960,6 +1127,7 @@ const EstimateLineSchema = z.object({
960
1127
  unitPriceCents: CentsSchema.nullable(),
961
1128
  amountCents: CentsSchema,
962
1129
  amountUahKopecks: CentsSchema.nullable(),
1130
+ components: z.array(LineComponentSchema).optional(),
963
1131
  warnings: z.array(EstimateMessageSchema),
964
1132
  error: EstimateMessageSchema.nullable()
965
1133
  });
@@ -997,18 +1165,32 @@ const RuleSampleSchema = z.object({
997
1165
  unit: ProductUnitSchema,
998
1166
  wastePercent: z.number().min(0)
999
1167
  }).optional(),
1168
+ surfaceArea: z.number().min(0).optional(),
1000
1169
  object: z.object({
1001
- totalAreaSqm: z.number().positive(),
1170
+ scope: ScopeSchema,
1171
+ totalAreaSqm: z.number().positive().nullable(),
1002
1172
  balconyAreaSqm: z.number().min(0),
1003
1173
  balconyTiles: z.boolean(),
1004
1174
  finishLevel: FinishLevelSchema,
1005
1175
  roomsCount: z.int().min(0),
1006
- 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)
1007
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
+ });
1008
1189
  });
1009
1190
  const RulePreviewSchema = z.object({
1010
1191
  quantity: z.number().nullable(),
1011
1192
  amountCents: CentsSchema.nullable(),
1193
+ laborCents: CentsSchema.nullable(),
1012
1194
  variables: z.record(z.string(), z.union([
1013
1195
  z.number(),
1014
1196
  z.boolean(),
@@ -1043,20 +1225,28 @@ function isIncomplete(lines) {
1043
1225
  }
1044
1226
  //#endregion
1045
1227
  //#region src/estimate/warnings.ts
1046
- function roomsAreaWarnings(configuration) {
1047
- const roomsAreaSqm = Math.round(configuration.rooms.reduce((sum, room) => sum + roomVariables(room).floorArea, 0) * 1e4) / 1e4;
1048
- return roomsAreaSqm > configuration.totalAreaSqm ? [{
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 [{
1049
1234
  code: "ROOMS_AREA_EXCEEDS_TOTAL",
1050
1235
  params: {
1051
1236
  roomsAreaSqm,
1052
- totalAreaSqm: configuration.totalAreaSqm
1237
+ totalAreaSqm
1053
1238
  }
1239
+ }];
1240
+ const missingSqm = roundArea(totalAreaSqm - roomsAreaSqm);
1241
+ return missingSqm > limits.missingAreaHintSqm ? [{
1242
+ code: "ROOMS_AREA_BELOW_TOTAL",
1243
+ params: { missingSqm }
1054
1244
  }] : [];
1055
1245
  }
1056
- function estimateWarnings(lines, configuration, rate) {
1246
+ function estimateWarnings(lines, configuration, rate, limits) {
1057
1247
  return [
1058
1248
  ...collectWarnings(lines),
1059
- ...roomsAreaWarnings(configuration),
1249
+ ...roomsAreaWarnings(configuration, limits),
1060
1250
  ...configuration.currency === "UAH" && rate === null ? [{ code: "EXCHANGE_RATE_UNAVAILABLE" }] : []
1061
1251
  ];
1062
1252
  }
@@ -1070,10 +1260,11 @@ function withKopecks(line, rate) {
1070
1260
  }
1071
1261
  function estimate(configuration, data) {
1072
1262
  const rate = data.exchangeRate?.rate ?? null;
1263
+ const variables = objectVariables(configuration);
1073
1264
  const lines = [
1074
- baseLine(configuration, data),
1075
- ...objectRuleLines(configuration, data),
1076
- ...materialLines(configuration, data),
1265
+ ...objectRuleLines(configuration, data, "BASE", variables),
1266
+ ...objectRuleLines(configuration, data, "OBJECT", variables),
1267
+ ...materialLines(configuration, data, variables),
1077
1268
  ...engineeringLines(configuration, data),
1078
1269
  ...additionalLines(configuration, data)
1079
1270
  ].map((line) => withKopecks(line, rate));
@@ -1083,7 +1274,7 @@ function estimate(configuration, data) {
1083
1274
  exchangeRate: data.exchangeRate,
1084
1275
  lines,
1085
1276
  totals: computeTotals(lines, rate !== null),
1086
- warnings: estimateWarnings(lines, configuration, rate),
1277
+ warnings: estimateWarnings(lines, configuration, rate, data.limits),
1087
1278
  incomplete: isIncomplete(lines)
1088
1279
  };
1089
1280
  }
@@ -1098,8 +1289,9 @@ function checkLimits(configuration, limits, options = []) {
1098
1289
  max
1099
1290
  });
1100
1291
  };
1101
- check("totalAreaSqm", configuration.totalAreaSqm, limits.totalAreaSqm.min, limits.totalAreaSqm.max);
1102
- 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);
1103
1295
  check("rooms", configuration.rooms.length, limits.rooms.min, limits.rooms.max);
1104
1296
  configuration.rooms.forEach((room, index) => {
1105
1297
  const path = `rooms[${index}]`;
@@ -1155,17 +1347,33 @@ function referencesOf(sources) {
1155
1347
  }
1156
1348
  function objectScope({ object }) {
1157
1349
  return {
1158
- totalArea: object.totalAreaSqm,
1350
+ scope: object.scope,
1351
+ totalArea: totalAreaOf(object.scope, object.totalAreaSqm, object.roomsFloorArea),
1159
1352
  balconyArea: object.balconyAreaSqm,
1160
1353
  balconyTiles: object.balconyTiles,
1161
1354
  finishLevel: object.finishLevel,
1162
1355
  roomsCount: object.roomsCount,
1163
- bathroomsCount: object.bathroomsCount
1356
+ bathroomsCount: object.bathroomsCount,
1357
+ roomsFloorArea: object.roomsFloorArea,
1358
+ roomsWallArea: object.roomsWallArea,
1359
+ roomsPerimeter: object.roomsPerimeter,
1360
+ wetRoomsCount: object.wetRoomsCount
1164
1361
  };
1165
1362
  }
1166
1363
  function scopeOf(level, sample) {
1167
1364
  if (level === "OBJECT") return { scope: objectScope(sample) };
1168
- 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
+ }
1169
1377
  if (room === void 0 || product === void 0) return { error: {
1170
1378
  code: "SAMPLE_INCOMPLETE",
1171
1379
  params: { missing: room === void 0 ? "room" : "product" }
@@ -1181,15 +1389,21 @@ function pick(source, names) {
1181
1389
  }
1182
1390
  function previewRule(input) {
1183
1391
  const { level, quantityFormula, costFormula, coefficients } = input;
1184
- const references = referencesOf([quantityFormula, costFormula]);
1185
- 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) => ({
1186
1399
  quantity,
1187
1400
  amountCents,
1401
+ laborCents,
1188
1402
  variables: pick(variables, references.variables),
1189
1403
  coefficients: pick(coefficients, references.coefficients),
1190
1404
  error
1191
1405
  });
1192
- 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" });
1193
1407
  const prepared = scopeOf(level, input.sample);
1194
1408
  if ("error" in prepared) return report({}, null, null, prepared.error);
1195
1409
  let variables = prepared.scope;
@@ -1215,7 +1429,15 @@ function previewRule(input) {
1215
1429
  id: "preview",
1216
1430
  section: "OBJECT"
1217
1431
  }, cost.value);
1218
- 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);
1219
1441
  }
1220
1442
  //#endregion
1221
1443
  //#region src/formula/suggest.ts
@@ -1287,10 +1509,16 @@ const FORMULA_SCOPE_VARIABLES = {
1287
1509
  boolean("balconyTiles"),
1288
1510
  enumeration("finishLevel", FinishLevelSchema.options),
1289
1511
  number("roomsCount"),
1290
- number("bathroomsCount")
1512
+ number("bathroomsCount"),
1513
+ enumeration("scope", ScopeSchema.options),
1514
+ number("roomsFloorArea"),
1515
+ number("roomsWallArea"),
1516
+ number("roomsPerimeter"),
1517
+ number("wetRoomsCount")
1291
1518
  ]
1292
1519
  };
1293
1520
  const FORMULA_QUANTITY_VARIABLE = number("quantity");
1521
+ const FORMULA_SURFACE_AREA_VARIABLE = number("surfaceArea");
1294
1522
  const FORMULA_VARIABLES = {
1295
1523
  OBJECT: FORMULA_SCOPE_VARIABLES.object,
1296
1524
  CATEGORY: [
@@ -1302,9 +1530,14 @@ const FORMULA_VARIABLES = {
1302
1530
  ...FORMULA_SCOPE_VARIABLES.room,
1303
1531
  ...FORMULA_SCOPE_VARIABLES.product,
1304
1532
  ...FORMULA_SCOPE_VARIABLES.object
1533
+ ],
1534
+ SURFACE: [
1535
+ ...FORMULA_SCOPE_VARIABLES.room,
1536
+ ...FORMULA_SCOPE_VARIABLES.object,
1537
+ FORMULA_SURFACE_AREA_VARIABLE
1305
1538
  ]
1306
1539
  };
1307
- 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));
1308
1541
  function span(node) {
1309
1542
  return {
1310
1543
  position: node.start,
@@ -1328,7 +1561,7 @@ function validateFormula(input) {
1328
1561
  const knownCoefficients = new Set(input.coefficientKeys);
1329
1562
  const usedCoefficients = /* @__PURE__ */ new Set();
1330
1563
  const errors = [];
1331
- const quantityAllowed = input.kind === "cost" && input.level !== "OBJECT";
1564
+ const quantityAllowed = input.kind !== "quantity" && (input.level === "CATEGORY" || input.level === "MATERIAL_TYPE");
1332
1565
  function mismatch(node, expected) {
1333
1566
  errors.push({
1334
1567
  code: "FORMULA_SYNTAX",
@@ -1516,4 +1749,4 @@ function validateFormula(input) {
1516
1749
  };
1517
1750
  }
1518
1751
  //#endregion
1519
- export { BaseRateSchema, COEFFICIENT_NAMESPACE, CentsSchema, ConfigurationSchema, CurrencySchema, EstimateLineSchema, EstimateMessageSchema, EstimateSchema, EstimateTotalsSchema, EstimateWarningSchema, ExchangeRateSchema, FORMULA_FUNCTIONS, FORMULA_FUNCTION_TABLE, FORMULA_LIMITS, FORMULA_QUANTITY_VARIABLE, FORMULA_SCOPE_VARIABLES, FORMULA_VARIABLES, FinishLevelSchema, LineStatusSchema, LocalizedTextSchema, OptionCodeSchema, OptionKindSchema, OptionSelectionSchema, OptionUnitSchema, PricingCategorySchema, PricingDataSchema, PricingLimitsSchema, PricingMaterialTypeSchema, PricingOptionSchema, PricingPackageItemSchema, PricingProductSchema, PricingRoomTypeSchema, PricingRuleSchema, ProductUnitSchema, RangeSchema, RateSourceSchema, RoomConfigurationSchema, RoomSelectionSchema, RoomTypeCodeSchema, RuleLevelSchema, RulePreviewSchema, RuleSampleSchema, SectionSchema, SectionTotalSchema, SurfaceKindSchema, checkLimits, convertCentsToKopecks, estimate, evaluate, levenshtein, objectVariables, parse, previewRule, productVariables, productVariablesOf, roomVariables, suggestNearest, toCents, validateFormula };
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 };