@deerdesign/deertools 1.2.36 → 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.
@@ -0,0 +1,1787 @@
1
+ 'use strict';
2
+ const { ensureDevToken } = require('./auth');
3
+ const domain = require('./domain');
4
+
5
+ const DEV_API_BASE = 'https://dev.deergroup.pl/api';
6
+ const DEFAULT_ORG_ID = '1';
7
+ const ESTIMATE_CATALOG_ID = '5';
8
+
9
+ /**
10
+ * Wykonuje autoryzowane zapytanie HTTP do https://dev.deergroup.pl/api.
11
+ */
12
+ async function callDevApi(method, path, { query = {}, body = undefined, orgId = DEFAULT_ORG_ID } = {}) {
13
+ const token = ensureDevToken();
14
+ const url = new URL(DEV_API_BASE + path);
15
+
16
+ for (const [k, v] of Object.entries(query)) {
17
+ if (v !== undefined && v !== null && v !== '') {
18
+ url.searchParams.set(k, String(v));
19
+ }
20
+ }
21
+
22
+ const headers = {
23
+ 'Authorization': `Bearer ${token}`,
24
+ 'x-org-id': String(orgId),
25
+ 'Accept': 'application/json'
26
+ };
27
+
28
+ let payload;
29
+ if (body !== undefined) {
30
+ headers['Content-Type'] = 'application/json';
31
+ payload = JSON.stringify(body);
32
+ }
33
+
34
+ const res = await fetch(url.toString(), { method, headers, body: payload });
35
+ const text = await res.text();
36
+ let json = null;
37
+ try { json = JSON.parse(text); } catch (_) {}
38
+
39
+ if (!res.ok) {
40
+ const errorMsg = (json && (json.message || json.error)) || text;
41
+ throw new Error(`DeerTools Dev API (${res.status}): ${errorMsg}`);
42
+ }
43
+
44
+ return json !== null ? json : text;
45
+ }
46
+
47
+ /**
48
+ * Pobiera listę projektów z dev.deergroup.pl/api/projects.
49
+ */
50
+ async function listProjects(params = {}) {
51
+ const {
52
+ page = 1,
53
+ pageSize = 20,
54
+ search,
55
+ projectType,
56
+ propertyStatus,
57
+ externalId,
58
+ sort = 'createdOn:desc',
59
+ orgId = DEFAULT_ORG_ID
60
+ } = params;
61
+
62
+ return await callDevApi('GET', '/projects', {
63
+ query: { page, pageSize, search, projectType, propertyStatus, externalId, sort },
64
+ orgId
65
+ });
66
+ }
67
+
68
+ /**
69
+ * Buduje zwięzłe podsumowanie rekordu dokumentu (bez kilkumegabajtowego templatesSnapshot).
70
+ */
71
+ function summarizeDocumentRecord(doc) {
72
+ const dj = doc.dataJson || {};
73
+ const items = dj.estimatePanelData?.estimateItems || [];
74
+ const activeItems = items.filter(x => !x.cancelled);
75
+ const reducedItems = items.filter(x => x.cancelled);
76
+ const templatePlaceholders = activeItems.filter(x => x.isTemplate && x.type === 'PRODUCT');
77
+ const rooms = dj.roomsAndMeasurements?.roomAnalyses?.[0]?.rooms || [];
78
+ const rawPkg = dj.finishStandard?.selectedPackageName || dj.finishStandard?.selectedPackageId || dj.estimateBindings?.packageId;
79
+ const resolvedPkg = rawPkg ? domain.resolvePackageConfig(rawPkg) : null;
80
+
81
+ return {
82
+ id: doc.id,
83
+ documentNumber: doc.documentNumber,
84
+ name: doc.name,
85
+ type: doc.type,
86
+ state: doc.state,
87
+ projectId: doc.projectId,
88
+ addedAt: doc.addedAt || doc.createdAt || null,
89
+ updatedAt: doc.updatedAt || null,
90
+ finishStandard: {
91
+ selectedPackageId: dj.finishStandard?.selectedPackageId || resolvedPkg?.selectedPackageId || null,
92
+ selectedPackageName: dj.finishStandard?.selectedPackageName || resolvedPkg?.selectedPackageName || null,
93
+ unitPriceM2: dj.finishStandard?.unitPriceM2 || resolvedPkg?.unitPriceM2 || null
94
+ },
95
+ roomsSummary: {
96
+ totalArea: dj.roomsAndMeasurements?.roomAnalyses?.[0]?.totalArea || domain.round2(rooms.reduce((s, r) => s + (Number(r.area) || 0), 0)),
97
+ roomsCount: rooms.length,
98
+ rooms: rooms.map(r => ({ id: r.id, name: r.name, area: r.area, perimeter: r.perimeter, height: r.height }))
99
+ },
100
+ itemsSummary: {
101
+ totalRows: items.length,
102
+ activeRows: activeItems.length,
103
+ reducedRows: reducedItems.length,
104
+ unfilledTemplateProductSlots: templatePlaceholders.length,
105
+ totalPrices: dj.estimatePanelData?.totalPrices || domain.recalculateDocumentTotals(dj)
106
+ }
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Pobiera szczegóły pojedynczego projektu wraz z powiązanymi dokumentami (w formie zwięzłych podsumowań).
112
+ */
113
+ async function getProject(projectId, { orgId = DEFAULT_ORG_ID } = {}) {
114
+ if (!projectId) throw new Error('Wymagany jest parametr projectId.');
115
+
116
+ const project = await callDevApi('GET', `/projects/${projectId}`, { orgId });
117
+ const documents = await callDevApi('GET', '/documents', {
118
+ query: { projectId, pageSize: 100 },
119
+ orgId
120
+ });
121
+
122
+ const rawDocs = documents.items || project.documents || [];
123
+ const compactDocs = rawDocs.map(summarizeDocumentRecord);
124
+
125
+ return {
126
+ ...project,
127
+ documents: compactDocs
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Pobiera listę dokumentów (kosztorysów, aneksów, umów) w formie zwięzłych podsumowań bez pełnego dataJson.
133
+ */
134
+ async function listDocuments(params = {}) {
135
+ const {
136
+ page = 1,
137
+ pageSize = 20,
138
+ search,
139
+ projectId,
140
+ sort = 'addedAt:desc',
141
+ orgId = DEFAULT_ORG_ID
142
+ } = params;
143
+
144
+ const res = await callDevApi('GET', '/documents', {
145
+ query: { page, pageSize, search, projectId, sort },
146
+ orgId
147
+ });
148
+
149
+ if (Array.isArray(res?.items)) {
150
+ return {
151
+ ...res,
152
+ items: res.items.map(summarizeDocumentRecord)
153
+ };
154
+ }
155
+ return res;
156
+ }
157
+
158
+ /**
159
+ * Pobiera pełny dokument kosztorysu wraz ze strukturą dataJson i podsumowaniem.
160
+ */
161
+ async function getDocument(documentId, { summaryOnly = false, orgId = DEFAULT_ORG_ID } = {}) {
162
+ if (!documentId) throw new Error('Wymagany jest parametr documentId.');
163
+ const doc = await callDevApi('GET', `/documents/${documentId}`, { orgId });
164
+
165
+ if (!summaryOnly) return doc;
166
+ return summarizeDocumentRecord(doc);
167
+ }
168
+
169
+ /**
170
+ * Bezpośrednia aktualizacja dokumentu PATCH /documents/:id.
171
+ */
172
+ async function patchDocument(documentId, updateDto, { orgId = DEFAULT_ORG_ID } = {}) {
173
+ if (!documentId) throw new Error('Wymagany jest parametr documentId.');
174
+ return await callDevApi('PATCH', `/documents/${documentId}`, {
175
+ body: updateDto,
176
+ orgId
177
+ });
178
+ }
179
+
180
+ /**
181
+ * Wyszukiwanie produktów i usług w Katalogu Kosztorysu (Catalog 5 na dev.deergroup.pl/api/catalogs/5/items).
182
+ * Obsługuje zarówno zablokowane filtry pakietowe/slotowe (lockedFilters z wiersza kosztorysu),
183
+ * jak i odblokowane wyszukiwanie w całym katalogu z automatycznym oznaczeniem dopłat i limitów.
184
+ */
185
+ async function searchEstimateCatalog(params = {}) {
186
+ const {
187
+ search,
188
+ type,
189
+ categoryPathPrefix,
190
+ packageFilter,
191
+ priceMin,
192
+ priceMax,
193
+ unit,
194
+ documentId,
195
+ slotRowId,
196
+ unlockFilters = false,
197
+ includeRelated = true,
198
+ page = 1,
199
+ pageSize = 20,
200
+ orgId = DEFAULT_ORG_ID
201
+ } = params;
202
+
203
+ let effectiveCategoryPath = categoryPathPrefix;
204
+ let effectivePackage = packageFilter && packageFilter !== 'all' ? String(packageFilter).toLowerCase() : undefined;
205
+ let effectivePriceMin = priceMin;
206
+ let effectivePriceMax = priceMax;
207
+ let effectiveUnit = unit ? domain.normalizePricingUnit(unit) : undefined;
208
+ let markupPct = 57;
209
+ let slotContext = null;
210
+
211
+ // Jeśli podano documentId + slotRowId, odczytujemy ograniczenia bezpośrednio ze slotu w kosztorysie
212
+ if (documentId && slotRowId) {
213
+ const doc = await getDocument(documentId, { orgId });
214
+ const dj = doc.dataJson || {};
215
+ const pkgCfg = domain.resolvePackageConfig(dj.finishStandard?.selectedPackageId || dj.finishStandard?.selectedPackageName || 'SMART');
216
+ markupPct = pkgCfg.markupPct;
217
+ const items = dj.estimatePanelData?.estimateItems || [];
218
+ const row = items.find(it =>
219
+ String(it.id) === String(slotRowId) ||
220
+ String(it.binding?.slotKey) === String(slotRowId) ||
221
+ String(it.templateMeta?.slotName) === String(slotRowId) ||
222
+ domain.norm(it.templateMeta?.slotName).includes(domain.norm(slotRowId)) ||
223
+ domain.norm(it.name).includes(domain.norm(slotRowId))
224
+ );
225
+ if (!row) {
226
+ throw new Error(`Nie znaleziono wiersza/slotu "${slotRowId}" w kosztorysie ${documentId}.`);
227
+ }
228
+ const tm = row.templateMeta || {};
229
+ slotContext = {
230
+ rowId: row.id,
231
+ name: row.name,
232
+ group: row.group,
233
+ unit: row.unit,
234
+ quantity: row.quantity,
235
+ categoryPath: tm.categoryPath || row.categoryPath,
236
+ allowedCategories: tm.allowedCategories || [],
237
+ priceMin: tm.priceMin ?? null,
238
+ priceMax: tm.priceMax ?? null,
239
+ priceAvg: tm.priceAvg ?? null,
240
+ packageKey: pkgCfg.packageKey
241
+ };
242
+
243
+ if (!unlockFilters) {
244
+ if (!effectiveCategoryPath) effectiveCategoryPath = slotContext.categoryPath;
245
+ if (!effectivePackage) effectivePackage = slotContext.packageKey;
246
+ if (effectivePriceMin === undefined && slotContext.priceMin != null) effectivePriceMin = slotContext.priceMin;
247
+ if (effectivePriceMax === undefined && slotContext.priceMax != null) effectivePriceMax = slotContext.priceMax;
248
+ if (!effectiveUnit && row.unit) effectiveUnit = domain.normalizePricingUnit(row.unit);
249
+ } else {
250
+ // Przy odblokowanych filtrach zachowujemy opcjonalnie tylko kategorię, jeśli użytkownik jej nie zmienił i nie podał frazy
251
+ if (effectiveCategoryPath === undefined && !search) {
252
+ effectiveCategoryPath = slotContext.categoryPath;
253
+ }
254
+ }
255
+ } else if (effectivePackage) {
256
+ markupPct = domain.resolvePackageConfig(effectivePackage).markupPct;
257
+ }
258
+
259
+ const query = {
260
+ page,
261
+ pageSize,
262
+ search: search ? String(search).trim() : undefined,
263
+ type: type ? String(type).toLowerCase() : undefined,
264
+ categoryPathPrefix: effectiveCategoryPath || undefined,
265
+ package: unlockFilters ? (packageFilter && packageFilter !== 'all' ? String(packageFilter).toLowerCase() : undefined) : effectivePackage,
266
+ purchasePricePerPricingUnitFrom: !unlockFilters && effectivePriceMin != null ? Number(effectivePriceMin) : undefined,
267
+ purchasePricePerPricingUnitTo: !unlockFilters && effectivePriceMax != null ? Number(effectivePriceMax) : undefined,
268
+ pricingUnit: !unlockFilters && effectiveUnit ? effectiveUnit : undefined,
269
+ includeRelated: includeRelated ? 'true' : undefined
270
+ };
271
+
272
+ let res = await callDevApi('GET', `/catalogs/${ESTIMATE_CATALOG_ID}/items`, { query, orgId });
273
+ let items = res.items || res.data || [];
274
+
275
+ // Fallback: jeśli filtr pricingUnit na serwerze odfiltrował pozycje z purchasePrice bez pricingUnit, powtórz bez pricingUnit
276
+ if (items.length === 0 && query.pricingUnit && (query.purchasePricePerPricingUnitTo != null || query.purchasePricePerPricingUnitFrom != null)) {
277
+ const fallbackQuery = {
278
+ ...query,
279
+ pricingUnit: undefined,
280
+ purchasePriceTo: query.purchasePricePerPricingUnitTo,
281
+ purchasePricePerPricingUnitTo: undefined,
282
+ purchasePricePerPricingUnitFrom: undefined
283
+ };
284
+ const resFallback = await callDevApi('GET', `/catalogs/${ESTIMATE_CATALOG_ID}/items`, { query: fallbackQuery, orgId });
285
+ if ((resFallback.items || []).length > 0) {
286
+ res = resFallback;
287
+ items = resFallback.items || [];
288
+ }
289
+ }
290
+
291
+ const limitMax = slotContext?.priceMax ?? (priceMax != null ? Number(priceMax) : null);
292
+ const targetPkg = slotContext?.packageKey ?? effectivePackage;
293
+
294
+ const enrichedItems = items.map(item => {
295
+ const sale = domain.computeSalePrice(item, markupPct);
296
+ const pkgInfo = domain.readPackaging(item);
297
+ const unitPurchase = Number(item.purchasePricePerPricingUnit ?? item.purchasePrice ?? 0);
298
+ const withinPriceLimit = limitMax == null ? true : (unitPurchase <= Number(limitMax) + 0.01);
299
+ const inPackageTier = !targetPkg || !item.package || String(item.package).toLowerCase() === String(targetPkg).toLowerCase();
300
+ const inPackageScope = withinPriceLimit && inPackageTier;
301
+
302
+ return {
303
+ id: item.id,
304
+ sku: item.sku,
305
+ name: item.name,
306
+ type: item.type,
307
+ package: item.package,
308
+ unit: item.unit,
309
+ pricingUnit: item.pricingUnit,
310
+ pricingUnitsPerPackage: item.pricingUnitsPerPackage,
311
+ purchasePrice: item.purchasePrice,
312
+ purchasePricePerPricingUnit: item.purchasePricePerPricingUnit,
313
+ salePriceGrossCatalog: item.salePrice,
314
+ computedUnitPriceNet: sale.unitPriceNet,
315
+ computedUnitPriceGross: sale.unitPriceGross,
316
+ vatRate: sale.vatRate,
317
+ categoryPath: item.attributes?.categoryPath,
318
+ manufacturerName: item.attributes?.manufacturerName,
319
+ ean: item.attributes?.ean,
320
+ imageUrl: item.attributes?.images?.[0] || null,
321
+ packaging: pkgInfo,
322
+ inPackageScope,
323
+ withinPriceLimit,
324
+ slotPriceMaxNet: limitMax,
325
+ requiresReductionAndReplacement: !inPackageScope,
326
+ relatedServices: (item.relatedItems || []).filter(r => r.type === 'service').map(r => ({
327
+ id: r.id,
328
+ sku: r.sku,
329
+ name: r.name,
330
+ unit: r.unit,
331
+ purchasePrice: r.purchasePrice,
332
+ salePrice: r.salePrice
333
+ }))
334
+ };
335
+ });
336
+
337
+ return {
338
+ total: res.total ?? enrichedItems.length,
339
+ page: res.page ?? page,
340
+ pageSize: res.pageSize ?? pageSize,
341
+ appliedFilters: {
342
+ unlockFilters,
343
+ search: query.search || null,
344
+ categoryPathPrefix: query.categoryPathPrefix || null,
345
+ package: query.package || null,
346
+ priceMinNet: query.purchasePricePerPricingUnitFrom ?? null,
347
+ priceMaxNet: query.purchasePricePerPricingUnitTo ?? query.purchasePriceTo ?? null,
348
+ pricingUnit: query.pricingUnit || null
349
+ },
350
+ slotContext,
351
+ items: enrichedItems
352
+ };
353
+ }
354
+
355
+ /**
356
+ * Pobiera pojedynczą pozycję z Katalogu 5 po ID lub SKU wraz z powiązanymi usługami (relatedItems).
357
+ */
358
+ async function getEstimateCatalogItem(itemIdOrSku, { markupPct = 57, orgId = DEFAULT_ORG_ID } = {}) {
359
+ if (!itemIdOrSku) throw new Error('Wymagany jest parametr itemIdOrSku.');
360
+ const raw = String(itemIdOrSku).trim();
361
+
362
+ let item = null;
363
+ if (/^\d+$/.test(raw)) {
364
+ try {
365
+ item = await callDevApi('GET', `/catalogs/${ESTIMATE_CATALOG_ID}/items/${raw}`, {
366
+ query: { includeRelated: 'true' },
367
+ orgId
368
+ });
369
+ } catch (_) {}
370
+ }
371
+
372
+ if (!item) {
373
+ const searchRes = await callDevApi('GET', `/catalogs/${ESTIMATE_CATALOG_ID}/items`, {
374
+ query: { search: raw, includeRelated: 'true', page: 1, pageSize: 10 },
375
+ orgId
376
+ });
377
+ const list = searchRes.items || [];
378
+ item = list.find(x => String(x.sku).toLowerCase() === raw.toLowerCase() || String(x.id) === raw) || list[0];
379
+ }
380
+
381
+ if (!item) {
382
+ throw new Error(`Nie znaleziono pozycji katalogowej "${raw}" w Katalogu 5.`);
383
+ }
384
+
385
+ return {
386
+ ...item,
387
+ computedSalePrice: domain.computeSalePrice(item, markupPct),
388
+ computedPackaging: domain.readPackaging(item)
389
+ };
390
+ }
391
+
392
+ /**
393
+ * Zwraca zdeduplikowane podpowiedzi usług i slotów produktowych ze wzorca pakietowego (templatesSnapshot).
394
+ */
395
+ async function listTemplateSuggestions(params = {}) {
396
+ const { documentId, package: pkgInput = 'SMART', kind, search, orgId = DEFAULT_ORG_ID } = params;
397
+ let templates = null;
398
+ let selectedPkgConfig = domain.resolvePackageConfig(pkgInput);
399
+
400
+ if (documentId) {
401
+ const doc = await getDocument(documentId, { orgId });
402
+ templates = doc.dataJson?.templatesSnapshot;
403
+ if (!params.package && doc.dataJson?.finishStandard?.selectedPackageId) {
404
+ selectedPkgConfig = domain.resolvePackageConfig(doc.dataJson.finishStandard.selectedPackageId);
405
+ }
406
+ }
407
+ if (!templates || !Array.isArray(templates.packages)) {
408
+ templates = domain.getDefaultTemplatesSnapshot();
409
+ }
410
+
411
+ const pkg = templates.packages.find(p => p.id === selectedPkgConfig.packageUuid || domain.norm(p.key) === domain.norm(selectedPkgConfig.key)) || templates.packages[0];
412
+ if (!pkg) return { package: selectedPkgConfig.key, count: 0, suggestions: [] };
413
+
414
+ const acc = new Map();
415
+ const pushRoom = (entry, roomName) => {
416
+ entry.usageCount += 1;
417
+ if (roomName && !entry.rooms.includes(roomName)) entry.rooms.push(roomName);
418
+ };
419
+
420
+ const walkSlot = (node, roomName) => {
421
+ for (const s of (node.services || [])) {
422
+ const sku = String(s.skuSnapshot || '').trim();
423
+ const name = String(s.nameSnapshot || sku).trim();
424
+ const key = `service:${(sku || s.catalogItemId || name).toLowerCase()}`;
425
+ if (!acc.has(key)) {
426
+ acc.set(key, {
427
+ id: key,
428
+ kind: 'service',
429
+ name,
430
+ sku: sku || null,
431
+ catalogItemId: s.catalogItemId || null,
432
+ unit: s.unitSnapshot || 'm2',
433
+ purchasePrice: s.purchasePriceSnapshot ?? null,
434
+ salePriceGross: s.salePriceSnapshot ?? null,
435
+ usageCount: 0,
436
+ rooms: []
437
+ });
438
+ }
439
+ pushRoom(acc.get(key), roomName);
440
+ }
441
+ for (const p of (node.products || [])) {
442
+ const catPath = p.category?.categoryPath || '';
443
+ if (!catPath) continue;
444
+ const priceMax = p.filter?.priceMax ?? null;
445
+ const key = `product:${catPath.toLowerCase()}:${priceMax ?? ''}`;
446
+ if (!acc.has(key)) {
447
+ const leaf = catPath.split('|').pop() || catPath;
448
+ acc.set(key, {
449
+ id: key,
450
+ kind: 'product',
451
+ name: leaf,
452
+ categoryPath: catPath,
453
+ categoryId: p.category?.categoryId || null,
454
+ priceMaxNet: priceMax,
455
+ priceAvgNet: p.filter?.priceAvg ?? null,
456
+ usageCount: 0,
457
+ rooms: []
458
+ });
459
+ }
460
+ pushRoom(acc.get(key), roomName);
461
+ }
462
+ for (const v of (node.variants || [])) walkSlot(v, roomName);
463
+ };
464
+
465
+ for (const r of (pkg.rooms || [])) {
466
+ for (const slot of (r.items || [])) {
467
+ walkSlot(slot, r.name || r.key);
468
+ }
469
+ }
470
+
471
+ let list = [...acc.values()];
472
+ if (kind && (kind === 'service' || kind === 'product')) {
473
+ list = list.filter(x => x.kind === kind);
474
+ }
475
+ if (search && String(search).trim()) {
476
+ const tokens = domain.norm(search).split(/\s+/).filter(Boolean);
477
+ list = list.filter(x => {
478
+ const hay = `${domain.norm(x.name)} ${domain.norm(x.sku)} ${domain.norm(x.categoryPath)}`;
479
+ return tokens.every(t => hay.includes(t));
480
+ });
481
+ }
482
+
483
+ return {
484
+ package: pkg.name,
485
+ packageId: pkg.id,
486
+ count: list.length,
487
+ suggestions: list
488
+ };
489
+ }
490
+
491
+ /**
492
+ * Przypisuje produkt lub usługę z Katalogu 5 do slotu/wiersza w kosztorysie (lub dodaje jako nową pozycję).
493
+ * Obsługuje automatyczne przeliczenie opakowań (packaging), narzutów, usług powiązanych (relatedItems),
494
+ * wypełnianie wszystkich pustych slotów tej samej kategorii (fillAllMatchingSlots) oraz automatyczną
495
+ * redukcję pozycji pakietowej i wstawienie zamiennika z dopłatą, gdy produkt przekracza limit pakietu!
496
+ */
497
+ async function assignCatalogItemToEstimate(documentId, params = {}) {
498
+ const {
499
+ catalogItemId,
500
+ sku,
501
+ rowId,
502
+ slotKey,
503
+ group = 'Inne',
504
+ quantity,
505
+ includeRelatedServices = false,
506
+ fillAllMatchingSlots = false,
507
+ asReplacement = false,
508
+ autoReduceOnExceed = true,
509
+ forceInSlot = false,
510
+ dryRun = false,
511
+ orgId = DEFAULT_ORG_ID
512
+ } = params;
513
+
514
+ if (!documentId) throw new Error('Wymagany jest parametr documentId.');
515
+ const lookupKey = catalogItemId || sku;
516
+ if (!lookupKey) throw new Error('Wymagany jest parametr catalogItemId lub sku.');
517
+
518
+ const doc = await getDocument(documentId, { orgId });
519
+ const dj = doc.dataJson || {};
520
+ const pkgCfg = domain.resolvePackageConfig(dj.finishStandard?.selectedPackageId || dj.finishStandard?.selectedPackageName || 'SMART');
521
+ const catalogItem = await getEstimateCatalogItem(lookupKey, { markupPct: pkgCfg.markupPct, orgId });
522
+
523
+ const panelData = dj.estimatePanelData || { estimateItems: [] };
524
+ let items = [...(panelData.estimateItems || [])];
525
+
526
+ // Jeśli nie wskazano rowId ani slotKey, dodajemy nową pozycję do wskazanego pokoju (group)
527
+ if (!rowId && !slotKey) {
528
+ const newRow = domain.buildEstimateRowFromCatalogItem(catalogItem, {
529
+ quantity: quantity != null ? Number(quantity) : 1,
530
+ group,
531
+ markupPct: pkgCfg.markupPct,
532
+ packageLabel: pkgCfg.selectedPackageName
533
+ });
534
+ newRow.outOfPackageScope = true;
535
+ newRow.settlementMode = 'surcharge';
536
+ items.push(newRow);
537
+
538
+ const addedIds = [newRow.id];
539
+ if (includeRelatedServices && Array.isArray(catalogItem.relatedItems)) {
540
+ for (const rel of catalogItem.relatedItems.filter(r => r.type === 'service')) {
541
+ const srvRow = domain.buildEstimateRowFromCatalogItem(rel, {
542
+ quantity: quantity != null ? Number(quantity) : newRow.quantity,
543
+ group,
544
+ markupPct: pkgCfg.markupPct,
545
+ packageLabel: pkgCfg.selectedPackageName
546
+ });
547
+ srvRow.parentId = newRow.id;
548
+ srvRow.settlementMode = 'surcharge';
549
+ items.push(srvRow);
550
+ addedIds.push(srvRow.id);
551
+ }
552
+ }
553
+
554
+ if (dryRun) {
555
+ return { dryRun: true, action: 'added_new_row', addedIds, row: newRow };
556
+ }
557
+
558
+ panelData.estimateItems = items;
559
+ dj.estimatePanelData = panelData;
560
+ const totals = domain.recalculateDocumentTotals(dj);
561
+ await patchDocument(documentId, { dataJson: dj }, { orgId });
562
+ return {
563
+ success: true,
564
+ action: 'added_new_row',
565
+ documentId,
566
+ addedIds,
567
+ row: newRow,
568
+ totalPrices: totals
569
+ };
570
+ }
571
+
572
+ // Szukamy istniejącego wiersza po rowId lub slotKey
573
+ const idx = items.findIndex(it =>
574
+ (rowId && String(it.id) === String(rowId)) ||
575
+ (slotKey && (String(it.binding?.slotKey) === String(slotKey) || String(it.templateMeta?.slotName) === String(slotKey)))
576
+ );
577
+ if (idx < 0) {
578
+ throw new Error(`Nie znaleziono pozycji o rowId="${rowId || ''}" / slotKey="${slotKey || ''}" w kosztorysie ${documentId}.`);
579
+ }
580
+
581
+ const targetRow = items[idx];
582
+ const tm = targetRow.templateMeta || {};
583
+ const rawQty = quantity != null
584
+ ? Number(quantity)
585
+ : (targetRow.packaging?.includesReserve ? (targetRow.packaging.rawAreaM2 ?? targetRow.quantity ?? 1) : (targetRow.quantity ?? 1));
586
+
587
+ const unitPurchase = Number(catalogItem.purchasePricePerPricingUnit ?? catalogItem.purchasePrice ?? 0);
588
+ const priceMax = tm.priceMax != null ? Number(tm.priceMax) : null;
589
+ const exceedsPriceMax = priceMax != null && unitPurchase > priceMax + 0.01;
590
+ const outOfPackageTier = Boolean(catalogItem.package && pkgCfg.packageKey && String(catalogItem.package).toLowerCase() !== String(pkgCfg.packageKey).toLowerCase());
591
+ const outOfScope = exceedsPriceMax || outOfPackageTier;
592
+ const shouldReplaceWithReduction = !forceInSlot && (asReplacement || (autoReduceOnExceed && outOfScope && Boolean(targetRow.binding)));
593
+
594
+ let mode = 'filled_in_slot';
595
+ const affectedIds = [];
596
+
597
+ if (shouldReplaceWithReduction) {
598
+ mode = 'reduced_and_replaced';
599
+ const replacementId = domain.newUuid();
600
+ // 1. Oznacz pozycję pakietową jako redukcję (rezygnację)
601
+ items[idx] = {
602
+ ...targetRow,
603
+ cancelled: true,
604
+ cancelledManual: true,
605
+ replacedByItemId: replacementId
606
+ };
607
+ affectedIds.push(targetRow.id);
608
+
609
+ // 2. Zbuduj wiersz zamiennika tuż pod zredukowaną pozycją
610
+ const replacementRow = domain.buildEstimateRowFromCatalogItem(catalogItem, {
611
+ quantity: rawQty,
612
+ group: targetRow.group,
613
+ rowId: replacementId,
614
+ expectedUnit: targetRow.unit,
615
+ reservePct: targetRow.reservePct,
616
+ markupPct: pkgCfg.markupPct,
617
+ packageLabel: catalogItem.package || pkgCfg.selectedPackageName
618
+ });
619
+ replacementRow.replacesItemId = targetRow.id;
620
+ replacementRow.outOfPackageScope = true;
621
+ replacementRow.settlementMode = 'surcharge';
622
+ replacementRow.dimensions = targetRow.dimensions ? { ...targetRow.dimensions } : undefined;
623
+ replacementRow.templateMeta = tm
624
+ ? { ...tm, priceMin: null, priceMax: null, catalogMissing: undefined }
625
+ : undefined;
626
+
627
+ items.splice(idx + 1, 0, replacementRow);
628
+ affectedIds.push(replacementId);
629
+ } else {
630
+ // Wypełnij slot bezpośrednio wybranym produktem
631
+ const built = domain.buildEstimateRowFromCatalogItem(catalogItem, {
632
+ quantity: rawQty,
633
+ group: targetRow.group,
634
+ rowId: targetRow.id,
635
+ expectedUnit: targetRow.unit,
636
+ reservePct: targetRow.reservePct,
637
+ markupPct: pkgCfg.markupPct,
638
+ packageLabel: targetRow.package || pkgCfg.selectedPackageName
639
+ });
640
+ const cleanMeta = { ...tm };
641
+ delete cleanMeta.catalogMissing;
642
+
643
+ items[idx] = {
644
+ ...targetRow,
645
+ ...built,
646
+ isTemplate: false,
647
+ cancelled: false,
648
+ cancelledManual: undefined,
649
+ binding: targetRow.binding,
650
+ templateMeta: Object.keys(cleanMeta).length > 0 ? cleanMeta : undefined,
651
+ outOfPackageScope: exceedsPriceMax ? true : undefined
652
+ };
653
+ affectedIds.push(targetRow.id);
654
+
655
+ // Opcjonalnie wypełnij wszystkie pozostałe puste sloty tej samej kategorii („Uzupełnij wszystkie”)
656
+ if (fillAllMatchingSlots && tm.categoryPath) {
657
+ items = items.map(row => {
658
+ if (row.id === targetRow.id) return row;
659
+ if (!row.isTemplate || row.cancelled) return row;
660
+ if (row.templateMeta?.categoryPath !== tm.categoryPath) return row;
661
+ const rQty = row.packaging?.includesReserve ? (row.packaging.rawAreaM2 ?? row.quantity ?? 1) : (row.quantity ?? 1);
662
+ const rBuilt = domain.buildEstimateRowFromCatalogItem(catalogItem, {
663
+ quantity: rQty,
664
+ group: row.group,
665
+ rowId: row.id,
666
+ expectedUnit: row.unit,
667
+ reservePct: row.reservePct,
668
+ markupPct: pkgCfg.markupPct,
669
+ packageLabel: row.package || pkgCfg.selectedPackageName
670
+ });
671
+ const rMeta = { ...(row.templateMeta || {}) };
672
+ delete rMeta.catalogMissing;
673
+ affectedIds.push(row.id);
674
+ return {
675
+ ...row,
676
+ ...rBuilt,
677
+ isTemplate: false,
678
+ binding: row.binding,
679
+ templateMeta: rMeta
680
+ };
681
+ });
682
+ }
683
+ }
684
+
685
+ // Opcjonalnie dodaj powiązane usługi montażowe z relatedItems (jeśli nie ma ich jeszcze w tym samym slocie)
686
+ if (includeRelatedServices && Array.isArray(catalogItem.relatedItems)) {
687
+ const slotId = targetRow.binding?.slotId;
688
+ const ocrRoomId = targetRow.binding?.ocrRoomId;
689
+ const existingSlotSkus = new Set(
690
+ items
691
+ .filter(x => x.binding && x.binding.slotId === slotId && x.binding.ocrRoomId === ocrRoomId && x.type === 'SERVICE')
692
+ .map(x => domain.norm(x.SKU))
693
+ );
694
+
695
+ let insertPos = items.findIndex(x => x.id === affectedIds[affectedIds.length - 1]) + 1;
696
+ for (const rel of catalogItem.relatedItems.filter(r => r.type === 'service')) {
697
+ if (rel.sku && existingSlotSkus.has(domain.norm(rel.sku))) continue;
698
+ const srvRow = domain.buildEstimateRowFromCatalogItem(rel, {
699
+ quantity: rawQty,
700
+ group: targetRow.group,
701
+ markupPct: pkgCfg.markupPct,
702
+ packageLabel: pkgCfg.selectedPackageName
703
+ });
704
+ srvRow.parentId = targetRow.id;
705
+ items.splice(insertPos, 0, srvRow);
706
+ insertPos++;
707
+ affectedIds.push(srvRow.id);
708
+ }
709
+ }
710
+
711
+ if (dryRun) {
712
+ return {
713
+ dryRun: true,
714
+ mode,
715
+ affectedIds,
716
+ exceedsPriceMax,
717
+ priceMax,
718
+ unitPurchase
719
+ };
720
+ }
721
+
722
+ panelData.estimateItems = items;
723
+ dj.estimatePanelData = panelData;
724
+ const totals = domain.recalculateDocumentTotals(dj);
725
+ await patchDocument(documentId, { dataJson: dj }, { orgId });
726
+
727
+ return {
728
+ success: true,
729
+ documentId,
730
+ mode,
731
+ affectedIds,
732
+ exceedsPriceMax,
733
+ slotPriceMaxNet: priceMax,
734
+ productPurchasePriceNet: unitPurchase,
735
+ totalPrices: totals
736
+ };
737
+ }
738
+
739
+ /**
740
+ * Obsługa redukcji („rezygnacji”) i zamienników na pozycjach kosztorysu:
741
+ * - action: "reduce" — oznacza pozycję (i opcjonalnie powiązane usługi w tym samym slocie) jako cancelled: true, cancelledManual: true.
742
+ * - action: "restore" — przywraca pozycję z redukcji (cancelled: false) i usuwa ewentualny zamiennik.
743
+ * - action: "replace" — redukuje pozycję pakietową i wstawia bezpośrednio pod nią powiązany wiersz zamiennika.
744
+ * - action: "remove_permanently" — usuwa pozycję i dopisuje jej klucz do estimateBindings.removedBindingKeys.
745
+ */
746
+ async function reduceOrReplaceItem(documentId, params = {}) {
747
+ const {
748
+ itemId,
749
+ slotKey,
750
+ roomGroup,
751
+ action = 'reduce',
752
+ includeSlotServices = false,
753
+ replacementCatalogItemId,
754
+ replacementSku,
755
+ replacementItem,
756
+ dryRun = false,
757
+ orgId = DEFAULT_ORG_ID
758
+ } = params;
759
+
760
+ if (!documentId) throw new Error('Wymagany jest parametr documentId.');
761
+ if (!itemId && !slotKey) throw new Error('Wymagany jest parametr itemId lub slotKey.');
762
+
763
+ if (action === 'replace' && (replacementCatalogItemId || replacementSku)) {
764
+ return await assignCatalogItemToEstimate(documentId, {
765
+ catalogItemId: replacementCatalogItemId,
766
+ sku: replacementSku,
767
+ rowId: itemId,
768
+ slotKey,
769
+ asReplacement: true,
770
+ dryRun,
771
+ orgId
772
+ });
773
+ }
774
+
775
+ const doc = await getDocument(documentId, { orgId });
776
+ const dj = doc.dataJson || {};
777
+ const panelData = dj.estimatePanelData || { estimateItems: [] };
778
+ let items = [...(panelData.estimateItems || [])];
779
+
780
+ const matchRow = (it) => {
781
+ if (itemId && String(it.id) === String(itemId)) return true;
782
+ if (slotKey) {
783
+ const sMatch = String(it.binding?.slotKey) === String(slotKey) ||
784
+ String(it.templateMeta?.slotName) === String(slotKey) ||
785
+ domain.norm(it.name).includes(domain.norm(slotKey));
786
+ if (!sMatch) return false;
787
+ if (roomGroup && !domain.norm(it.group).includes(domain.norm(roomGroup))) return false;
788
+ return true;
789
+ }
790
+ return false;
791
+ };
792
+
793
+ const targetIdx = items.findIndex(matchRow);
794
+ if (targetIdx < 0) {
795
+ throw new Error(`Nie znaleziono pozycji (itemId=${itemId || ''}, slotKey=${slotKey || ''}) w kosztorysie ${documentId}.`);
796
+ }
797
+
798
+ const target = items[targetIdx];
799
+ const affectedIds = [target.id];
800
+
801
+ // Znajdź powiązane pozycje w tym samym slocie i pokoju, jeśli includeSlotServices = true
802
+ const isSameSlot = (it) =>
803
+ includeSlotServices &&
804
+ target.binding?.slotId &&
805
+ it.binding?.slotId === target.binding.slotId &&
806
+ it.binding?.ocrRoomId === target.binding.ocrRoomId;
807
+
808
+ if (action === 'reduce') {
809
+ items = items.map(it => {
810
+ if (it.id === target.id || isSameSlot(it)) {
811
+ if (!affectedIds.includes(it.id)) affectedIds.push(it.id);
812
+ return { ...it, cancelled: true, cancelledManual: true };
813
+ }
814
+ return it;
815
+ });
816
+ } else if (action === 'restore') {
817
+ const replacementIdsToRemove = new Set();
818
+ items = items.map(it => {
819
+ if (it.id === target.id || isSameSlot(it)) {
820
+ if (!affectedIds.includes(it.id)) affectedIds.push(it.id);
821
+ if (it.replacedByItemId) replacementIdsToRemove.add(String(it.replacedByItemId));
822
+ const copy = { ...it, cancelled: false };
823
+ delete copy.cancelledManual;
824
+ delete copy.replacedByItemId;
825
+ return copy;
826
+ }
827
+ return it;
828
+ }).filter(it => !replacementIdsToRemove.has(String(it.id)) && String(it.replacesItemId) !== String(target.id));
829
+ } else if (action === 'replace') {
830
+ const repId = domain.newUuid();
831
+ const qty = Number(replacementItem?.quantity ?? target.quantity ?? 1);
832
+ const unitPrice = Number(replacementItem?.unitPrice ?? target.unitPrice ?? 0);
833
+ const vatRate = Number(replacementItem?.vatRate ?? target.vatRate ?? 8);
834
+ const prices = domain.calculateRowPrices(qty, unitPrice, vatRate);
835
+
836
+ const newRepRow = {
837
+ id: repId,
838
+ name: replacementItem?.name || `${target.name} (zamiennik)`,
839
+ group: replacementItem?.group || target.group,
840
+ unit: replacementItem?.unit || target.unit || 'szt',
841
+ quantity: qty,
842
+ unitPrice,
843
+ purchasePrice: Number(replacementItem?.purchasePrice ?? unitPrice * 0.7),
844
+ vatRate,
845
+ type: replacementItem?.type || target.type || 'PRODUCT',
846
+ itemKind: replacementItem?.itemKind || target.itemKind || 'material',
847
+ SKU: replacementItem?.SKU || '',
848
+ replacesItemId: target.id,
849
+ outOfPackageScope: true,
850
+ settlementMode: 'surcharge',
851
+ ...prices
852
+ };
853
+
854
+ items[targetIdx] = {
855
+ ...target,
856
+ cancelled: true,
857
+ cancelledManual: true,
858
+ replacedByItemId: repId
859
+ };
860
+ items.splice(targetIdx + 1, 0, newRepRow);
861
+ affectedIds.push(repId);
862
+ } else if (action === 'remove_permanently') {
863
+ if (!dj.estimateBindings) dj.estimateBindings = { removedBindingKeys: [] };
864
+ if (!Array.isArray(dj.estimateBindings.removedBindingKeys)) dj.estimateBindings.removedBindingKeys = [];
865
+ const remSet = new Set(dj.estimateBindings.removedBindingKeys);
866
+
867
+ const toRemoveIds = new Set();
868
+ for (const it of items) {
869
+ if (it.id === target.id || isSameSlot(it)) {
870
+ toRemoveIds.add(it.id);
871
+ if (it.binding) {
872
+ remSet.add(domain.looseBindingKey(it.binding));
873
+ remSet.add(domain.removedBindingKey(it.binding));
874
+ }
875
+ }
876
+ }
877
+ dj.estimateBindings.removedBindingKeys = [...remSet];
878
+ items = items.filter(it => !toRemoveIds.has(it.id));
879
+ } else {
880
+ throw new Error(`Nieznana akcja redukcji: ${action}`);
881
+ }
882
+
883
+ if (dryRun) {
884
+ return { dryRun: true, documentId, action, affectedIds, itemsCount: items.length };
885
+ }
886
+
887
+ panelData.estimateItems = items;
888
+ dj.estimatePanelData = panelData;
889
+ const totals = domain.recalculateDocumentTotals(dj);
890
+ await patchDocument(documentId, { dataJson: dj }, { orgId });
891
+
892
+ return {
893
+ success: true,
894
+ documentId,
895
+ action,
896
+ affectedIds,
897
+ totalPrices: totals
898
+ };
899
+ }
900
+
901
+ /**
902
+ * Pobiera pomieszczenia i obmiary (roomsAndMeasurements) oraz ich mapowania na wzorzec pakietowy (roomMappings).
903
+ */
904
+ async function getRoomsAndMeasurements(documentId, { orgId = DEFAULT_ORG_ID } = {}) {
905
+ if (!documentId) throw new Error('Wymagany jest parametr documentId.');
906
+ const doc = await getDocument(documentId, { orgId });
907
+ const dj = doc.dataJson || {};
908
+ const analysis = dj.roomsAndMeasurements?.roomAnalyses?.[0] || { rooms: [] };
909
+ const rooms = analysis.rooms || [];
910
+ const totalArea = analysis.totalArea ?? domain.round2(rooms.reduce((s, r) => s + (Number(r.area) || 0), 0));
911
+
912
+ return {
913
+ documentId: doc.id,
914
+ documentName: doc.name,
915
+ totalArea,
916
+ balconyArea: analysis.balconyArea ?? 0,
917
+ roomsCount: rooms.length,
918
+ rooms,
919
+ roomMappings: dj.estimateBindings?.roomMappings || [],
920
+ dimensionsSnapshot: dj.estimateBindings?.dimensionsSnapshot || {}
921
+ };
922
+ }
923
+
924
+ /**
925
+ * Aktualizuje obmiary i listę pomieszczeń (dodawanie, edycja wymiarów/otworów, usuwanie, zmiana udziału aneksu kuchennego)
926
+ * oraz automatycznie przelicza ilości i ceny pozycji kosztorysu powiązanych z obmiarami.
927
+ */
928
+ async function updateRoomsAndMeasurements(documentId, params = {}) {
929
+ const {
930
+ rooms: replaceRooms,
931
+ updateRooms = [],
932
+ addRooms = [],
933
+ removeRoomIds = [],
934
+ balconyArea,
935
+ recalculateItems = true,
936
+ dryRun = false,
937
+ orgId = DEFAULT_ORG_ID
938
+ } = params;
939
+
940
+ if (!documentId) throw new Error('Wymagany jest parametr documentId.');
941
+ const doc = await getDocument(documentId, { orgId });
942
+ const dj = doc.dataJson || {};
943
+
944
+ if (!dj.roomsAndMeasurements) {
945
+ dj.roomsAndMeasurements = { roomFiles: [], roomAnalyses: [{ id: `a_${Date.now()}`, status: 'completed', rooms: [] }] };
946
+ }
947
+ if (!Array.isArray(dj.roomsAndMeasurements.roomAnalyses) || dj.roomsAndMeasurements.roomAnalyses.length === 0) {
948
+ dj.roomsAndMeasurements.roomAnalyses = [{ id: `a_${Date.now()}`, status: 'completed', rooms: [] }];
949
+ }
950
+
951
+ const analysis = dj.roomsAndMeasurements.roomAnalyses[0];
952
+ let rooms = Array.isArray(replaceRooms) ? [...replaceRooms] : [...(analysis.rooms || [])];
953
+
954
+ // 1. Usunięcie wskazanych pokoi
955
+ if (Array.isArray(removeRoomIds) && removeRoomIds.length > 0) {
956
+ const rem = new Set(removeRoomIds.map(String));
957
+ rooms = rooms.filter(r => !rem.has(String(r.id)) && !rem.has(String(r.name)));
958
+ }
959
+
960
+ // 2. Aktualizacja istniejących pokoi (po id lub nazwie)
961
+ for (const upd of (updateRooms || [])) {
962
+ const idx = rooms.findIndex(r =>
963
+ (upd.id && String(r.id) === String(upd.id)) ||
964
+ (upd.name && domain.norm(r.name) === domain.norm(upd.name))
965
+ );
966
+ if (idx >= 0) {
967
+ const cur = rooms[idx];
968
+ const nextArea = upd.area !== undefined ? Number(upd.area) : Number(cur.area || 0);
969
+ const nextPerim = upd.perimeter !== undefined
970
+ ? Number(upd.perimeter)
971
+ : (cur.perimeter !== undefined ? Number(cur.perimeter) : domain.round2(4 * Math.sqrt(Math.max(1, nextArea))));
972
+ rooms[idx] = {
973
+ ...cur,
974
+ ...upd,
975
+ id: cur.id || domain.newUuid(),
976
+ name: upd.newName || upd.name || cur.name,
977
+ area: nextArea,
978
+ perimeter: nextPerim,
979
+ height: upd.height !== undefined ? Number(upd.height) : Number(cur.height || 2.7),
980
+ openings: upd.openings !== undefined ? upd.openings : (cur.openings || [])
981
+ };
982
+ }
983
+ }
984
+
985
+ // 3. Dodanie nowych pokoi
986
+ for (const add of (addRooms || [])) {
987
+ const area = Number(add.area) || 10;
988
+ const perim = add.perimeter !== undefined ? Number(add.perimeter) : domain.round2(4 * Math.sqrt(Math.max(1, area)));
989
+ rooms.push({
990
+ id: add.id || domain.newUuid(),
991
+ name: add.name || 'Nowe pomieszczenie',
992
+ area,
993
+ perimeter: perim,
994
+ height: Number(add.height) || 2.7,
995
+ openings: add.openings || [
996
+ { id: domain.newUuid(), kind: 'door', width: 0.9, count: 1, source: 'manual' }
997
+ ],
998
+ ...(add.kitchenAreaShare !== undefined ? { kitchenAreaShare: Number(add.kitchenAreaShare) } : {}),
999
+ ...(add.templateTargets !== undefined ? { templateTargets: add.templateTargets } : {})
1000
+ });
1001
+ }
1002
+
1003
+ // Upewnij się, że każdy pokój ma id, area, perimeter, height
1004
+ rooms = rooms.map(r => {
1005
+ const area = Number(r.area) || 0;
1006
+ return {
1007
+ ...r,
1008
+ id: r.id || domain.newUuid(),
1009
+ area,
1010
+ perimeter: r.perimeter !== undefined ? Number(r.perimeter) : domain.round2(4 * Math.sqrt(Math.max(1, area))),
1011
+ height: Number(r.height) || 2.7,
1012
+ openings: Array.isArray(r.openings) ? r.openings : []
1013
+ };
1014
+ });
1015
+
1016
+ const totalArea = domain.round2(rooms.reduce((s, r) => s + (Number(r.area) || 0), 0));
1017
+ analysis.rooms = rooms;
1018
+ analysis.totalArea = totalArea;
1019
+ analysis.status = 'completed';
1020
+ if (balconyArea !== undefined) analysis.balconyArea = Number(balconyArea);
1021
+ if (dj.project) dj.project.area = totalArea;
1022
+
1023
+ // Zaktualizuj mapowania pokoi i przelicz pozycje wzorca
1024
+ if (!dj.templatesSnapshot || !Array.isArray(dj.templatesSnapshot.packages)) {
1025
+ dj.templatesSnapshot = domain.getDefaultTemplatesSnapshot();
1026
+ }
1027
+ const pkgCfg = domain.resolvePackageConfig(dj.finishStandard?.selectedPackageId || dj.finishStandard?.selectedPackageName || 'SMART');
1028
+ const pkg = dj.templatesSnapshot.packages.find(p => p.id === pkgCfg.packageUuid) || dj.templatesSnapshot.packages[0];
1029
+
1030
+ const roomMappings = domain.buildRoomMappingsForPackage(rooms, pkg, dj.estimateBindings?.roomMappings || []);
1031
+ const { variantAnswers } = domain.collectQuestionsAndDefaultAnswers(
1032
+ pkg,
1033
+ roomMappings,
1034
+ totalArea,
1035
+ dj.finishStandard?.variantAnswers || {}
1036
+ );
1037
+
1038
+ if (recalculateItems && pkg) {
1039
+ const reconciled = domain.generateAndReconcilePackageItems({
1040
+ pkg,
1041
+ pkgConfig: pkgCfg,
1042
+ ocrRooms: rooms,
1043
+ roomMappings,
1044
+ variantAnswers,
1045
+ currentItems: dj.estimatePanelData?.estimateItems || [],
1046
+ removedBindingKeys: dj.estimateBindings?.removedBindingKeys || [],
1047
+ preserveChosenProducts: true
1048
+ });
1049
+ if (!dj.estimatePanelData) dj.estimatePanelData = {};
1050
+ dj.estimatePanelData.estimateItems = reconciled.items;
1051
+ if (!dj.estimateBindings) dj.estimateBindings = {};
1052
+ dj.estimateBindings.dimensionsSnapshot = reconciled.dimensionsSnapshot;
1053
+ }
1054
+
1055
+ const { signature, variantAnswersHash } = domain.computeEstimateSignature(
1056
+ String(documentId),
1057
+ pkgCfg.packageUuid,
1058
+ variantAnswers,
1059
+ rooms,
1060
+ roomMappings
1061
+ );
1062
+
1063
+ dj.estimateBindings = {
1064
+ ...(dj.estimateBindings || {}),
1065
+ packageId: pkgCfg.packageUuid,
1066
+ finishPackageId: pkgCfg.selectedPackageId,
1067
+ roomMappings,
1068
+ variantAnswersHash,
1069
+ signature
1070
+ };
1071
+ if (dj.finishStandard) {
1072
+ dj.finishStandard.variantAnswers = variantAnswers;
1073
+ }
1074
+
1075
+ const totals = domain.recalculateDocumentTotals(dj);
1076
+
1077
+ if (dryRun) {
1078
+ return {
1079
+ dryRun: true,
1080
+ documentId,
1081
+ totalArea,
1082
+ roomsCount: rooms.length,
1083
+ rooms,
1084
+ roomMappings,
1085
+ itemsCount: (dj.estimatePanelData?.estimateItems || []).length,
1086
+ totalPrices: totals
1087
+ };
1088
+ }
1089
+
1090
+ await patchDocument(documentId, { dataJson: dj }, { orgId });
1091
+
1092
+ return {
1093
+ success: true,
1094
+ documentId,
1095
+ totalArea,
1096
+ roomsCount: rooms.length,
1097
+ rooms,
1098
+ roomMappings,
1099
+ itemsCount: (dj.estimatePanelData?.estimateItems || []).length,
1100
+ totalPrices: totals
1101
+ };
1102
+ }
1103
+
1104
+ /**
1105
+ * Pobiera aktualnie wybrany pakiet kosztorysu, dostępne pakiety oraz listę pytań i odpowiedzi ankietowych (variantAnswers).
1106
+ */
1107
+ async function getPackageAndVariants(documentId, { orgId = DEFAULT_ORG_ID } = {}) {
1108
+ if (!documentId) throw new Error('Wymagany jest parametr documentId.');
1109
+ const doc = await getDocument(documentId, { orgId });
1110
+ const dj = doc.dataJson || {};
1111
+ const templates = dj.templatesSnapshot?.packages ? dj.templatesSnapshot : domain.getDefaultTemplatesSnapshot();
1112
+ const pkgCfg = domain.resolvePackageConfig(dj.finishStandard?.selectedPackageId || dj.finishStandard?.selectedPackageName || 'SMART');
1113
+ const pkg = templates.packages.find(p => p.id === pkgCfg.packageUuid) || templates.packages[0];
1114
+
1115
+ const rooms = dj.roomsAndMeasurements?.roomAnalyses?.[0]?.rooms || [];
1116
+ const totalArea = dj.roomsAndMeasurements?.roomAnalyses?.[0]?.totalArea || domain.round2(rooms.reduce((s, r) => s + (Number(r.area) || 0), 0));
1117
+ const roomMappings = dj.estimateBindings?.roomMappings?.length
1118
+ ? dj.estimateBindings.roomMappings
1119
+ : domain.buildRoomMappingsForPackage(rooms, pkg, []);
1120
+
1121
+ const { questions, variantAnswers } = domain.collectQuestionsAndDefaultAnswers(
1122
+ pkg,
1123
+ roomMappings,
1124
+ totalArea,
1125
+ dj.finishStandard?.variantAnswers || {}
1126
+ );
1127
+
1128
+ return {
1129
+ documentId: doc.id,
1130
+ documentName: doc.name,
1131
+ currentPackage: {
1132
+ key: pkgCfg.key,
1133
+ selectedPackageId: pkgCfg.selectedPackageId,
1134
+ selectedPackageName: pkgCfg.selectedPackageName,
1135
+ packageUuid: pkgCfg.packageUuid,
1136
+ unitPriceM2: dj.finishStandard?.unitPriceM2 ?? pkgCfg.unitPriceM2,
1137
+ markupPct: pkgCfg.markupPct
1138
+ },
1139
+ availablePackages: Object.values(domain.PACKAGE_CONFIGS),
1140
+ questionsCount: questions.length,
1141
+ questions,
1142
+ variantAnswers
1143
+ };
1144
+ }
1145
+
1146
+ /**
1147
+ * Zmienia pakiet wykończeniowy kosztorysu (SMART / MODERN / HIGH) i/lub odpowiedzi ankietowe (variantAnswers),
1148
+ * automatycznie mapuje pomieszczenia na nowy pakiet i przebudowuje/uzgadnia pozycje wzorca (estimateItems).
1149
+ */
1150
+ async function setPackageAndVariants(documentId, params = {}) {
1151
+ const {
1152
+ package: pkgInput,
1153
+ variantAnswers: customAnswers = {},
1154
+ preserveChosenProducts = true,
1155
+ resetReductions = false,
1156
+ dryRun = false,
1157
+ orgId = DEFAULT_ORG_ID
1158
+ } = params;
1159
+
1160
+ if (!documentId) throw new Error('Wymagany jest parametr documentId.');
1161
+ const doc = await getDocument(documentId, { orgId });
1162
+ const dj = doc.dataJson || {};
1163
+
1164
+ if (!dj.templatesSnapshot || !Array.isArray(dj.templatesSnapshot.packages)) {
1165
+ dj.templatesSnapshot = domain.getDefaultTemplatesSnapshot();
1166
+ }
1167
+
1168
+ const currentPkgId = dj.finishStandard?.selectedPackageId || dj.finishStandard?.selectedPackageName || 'SMART';
1169
+ const pkgCfg = domain.resolvePackageConfig(pkgInput || currentPkgId);
1170
+ const pkg = dj.templatesSnapshot.packages.find(p => p.id === pkgCfg.packageUuid) || dj.templatesSnapshot.packages[0];
1171
+
1172
+ const rooms = dj.roomsAndMeasurements?.roomAnalyses?.[0]?.rooms || [];
1173
+ const totalArea = dj.roomsAndMeasurements?.roomAnalyses?.[0]?.totalArea || domain.round2(rooms.reduce((s, r) => s + (Number(r.area) || 0), 0));
1174
+ const roomMappings = domain.buildRoomMappingsForPackage(rooms, pkg, dj.estimateBindings?.roomMappings || []);
1175
+
1176
+ // Połącz dotychczasowe odpowiedzi z nowymi przekazanymi przez użytkownika
1177
+ const mergedInputAnswers = {
1178
+ ...(dj.finishStandard?.variantAnswers || {}),
1179
+ ...(customAnswers || {})
1180
+ };
1181
+
1182
+ const { questions, variantAnswers } = domain.collectQuestionsAndDefaultAnswers(
1183
+ pkg,
1184
+ roomMappings,
1185
+ totalArea,
1186
+ mergedInputAnswers
1187
+ );
1188
+
1189
+ let baseItems = dj.estimatePanelData?.estimateItems || [];
1190
+ if (resetReductions) {
1191
+ baseItems = baseItems.map(it => ({ ...it, cancelled: false, cancelledManual: undefined }));
1192
+ }
1193
+
1194
+ const reconciled = domain.generateAndReconcilePackageItems({
1195
+ pkg,
1196
+ pkgConfig: pkgCfg,
1197
+ ocrRooms: rooms,
1198
+ roomMappings,
1199
+ variantAnswers,
1200
+ currentItems: baseItems,
1201
+ removedBindingKeys: resetReductions ? [] : (dj.estimateBindings?.removedBindingKeys || []),
1202
+ preserveChosenProducts
1203
+ });
1204
+
1205
+ const scopes = [];
1206
+ for (const m of roomMappings) {
1207
+ for (const t of (m.templateRoomIds || [])) {
1208
+ scopes.push(`${m.ocrRoomId}|${t.templateRoomId}`);
1209
+ }
1210
+ }
1211
+ const wholeRoom = (pkg.rooms || []).find(r => r.key === 'caly_lokal');
1212
+ if (wholeRoom) {
1213
+ scopes.push(`${domain.WHOLE_UNIT_OCR_ROOM_ID}|${wholeRoom.id}`);
1214
+ }
1215
+ const surveyConfirmedFor = `${pkgCfg.packageUuid}::${scopes.sort().join(';')}`;
1216
+ const nowIso = new Date().toISOString();
1217
+ const { signature, variantAnswersHash } = domain.computeEstimateSignature(
1218
+ String(documentId),
1219
+ pkgCfg.packageUuid,
1220
+ variantAnswers,
1221
+ rooms,
1222
+ roomMappings
1223
+ );
1224
+
1225
+ dj.finishStandard = {
1226
+ ...(dj.finishStandard || {}),
1227
+ selectedPackageId: pkgCfg.selectedPackageId,
1228
+ selectedPackageName: pkgCfg.selectedPackageName,
1229
+ unitPriceM2: pkgCfg.unitPriceM2,
1230
+ variantAnswers,
1231
+ surveyConfirmedAt: nowIso,
1232
+ surveyConfirmedFor
1233
+ };
1234
+
1235
+ dj.estimateBindings = {
1236
+ ...(dj.estimateBindings || {}),
1237
+ packageId: pkgCfg.packageUuid,
1238
+ finishPackageId: pkgCfg.selectedPackageId,
1239
+ roomMappings,
1240
+ dimensionsSnapshot: reconciled.dimensionsSnapshot,
1241
+ removedBindingKeys: resetReductions ? [] : (dj.estimateBindings?.removedBindingKeys || []),
1242
+ variantAnswersHash,
1243
+ signature
1244
+ };
1245
+
1246
+ if (!dj.estimatePanelData) dj.estimatePanelData = {};
1247
+ dj.estimatePanelData.estimateItems = reconciled.items;
1248
+ const totals = domain.recalculateDocumentTotals(dj);
1249
+
1250
+ if (dryRun) {
1251
+ return {
1252
+ dryRun: true,
1253
+ documentId,
1254
+ selectedPackage: pkgCfg,
1255
+ questionsCount: questions.length,
1256
+ itemsCount: reconciled.items.length,
1257
+ totalPrices: totals
1258
+ };
1259
+ }
1260
+
1261
+ await patchDocument(documentId, { dataJson: dj }, { orgId });
1262
+
1263
+ return {
1264
+ success: true,
1265
+ documentId,
1266
+ selectedPackage: pkgCfg,
1267
+ questionsCount: questions.length,
1268
+ itemsCount: reconciled.items.length,
1269
+ totalPrices: totals
1270
+ };
1271
+ }
1272
+
1273
+ /**
1274
+ * Tworzy nowy dokument kosztorysu (POST /documents) dla wybranego projektu,
1275
+ * z inicjalizacją pakietu (SMART / MODERN / HIGH), obmiarów pomieszczeń i wygenerowaniem pozycji ze wzorca.
1276
+ */
1277
+ async function createEstimateDocument(params = {}) {
1278
+ const {
1279
+ projectId,
1280
+ name,
1281
+ package: pkgInput = 'SMART',
1282
+ rooms: inputRooms = [],
1283
+ variantAnswers: customAnswers = {},
1284
+ orgId = DEFAULT_ORG_ID
1285
+ } = params;
1286
+
1287
+ if (!projectId) throw new Error('Wymagany jest parametr projectId.');
1288
+
1289
+ const project = await callDevApi('GET', `/projects/${projectId}`, { orgId });
1290
+ const pkgCfg = domain.resolvePackageConfig(pkgInput);
1291
+ const templatesSnapshot = domain.getDefaultTemplatesSnapshot();
1292
+ const pkg = templatesSnapshot.packages.find(p => p.id === pkgCfg.packageUuid) || templatesSnapshot.packages[0];
1293
+
1294
+ const defaultRooms = inputRooms.length > 0 ? inputRooms : [
1295
+ { id: domain.newUuid(), name: 'Hall', area: 6.5, perimeter: 11.0, height: 2.7, openings: [{ id: domain.newUuid(), kind: 'door', width: 0.9, count: 1 }] },
1296
+ { id: domain.newUuid(), name: 'Salon+kuchnia', area: 22.0, perimeter: 19.5, height: 2.7, kitchenAreaShare: 4.0, openings: [{ id: domain.newUuid(), kind: 'door', width: 0.9, count: 1 }] },
1297
+ { id: domain.newUuid(), name: 'Sypialnia', area: 12.5, perimeter: 14.2, height: 2.7, openings: [{ id: domain.newUuid(), kind: 'door', width: 0.9, count: 1 }] },
1298
+ { id: domain.newUuid(), name: 'Łazienka', area: 4.5, perimeter: 8.6, height: 2.7, openings: [{ id: domain.newUuid(), kind: 'door', width: 0.8, count: 1 }] }
1299
+ ];
1300
+
1301
+ const rooms = defaultRooms.map(r => {
1302
+ const area = Number(r.area) || 10;
1303
+ return {
1304
+ ...r,
1305
+ id: r.id || domain.newUuid(),
1306
+ area,
1307
+ perimeter: r.perimeter !== undefined ? Number(r.perimeter) : domain.round2(4 * Math.sqrt(Math.max(1, area))),
1308
+ height: Number(r.height) || 2.7,
1309
+ openings: Array.isArray(r.openings) ? r.openings : []
1310
+ };
1311
+ });
1312
+
1313
+ const totalArea = domain.round2(rooms.reduce((s, r) => s + (Number(r.area) || 0), 0));
1314
+ const roomMappings = domain.buildRoomMappingsForPackage(rooms, pkg, []);
1315
+ const { variantAnswers } = domain.collectQuestionsAndDefaultAnswers(pkg, roomMappings, totalArea, customAnswers);
1316
+
1317
+ const reconciled = domain.generateAndReconcilePackageItems({
1318
+ pkg,
1319
+ pkgConfig: pkgCfg,
1320
+ ocrRooms: rooms,
1321
+ roomMappings,
1322
+ variantAnswers,
1323
+ currentItems: [],
1324
+ removedBindingKeys: [],
1325
+ preserveChosenProducts: false
1326
+ });
1327
+
1328
+ const nowIso = new Date().toISOString();
1329
+ const dataJson = {
1330
+ project: {
1331
+ id: String(project.id || projectId),
1332
+ name: project.name || '',
1333
+ type: project.projectType || 'MIESZKANIE',
1334
+ number: project.number || '',
1335
+ status: project.propertyStatus || 'RYNEK_PIERWOTNY',
1336
+ address: project.address || null,
1337
+ area: totalArea
1338
+ },
1339
+ client: project.clients || [],
1340
+ organization: { id: String(orgId), name: 'Deer Design' },
1341
+ roomsAndMeasurements: {
1342
+ roomFiles: [],
1343
+ roomAnalyses: [
1344
+ {
1345
+ id: `a_${Date.now()}`,
1346
+ status: 'completed',
1347
+ totalArea,
1348
+ balconyArea: 0,
1349
+ rooms
1350
+ }
1351
+ ]
1352
+ },
1353
+ finishStandard: {
1354
+ selectedPackageId: pkgCfg.selectedPackageId,
1355
+ selectedPackageName: pkgCfg.selectedPackageName,
1356
+ unitPriceM2: pkgCfg.unitPriceM2,
1357
+ variantAnswers,
1358
+ surveyConfirmedAt: nowIso
1359
+ },
1360
+ estimateBindings: {
1361
+ packageId: pkgCfg.packageUuid,
1362
+ finishPackageId: pkgCfg.selectedPackageId,
1363
+ roomMappings,
1364
+ dimensionsSnapshot: reconciled.dimensionsSnapshot,
1365
+ removedBindingKeys: [],
1366
+ variantAnswersHash: domain.hashVariantAnswers(variantAnswers)
1367
+ },
1368
+ templatesSnapshot,
1369
+ estimatePanelData: {
1370
+ estimateItems: reconciled.items,
1371
+ totalPrices: { totalNetPrice: 0, totalVatPrice: 0, totalGrossPrice: 0 }
1372
+ },
1373
+ additionalInfo: {
1374
+ estimateRemarks: `Kosztorys pakietowy ${pkgCfg.selectedPackageName} (${totalArea} m²) utworzony przez DeerTools MCP.`
1375
+ }
1376
+ };
1377
+
1378
+ domain.recalculateDocumentTotals(dataJson);
1379
+
1380
+ const docName = name || `Kosztorys ${pkgCfg.selectedPackageName} — ${project.name || project.number || projectId}`;
1381
+ const created = await callDevApi('POST', '/documents', {
1382
+ body: {
1383
+ name: docName,
1384
+ type: 'ESTIMATE',
1385
+ projectId: String(projectId),
1386
+ dataJson
1387
+ },
1388
+ orgId
1389
+ });
1390
+
1391
+ const docId = String(created.id);
1392
+ const { signature } = domain.computeEstimateSignature(docId, pkgCfg.packageUuid, variantAnswers, rooms, roomMappings);
1393
+ dataJson.estimateBindings.signature = signature;
1394
+
1395
+ const updated = await patchDocument(docId, { name: docName, type: 'ESTIMATE', dataJson }, { orgId });
1396
+
1397
+ return {
1398
+ success: true,
1399
+ documentId: docId,
1400
+ documentNumber: updated.documentNumber || created.documentNumber,
1401
+ name: docName,
1402
+ projectId: String(projectId),
1403
+ package: pkgCfg.selectedPackageName,
1404
+ totalArea,
1405
+ roomsCount: rooms.length,
1406
+ itemsCount: reconciled.items.length,
1407
+ totalPrices: dataJson.estimatePanelData.totalPrices
1408
+ };
1409
+ }
1410
+
1411
+ /**
1412
+ * Bezpieczna, całościowa aktualizacja pozycji kosztorysu (estimateItems).
1413
+ */
1414
+ async function updateAllEstimateItems(documentId, newItems, options = {}) {
1415
+ const { recalculateTotals = true, dryRun = false, orgId = DEFAULT_ORG_ID } = options;
1416
+
1417
+ if (!documentId) throw new Error('Wymagany jest parametr documentId.');
1418
+ if (!Array.isArray(newItems)) throw new Error('Parametr newItems musi być tablicą pozycji.');
1419
+
1420
+ const doc = await getDocument(documentId, { orgId });
1421
+ const dataJson = doc.dataJson || {};
1422
+ const panelData = dataJson.estimatePanelData || {};
1423
+ const oldItems = panelData.estimateItems || [];
1424
+
1425
+ if (dryRun) {
1426
+ return {
1427
+ dryRun: true,
1428
+ documentId,
1429
+ oldCount: oldItems.length,
1430
+ newCount: newItems.length,
1431
+ itemsSample: newItems.slice(0, 3)
1432
+ };
1433
+ }
1434
+
1435
+ dataJson._lastBackup = {
1436
+ updatedAt: new Date().toISOString(),
1437
+ itemsCount: oldItems.length,
1438
+ itemsSnapshot: oldItems
1439
+ };
1440
+
1441
+ panelData.estimateItems = newItems;
1442
+ dataJson.estimatePanelData = panelData;
1443
+
1444
+ let totals = panelData.totalPrices;
1445
+ if (recalculateTotals) {
1446
+ totals = domain.recalculateDocumentTotals(dataJson);
1447
+ }
1448
+
1449
+ const updatedDoc = await patchDocument(documentId, { dataJson }, { orgId });
1450
+
1451
+ return {
1452
+ success: true,
1453
+ documentId,
1454
+ itemsCount: newItems.length,
1455
+ totalPrices: totals,
1456
+ totalNet: panelData.totalNet,
1457
+ totalGross: panelData.totalGross,
1458
+ document: updatedDoc
1459
+ };
1460
+ }
1461
+
1462
+ /**
1463
+ * Atomowa modyfikacja wybranych pozycji kosztorysu (add, update, remove).
1464
+ */
1465
+ async function patchEstimateItems(documentId, changes = {}) {
1466
+ const { add = [], update = [], remove = [], dryRun = false, orgId = DEFAULT_ORG_ID } = changes;
1467
+
1468
+ if (!documentId) throw new Error('Wymagany jest parametr documentId.');
1469
+
1470
+ const doc = await getDocument(documentId, { orgId });
1471
+ const dataJson = doc.dataJson || {};
1472
+ const panelData = dataJson.estimatePanelData || {};
1473
+ let items = [...(panelData.estimateItems || [])];
1474
+
1475
+ const removeIds = new Set((remove || []).map(String));
1476
+ const updateMap = new Map((update || []).map(u => [String(u.id || u.slotKey), u]));
1477
+
1478
+ if (removeIds.size > 0) {
1479
+ items = items.filter(it => !removeIds.has(String(it.id)) && !removeIds.has(String(it.binding?.slotKey)));
1480
+ }
1481
+
1482
+ items = items.map(it => {
1483
+ const keyId = String(it.id);
1484
+ const keySlot = it.binding?.slotKey ? String(it.binding.slotKey) : null;
1485
+ const patch = updateMap.get(keyId) || (keySlot && updateMap.get(keySlot));
1486
+
1487
+ if (patch) {
1488
+ return {
1489
+ ...it,
1490
+ ...patch,
1491
+ binding: patch.binding ? { ...it.binding, ...patch.binding } : it.binding,
1492
+ templateMeta: patch.templateMeta ? { ...it.templateMeta, ...patch.templateMeta } : it.templateMeta
1493
+ };
1494
+ }
1495
+ return it;
1496
+ });
1497
+
1498
+ if (Array.isArray(add) && add.length > 0) {
1499
+ for (const itemToAdd of add) {
1500
+ if (!itemToAdd.id) {
1501
+ itemToAdd.id = domain.newUuid();
1502
+ }
1503
+ items.push(itemToAdd);
1504
+ }
1505
+ }
1506
+
1507
+ if (dryRun) {
1508
+ return {
1509
+ dryRun: true,
1510
+ documentId,
1511
+ added: (add || []).length,
1512
+ updated: (update || []).length,
1513
+ removed: (remove || []).length,
1514
+ resultingCount: items.length
1515
+ };
1516
+ }
1517
+
1518
+ return await updateAllEstimateItems(documentId, items, { dryRun: false, orgId });
1519
+ }
1520
+
1521
+ /**
1522
+ * Zmiana statusu pakietowego pozycji (np. przejście na dopłatę SURCHARGE, INCLUDED lub REDUCTION).
1523
+ */
1524
+ async function setItemBinding(documentId, itemId, bindingData = {}) {
1525
+ const { binding = 'SURCHARGE', surchargeAmountNet = 0, dryRun = false, orgId = DEFAULT_ORG_ID } = bindingData;
1526
+
1527
+ const doc = await getDocument(documentId, { orgId });
1528
+ const items = doc.dataJson?.estimatePanelData?.estimateItems || [];
1529
+ const targetItem = items.find(it => String(it.id) === String(itemId) || String(it.binding?.slotKey) === String(itemId));
1530
+
1531
+ if (!targetItem) {
1532
+ throw new Error(`Nie znaleziono pozycji o ID: ${itemId} w kosztorysie ${documentId}.`);
1533
+ }
1534
+
1535
+ const updatedBinding = typeof targetItem.binding === 'object' && targetItem.binding !== null ? { ...targetItem.binding } : {};
1536
+ updatedBinding.bindingStatus = binding;
1537
+ if (surchargeAmountNet) {
1538
+ updatedBinding.surchargeAmountNet = Number(surchargeAmountNet);
1539
+ }
1540
+
1541
+ const settlementMode = binding === 'INCLUDED'
1542
+ ? 'included'
1543
+ : (binding === 'REDUCTION' ? 'reduction' : 'surcharge');
1544
+
1545
+ return await patchEstimateItems(documentId, {
1546
+ update: [{
1547
+ id: targetItem.id,
1548
+ binding: updatedBinding,
1549
+ settlementMode,
1550
+ ...(binding === 'REDUCTION' ? { cancelled: true, cancelledManual: true } : {})
1551
+ }],
1552
+ dryRun,
1553
+ orgId
1554
+ });
1555
+ }
1556
+
1557
+ /**
1558
+ * Automatycznie dobiera produkty z Katalogu 5 do wszystkich (lub wybranych) pustych slotów wzorcowych w kosztorysie,
1559
+ * z zachowaniem filtrów kategorii, pakietu i limitu cenowego (priceMax).
1560
+ */
1561
+ async function autoFillTemplateSlots(documentId, params = {}) {
1562
+ const {
1563
+ onlyUnfilled = true,
1564
+ keepIsTemplateFlag = false,
1565
+ documentName,
1566
+ dryRun = false,
1567
+ orgId = DEFAULT_ORG_ID
1568
+ } = params;
1569
+
1570
+ if (!documentId) throw new Error('Wymagany jest parametr documentId.');
1571
+ const doc = await getDocument(documentId, { orgId });
1572
+ const dj = doc.dataJson || {};
1573
+ const pkgCfg = domain.resolvePackageConfig(dj.finishStandard?.selectedPackageId || dj.finishStandard?.selectedPackageName || dj.estimateBindings?.packageId || 'SMART');
1574
+
1575
+ // Upewnij się, że finishStandard ma pełne pola pakietu
1576
+ if (!dj.finishStandard) dj.finishStandard = {};
1577
+ if (!dj.finishStandard.selectedPackageId) dj.finishStandard.selectedPackageId = pkgCfg.selectedPackageId;
1578
+ if (!dj.finishStandard.selectedPackageName) dj.finishStandard.selectedPackageName = pkgCfg.selectedPackageName;
1579
+ if (!dj.finishStandard.unitPriceM2) dj.finishStandard.unitPriceM2 = pkgCfg.unitPriceM2;
1580
+
1581
+ const panelData = dj.estimatePanelData || { estimateItems: [] };
1582
+ const items = [...(panelData.estimateItems || [])];
1583
+
1584
+ // Cache dobranych produktów per (categoryPath + priceMax) dla spójności w całym mieszkaniu
1585
+ const chosenByCatAndLimit = new Map();
1586
+ let filledCount = 0;
1587
+ const filledDetails = [];
1588
+
1589
+ for (let i = 0; i < items.length; i++) {
1590
+ const row = items[i];
1591
+ if (row.cancelled) continue;
1592
+ const isProductRow = row.type === 'PRODUCT' || row.itemKind === 'material';
1593
+ if (!isProductRow) continue;
1594
+ if (onlyUnfilled && !row.isTemplate && row.productId) continue;
1595
+
1596
+ const tm = row.templateMeta || {};
1597
+ const catPath = tm.categoryPath || row.categoryPath;
1598
+ if (!catPath) continue;
1599
+ const priceMax = tm.priceMax != null ? Number(tm.priceMax) : null;
1600
+ const cacheKey = `${catPath}::${priceMax ?? 'nolimit'}`;
1601
+
1602
+ let bestItem = chosenByCatAndLimit.get(cacheKey);
1603
+ if (bestItem === undefined) {
1604
+ // Krok 1: Szukaj w pakiecie i limicie ceny
1605
+ let searchRes = await searchEstimateCatalog({
1606
+ type: 'product',
1607
+ categoryPathPrefix: catPath,
1608
+ packageFilter: pkgCfg.packageKey,
1609
+ priceMax: priceMax != null ? priceMax : undefined,
1610
+ unit: row.unit,
1611
+ unlockFilters: false,
1612
+ pageSize: 15,
1613
+ orgId
1614
+ });
1615
+
1616
+ let candidates = searchRes.items || [];
1617
+
1618
+ // Krok 2: Jeśli brak w ścisłym limicie pakietu, szukaj w tej kategorii w pakiecie bez filtru ceny i wybierz najbliższy limitu
1619
+ if (candidates.length === 0) {
1620
+ const resPkg = await callDevApi('GET', `/catalogs/${ESTIMATE_CATALOG_ID}/items`, {
1621
+ query: {
1622
+ type: 'product',
1623
+ categoryPathPrefix: catPath,
1624
+ package: pkgCfg.packageKey,
1625
+ includeRelated: 'true',
1626
+ page: 1,
1627
+ pageSize: 20
1628
+ },
1629
+ orgId
1630
+ });
1631
+ candidates = resPkg.items || [];
1632
+ }
1633
+
1634
+ // Krok 3: Jeśli nadal brak, szukaj w całej kategorii (niezależnie od pakietu)
1635
+ if (candidates.length === 0) {
1636
+ const resAny = await callDevApi('GET', `/catalogs/${ESTIMATE_CATALOG_ID}/items`, {
1637
+ query: {
1638
+ type: 'product',
1639
+ categoryPathPrefix: catPath,
1640
+ includeRelated: 'true',
1641
+ page: 1,
1642
+ pageSize: 20
1643
+ },
1644
+ orgId
1645
+ });
1646
+ candidates = resAny.items || [];
1647
+ }
1648
+
1649
+ if (candidates.length > 0) {
1650
+ // Wybierz produkt o dodatniej cenie zakupu <= priceMax (najbliżej priceMax), lub pierwszy dostępny
1651
+ const withPrice = candidates.filter(c => Number(c.purchasePricePerPricingUnit ?? c.purchasePrice ?? 0) > 0);
1652
+ const pool = withPrice.length > 0 ? withPrice : candidates;
1653
+ if (priceMax != null) {
1654
+ const within = pool.filter(c => Number(c.purchasePricePerPricingUnit ?? c.purchasePrice ?? 0) <= priceMax + 0.01);
1655
+ if (within.length > 0) {
1656
+ within.sort((a, b) => Number(b.purchasePricePerPricingUnit ?? b.purchasePrice ?? 0) - Number(a.purchasePricePerPricingUnit ?? a.purchasePrice ?? 0));
1657
+ bestItem = within[0];
1658
+ } else {
1659
+ pool.sort((a, b) => Number(a.purchasePricePerPricingUnit ?? a.purchasePrice ?? 0) - Number(b.purchasePricePerPricingUnit ?? b.purchasePrice ?? 0));
1660
+ bestItem = pool[0];
1661
+ }
1662
+ } else {
1663
+ bestItem = pool[0];
1664
+ }
1665
+ // Pobierz pełny obiekt katalogowy jeśli przyszedł ze wzbogaconej listy searchEstimateCatalog
1666
+ if (bestItem && bestItem.computedUnitPriceNet !== undefined) {
1667
+ bestItem = await getEstimateCatalogItem(bestItem.id, { markupPct: pkgCfg.markupPct, orgId });
1668
+ }
1669
+ } else {
1670
+ bestItem = null;
1671
+ }
1672
+
1673
+ chosenByCatAndLimit.set(cacheKey, bestItem);
1674
+ }
1675
+
1676
+ if (!bestItem) continue;
1677
+
1678
+ const rawQty = row.packaging?.includesReserve ? (row.packaging.rawAreaM2 ?? row.quantity ?? 1) : (row.quantity ?? 1);
1679
+ const isPaint = catPath.toLowerCase().includes('farby');
1680
+ let built;
1681
+ if (isPaint && priceMax != null) {
1682
+ // Farby wyceniane są w m² wg stawki wzorca (np. 11.71 zł/m² netto zakupu)
1683
+ built = domain.buildEstimateRowFromCatalogItem(bestItem, {
1684
+ quantity: rawQty,
1685
+ group: row.group,
1686
+ rowId: row.id,
1687
+ expectedUnit: row.unit,
1688
+ reservePct: row.reservePct,
1689
+ markupPct: pkgCfg.markupPct,
1690
+ packageLabel: pkgCfg.selectedPackageName
1691
+ });
1692
+ built.unit = row.unit || 'm2';
1693
+ built.quantity = rawQty;
1694
+ built.purchasePrice = priceMax;
1695
+ built.unitPrice = domain.round2(priceMax * (1 + pkgCfg.markupPct / 100));
1696
+ Object.assign(built, domain.calculateRowPrices(rawQty, built.unitPrice, built.vatRate));
1697
+ } else {
1698
+ built = domain.buildEstimateRowFromCatalogItem(bestItem, {
1699
+ quantity: rawQty,
1700
+ group: row.group,
1701
+ rowId: row.id,
1702
+ expectedUnit: row.unit,
1703
+ reservePct: row.reservePct,
1704
+ markupPct: pkgCfg.markupPct,
1705
+ packageLabel: pkgCfg.selectedPackageName
1706
+ });
1707
+ }
1708
+
1709
+ const cleanMeta = { ...tm };
1710
+ delete cleanMeta.catalogMissing;
1711
+
1712
+ items[i] = {
1713
+ ...row,
1714
+ ...built,
1715
+ isTemplate: keepIsTemplateFlag ? true : false,
1716
+ binding: row.binding,
1717
+ templateMeta: cleanMeta
1718
+ };
1719
+ filledCount++;
1720
+ filledDetails.push({
1721
+ rowId: row.id,
1722
+ group: row.group,
1723
+ slotName: tm.slotName || row.name,
1724
+ assignedProductId: bestItem.id,
1725
+ assignedSku: bestItem.sku,
1726
+ assignedName: bestItem.name,
1727
+ quantity: items[i].quantity,
1728
+ unit: items[i].unit,
1729
+ unitPriceNet: items[i].unitPrice,
1730
+ totalNetPrice: items[i].totalNetPrice
1731
+ });
1732
+ }
1733
+
1734
+ panelData.estimateItems = items;
1735
+ dj.estimatePanelData = panelData;
1736
+ const totals = domain.recalculateDocumentTotals(dj);
1737
+
1738
+ if (dryRun) {
1739
+ return {
1740
+ dryRun: true,
1741
+ documentId,
1742
+ package: pkgCfg.selectedPackageName,
1743
+ filledCount,
1744
+ filledSample: filledDetails.slice(0, 10),
1745
+ totalPrices: totals
1746
+ };
1747
+ }
1748
+
1749
+ const patchPayload = { dataJson: dj };
1750
+ if (documentName && String(documentName).trim()) {
1751
+ patchPayload.name = String(documentName).trim();
1752
+ }
1753
+ await patchDocument(documentId, patchPayload, { orgId });
1754
+
1755
+ return {
1756
+ success: true,
1757
+ documentId,
1758
+ documentName: patchPayload.name || doc.name,
1759
+ package: pkgCfg.selectedPackageName,
1760
+ filledCount,
1761
+ filledDetails,
1762
+ totalPrices: totals
1763
+ };
1764
+ }
1765
+
1766
+ module.exports = {
1767
+ callDevApi,
1768
+ listProjects,
1769
+ getProject,
1770
+ listDocuments,
1771
+ getDocument,
1772
+ patchDocument,
1773
+ createEstimateDocument,
1774
+ searchEstimateCatalog,
1775
+ getEstimateCatalogItem,
1776
+ listTemplateSuggestions,
1777
+ assignCatalogItemToEstimate,
1778
+ reduceOrReplaceItem,
1779
+ getRoomsAndMeasurements,
1780
+ updateRoomsAndMeasurements,
1781
+ getPackageAndVariants,
1782
+ setPackageAndVariants,
1783
+ autoFillTemplateSlots,
1784
+ updateAllEstimateItems,
1785
+ patchEstimateItems,
1786
+ setItemBinding
1787
+ };