@deerdesign/deertools 1.2.35 → 1.3.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/package.json +1 -1
- package/src/baselinker/mcp-server.js +45 -24
- package/src/baselinker-commands.js +1 -2
- package/src/catalog/cli-commands.js +19 -0
- package/src/catalog/mcp-server.js +178 -0
- package/src/cli.js +8 -1
- package/src/commands.js +100 -71
- package/src/deliveries/cli-commands.js +19 -0
- package/src/deliveries/mcp-server.js +164 -0
- package/src/doctor.js +1 -1
- package/src/estimate/auth.js +110 -0
- package/src/estimate/cli-commands.js +161 -0
- package/src/estimate/client.js +1787 -0
- package/src/estimate/default-templates-snapshot.json +1 -0
- package/src/estimate/domain.js +1016 -0
- package/src/estimate/mcp-server.js +592 -0
- package/src/registry.js +4 -4
- package/src/skills-hub.js +14 -2
|
@@ -0,0 +1,1016 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const crypto = require('crypto');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const WHOLE_UNIT_OCR_ROOM_ID = '__caly_lokal__';
|
|
7
|
+
const ANY_ROOM = '*';
|
|
8
|
+
|
|
9
|
+
const PACKAGE_CONFIGS = {
|
|
10
|
+
SMART: {
|
|
11
|
+
key: 'SMART',
|
|
12
|
+
packageKey: 'smart',
|
|
13
|
+
selectedPackageId: 'smart_life',
|
|
14
|
+
selectedPackageName: 'Smart',
|
|
15
|
+
packageUuid: '21f4865a-3694-47a4-8f95-6ea67d9f4e40',
|
|
16
|
+
unitPriceM2: 1290,
|
|
17
|
+
markupPct: 57,
|
|
18
|
+
defaultVatRate: 8
|
|
19
|
+
},
|
|
20
|
+
MODERN: {
|
|
21
|
+
key: 'MODERN',
|
|
22
|
+
packageKey: 'modern',
|
|
23
|
+
selectedPackageId: 'modern_life',
|
|
24
|
+
selectedPackageName: 'Modern',
|
|
25
|
+
packageUuid: '919e7e6f-f840-4198-ad7d-fb666ac91764',
|
|
26
|
+
unitPriceM2: 1640,
|
|
27
|
+
markupPct: 43,
|
|
28
|
+
defaultVatRate: 8
|
|
29
|
+
},
|
|
30
|
+
HIGH: {
|
|
31
|
+
key: 'HIGH',
|
|
32
|
+
packageKey: 'high',
|
|
33
|
+
selectedPackageId: 'high_life',
|
|
34
|
+
selectedPackageName: 'High',
|
|
35
|
+
packageUuid: '8de495c0-e52b-414b-8f41-89d28c026ffa',
|
|
36
|
+
unitPriceM2: 1990,
|
|
37
|
+
markupPct: 35,
|
|
38
|
+
defaultVatRate: 8
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
let _cachedDefaultTemplates = null;
|
|
43
|
+
function getDefaultTemplatesSnapshot() {
|
|
44
|
+
if (!_cachedDefaultTemplates) {
|
|
45
|
+
const p = path.join(__dirname, 'default-templates-snapshot.json');
|
|
46
|
+
_cachedDefaultTemplates = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
47
|
+
}
|
|
48
|
+
return JSON.parse(JSON.stringify(_cachedDefaultTemplates));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function resolvePackageConfig(pkgInput) {
|
|
52
|
+
const s = String(pkgInput || 'SMART').trim().toLowerCase();
|
|
53
|
+
if (s.includes('high') || s === '8de495c0-e52b-414b-8f41-89d28c026ffa') return PACKAGE_CONFIGS.HIGH;
|
|
54
|
+
if (s.includes('modern') || s === '919e7e6f-f840-4198-ad7d-fb666ac91764') return PACKAGE_CONFIGS.MODERN;
|
|
55
|
+
return PACKAGE_CONFIGS.SMART;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function round2(n) {
|
|
59
|
+
const v = Number(n) || 0;
|
|
60
|
+
return Math.round(v * 100) / 100;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function newUuid() {
|
|
64
|
+
return crypto.randomUUID();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function norm(s) {
|
|
68
|
+
return String(s ?? '').trim().toLowerCase();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizePricingUnit(unit) {
|
|
72
|
+
const value = String(unit ?? '').trim().toLowerCase();
|
|
73
|
+
if (['m2', 'm²'].includes(value)) return 'm2';
|
|
74
|
+
if (['mb', 'm.b.', 'm'].includes(value)) return 'mb';
|
|
75
|
+
if (['szt', 'szt.', 'pcs', 'piece'].includes(value)) return 'szt';
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Wylicza cenę sprzedaży netto/brutto z pozycji katalogowej (zgodnie z computeSalePrice.ts).
|
|
81
|
+
*/
|
|
82
|
+
function computeSalePrice(catalogItem, markupPct = 57) {
|
|
83
|
+
const vatBps = Number(catalogItem?.vatRateBps);
|
|
84
|
+
const vatRate = Number.isFinite(vatBps) && vatBps > 0 ? vatBps / 100 : 8;
|
|
85
|
+
const vatMultiplier = 1 + vatRate / 100;
|
|
86
|
+
|
|
87
|
+
const salePrice = Number(catalogItem?.salePrice);
|
|
88
|
+
if (Number.isFinite(salePrice) && salePrice > 0) {
|
|
89
|
+
const unitPriceNet = round2(salePrice / vatMultiplier);
|
|
90
|
+
return {
|
|
91
|
+
unitPriceNet,
|
|
92
|
+
unitPriceGross: round2(salePrice),
|
|
93
|
+
vatRate,
|
|
94
|
+
source: 'salePrice'
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const purchasePrice = Number(catalogItem?.purchasePricePerPricingUnit ?? catalogItem?.purchasePrice);
|
|
99
|
+
if (Number.isFinite(purchasePrice) && purchasePrice > 0) {
|
|
100
|
+
const m = Number.isFinite(markupPct) ? markupPct : 57;
|
|
101
|
+
const unitPriceNet = round2(purchasePrice * (1 + m / 100));
|
|
102
|
+
return {
|
|
103
|
+
unitPriceNet,
|
|
104
|
+
unitPriceGross: round2(unitPriceNet * vatMultiplier),
|
|
105
|
+
vatRate,
|
|
106
|
+
source: 'purchasePriceMarkup'
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
unitPriceNet: 0,
|
|
112
|
+
unitPriceGross: 0,
|
|
113
|
+
vatRate,
|
|
114
|
+
source: 'none'
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Odczytuje dane opakowania z pozycji katalogowej (zgodnie z packaging.ts).
|
|
120
|
+
*/
|
|
121
|
+
function readPackaging(catalogItem) {
|
|
122
|
+
if (!catalogItem) return null;
|
|
123
|
+
if (catalogItem.pricingUnitsPerPackage !== undefined && catalogItem.pricingUnitsPerPackage !== null) {
|
|
124
|
+
const size = Number(catalogItem.pricingUnitsPerPackage);
|
|
125
|
+
const unit = normalizePricingUnit(catalogItem.pricingUnit);
|
|
126
|
+
if (!Number.isFinite(size) || size <= 0 || !unit) return null;
|
|
127
|
+
const waste = Number(catalogItem.wastePercent ?? 0);
|
|
128
|
+
return {
|
|
129
|
+
areaPerPackage: size,
|
|
130
|
+
pricingUnitsPerPackage: size,
|
|
131
|
+
pricingUnit: unit,
|
|
132
|
+
wastePercent: Number.isFinite(waste) && waste >= 0 && waste <= 100 ? waste : 0,
|
|
133
|
+
includesReserve: true
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
const attrs = catalogItem.attributes || {};
|
|
137
|
+
const areaPerPkg = Number(attrs.areaPerPackage);
|
|
138
|
+
if (Number.isFinite(areaPerPkg) && areaPerPkg > 0) {
|
|
139
|
+
const unitsPerPkg = Number(attrs.unitsPerPackage);
|
|
140
|
+
return {
|
|
141
|
+
areaPerPackage: areaPerPkg,
|
|
142
|
+
...(Number.isFinite(unitsPerPkg) && unitsPerPkg > 0 ? { unitsPerPackage: unitsPerPkg } : {})
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Przelicza powierzchnię na pełne opakowania (zawsze zaokrąglane w górę).
|
|
150
|
+
*/
|
|
151
|
+
function computePackaging(areaM2, packaging, wastePercentOverride) {
|
|
152
|
+
if (!packaging) return null;
|
|
153
|
+
const raw = Number(areaM2);
|
|
154
|
+
const { areaPerPackage, unitsPerPackage } = packaging;
|
|
155
|
+
const wastePercent = wastePercentOverride !== undefined && wastePercentOverride !== null
|
|
156
|
+
? Number(wastePercentOverride)
|
|
157
|
+
: Number(packaging.wastePercent ?? 0);
|
|
158
|
+
|
|
159
|
+
if (!Number.isFinite(raw) || raw < 0 || (!packaging.includesReserve && raw === 0) || !Number.isFinite(areaPerPackage) || areaPerPackage <= 0) {
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const ratio = (raw * (1 + wastePercent / 100)) / areaPerPackage;
|
|
164
|
+
const nearest = Math.round(ratio);
|
|
165
|
+
const packages = Math.ceil(Math.abs(ratio - nearest) <= 1e-10 * Math.max(1, ratio) ? nearest : ratio);
|
|
166
|
+
const billedAreaM2 = Number((packages * areaPerPackage).toFixed(6));
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
...packaging,
|
|
170
|
+
rawAreaM2: raw,
|
|
171
|
+
wastePercent,
|
|
172
|
+
packages,
|
|
173
|
+
billedAreaM2,
|
|
174
|
+
...(unitsPerPackage ? { pieces: packages * unitsPerPackage } : {}),
|
|
175
|
+
surplusM2: round2(billedAreaM2 - raw)
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function toItemPackaging(result) {
|
|
180
|
+
if (!result) return undefined;
|
|
181
|
+
return {
|
|
182
|
+
areaPerPackage: result.areaPerPackage,
|
|
183
|
+
...(result.unitsPerPackage ? { unitsPerPackage: result.unitsPerPackage } : {}),
|
|
184
|
+
packages: result.packages,
|
|
185
|
+
rawAreaM2: result.rawAreaM2,
|
|
186
|
+
...(result.pricingUnitsPerPackage != null
|
|
187
|
+
? {
|
|
188
|
+
pricingUnitsPerPackage: result.pricingUnitsPerPackage,
|
|
189
|
+
pricingUnit: result.pricingUnit,
|
|
190
|
+
wastePercent: result.wastePercent,
|
|
191
|
+
includesReserve: result.includesReserve
|
|
192
|
+
}
|
|
193
|
+
: {})
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function calculateRowPrices(quantity, unitPrice, vatRate = 8) {
|
|
198
|
+
const qty = Number(quantity) || 0;
|
|
199
|
+
const netUnit = Number(unitPrice) || 0;
|
|
200
|
+
const vat = vatRate === 'zw' ? 0 : (Number.isFinite(Number(vatRate)) ? Number(vatRate) : 8);
|
|
201
|
+
const totalNetPrice = round2(qty * netUnit);
|
|
202
|
+
const totalVatPrice = round2(totalNetPrice * (vat / 100));
|
|
203
|
+
const totalGrossPrice = round2(totalNetPrice + totalVatPrice);
|
|
204
|
+
return { totalNetPrice, totalVatPrice, totalGrossPrice };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Buduje pełny wiersz kosztorysu z pozycji katalogowej (zgodnie z createRowData w gridHelpers.ts).
|
|
209
|
+
*/
|
|
210
|
+
function buildEstimateRowFromCatalogItem(catalogItem, {
|
|
211
|
+
quantity = 1,
|
|
212
|
+
group = 'Inne',
|
|
213
|
+
rowId = undefined,
|
|
214
|
+
expectedUnit = undefined,
|
|
215
|
+
reservePct = undefined,
|
|
216
|
+
markupPct = 57,
|
|
217
|
+
packageLabel = undefined
|
|
218
|
+
} = {}) {
|
|
219
|
+
const { unitPriceNet, vatRate } = computeSalePrice(catalogItem, markupPct);
|
|
220
|
+
const attrs = catalogItem.attributes || {};
|
|
221
|
+
const imageUrl = Array.isArray(attrs.images) && attrs.images.length > 0 ? attrs.images[0] : undefined;
|
|
222
|
+
|
|
223
|
+
const packagingInfo = readPackaging(catalogItem);
|
|
224
|
+
const compatible = !packagingInfo?.pricingUnit ||
|
|
225
|
+
(!expectedUnit || normalizePricingUnit(expectedUnit) === packagingInfo.pricingUnit);
|
|
226
|
+
const waste = reservePct != null ? Number(reservePct) : (packagingInfo?.wastePercent ?? 0);
|
|
227
|
+
const packagingResult = packagingInfo && compatible ? computePackaging(quantity, packagingInfo, waste) : null;
|
|
228
|
+
|
|
229
|
+
let unitPrice = packagingResult?.includesReserve
|
|
230
|
+
? (catalogItem.purchasePricePerPricingUnit != null && Number.isFinite(Number(catalogItem.purchasePricePerPricingUnit))
|
|
231
|
+
? round2(Number(catalogItem.purchasePricePerPricingUnit) * (1 + markupPct / 100))
|
|
232
|
+
: unitPriceNet)
|
|
233
|
+
: unitPriceNet;
|
|
234
|
+
|
|
235
|
+
const billedQuantity = packagingResult ? packagingResult.billedAreaM2 : Number(quantity) || 0;
|
|
236
|
+
const rawType = String(catalogItem.type || 'product').toLowerCase();
|
|
237
|
+
const isService = rawType === 'service';
|
|
238
|
+
const itemKind = isService ? 'labour' : 'material';
|
|
239
|
+
const rowType = isService ? 'SERVICE' : 'PRODUCT';
|
|
240
|
+
|
|
241
|
+
const prices = calculateRowPrices(billedQuantity, unitPrice, vatRate);
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
id: rowId || newUuid(),
|
|
245
|
+
productId: String(catalogItem.id),
|
|
246
|
+
sourceProductId: String(attrs.sourceProductId || catalogItem.id),
|
|
247
|
+
name: catalogItem.name,
|
|
248
|
+
SKU: catalogItem.sku || '',
|
|
249
|
+
unit: packagingResult?.pricingUnit || (packagingResult ? 'm2' : (catalogItem.unit || expectedUnit || 'szt')),
|
|
250
|
+
...(packagingResult ? { packaging: toItemPackaging(packagingResult) } : {}),
|
|
251
|
+
package: packageLabel || catalogItem.package || '',
|
|
252
|
+
type: rowType,
|
|
253
|
+
itemKind,
|
|
254
|
+
unitPrice,
|
|
255
|
+
vatRate,
|
|
256
|
+
quantity: billedQuantity,
|
|
257
|
+
group,
|
|
258
|
+
imageUrl,
|
|
259
|
+
description: catalogItem.description || '',
|
|
260
|
+
purchasePrice: packagingResult?.includesReserve
|
|
261
|
+
? (catalogItem.purchasePricePerPricingUnit ?? catalogItem.purchasePrice ?? 0)
|
|
262
|
+
: (catalogItem.purchasePricePerPricingUnit ?? catalogItem.purchasePrice ?? 0),
|
|
263
|
+
manufacturerName: attrs.manufacturerName || '',
|
|
264
|
+
manufacturerId: attrs.manufacturerId ?? null,
|
|
265
|
+
categoryName: attrs.categoryName || '',
|
|
266
|
+
categoryPath: attrs.categoryPath || '',
|
|
267
|
+
categoryId: attrs.categoryId ?? null,
|
|
268
|
+
ean: attrs.ean || '',
|
|
269
|
+
link: attrs.link ?? null,
|
|
270
|
+
width: attrs.width,
|
|
271
|
+
height: attrs.height,
|
|
272
|
+
length: attrs.length,
|
|
273
|
+
weight: attrs.weight,
|
|
274
|
+
material: attrs.material,
|
|
275
|
+
catalogId: String(catalogItem.catalogId || '5'),
|
|
276
|
+
images: attrs.images || [],
|
|
277
|
+
...(reservePct != null && !packagingResult?.includesReserve ? { reservePct: Number(reservePct) } : {}),
|
|
278
|
+
...(attrs.reserve != null ? { extraReserve: String(attrs.reserve) } : {}),
|
|
279
|
+
isBundle: Boolean(attrs.isBundle),
|
|
280
|
+
averageLandedCost: Number(attrs.averageLandedCost) || 0,
|
|
281
|
+
...prices
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Klucze powiązań (signedTotals.ts).
|
|
287
|
+
*/
|
|
288
|
+
function strictBindingKey(b) {
|
|
289
|
+
if (!b) return '';
|
|
290
|
+
return [b.ocrRoomId, b.templateRoomId, b.slotId, b.kind].join('§');
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function looseBindingKey(b) {
|
|
294
|
+
if (!b) return '';
|
|
295
|
+
return [b.ocrRoomId, b.roomKey ?? b.templateRoomId, b.slotKey ?? b.slotId, b.kind].join('§');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function removedBindingKey(b) {
|
|
299
|
+
if (!b) return '';
|
|
300
|
+
return [
|
|
301
|
+
looseBindingKey(b),
|
|
302
|
+
(b.variantPath || []).join('/'),
|
|
303
|
+
String(b.catalogRef ?? '').trim().toLowerCase()
|
|
304
|
+
].join('§v§');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function hashVariantAnswers(answers) {
|
|
308
|
+
if (!answers || typeof answers !== 'object') return '';
|
|
309
|
+
return Object.keys(answers)
|
|
310
|
+
.sort()
|
|
311
|
+
.map(k => `${k}=${String(answers[k] ?? '').trim().toLowerCase()}`)
|
|
312
|
+
.join('|');
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Wylicza podpis (signature) powiązań kosztorysu.
|
|
317
|
+
*/
|
|
318
|
+
function computeEstimateSignature(docId, packageUuid, variantAnswers, ocrRooms, roomMappings) {
|
|
319
|
+
const vaHash = hashVariantAnswers(variantAnswers);
|
|
320
|
+
const roomsParts = (ocrRooms || []).map(r => {
|
|
321
|
+
const rid = r.id || '';
|
|
322
|
+
const area = Number(r.area || 0).toFixed(2);
|
|
323
|
+
const perim = Number(r.perimeter || 0).toFixed(2);
|
|
324
|
+
const height = Number(r.height || 2.7).toFixed(2);
|
|
325
|
+
const ops = [...(r.openings || [])].sort((a, b) => String(a.id || '').localeCompare(String(b.id || '')));
|
|
326
|
+
const opsStr = ops.map(o => `${o.kind}:${Number(o.width || 0).toFixed(2)}x${Number(o.count || 1)}`).join(',');
|
|
327
|
+
return `${rid}:${area}/${perim}/${height}[${opsStr}]`;
|
|
328
|
+
});
|
|
329
|
+
const roomsStr = roomsParts.join('|');
|
|
330
|
+
|
|
331
|
+
const mapParts = (roomMappings || []).map(m => {
|
|
332
|
+
const tParts = (m.templateRoomIds || []).map(t => {
|
|
333
|
+
if (t.role === 'addon' && t.areaShare !== undefined) {
|
|
334
|
+
return `${t.role}:${t.templateRoomId}@${t.areaShare}`;
|
|
335
|
+
}
|
|
336
|
+
return `${t.role}:${t.templateRoomId}@`;
|
|
337
|
+
});
|
|
338
|
+
return `${m.ocrRoomId}->${tParts.join('+')}`;
|
|
339
|
+
});
|
|
340
|
+
const mapsStr = mapParts.join('|');
|
|
341
|
+
const signature = `${docId}::${packageUuid}::${vaHash}::${roomsStr}::${mapsStr}`;
|
|
342
|
+
return { signature, variantAnswersHash: vaHash };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Przelicza totalPrices w dataJson.estimatePanelData (pomijając pozycje zredukowane cancelled: true).
|
|
347
|
+
*/
|
|
348
|
+
function recalculateDocumentTotals(dataJson) {
|
|
349
|
+
if (!dataJson.estimatePanelData) {
|
|
350
|
+
dataJson.estimatePanelData = { estimateItems: [], totalPrices: { totalNetPrice: 0, totalVatPrice: 0, totalGrossPrice: 0 } };
|
|
351
|
+
}
|
|
352
|
+
const items = dataJson.estimatePanelData.estimateItems || [];
|
|
353
|
+
let totalNet = 0;
|
|
354
|
+
let totalVat = 0;
|
|
355
|
+
let totalGross = 0;
|
|
356
|
+
|
|
357
|
+
for (const it of items) {
|
|
358
|
+
const qty = Number(it.quantity) || 0;
|
|
359
|
+
const unitNet = Number(it.unitPrice ?? it.unitPriceNet) || 0;
|
|
360
|
+
const vat = it.vatRate === 'zw' ? 0 : (Number.isFinite(Number(it.vatRate)) ? Number(it.vatRate) : 8);
|
|
361
|
+
const rowPrices = calculateRowPrices(qty, unitNet, vat);
|
|
362
|
+
|
|
363
|
+
if (it.unitPrice !== undefined && it.unitPrice !== null) {
|
|
364
|
+
it.totalNetPrice = rowPrices.totalNetPrice;
|
|
365
|
+
it.totalVatPrice = rowPrices.totalVatPrice;
|
|
366
|
+
it.totalGrossPrice = rowPrices.totalGrossPrice;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (it.cancelled) {
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
const sign = it.settlementMode === 'reduction' ? -1 : 1;
|
|
373
|
+
totalNet += sign * (Number(it.totalNetPrice) || 0);
|
|
374
|
+
totalVat += sign * (Number(it.totalVatPrice) || 0);
|
|
375
|
+
totalGross += sign * (Number(it.totalGrossPrice) || 0);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
dataJson.estimatePanelData.totalPrices = {
|
|
379
|
+
totalNetPrice: round2(totalNet),
|
|
380
|
+
totalVatPrice: round2(totalVat),
|
|
381
|
+
totalGrossPrice: round2(totalGross)
|
|
382
|
+
};
|
|
383
|
+
dataJson.estimatePanelData.totalNet = round2(totalNet);
|
|
384
|
+
dataJson.estimatePanelData.totalGross = round2(totalGross);
|
|
385
|
+
|
|
386
|
+
return dataJson.estimatePanelData.totalPrices;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Heurystyczne mapowanie nazwy pomieszczenia na klucz pokoju wzorca.
|
|
391
|
+
*/
|
|
392
|
+
function guessTemplateRoomKeysForRoomName(roomName) {
|
|
393
|
+
const n = norm(roomName);
|
|
394
|
+
if (n.includes('salon') && (n.includes('kuchni') || n.includes('aneks'))) {
|
|
395
|
+
return [{ role: 'primary', key: 'salon' }, { role: 'addon', key: 'kuchnia_otwarta' }];
|
|
396
|
+
}
|
|
397
|
+
if (n.includes('aneks') || n.includes('kuchnia otwarta')) {
|
|
398
|
+
return [{ role: 'primary', key: 'kuchnia_otwarta' }];
|
|
399
|
+
}
|
|
400
|
+
if (n.includes('kuchni')) return [{ role: 'primary', key: 'kuchnia' }];
|
|
401
|
+
if (n.includes('salon') || n.includes('pokój dzienny') || n.includes('pokoj dzienny')) return [{ role: 'primary', key: 'salon' }];
|
|
402
|
+
if (n.includes('sypialni') || n.includes('pokój') || n.includes('pokoj') || n.includes('gabinet')) return [{ role: 'primary', key: 'sypialnia' }];
|
|
403
|
+
if (n.includes('łazienk') || n.includes('lazienk')) return [{ role: 'primary', key: 'lazienka' }];
|
|
404
|
+
if (n === 'wc' || n.includes('toalet')) return [{ role: 'primary', key: 'wc' }];
|
|
405
|
+
if (n.includes('garderob')) return [{ role: 'primary', key: 'garderoba' }];
|
|
406
|
+
if (n.includes('pralni') || n.includes(' kotłown') || n.includes('technicz')) return [{ role: 'primary', key: 'pralnia' }];
|
|
407
|
+
if (n.includes('spiżarn') || n.includes('spizarn') || n.includes('schowek')) return [{ role: 'primary', key: 'spizarnia' }];
|
|
408
|
+
return [{ role: 'primary', key: 'przedpokoj' }];
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Buduje lub aktualizuje roomMappings dla listy pomieszczeń OCR i wybranego pakietu wzorca.
|
|
413
|
+
*/
|
|
414
|
+
function buildRoomMappingsForPackage(ocrRooms, pkg, existingMappings = []) {
|
|
415
|
+
const roomByKey = new Map();
|
|
416
|
+
const roomById = new Map();
|
|
417
|
+
for (const r of (pkg.rooms || [])) {
|
|
418
|
+
if (r.key) roomByKey.set(r.key, r);
|
|
419
|
+
roomById.set(r.id, r);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Indeks starych mapowań, aby zachować role primary/addon i areaShare przy zmianie pakietu
|
|
423
|
+
const prevByOcrId = new Map();
|
|
424
|
+
for (const m of (existingMappings || [])) {
|
|
425
|
+
if (m.ocrRoomId) prevByOcrId.set(m.ocrRoomId, m);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return (ocrRooms || []).map(ocr => {
|
|
429
|
+
const prev = prevByOcrId.get(ocr.id);
|
|
430
|
+
if (ocr.templateTargets && Array.isArray(ocr.templateTargets) && ocr.templateTargets.length > 0) {
|
|
431
|
+
const templateRoomIds = ocr.templateTargets.map((t, idx) => {
|
|
432
|
+
const matchedRoom = roomById.get(t.templateRoomId) || roomByKey.get(t.templateRoomKey || t.key);
|
|
433
|
+
return {
|
|
434
|
+
role: t.role || (idx === 0 ? 'primary' : 'addon'),
|
|
435
|
+
templateRoomId: matchedRoom ? matchedRoom.id : t.templateRoomId,
|
|
436
|
+
...(t.areaShare !== undefined ? { areaShare: Number(t.areaShare) } : {})
|
|
437
|
+
};
|
|
438
|
+
}).filter(x => x.templateRoomId);
|
|
439
|
+
if (templateRoomIds.length > 0) {
|
|
440
|
+
return {
|
|
441
|
+
ocrRoomId: ocr.id,
|
|
442
|
+
confidence: 1,
|
|
443
|
+
ocrRoomName: ocr.name,
|
|
444
|
+
templateRoomId: templateRoomIds[0].templateRoomId,
|
|
445
|
+
templateRoomIds
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const guessed = guessTemplateRoomKeysForRoomName(ocr.name);
|
|
451
|
+
const templateRoomIds = guessed.map(g => {
|
|
452
|
+
const tr = roomByKey.get(g.key);
|
|
453
|
+
// Sprawdź czy w poprzednim mapowaniu był addon z areaShare
|
|
454
|
+
const prevAddon = prev?.templateRoomIds?.find(x => x.role === 'addon' && x.areaShare !== undefined);
|
|
455
|
+
const areaShare = g.role === 'addon'
|
|
456
|
+
? (ocr.kitchenAreaShare !== undefined ? Number(ocr.kitchenAreaShare) : (prevAddon?.areaShare ?? round2(Math.min(4.0, (Number(ocr.area) || 18) * 0.2))))
|
|
457
|
+
: undefined;
|
|
458
|
+
return {
|
|
459
|
+
role: g.role,
|
|
460
|
+
templateRoomId: tr ? tr.id : '',
|
|
461
|
+
...(areaShare !== undefined ? { areaShare } : {})
|
|
462
|
+
};
|
|
463
|
+
}).filter(x => x.templateRoomId);
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
ocrRoomId: ocr.id,
|
|
467
|
+
confidence: 1,
|
|
468
|
+
ocrRoomName: ocr.name,
|
|
469
|
+
templateRoomId: templateRoomIds[0]?.templateRoomId || '',
|
|
470
|
+
templateRoomIds
|
|
471
|
+
};
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Zbiera wszystkie osiągalne pytania wariantowe w pakiecie wraz z domyślnymi odpowiedziami.
|
|
477
|
+
*/
|
|
478
|
+
function collectQuestionsAndDefaultAnswers(pkg, roomMappings, totalArea = 55, existingAnswers = {}) {
|
|
479
|
+
const answers = { ...existingAnswers };
|
|
480
|
+
const questions = [];
|
|
481
|
+
|
|
482
|
+
const areaBracket = totalArea < 35
|
|
483
|
+
? 'lokal do 35m2'
|
|
484
|
+
: totalArea <= 55
|
|
485
|
+
? 'lokal od 35m2 do 55m2'
|
|
486
|
+
: totalArea <= 80
|
|
487
|
+
? 'lokal od 55m2 do 80m2'
|
|
488
|
+
: 'lokal powyżej 80m2';
|
|
489
|
+
|
|
490
|
+
const scopes = [];
|
|
491
|
+
for (const m of (roomMappings || [])) {
|
|
492
|
+
for (const t of (m.templateRoomIds || [])) {
|
|
493
|
+
scopes.push({ ocrRoomId: m.ocrRoomId, ocrRoomName: m.ocrRoomName, templateRoomId: t.templateRoomId });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
const wholeRoom = (pkg.rooms || []).find(r => r.key === 'caly_lokal');
|
|
497
|
+
if (wholeRoom) {
|
|
498
|
+
scopes.push({ ocrRoomId: WHOLE_UNIT_OCR_ROOM_ID, ocrRoomName: 'Cały lokal', templateRoomId: wholeRoom.id });
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
for (const sc of scopes) {
|
|
502
|
+
const tr = (pkg.rooms || []).find(r => r.id === sc.templateRoomId);
|
|
503
|
+
if (!tr) continue;
|
|
504
|
+
|
|
505
|
+
for (const slot of (tr.items || [])) {
|
|
506
|
+
const walk = (node, pathIds = []) => {
|
|
507
|
+
const variants = node.variants || [];
|
|
508
|
+
if (!variants.length) return;
|
|
509
|
+
const qText = (node.question || '').trim();
|
|
510
|
+
const owner = pathIds.length ? pathIds[pathIds.length - 1] : undefined;
|
|
511
|
+
const qId = owner ? `${slot.id}>${owner}` : slot.id;
|
|
512
|
+
const instanceKey = `${sc.ocrRoomId}|${tr.id}|${qId}`;
|
|
513
|
+
const wildcardKey = `${ANY_ROOM}|${tr.id}|${qId}`;
|
|
514
|
+
|
|
515
|
+
const availableOptions = variants.map(v => (v.answer || '').trim()).filter(Boolean);
|
|
516
|
+
let chosenAnswer = answers[instanceKey] || answers[wildcardKey] || (qText ? answers[qText] : undefined);
|
|
517
|
+
|
|
518
|
+
if (!chosenAnswer && availableOptions.length > 0) {
|
|
519
|
+
// Inteligentny wybór domyślny
|
|
520
|
+
if (tr.key === 'caly_lokal') {
|
|
521
|
+
chosenAnswer = availableOptions.find(a => norm(a) === norm(areaBracket)) || availableOptions[0];
|
|
522
|
+
} else if (qText.toLowerCase().includes('nad blatem')) {
|
|
523
|
+
chosenAnswer = availableOptions.find(a => norm(a).includes('mini')) || availableOptions[0];
|
|
524
|
+
} else if (qText.toLowerCase().includes('format płytek')) {
|
|
525
|
+
chosenAnswer = availableOptions.find(a => norm(a).includes('duży') || norm(a).includes('duzy')) || availableOptions[0];
|
|
526
|
+
} else if (qText.toLowerCase().includes('brodzik') || qText.toLowerCase().includes('wanna')) {
|
|
527
|
+
chosenAnswer = availableOptions[0];
|
|
528
|
+
} else {
|
|
529
|
+
chosenAnswer = availableOptions[0];
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (chosenAnswer) {
|
|
534
|
+
answers[instanceKey] = chosenAnswer;
|
|
535
|
+
answers[wildcardKey] = chosenAnswer;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (variants.length > 1 && qText) {
|
|
539
|
+
questions.push({
|
|
540
|
+
ocrRoomId: sc.ocrRoomId,
|
|
541
|
+
ocrRoomName: sc.ocrRoomName,
|
|
542
|
+
templateRoomId: tr.id,
|
|
543
|
+
templateRoomName: tr.name,
|
|
544
|
+
slotId: slot.id,
|
|
545
|
+
slotName: slot.name,
|
|
546
|
+
questionId: qId,
|
|
547
|
+
question: qText,
|
|
548
|
+
options: availableOptions,
|
|
549
|
+
currentAnswer: chosenAnswer,
|
|
550
|
+
answerKey: instanceKey
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const matchedVariant = variants.find(v => norm(v.answer) === norm(chosenAnswer)) || variants[0];
|
|
555
|
+
if (matchedVariant) {
|
|
556
|
+
walk(matchedVariant, [...pathIds, matchedVariant.id]);
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
walk(slot, []);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return { questions, variantAnswers: answers };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Oblicza ilość pozycji ze wzorca na podstawie reguły ilościowej i wymiarów pokoju.
|
|
569
|
+
*/
|
|
570
|
+
function computeSlotQuantity(rule, defaultQty, dim, opts = {}) {
|
|
571
|
+
const dQty = Number(defaultQty) || 1;
|
|
572
|
+
const floorArea = Number(dim.floorArea ?? dim.area ?? 0);
|
|
573
|
+
const perimeter = Number(dim.perimeter ?? 0);
|
|
574
|
+
const height = Number(dim.height ?? 2.7);
|
|
575
|
+
const limit = Number.isFinite(Number(opts.wallHeightLimit)) ? Number(opts.wallHeightLimit) : 2.2;
|
|
576
|
+
const openingsWidth = Number(dim.openingsWidth ?? 0);
|
|
577
|
+
const doorsCount = Number.isFinite(Number(dim.doors)) ? Number(dim.doors) : 1;
|
|
578
|
+
const openingsArea = Number(dim.openingsArea ?? (doorsCount * 1.8));
|
|
579
|
+
|
|
580
|
+
const applyWallDed = (base) => {
|
|
581
|
+
let val = base;
|
|
582
|
+
if (opts.deductDoors) val -= openingsArea;
|
|
583
|
+
if (Number(opts.mirrorDeduction) > 0) val -= Number(opts.mirrorDeduction);
|
|
584
|
+
return Math.max(0, round2(val));
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
switch (rule) {
|
|
588
|
+
case 'powierzchnia_podlogi':
|
|
589
|
+
return round2(floorArea * dQty);
|
|
590
|
+
case 'powierzchnia_scian':
|
|
591
|
+
return round2(applyWallDed(perimeter * height) * dQty);
|
|
592
|
+
case 'powierzchnia_scian_do_wysokosci':
|
|
593
|
+
return round2(applyWallDed(perimeter * Math.min(height, limit)) * dQty);
|
|
594
|
+
case 'powierzchnia_scian_powyzej_wysokosci':
|
|
595
|
+
return round2(applyWallDed(perimeter * Math.max(height - limit, 0)) * dQty);
|
|
596
|
+
case 'powierzchnia_scian_pasmo': {
|
|
597
|
+
const from = Number.isFinite(Number(opts.bandFrom)) ? Math.max(0, Number(opts.bandFrom)) : 0;
|
|
598
|
+
const to = Number.isFinite(Number(opts.bandTo)) ? Number(opts.bandTo) : height;
|
|
599
|
+
const bandH = Math.max(0, Math.min(height, to) - from);
|
|
600
|
+
return round2(applyWallDed(perimeter * bandH) * dQty);
|
|
601
|
+
}
|
|
602
|
+
case 'obwod': {
|
|
603
|
+
const netPerim = opts.deductOpenings ? Math.max(0, round2(perimeter - openingsWidth)) : perimeter;
|
|
604
|
+
return round2(netPerim * dQty);
|
|
605
|
+
}
|
|
606
|
+
case 'sztuka':
|
|
607
|
+
return (Number(dim.pieces) || 1) * dQty;
|
|
608
|
+
case 'metry_biezace':
|
|
609
|
+
return (Number(dim.linearMeters) || 1) * dQty;
|
|
610
|
+
case 'stala':
|
|
611
|
+
default:
|
|
612
|
+
return Number(defaultQty) || 1;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Rozwiązuje liść drzewa wariantów dla slotu.
|
|
618
|
+
*/
|
|
619
|
+
function resolveSlotVariant(slot, variantAnswers, ocrRoomId, templateRoomId) {
|
|
620
|
+
let currentVariants = slot.variants || [];
|
|
621
|
+
let currentQuestion = slot.question;
|
|
622
|
+
const chain = [slot];
|
|
623
|
+
const pathIds = [];
|
|
624
|
+
|
|
625
|
+
while (currentVariants && currentVariants.length) {
|
|
626
|
+
const owner = pathIds.length ? pathIds[pathIds.length - 1] : undefined;
|
|
627
|
+
const qId = owner ? `${slot.id}>${owner}` : slot.id;
|
|
628
|
+
const k1 = `${ocrRoomId}|${templateRoomId}|${qId}`;
|
|
629
|
+
const k2 = `${ANY_ROOM}|${templateRoomId}|${qId}`;
|
|
630
|
+
const wanted = variantAnswers[k1] || variantAnswers[k2] || (currentQuestion ? variantAnswers[currentQuestion.trim()] : undefined);
|
|
631
|
+
|
|
632
|
+
const matched = wanted
|
|
633
|
+
? currentVariants.find(v => norm(v.answer) === norm(wanted))
|
|
634
|
+
: currentVariants[0];
|
|
635
|
+
const chosen = matched || currentVariants[0];
|
|
636
|
+
if (!chosen) break;
|
|
637
|
+
pathIds.push(chosen.id);
|
|
638
|
+
chain.push(chosen);
|
|
639
|
+
currentVariants = chosen.variants || [];
|
|
640
|
+
currentQuestion = chosen.question;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const deepest = (key) => {
|
|
644
|
+
for (let i = chain.length - 1; i >= 0; i--) {
|
|
645
|
+
const val = chain[i][key];
|
|
646
|
+
if (val !== undefined && val !== null && val !== '') return val;
|
|
647
|
+
}
|
|
648
|
+
return undefined;
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
const services = [];
|
|
652
|
+
const products = [];
|
|
653
|
+
for (const node of chain) {
|
|
654
|
+
for (const s of (node.services || [])) services.push(s);
|
|
655
|
+
for (const p of (node.products || [])) products.push(p);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
return {
|
|
659
|
+
services,
|
|
660
|
+
products,
|
|
661
|
+
name: deepest('name') || slot.name,
|
|
662
|
+
unitOverride: deepest('unitOverride'),
|
|
663
|
+
quantityRule: deepest('quantityRule') || slot.quantityRule,
|
|
664
|
+
defaultQuantity: deepest('defaultQuantity') ?? slot.defaultQuantity,
|
|
665
|
+
maxQuantity: deepest('maxQuantity') ?? slot.maxQuantity,
|
|
666
|
+
wallHeightLimit: deepest('wallHeightLimit'),
|
|
667
|
+
bandFrom: deepest('bandFrom'),
|
|
668
|
+
bandTo: deepest('bandTo'),
|
|
669
|
+
deductDoors: deepest('deductDoors'),
|
|
670
|
+
mirrorDeduction: deepest('mirrorDeduction'),
|
|
671
|
+
deductOpenings: deepest('deductOpenings'),
|
|
672
|
+
settlementMode: deepest('settlementMode'),
|
|
673
|
+
variantPath: pathIds
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Generuje wiersze wzorca dla pakietu, pokoi OCR, mapowań i odpowiedzi wariantowych,
|
|
679
|
+
* oraz uzgadnia (reconcile) je z istniejącymi pozycjami (zachowując wybrane produkty i ręczne redukcje).
|
|
680
|
+
*/
|
|
681
|
+
function generateAndReconcilePackageItems({
|
|
682
|
+
pkg,
|
|
683
|
+
pkgConfig,
|
|
684
|
+
ocrRooms,
|
|
685
|
+
roomMappings,
|
|
686
|
+
variantAnswers,
|
|
687
|
+
currentItems = [],
|
|
688
|
+
removedBindingKeys = [],
|
|
689
|
+
preserveChosenProducts = true
|
|
690
|
+
}) {
|
|
691
|
+
const removedSet = new Set(removedBindingKeys || []);
|
|
692
|
+
const ocrById = new Map((ocrRooms || []).map(r => [r.id, r]));
|
|
693
|
+
const desired = [];
|
|
694
|
+
const dimensionsSnapshot = {};
|
|
695
|
+
const seenSingletonSlots = new Set();
|
|
696
|
+
|
|
697
|
+
const buildRowsForTarget = (ocrRoomId, ocrRoomName, tr, dim, skipAreaRules = false) => {
|
|
698
|
+
const groupLabel = ocrRoomId === WHOLE_UNIT_OCR_ROOM_ID
|
|
699
|
+
? 'Cały lokal (pozycje wspólne)'
|
|
700
|
+
: (tr.name && ocrRoomName && norm(tr.name) !== norm(ocrRoomName)
|
|
701
|
+
? `${tr.name} (${ocrRoomName})`
|
|
702
|
+
: (ocrRoomName || tr.name));
|
|
703
|
+
|
|
704
|
+
for (const slot of (tr.items || [])) {
|
|
705
|
+
if (slot.optional) {
|
|
706
|
+
const optKey1 = `${ocrRoomId}|${tr.id}|optional>${slot.id}`;
|
|
707
|
+
const optKey2 = `${ANY_ROOM}|${tr.id}|optional>${slot.id}`;
|
|
708
|
+
if (norm(variantAnswers[optKey1] || variantAnswers[optKey2]) !== 'tak') continue;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (slot.singleton) {
|
|
712
|
+
const sKey = slot.slot || slot.name || slot.id;
|
|
713
|
+
if (seenSingletonSlots.has(sKey)) continue;
|
|
714
|
+
seenSingletonSlots.add(sKey);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const resolved = resolveSlotVariant(slot, variantAnswers, ocrRoomId, tr.id);
|
|
718
|
+
const wallOpts = {
|
|
719
|
+
wallHeightLimit: resolved.wallHeightLimit,
|
|
720
|
+
bandFrom: resolved.bandFrom,
|
|
721
|
+
bandTo: resolved.bandTo,
|
|
722
|
+
deductDoors: resolved.deductDoors,
|
|
723
|
+
mirrorDeduction: resolved.mirrorDeduction,
|
|
724
|
+
deductOpenings: resolved.deductOpenings
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
// 1. Produkty slotu
|
|
728
|
+
const seenAltGroups = new Set();
|
|
729
|
+
for (const prod of resolved.products) {
|
|
730
|
+
if (prod.altGroupId) {
|
|
731
|
+
if (seenAltGroups.has(prod.altGroupId)) continue;
|
|
732
|
+
seenAltGroups.add(prod.altGroupId);
|
|
733
|
+
}
|
|
734
|
+
const rule = prod.quantityRule || resolved.quantityRule || 'sztuka';
|
|
735
|
+
const isArea = ['powierzchnia_podlogi', 'powierzchnia_scian', 'powierzchnia_scian_do_wysokosci', 'powierzchnia_scian_powyzej_wysokosci', 'powierzchnia_scian_pasmo'].includes(rule);
|
|
736
|
+
const qty = (skipAreaRules && isArea) ? 0 : computeSlotQuantity(rule, resolved.defaultQuantity, dim, wallOpts);
|
|
737
|
+
const unit = resolved.unitOverride || (isArea ? 'm2' : (rule === 'obwod' || rule === 'metry_biezace' ? 'mb' : 'szt'));
|
|
738
|
+
const priceMax = prod.filter?.priceMax != null ? Number(prod.filter.priceMax) : null;
|
|
739
|
+
const priceMin = prod.filter?.priceMin != null ? Number(prod.filter.priceMin) : null;
|
|
740
|
+
const priceAvg = prod.filter?.priceAvg != null ? Number(prod.filter.priceAvg) : null;
|
|
741
|
+
const reservePct = prod.filter?.reservePct != null ? Number(prod.filter.reservePct) : undefined;
|
|
742
|
+
const unitPrice = priceMax != null ? round2(priceMax * (1 + pkgConfig.markupPct / 100)) : 0;
|
|
743
|
+
const prices = calculateRowPrices(qty, unitPrice, pkgConfig.defaultVatRate);
|
|
744
|
+
const leafCat = (prod.category?.categoryPath || '').split('|').pop() || resolved.name;
|
|
745
|
+
|
|
746
|
+
const binding = {
|
|
747
|
+
ocrRoomId,
|
|
748
|
+
templateRoomId: tr.id,
|
|
749
|
+
slotId: slot.id,
|
|
750
|
+
roomKey: tr.key || tr.name,
|
|
751
|
+
slotKey: slot.slot || slot.name,
|
|
752
|
+
catalogRef: prod.altGroupId || prod.id,
|
|
753
|
+
variantPath: resolved.variantPath,
|
|
754
|
+
packageId: pkg.id,
|
|
755
|
+
kind: 'product'
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
if (removedSet.has(looseBindingKey(binding)) || removedSet.has(removedBindingKey(binding))) continue;
|
|
759
|
+
|
|
760
|
+
desired.push({
|
|
761
|
+
id: newUuid(),
|
|
762
|
+
group: groupLabel,
|
|
763
|
+
name: (prod.preferredProductNameSnapshot || '').trim() || leafCat || resolved.name,
|
|
764
|
+
unit,
|
|
765
|
+
quantity: qty,
|
|
766
|
+
type: 'PRODUCT',
|
|
767
|
+
itemKind: 'material',
|
|
768
|
+
package: pkg.name,
|
|
769
|
+
isTemplate: true,
|
|
770
|
+
purchasePrice: priceMax ?? 0,
|
|
771
|
+
unitPrice,
|
|
772
|
+
vatRate: pkgConfig.defaultVatRate,
|
|
773
|
+
...(reservePct != null ? { reservePct } : {}),
|
|
774
|
+
...prices,
|
|
775
|
+
templateMeta: {
|
|
776
|
+
categoryId: prod.category?.categoryId,
|
|
777
|
+
categoryPath: prod.category?.categoryPath,
|
|
778
|
+
priceMin,
|
|
779
|
+
priceMax,
|
|
780
|
+
priceAvg,
|
|
781
|
+
packageId: pkg.id,
|
|
782
|
+
packageName: pkg.name,
|
|
783
|
+
quantityRule: rule,
|
|
784
|
+
catalogMissing: true,
|
|
785
|
+
slotName: resolved.name,
|
|
786
|
+
...(typeof resolved.maxQuantity === 'number' ? { maxQuantity: resolved.maxQuantity } : {})
|
|
787
|
+
},
|
|
788
|
+
binding
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// 2. Usługi slotu
|
|
793
|
+
for (const srv of resolved.services) {
|
|
794
|
+
const rule = srv.quantityRule || resolved.quantityRule || 'sztuka';
|
|
795
|
+
const isArea = ['powierzchnia_podlogi', 'powierzchnia_scian', 'powierzchnia_scian_do_wysokosci', 'powierzchnia_scian_powyzej_wysokosci', 'powierzchnia_scian_pasmo'].includes(rule);
|
|
796
|
+
const qty = (skipAreaRules && isArea) ? 0 : computeSlotQuantity(rule, resolved.defaultQuantity, dim, wallOpts);
|
|
797
|
+
const unit = resolved.unitOverride || srv.unitSnapshot || (isArea ? 'm2' : (rule === 'obwod' || rule === 'metry_biezace' ? 'mb' : 'szt'));
|
|
798
|
+
const purchasePrice = Number(srv.purchasePriceSnapshot) || 0;
|
|
799
|
+
const salePriceGross = Number(srv.salePriceSnapshot) || 0;
|
|
800
|
+
const vatRate = srv.vatRateBpsSnapshot ? Number(srv.vatRateBpsSnapshot) / 100 : pkgConfig.defaultVatRate;
|
|
801
|
+
const unitPrice = salePriceGross > 0
|
|
802
|
+
? round2(salePriceGross / (1 + vatRate / 100))
|
|
803
|
+
: round2(purchasePrice * (1 + pkgConfig.markupPct / 100));
|
|
804
|
+
const prices = calculateRowPrices(qty, unitPrice, vatRate);
|
|
805
|
+
|
|
806
|
+
const binding = {
|
|
807
|
+
ocrRoomId,
|
|
808
|
+
templateRoomId: tr.id,
|
|
809
|
+
slotId: slot.id,
|
|
810
|
+
roomKey: tr.key || tr.name,
|
|
811
|
+
slotKey: slot.slot || slot.name,
|
|
812
|
+
catalogRef: srv.skuSnapshot || srv.id || srv.catalogItemId,
|
|
813
|
+
variantPath: resolved.variantPath,
|
|
814
|
+
packageId: pkg.id,
|
|
815
|
+
kind: 'service'
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
if (removedSet.has(looseBindingKey(binding)) || removedSet.has(removedBindingKey(binding))) continue;
|
|
819
|
+
|
|
820
|
+
desired.push({
|
|
821
|
+
id: newUuid(),
|
|
822
|
+
group: groupLabel,
|
|
823
|
+
name: srv.nameSnapshot || resolved.name,
|
|
824
|
+
productId: srv.catalogItemId ? String(srv.catalogItemId) : undefined,
|
|
825
|
+
SKU: srv.skuSnapshot || '',
|
|
826
|
+
catalogId: '5',
|
|
827
|
+
unit,
|
|
828
|
+
quantity: qty,
|
|
829
|
+
type: 'SERVICE',
|
|
830
|
+
itemKind: 'labour',
|
|
831
|
+
package: pkg.name,
|
|
832
|
+
purchasePrice,
|
|
833
|
+
unitPrice,
|
|
834
|
+
vatRate,
|
|
835
|
+
averageLandedCost: Number(srv.averageLandedCostSnapshot) || 0,
|
|
836
|
+
...(srv.extraReserveSnapshot != null ? { extraReserve: String(srv.extraReserveSnapshot) } : {}),
|
|
837
|
+
...prices,
|
|
838
|
+
templateMeta: {
|
|
839
|
+
packageId: pkg.id,
|
|
840
|
+
packageName: pkg.name,
|
|
841
|
+
quantityRule: rule,
|
|
842
|
+
slotName: resolved.name,
|
|
843
|
+
...(typeof resolved.maxQuantity === 'number' ? { maxQuantity: resolved.maxQuantity } : {})
|
|
844
|
+
},
|
|
845
|
+
binding
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
for (const m of (roomMappings || [])) {
|
|
852
|
+
const ocr = ocrById.get(m.ocrRoomId);
|
|
853
|
+
if (!ocr) continue;
|
|
854
|
+
const roomArea = Number(ocr.area) || 0;
|
|
855
|
+
const roomPerim = Number(ocr.perimeter) || round2(4 * Math.sqrt(Math.max(1, roomArea)));
|
|
856
|
+
const roomHeight = Number(ocr.height) || 2.7;
|
|
857
|
+
const doors = (ocr.openings || []).filter(o => o.kind === 'door' || o.kind === 'internal_door').length || 1;
|
|
858
|
+
const openingsWidth = (ocr.openings || []).reduce((acc, o) => acc + (Number(o.width) || 0) * (Number(o.count) || 1), 0);
|
|
859
|
+
|
|
860
|
+
dimensionsSnapshot[m.ocrRoomId] = {
|
|
861
|
+
area: roomArea,
|
|
862
|
+
perimeter: roomPerim,
|
|
863
|
+
height: roomHeight
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
const targets = m.templateRoomIds || (m.templateRoomId ? [{ role: 'primary', templateRoomId: m.templateRoomId }] : []);
|
|
867
|
+
const addonShare = targets.reduce((acc, t) => acc + (t.role === 'addon' && t.areaShare ? Number(t.areaShare) : 0), 0);
|
|
868
|
+
|
|
869
|
+
for (const t of targets) {
|
|
870
|
+
const tr = (pkg.rooms || []).find(r => r.id === t.templateRoomId);
|
|
871
|
+
if (!tr || tr.key === 'caly_lokal') continue;
|
|
872
|
+
|
|
873
|
+
let targetArea = roomArea;
|
|
874
|
+
let targetPerim = roomPerim;
|
|
875
|
+
let skipAreaRules = false;
|
|
876
|
+
if (t.role === 'primary' && addonShare > 0) {
|
|
877
|
+
targetArea = Math.max(0, round2(roomArea - addonShare));
|
|
878
|
+
} else if (t.role === 'addon') {
|
|
879
|
+
if (t.areaShare && Number(t.areaShare) > 0) {
|
|
880
|
+
targetArea = Number(t.areaShare);
|
|
881
|
+
targetPerim = round2(4 * Math.sqrt(targetArea));
|
|
882
|
+
} else {
|
|
883
|
+
targetArea = 0;
|
|
884
|
+
skipAreaRules = true;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
buildRowsForTarget(m.ocrRoomId, ocr.name, tr, {
|
|
889
|
+
floorArea: targetArea,
|
|
890
|
+
perimeter: targetPerim,
|
|
891
|
+
height: roomHeight,
|
|
892
|
+
doors,
|
|
893
|
+
openingsWidth
|
|
894
|
+
}, skipAreaRules);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Wirtualne pomieszczenie „Cały lokal”
|
|
899
|
+
const wholeUnitRoom = (pkg.rooms || []).find(r => r.key === 'caly_lokal');
|
|
900
|
+
if (wholeUnitRoom) {
|
|
901
|
+
const totalArea = (ocrRooms || []).reduce((s, r) => s + (Number(r.area) || 0), 0);
|
|
902
|
+
const totalPerim = (ocrRooms || []).reduce((s, r) => s + (Number(r.perimeter) || 0), 0);
|
|
903
|
+
dimensionsSnapshot[WHOLE_UNIT_OCR_ROOM_ID] = {
|
|
904
|
+
area: round2(totalArea),
|
|
905
|
+
perimeter: round2(totalPerim),
|
|
906
|
+
height: 2.7
|
|
907
|
+
};
|
|
908
|
+
buildRowsForTarget(WHOLE_UNIT_OCR_ROOM_ID, 'Cały lokal (pozycje wspólne)', wholeUnitRoom, {
|
|
909
|
+
floorArea: round2(totalArea),
|
|
910
|
+
perimeter: round2(totalPerim),
|
|
911
|
+
height: 2.7,
|
|
912
|
+
totalApartmentArea: round2(totalArea)
|
|
913
|
+
}, false);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// Uzgadnianie (Reconcile) z currentItems
|
|
917
|
+
const existingStrict = new Map();
|
|
918
|
+
const existingLoose = new Map();
|
|
919
|
+
const consumed = new Set();
|
|
920
|
+
|
|
921
|
+
for (const it of (currentItems || [])) {
|
|
922
|
+
if (!it.binding) continue;
|
|
923
|
+
const sk = strictBindingKey(it.binding);
|
|
924
|
+
if (!existingStrict.has(sk)) existingStrict.set(sk, it);
|
|
925
|
+
const lk = looseBindingKey(it.binding);
|
|
926
|
+
const arr = existingLoose.get(lk) || [];
|
|
927
|
+
arr.push(it);
|
|
928
|
+
existingLoose.set(lk, arr);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const merged = [];
|
|
932
|
+
for (const d of desired) {
|
|
933
|
+
const sk = strictBindingKey(d.binding);
|
|
934
|
+
let ex = existingStrict.get(sk);
|
|
935
|
+
if (!ex || consumed.has(ex.id)) {
|
|
936
|
+
const lk = looseBindingKey(d.binding);
|
|
937
|
+
const candidates = existingLoose.get(lk) || [];
|
|
938
|
+
ex = candidates.find(c => !consumed.has(c.id));
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
if (ex && preserveChosenProducts) {
|
|
942
|
+
consumed.add(ex.id);
|
|
943
|
+
const hasChosenProduct = Boolean(ex.productId && !ex.isTemplate && d.type === 'PRODUCT');
|
|
944
|
+
if (hasChosenProduct) {
|
|
945
|
+
let qty = ex.quantityConfirmed ? ex.quantity : d.quantity;
|
|
946
|
+
let packaging = ex.packaging;
|
|
947
|
+
if (packaging && packaging.areaPerPackage > 0 && !ex.quantityConfirmed) {
|
|
948
|
+
const pkgRes = computePackaging(d.quantity, packaging, packaging.wastePercent ?? ex.reservePct ?? 0);
|
|
949
|
+
if (pkgRes) {
|
|
950
|
+
packaging = toItemPackaging(pkgRes);
|
|
951
|
+
qty = pkgRes.billedAreaM2;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
const prices = calculateRowPrices(qty, ex.unitPrice, ex.vatRate);
|
|
955
|
+
merged.push({
|
|
956
|
+
...ex,
|
|
957
|
+
binding: d.binding,
|
|
958
|
+
package: d.package,
|
|
959
|
+
group: ex.groupManual ? ex.group : d.group,
|
|
960
|
+
templateMeta: d.templateMeta,
|
|
961
|
+
quantity: qty,
|
|
962
|
+
...(packaging ? { packaging } : {}),
|
|
963
|
+
...prices
|
|
964
|
+
});
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Zachowaj ręczną redukcję (cancelledManual) lub zaktualizuj ilość i ceny wzorca
|
|
969
|
+
merged.push({
|
|
970
|
+
...d,
|
|
971
|
+
id: ex.id,
|
|
972
|
+
cancelled: ex.cancelledManual ? true : false,
|
|
973
|
+
cancelledManual: ex.cancelledManual || undefined,
|
|
974
|
+
replacedByItemId: ex.replacedByItemId || undefined
|
|
975
|
+
});
|
|
976
|
+
} else {
|
|
977
|
+
merged.push(d);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// Zachowaj pozycje dodatkowe spoza wzorca (bez bindingu lub zamienniki replacesItemId)
|
|
982
|
+
const customItems = (currentItems || []).filter(it => !it.binding);
|
|
983
|
+
return {
|
|
984
|
+
items: [...merged, ...customItems],
|
|
985
|
+
dimensionsSnapshot
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
module.exports = {
|
|
990
|
+
WHOLE_UNIT_OCR_ROOM_ID,
|
|
991
|
+
ANY_ROOM,
|
|
992
|
+
PACKAGE_CONFIGS,
|
|
993
|
+
getDefaultTemplatesSnapshot,
|
|
994
|
+
resolvePackageConfig,
|
|
995
|
+
round2,
|
|
996
|
+
newUuid,
|
|
997
|
+
norm,
|
|
998
|
+
normalizePricingUnit,
|
|
999
|
+
computeSalePrice,
|
|
1000
|
+
readPackaging,
|
|
1001
|
+
computePackaging,
|
|
1002
|
+
toItemPackaging,
|
|
1003
|
+
calculateRowPrices,
|
|
1004
|
+
buildEstimateRowFromCatalogItem,
|
|
1005
|
+
strictBindingKey,
|
|
1006
|
+
looseBindingKey,
|
|
1007
|
+
removedBindingKey,
|
|
1008
|
+
hashVariantAnswers,
|
|
1009
|
+
computeEstimateSignature,
|
|
1010
|
+
recalculateDocumentTotals,
|
|
1011
|
+
guessTemplateRoomKeysForRoomName,
|
|
1012
|
+
buildRoomMappingsForPackage,
|
|
1013
|
+
collectQuestionsAndDefaultAnswers,
|
|
1014
|
+
computeSlotQuantity,
|
|
1015
|
+
generateAndReconcilePackageItems
|
|
1016
|
+
};
|