@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,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
|
+
};
|