CI - Test & Build / Lint & Type Check (push) Canceled after 0s
CI - Test & Build / Backend Unit Tests (push) Canceled after 0s
CI - Test & Build / Frontend Build (push) Canceled after 0s
CI - Test & Build / Security Scan (push) Canceled after 0s
CI - Test & Build / E2E Tests (Playwright) (push) Canceled after 0s
CI - Test & Build / CI Summary (push) Canceled after 0s
* feat: move login credentials to a DB-backed users table with an admin management page Replaces the hardcoded AUTH_USERNAME/AUTH_PASSWORD login check with a new auth_accounts table (bcrypt-hashed passwords, admin/user roles). Adds admin-only /api/users CRUD routes and a "Brugere" admin page in the frontend for managing logins without redeploying. Removes the unused, unmounted duplicate login route in src/routes/auth.js. * docs: add architecture codemaps with diagrams for the whole system Adds codemaps/architecture.md, backend.md, frontend.md, and data.md — Mermaid-diagrammed design documentation verified against the live codebase and database rather than assumed from CLAUDE.md. Covers the unified-server.js request flow (mounted routers + ~183 inline routes), 68 backend services grouped by domain, the frontend's state-driven view-switch (no React Router in practice despite BrowserRouter being present), and the full 122-table DB schema with the auth_accounts vs unrelated users table naming trap flagged explicitly. Links added from the root README. Co-Authored-By: Claude Sonnet 5 <[email protected]> * feat: ship canonical roof quote workflow * fix: keep migration dry-run idempotent * [verified] feat: complete Smart Pakker management * [verified] fix: ignore blank task dependencies * [verified] fix: align package duplication with schema * [verified] fix: enforce Discord status limits * [verified] fix: link Smart Pakke materials safely * [verified] fix: harden material link review * [verified] feat: improve material matching * fix: scope pitch validation to roof packages * fix: support canonical snapshots on production schema * [verified] fix: hide internal package metadata from PDF * [verified] feat: deliver sales-ready customer PDF * [verified] feat: ship sales-ready PDF with AI overview * [verified] fix: authenticate project list requests * [verified] fix: refresh project-list authentication * [verified] fix: open existing project details * [verified] fix: keep roof components searchable in builder * [verified] fix: expose all Smart Package categories * [verified] fix: authenticate project creation * [verified] feat: make Smart Pakker the universal project flow * [verified] feat: preview Smart Package contents * [verified] test: keep generic release isolated from downpipe work * feat: add first-class Smart Pakke rentals * [verified] feat: add gutter and downpipe smart packages * [verified] fix: prepare six-house gutter quote flow * [verified] fix: open generic quotes without roof geometry * [verified] fix: review generic quotes with authenticated APIs * [verified] fix: calculate generic Smart Package quotes * [verified] fix: return generic calculation breakdown * feat: checkpoint generic signed snapshot validation with red-green tests * feat: complete fail-closed generic quote approval and customer PDF flow * feat: use generic signed snapshot in final review * feat: redesign generic quote final review * fix: harden generic review summaries * feat: add auditable six-house package basis * [verified] feat: finish auditable Smart Pakke UI * [verified] fix: bind auditable quantity and price bases * [verified] fix: keep six-house basis across package versions * [verified] fix: complete smart package discovery management * [verified] fix: simplify composition and generic scope * [verified] test: keep explicit roof contracts fail closed * [verified] fix: harden generic quote snapshots * fix: make generic quote delivery customer safe * [verified] fix: secure package catalog reads * [verified] fix: close workspace provenance blockers * fix: harden customer document language boundary * [verified] fix: secure smart package internal reads * fix: version package child mutations atomically * feat: add generic customer quote text flow * [verified] fix: allow manual customer numbers * [verified] fix: expose optional roof geometry * [verified] fix: rebase hydrated packages after geometry edits * [verified] feat: add free editable site area map * [verified] fix: harden map recovery and geocoding gate * fix: bind map quantities to authoritative geometry * fix: release geocoder lock before dispatch * fix: separate roof and site geometry provenance * fix: revoke stale admin authorization * fix: migrate task geometry basis * fix: make backend CI dependency-complete * ci: seed isolated e2e login account * fix: allow clean database bootstrap * fix: skip indexes for optional tables * test: use canonical mansard geometry in e2e * [verified] fix(auth): enforce live operator boundary * fix: fail close Ordrestyring offer transport * fix(frontend): authenticate customer project requests * fix: align canonical roof type contract * [verified] fix: reconcile legacy package labor safely * [verified] fix: audit site geometry deletion * docs: add PR 31 reviewer guide * docs: synchronize Obsidian vault * docs: sync integrated reviewer guide to Obsidian * ci: seed isolated auth account explicitly * fix: close offer bootstrap and service readiness gaps * fix: authenticate protected package callers * fix: provision initial admin and disable generic send * [verified] fix: close final quote release blockers * [verified] fix: seed gutter packages before deployment --------- Co-authored-by: alexpolo1 <[email protected]> Co-authored-by: Claude Sonnet 5 <[email protected]>
630 lines
34 KiB
JavaScript
630 lines
34 KiB
JavaScript
const toFinite = value => {
|
||
const parsed = Number.parseFloat(value);
|
||
return Number.isFinite(parsed) ? parsed : null;
|
||
};
|
||
const toStrictLocalizedFinite = value => {
|
||
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
||
const normalized = String(value ?? '').trim().replace(',', '.');
|
||
if (!/^\d+(?:\.\d+)?$/.test(normalized)) return null;
|
||
const parsed = Number(normalized);
|
||
return Number.isFinite(parsed) ? parsed : null;
|
||
};
|
||
const toNumber = (value, fallback = 0) => toFinite(value) ?? fallback;
|
||
const round = value => Math.round(toNumber(value) * 1000) / 1000;
|
||
const da = value => round(value).toLocaleString('da-DK', { maximumFractionDigits: 3 });
|
||
const SIX_HOUSE_PACKAGE_METRIC = Object.freeze({
|
||
956: { key: 'gutterLength', basis: 'six_house_gutter_length', unit: 'løbende m' },
|
||
960: { key: 'downpipeCount', basis: 'six_house_downpipe_count', unit: 'stk' }
|
||
});
|
||
|
||
const sixHouseMetric = source => {
|
||
const catalogKey = String(source?.price_source_value ?? source?.priceSourceValue ?? source?.catalog_key ?? source?.catalogKey ?? '').trim();
|
||
if (/^gutter-/.test(catalogKey)) return { key: 'gutterLength', basis: 'six_house_gutter_length', unit: 'løbende m' };
|
||
if (/^downpipe-/.test(catalogKey)) return { key: 'downpipeCount', basis: 'six_house_downpipe_count', unit: 'stk' };
|
||
return SIX_HOUSE_PACKAGE_METRIC[String(source?.id ?? source?.sourcePackageId)] || null;
|
||
};
|
||
|
||
export const isSixHouseBasisPackage = source => Boolean(sixHouseMetric(source));
|
||
|
||
export const emptySixHouseRows = () => Array.from({ length: 6 }, (_, index) => ({
|
||
id: `H${index + 1}`,
|
||
name: `Hus ${index + 1}`,
|
||
gutterLength: '',
|
||
downpipeCount: ''
|
||
}));
|
||
|
||
export const createSixHouseBasis = houses => {
|
||
if (!Array.isArray(houses) || houses.length !== 6) throw new Error('Mængdegrundlaget skal indeholde præcis 6 huse');
|
||
const normalized = houses.map((house, index) => {
|
||
const id = String(house?.id || '').trim();
|
||
const name = String(house?.name || '').trim();
|
||
if (!id) throw new Error(`Hus ${index + 1} mangler identifikation`);
|
||
if (!name) throw new Error(`Hus ${index + 1} mangler navn`);
|
||
const gutterLength = toStrictLocalizedFinite(house?.gutterLength);
|
||
if (gutterLength === null || gutterLength < 0) throw new Error(`Tagrendelængde mangler for ${name}`);
|
||
const downpipeCount = toStrictLocalizedFinite(house?.downpipeCount);
|
||
if (downpipeCount === null || downpipeCount < 0 || !Number.isInteger(downpipeCount)) {
|
||
throw new Error(`Antal nedløb mangler eller er ugyldigt for ${name}`);
|
||
}
|
||
return { id, name, gutterLength: round(gutterLength), downpipeCount };
|
||
});
|
||
if (new Set(normalized.map(house => house.id.toLocaleLowerCase('da-DK'))).size !== 6) {
|
||
throw new Error('Husenes identifikation skal være unik');
|
||
}
|
||
return {
|
||
kind: 'six_house_gutter_downpipe',
|
||
version: 1,
|
||
houses: normalized,
|
||
subtotals: {
|
||
gutterLength: round(normalized.reduce((sum, house) => sum + house.gutterLength, 0)),
|
||
downpipeCount: normalized.reduce((sum, house) => sum + house.downpipeCount, 0)
|
||
}
|
||
};
|
||
};
|
||
|
||
const sixHousePrimary = (source, basis) => {
|
||
const metric = sixHouseMetric(source);
|
||
if (!metric) throw new Error('Pakken understøtter ikke seks-hus-grundlag');
|
||
const quantity = basis.subtotals[metric.key];
|
||
if (!(quantity > 0)) throw new Error(`Subtotal for ${metric.unit} skal være større end nul`);
|
||
const values = basis.houses.map(house => house[metric.key]);
|
||
return {
|
||
basis: metric.basis,
|
||
factor: 1,
|
||
count: metric.key === 'downpipeCount' ? quantity : null,
|
||
measuredValue: quantity,
|
||
measuredUnit: metric.unit,
|
||
calculatedQuantity: quantity,
|
||
manualQuantity: null,
|
||
quantityMode: 'calculated',
|
||
formula: `${values.map(da).join(' + ')} = ${da(quantity)} ${metric.unit}`,
|
||
unit: metric.unit,
|
||
explicitBasis: true
|
||
};
|
||
};
|
||
|
||
const sixHouseQuantityBasis = (source, basis) => {
|
||
const metric = sixHouseMetric(source);
|
||
if (!metric) throw new Error('Pakken understøtter ikke seks-hus-grundlag');
|
||
return {
|
||
mode: 'per_house_breakdown',
|
||
unit: metric.unit,
|
||
houses: basis.houses.map(house => ({ id: house.id, name: house.name, quantity: house[metric.key] })),
|
||
total: basis.subtotals[metric.key]
|
||
};
|
||
};
|
||
|
||
const directQuantityBasis = primary => ({
|
||
mode: 'direct',
|
||
quantity: primary.manualQuantity ?? primary.calculatedQuantity,
|
||
unit: primary.unit
|
||
});
|
||
|
||
const uniqueInstanceId = sourceId => {
|
||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return `${sourceId}-${crypto.randomUUID()}`;
|
||
return `${sourceId}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||
};
|
||
|
||
const firstPositive = (geometry, keys) => {
|
||
for (const key of keys) {
|
||
const value = toFinite(geometry?.[key]);
|
||
if (value !== null && value > 0) return value;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
const positiveValue = value => {
|
||
const candidate = value && typeof value === 'object' ? (value.total ?? value.length ?? value.value) : value;
|
||
const parsed = toFinite(candidate);
|
||
return parsed !== null && parsed > 0 ? parsed : null;
|
||
};
|
||
const canonicalEdge = (geometry, key, aliases = []) => {
|
||
const containers = [geometry?.edges, geometry?.edgeLengths, geometry?.roofGeometry?.lengths, geometry?.lengths];
|
||
for (const container of containers) {
|
||
for (const name of [key, ...aliases]) {
|
||
const value = positiveValue(container?.[name]);
|
||
if (value !== null) return value;
|
||
}
|
||
}
|
||
return firstPositive(geometry, [key, ...aliases]);
|
||
};
|
||
|
||
const normalizedGeometry = geometry => ({
|
||
roofArea: firstPositive(geometry, ['roofCoveringArea', 'roof_covering_area', 'roofArea', 'area_m2', 'total_area']),
|
||
baseArea: firstPositive(geometry, ['baseArea', 'base_area', 'totalArea', 'total_area', 'area_m2']),
|
||
length: firstPositive(geometry, ['length', 'length_m', 'length_main', 'building_length']),
|
||
width: firstPositive(geometry, ['width', 'width_m', 'width_main', 'building_width']),
|
||
perimeter: firstPositive(geometry, ['perimeter', 'buildingPerimeter', 'building_perimeter']),
|
||
wallHeight: firstPositive(geometry, ['wallHeight', 'wall_height']),
|
||
wallArea: firstPositive(geometry, ['wallArea', 'wall_area', 'wall_area_m2']),
|
||
ridge: canonicalEdge(geometry, 'ridge', ['ridgeLength', 'ridge_length']),
|
||
eaves: canonicalEdge(geometry, 'eaves', ['eavesLength', 'eaves_length']),
|
||
verges: canonicalEdge(geometry, 'verges', ['vergeLength', 'verge_length']),
|
||
hips: canonicalEdge(geometry, 'hips', ['hipLength', 'hip_length']),
|
||
valleys: canonicalEdge(geometry, 'valleys', ['valleyLength', 'valley_length']),
|
||
roofSides: firstPositive(geometry, ['roofSides', 'roof_sides']) || 2
|
||
});
|
||
|
||
const BASIS_ALIASES = {
|
||
area: 'roof_area',
|
||
length: 'building_length',
|
||
building_perimeter: 'perimeter'
|
||
};
|
||
const CANONICAL_BASES = new Set([
|
||
'roof_area', 'base_area', 'building_length', 'building_width', 'perimeter', 'wall_height',
|
||
'wall_area', 'ridge', 'eaves', 'verges', 'hips', 'valleys', 'count', 'fixed', 'manual',
|
||
'roof_sides_x_length', 'count_x_wall_height'
|
||
]);
|
||
const canonicalBasis = basis => {
|
||
const key = typeof basis === 'string' ? basis.trim() : basis;
|
||
const normalized = BASIS_ALIASES[key] || key;
|
||
if (!CANONICAL_BASES.has(normalized)) throw new Error(`Ukendt geometribasis: ${basis || 'mangler'}`);
|
||
return normalized;
|
||
};
|
||
const required = (value, message) => {
|
||
if (value === null || value === undefined || !Number.isFinite(Number(value)) || Number(value) <= 0) throw new Error(message);
|
||
return Number(value);
|
||
};
|
||
|
||
const inferBasis = source => {
|
||
const explicit = source.geometry_basis ?? source.geometryBasis ?? source.geometry_multiplier ?? source.geometryMultiplier;
|
||
if (typeof explicit === 'string' && explicit.trim()) return canonicalBasis(explicit);
|
||
const text = `${source.name || ''} ${source.category || ''}`.toLocaleLowerCase('da-DK');
|
||
if (/nedløb/.test(text)) return 'count_x_wall_height';
|
||
if (/tagrende/.test(text)) return 'roof_sides_x_length';
|
||
if (/m²|m2/.test(source.unit || '')) {
|
||
if (/tag|undertag|tagpap|tegl|eternit/.test(text) && !/maling/.test(text)) return 'roof_area';
|
||
if (/gulv/.test(text)) return 'base_area';
|
||
return 'manual';
|
||
}
|
||
if (/løbende|lbm|meter/.test(source.unit || '')) {
|
||
if (/stern|vindskede|skotrende/.test(text)) return 'building_length';
|
||
return 'manual';
|
||
}
|
||
return 'fixed';
|
||
};
|
||
|
||
const REQUIREMENT_BY_BASIS = {
|
||
base_area: { key: 'baseArea', label: 'Grundareal', unit: 'm²', roof: false },
|
||
building_length: { key: 'length', label: 'Bygningslængde', unit: 'm', roof: false },
|
||
building_width: { key: 'width', label: 'Bygningsbredde', unit: 'm', roof: false },
|
||
perimeter: { key: 'perimeter', label: 'Omkreds', unit: 'm', roof: false },
|
||
wall_height: { key: 'wallHeight', label: 'Væghøjde', unit: 'm', roof: false },
|
||
wall_area: { key: 'wallArea', label: 'Vægareal', unit: 'm²', roof: false },
|
||
roof_sides_x_length: { key: 'length', label: 'Bygningslængde', unit: 'm', roof: false },
|
||
count_x_wall_height: { key: 'wallHeight', label: 'Væghøjde', unit: 'm', roof: false }
|
||
};
|
||
const ROOF_BASES = new Set(['roof_area', 'ridge', 'eaves', 'verges', 'hips', 'valleys']);
|
||
const hasCanonicalRoofGeometry = geometry => geometry?.roofGeometry?.provenance?.engine === 'roofReplacementGeometry'
|
||
&& positiveValue(geometry?.roofGeometry?.area?.roofSurface ?? geometry?.roofCoveringArea ?? geometry?.roof_covering_area) !== null;
|
||
|
||
export const getPackageGeometryBases = (source = {}) => {
|
||
const bases = new Set([inferBasis(source)]);
|
||
[
|
||
...(source.materials || []),
|
||
...(source.projectLines || []),
|
||
...(source.rentals || []),
|
||
...(source.referenceServices || []),
|
||
...(source.tasks || [])
|
||
].forEach(line => {
|
||
const explicit = explicitLineBasis(line);
|
||
if (explicit) bases.add(explicit);
|
||
});
|
||
return [...bases];
|
||
};
|
||
|
||
export const getPackageGeometryRequirements = (source = {}, geometry = {}) => {
|
||
const bases = getPackageGeometryBases(source);
|
||
if (bases.some(basis => ROOF_BASES.has(basis)) && !hasCanonicalRoofGeometry(geometry)) {
|
||
return [{ key: 'roofGeometry', label: 'kanonisk taggeometri', unit: '', roof: true }];
|
||
}
|
||
const normalized = normalizedGeometry(geometry);
|
||
const values = {
|
||
baseArea: normalized.baseArea,
|
||
length: normalized.length,
|
||
width: normalized.width,
|
||
perimeter: normalized.perimeter || ((normalized.length && normalized.width) ? 2 * (normalized.length + normalized.width) : null),
|
||
wallHeight: normalized.wallHeight,
|
||
wallArea: normalized.wallArea
|
||
};
|
||
return [...bases]
|
||
.map(basis => REQUIREMENT_BY_BASIS[basis])
|
||
.filter(Boolean)
|
||
.filter((requirement, index, items) => items.findIndex(item => item.key === requirement.key) === index)
|
||
.filter(requirement => !values[requirement.key]);
|
||
};
|
||
|
||
const geometryValue = (basis, geometry, { count } = {}) => {
|
||
const geo = normalizedGeometry(geometry);
|
||
const definitions = {
|
||
roof_area: [geo.roofArea, 'Tagareal mangler i Geometri', 'm²'],
|
||
base_area: [geo.baseArea, 'Grundareal mangler i Geometri', 'm²'],
|
||
building_length: [geo.length, 'Bygningslængde mangler i Geometri', 'm'],
|
||
building_width: [geo.width, 'Bygningsbredde mangler i Geometri', 'm'],
|
||
wall_height: [geo.wallHeight, 'Væghøjde mangler i Geometri', 'm'],
|
||
wall_area: [geo.wallArea, 'Vægareal mangler i Geometri', 'm²'],
|
||
ridge: [geo.ridge, 'Kiplængde mangler i Geometri', 'm'],
|
||
eaves: [geo.eaves, 'Tagfodslængde mangler i Geometri', 'm'],
|
||
verges: [geo.verges, 'Vindskedelængde mangler i Geometri', 'm'],
|
||
hips: [geo.hips, 'Gratlængde mangler i Geometri', 'm'],
|
||
valleys: [geo.valleys, 'Skotrendelængde mangler i Geometri', 'm']
|
||
};
|
||
if (basis === 'perimeter') {
|
||
const perimeter = geo.perimeter || ((geo.length && geo.width) ? 2 * (geo.length + geo.width) : null);
|
||
return { value: required(perimeter, 'Omkreds mangler i Geometri'), unit: 'm' };
|
||
}
|
||
if (basis === 'count') return { value: required(count, 'Antal mangler'), unit: 'stk.' };
|
||
const definition = definitions[basis];
|
||
if (!definition) throw new Error(`Ukendt geometribasis: ${basis || 'mangler'}`);
|
||
return { value: required(definition[0], definition[1]), unit: definition[2] };
|
||
};
|
||
|
||
const calculateBasis = (basisValue, geometry, options = {}) => {
|
||
const basis = canonicalBasis(basisValue);
|
||
const factor = required(options.factor ?? 1, 'Geometrifaktor skal være større end nul');
|
||
if (basis === 'manual') {
|
||
const quantity = round(options.manualQuantity ?? options.calculatedQuantity ?? options.fixedQuantity ?? 0);
|
||
return { basis, factor: 1, count: null, measuredValue: quantity, measuredUnit: null, calculatedQuantity: quantity, manualQuantity: quantity, quantityMode: 'manual', formula: `Manuelt tilrettet: ${da(quantity)}` };
|
||
}
|
||
if (basis === 'fixed') {
|
||
const quantity = required(options.fixedQuantity, 'Fast mængde mangler');
|
||
return { basis, factor: 1, count: null, measuredValue: quantity, measuredUnit: null, calculatedQuantity: round(quantity), manualQuantity: null, quantityMode: 'calculated', formula: `Fast mængde: ${da(quantity)}` };
|
||
}
|
||
if (basis === 'roof_sides_x_length') {
|
||
const length = geometryValue('building_length', geometry).value;
|
||
const quantity = round(length * factor);
|
||
return { basis, factor, count: null, measuredValue: length, measuredUnit: 'm', calculatedQuantity: quantity, manualQuantity: null, quantityMode: 'calculated', formula: `${da(factor)} tagsider × ${da(length)} m = ${da(quantity)} løbende m` };
|
||
}
|
||
if (basis === 'count_x_wall_height') {
|
||
const wallHeight = geometryValue('wall_height', geometry).value;
|
||
const count = required(options.count, 'Antal mangler');
|
||
const quantity = round(count * wallHeight * factor);
|
||
return { basis, factor, count, measuredValue: wallHeight, measuredUnit: 'm', calculatedQuantity: quantity, manualQuantity: null, quantityMode: 'calculated', formula: `${da(count)} stk. × ${da(wallHeight)} m = ${da(quantity)} løbende m` };
|
||
}
|
||
const measured = geometryValue(basis, geometry, options);
|
||
const quantity = round(measured.value * factor);
|
||
return { basis, factor, count: basis === 'count' ? measured.value : null, measuredValue: measured.value, measuredUnit: measured.unit, calculatedQuantity: quantity, manualQuantity: null, quantityMode: 'calculated', formula: `${da(measured.value)} ${measured.unit} × ${da(factor)} = ${da(quantity)} ${measured.unit}` };
|
||
};
|
||
|
||
const lineProvenance = (mode, geometry) => mode === 'manual'
|
||
|| (mode === 'base_area' && geometry?.siteGeometryMethod === 'manual_numeric')
|
||
? { source: 'manual', reason: geometry?.siteGeometryMethod === 'manual_numeric' ? 'manual_numeric' : 'builder_edit' }
|
||
: mode === 'base_area' && geometry?.siteGeometry?.schema === 'site_geometry_v1'
|
||
? { source: 'user_drawn', provider: 'openstreetmap', quality: 'unverified', geometryEngine: 'siteGeometry', geometryVersion: geometry.siteGeometry.revision, geometrySignature: geometry.siteGeometry.signature }
|
||
: {
|
||
source: String(mode).startsWith('six_house_') ? 'six_house_basis' : (mode === 'fixed' ? 'fixed' : 'geometry'),
|
||
geometryEngine: geometry?.provenance?.engine ?? null,
|
||
geometryVersion: geometry?.provenance?.version ?? geometry?.version ?? null,
|
||
geometrySignature: geometry?.signature ?? geometry?.sourceSignature ?? geometry?.provenance?.signature ?? geometry?.roofGeometry?.signature ?? null
|
||
};
|
||
|
||
const primaryGeometry = (source, geometry) => {
|
||
const basis = inferBasis(source);
|
||
const factor = toFinite(source.geometry_factor) ?? (basis === 'roof_sides_x_length' ? normalizedGeometry(geometry).roofSides : 1);
|
||
const count = toFinite(source.default_count) ?? 2;
|
||
const fixedQuantity = toFinite(source.default_quantity ?? source.quantity) ?? 1;
|
||
const result = calculateBasis(basis, geometry, { factor, count, fixedQuantity, manualQuantity: source.manualQuantity });
|
||
const unit = source.unit || (['roof_area', 'base_area', 'wall_area'].includes(basis) ? 'm²' : 'stk');
|
||
const normalizedUnit = String(unit).trim().toLocaleLowerCase('da-DK');
|
||
if (['stk', 'stk.', 'sæt'].includes(normalizedUnit)
|
||
&& (!Number.isInteger(result.calculatedQuantity) || result.calculatedQuantity < 1)) {
|
||
throw new Error('Mængden skal være et positivt heltal for stk. og sæt');
|
||
}
|
||
if (basis === 'manual') result.formula = `Manuelt tilrettet: ${da(result.calculatedQuantity)} ${unit}`;
|
||
if (basis === 'fixed') result.formula = `Fast mængde: ${da(result.calculatedQuantity)} ${unit}`;
|
||
return { ...result, unit, explicitBasis: Boolean(source.geometry_basis) };
|
||
};
|
||
|
||
const explicitLineBasis = line => {
|
||
if (line.quantityMode === 'manual' || line.quantity_mode === 'manual' || line.basisInherited) return null;
|
||
if (line.geometry_basis || line.geometryBasis) return canonicalBasis(line.geometry_basis || line.geometryBasis);
|
||
const multiplier = line.geometry_multiplier ?? line.geometryMultiplier;
|
||
if (typeof multiplier === 'string' && multiplier.trim()) return canonicalBasis(multiplier.trim());
|
||
return null;
|
||
};
|
||
|
||
const scaleMaterials = (lines, context, instanceId, { fixedByDefault = false } = {}) => (lines || []).map(line => {
|
||
const priorManual = line.quantityMode === 'manual' || line.quantity_mode === 'manual';
|
||
const explicit = explicitLineBasis(line);
|
||
const inheritsPrimary = !priorManual && !explicit && (!fixedByDefault || (context.primary.explicitBasis && context.primary.basis !== 'roof_area'));
|
||
const basis = priorManual ? 'manual' : (explicit || (inheritsPrimary ? context.primary.basis : 'fixed'));
|
||
const baseQuantity = toFinite(line.base_quantity ?? line.baseQuantity) ?? 1;
|
||
const wasteFactor = toFinite(line.waste_factor ?? line.wasteFactor) > 0 ? toFinite(line.waste_factor ?? line.wasteFactor) : 1;
|
||
const numericMultiplier = toFinite(line.geometry_factor ?? line.geometryFactor ?? (typeof line.geometry_multiplier === 'number' ? line.geometry_multiplier : null)) ?? 1;
|
||
const manualQuantity = toFinite(line.manualQuantity ?? line.manual_quantity ?? line.quantity);
|
||
const fixedQuantity = toFinite(line.fixed_quantity ?? line.fixedQuantity) ?? baseQuantity;
|
||
const calculation = inheritsPrimary ? context.primary : calculateBasis(basis, context.geometry, {
|
||
factor: numericMultiplier,
|
||
count: line.count ?? line.default_count ?? context.primary.count,
|
||
fixedQuantity,
|
||
manualQuantity,
|
||
calculatedQuantity: line.quantity
|
||
});
|
||
const physicalQuantityPerPrimary = toFinite(line.physicalQuantityPerPrimary ?? line.physical_quantity_per_primary);
|
||
const piecesPerPurchaseUnit = toFinite(line.piecesPerPurchaseUnit ?? line.pieces_per_purchase_unit);
|
||
const physicalQuantity = inheritsPrimary && physicalQuantityPerPrimary && piecesPerPurchaseUnit
|
||
? round(context.primary.calculatedQuantity * physicalQuantityPerPrimary)
|
||
: null;
|
||
const raw = physicalQuantity !== null
|
||
? Math.ceil(physicalQuantity / piecesPerPurchaseUnit)
|
||
: (!inheritsPrimary && (basis === 'manual' || basis === 'fixed')
|
||
? calculation.calculatedQuantity
|
||
: calculation.calculatedQuantity * baseQuantity * wasteFactor);
|
||
const quantity = round(raw);
|
||
const baseFormula = basis === 'manual'
|
||
? `Manuelt tilrettet: ${da(quantity)} ${line.unit || 'stk'}`
|
||
: basis === 'fixed'
|
||
? `Fast mængde: ${da(quantity)} ${line.unit || 'stk'}`
|
||
: `${calculation.formula} × ${da(baseQuantity)} grundmængde × ${da(wasteFactor)} spild = ${da(quantity)} ${line.unit || 'stk'}`;
|
||
const lengthPerPieceMeters = toFinite(line.lengthPerPieceMeters ?? line.length_per_piece_meters);
|
||
const formula = physicalQuantity !== null
|
||
? `${da(context.primary.calculatedQuantity)} nedløb × ${da(physicalQuantityPerPrimary)} holdere = ${da(physicalQuantity)} ${line.physicalUnit || 'stk'} · indkøb: ${da(quantity)} ${line.unit || 'sæt'} á ${da(piecesPerPurchaseUnit)} stk`
|
||
: line.componentType === 'pipe' && lengthPerPieceMeters
|
||
? `${baseFormula} · ${da(quantity)} stk × ${da(lengthPerPieceMeters)} m = ${da(quantity * lengthPerPieceMeters)} m rør`
|
||
: baseFormula;
|
||
return {
|
||
...line,
|
||
name: line.name || line.material_name,
|
||
materialId: line.materialId || line.material_id || null,
|
||
basis,
|
||
basisInherited: inheritsPrimary,
|
||
geometryBasis: basis,
|
||
geometryFactor: calculation.factor,
|
||
count: calculation.count,
|
||
measuredValue: calculation.measuredValue,
|
||
measuredUnit: calculation.measuredUnit || line.unit || 'stk',
|
||
baseQuantity,
|
||
physicalQuantityPerPrimary,
|
||
piecesPerPurchaseUnit,
|
||
physicalQuantity,
|
||
physicalUnit: line.physicalUnit ?? line.physical_unit ?? null,
|
||
wasteFactor,
|
||
rounding: { method: 'round', decimals: 3 },
|
||
quantity,
|
||
calculatedQuantity: quantity,
|
||
manualQuantity: !inheritsPrimary && basis === 'manual' ? quantity : null,
|
||
quantityMode: inheritsPrimary ? 'calculated' : calculation.quantityMode,
|
||
quantityProvenance: lineProvenance(basis, context.geometry),
|
||
formula,
|
||
unit: line.unit || 'stk',
|
||
unitPrice: toNumber(line.unitPrice ?? line.unit_price ?? line.price),
|
||
packageInstanceId: instanceId
|
||
};
|
||
});
|
||
|
||
const scaleTasks = (tasks, context, instanceId) => (tasks || []).map(task => {
|
||
const explicit = explicitLineBasis(task);
|
||
const timeUnit = task.timeUnit || task.time_unit || 'total';
|
||
const isFixedTime = ['total', 'per_project', 'per_hour'].includes(timeUnit);
|
||
const inheritsPrimary = task.quantityMode !== 'manual' && task.quantity_mode !== 'manual' && !explicit && !isFixedTime;
|
||
const basis = task.quantityMode === 'manual' || task.quantity_mode === 'manual'
|
||
? 'manual'
|
||
: (explicit || (inheritsPrimary ? context.primary.basis : 'fixed'));
|
||
const timeBasis = toFinite(task.timeBasis ?? task.time_per_unit ?? task.timePerM2 ?? task.timePerM ?? task.hours ?? task.fixedHours) ?? 0;
|
||
const manualHours = toFinite(task.manualHours ?? task.manual_hours ?? task.totalHours);
|
||
const calculation = inheritsPrimary ? context.primary : calculateBasis(basis, context.geometry, {
|
||
factor: toFinite(task.geometry_factor ?? task.geometryFactor) ?? 1,
|
||
count: task.count ?? task.default_count ?? context.primary.count,
|
||
fixedQuantity: timeBasis,
|
||
manualQuantity: manualHours,
|
||
calculatedQuantity: task.totalHours
|
||
});
|
||
const totalHours = round(inheritsPrimary ? calculation.calculatedQuantity * timeBasis : (basis === 'manual' || basis === 'fixed' ? calculation.calculatedQuantity : calculation.calculatedQuantity * timeBasis));
|
||
const rate = toNumber(task.rate, 580);
|
||
return {
|
||
...task,
|
||
name: task.name || task.task_name,
|
||
basis,
|
||
basisInherited: inheritsPrimary,
|
||
geometryBasis: basis,
|
||
geometryFactor: calculation.factor,
|
||
count: calculation.count,
|
||
measuredValue: calculation.measuredValue,
|
||
measuredUnit: calculation.measuredUnit || 'timer',
|
||
baseQuantity: timeBasis || 1,
|
||
wasteFactor: 1,
|
||
rounding: { method: 'round', decimals: 3 },
|
||
timeBasis,
|
||
timeUnit,
|
||
totalHours,
|
||
calculatedHours: totalHours,
|
||
manualHours: !inheritsPrimary && basis === 'manual' ? totalHours : null,
|
||
quantityMode: inheritsPrimary ? 'calculated' : calculation.quantityMode,
|
||
quantityProvenance: lineProvenance(basis, context.geometry),
|
||
formula: basis === 'manual' ? `Manuelt tilrettet: ${da(totalHours)} timer` : basis === 'fixed' ? `Fast tid: ${da(totalHours)} timer` : `${calculation.formula} × ${da(timeBasis)} timer = ${da(totalHours)} timer`,
|
||
rate,
|
||
totalCost: round(totalHours * rate),
|
||
packageInstanceId: instanceId
|
||
};
|
||
});
|
||
|
||
const scaleInstanceLines = (instance, primary, geometry) => {
|
||
const context = { primary, geometry };
|
||
const geometryEngine = geometry?.provenance?.engine ?? instance.geometry?.sourceGeometryEngine ?? null;
|
||
const geometryVersion = geometry?.version ?? geometry?.provenance?.version ?? instance.geometry?.sourceGeometryVersion ?? null;
|
||
const geometryEngineVersion = geometry?.provenance?.version ?? instance.geometry?.sourceGeometryEngineVersion ?? null;
|
||
const geometrySignature = geometry?.signature ?? geometry?.sourceSignature ?? geometry?.provenance?.signature ?? geometry?.roofGeometry?.signature ?? instance.geometry?.sourceGeometrySignature ?? null;
|
||
// A trust stamp is issued only by the backend after locking and comparing the
|
||
// canonical project_site_geometry row. Any client-side recalculation must drop
|
||
// the previous stamp so a changed geometry cannot retain stale authority.
|
||
const { siteGeometryTrust: _siteGeometryTrust, ...untrustedInstance } = instance;
|
||
return {
|
||
...untrustedInstance,
|
||
geometry: {
|
||
...primary,
|
||
sourceGeometryEngine: geometryEngine,
|
||
sourceGeometryEngineVersion: geometryEngineVersion,
|
||
sourceGeometryVersion: geometryVersion,
|
||
sourceGeometrySignature: geometrySignature
|
||
},
|
||
geometryData: geometry,
|
||
materials: scaleMaterials(instance.materials, context, instance.instanceId),
|
||
rentals: scaleMaterials(instance.rentals, context, instance.instanceId, { fixedByDefault: true }),
|
||
referenceServices: scaleMaterials(instance.referenceServices, context, instance.instanceId, { fixedByDefault: true }),
|
||
tasks: scaleTasks(instance.tasks, context, instance.instanceId)
|
||
};
|
||
};
|
||
|
||
export const createWorkspaceInstance = (source, geometry, { instanceId, position = 0, sixHouseBasis } = {}) => {
|
||
const id = instanceId || uniqueInstanceId(source.id || 'package');
|
||
const isRentalPackage = source.package_type === 'rental_service' || source.packageType === 'rental_service';
|
||
if (isRentalPackage && source.validation_status && source.validation_status !== 'verified') {
|
||
throw new Error('Udlejningspakken er ikke verificeret');
|
||
}
|
||
const rentalSourceLines = source.rentals || [];
|
||
if (isRentalPackage && rentalSourceLines.length === 0) throw new Error('Udlejningspakken mangler en prislinje');
|
||
rentalSourceLines.forEach(line => {
|
||
if (!String(line?.unit || '').trim()) throw new Error('Udlejningspakken mangler en eksplicit enhed');
|
||
if (!Number.isFinite(Number(line?.unitPrice ?? line?.unit_price)) || Number(line?.unitPrice ?? line?.unit_price) <= 0) {
|
||
throw new Error('Udlejningspakken mangler en positiv pris ekskl. moms');
|
||
}
|
||
});
|
||
if (isSixHouseBasisPackage(source) && !sixHouseBasis) {
|
||
throw new Error('Pakken kræver et udfyldt seks-hus-grundlag');
|
||
}
|
||
const normalizedSixHouseBasis = sixHouseBasis
|
||
? createSixHouseBasis(sixHouseBasis.houses || sixHouseBasis)
|
||
: null;
|
||
const primary = normalizedSixHouseBasis
|
||
? sixHousePrimary(source, normalizedSixHouseBasis)
|
||
: primaryGeometry(source, geometry);
|
||
const sourceLines = (source.materials || []).length > 0 ? source.materials : (source.projectLines || []);
|
||
const isReference = line => line?.lineType === 'reference_service' || String(line?.category || '').toLocaleLowerCase('da-DK').includes('referenceydelse');
|
||
const referenceLines = sourceLines.filter(isReference);
|
||
const rentalLines = [...sourceLines.filter(line => line?.isRental === true && !isReference(line)), ...(source.rentals || [])];
|
||
const materialLines = sourceLines.filter(line => line?.isRental !== true && !isReference(line));
|
||
const packageFormula = source.packageFormula ?? source.package_formula ?? source.formulaDefinition ?? {
|
||
id: `${source.geometry_basis || primary.basis}-v${source.version || 1}`,
|
||
version: source.version || 1,
|
||
expression: primary.formula,
|
||
signature: `package:${source.id}:v${source.version || 1}:${source.geometry_basis || primary.basis}:${source.geometry_factor ?? primary.factor}`
|
||
};
|
||
const instance = {
|
||
instanceId: id,
|
||
sourcePackageId: source.id,
|
||
sourcePackageVersion: source.version || 1,
|
||
packageType: source.package_type || source.packageType || null,
|
||
packageFormula,
|
||
name: source.name,
|
||
description: source.description || '',
|
||
category: source.category || 'Øvrige',
|
||
unit: primary.unit,
|
||
position,
|
||
quantityBasis: normalizedSixHouseBasis
|
||
? sixHouseQuantityBasis(source, normalizedSixHouseBasis)
|
||
: directQuantityBasis(primary),
|
||
...(normalizedSixHouseBasis ? {
|
||
sixHouseBasis: normalizedSixHouseBasis
|
||
} : {}),
|
||
geometry: primary,
|
||
geometryData: geometry,
|
||
materials: materialLines,
|
||
rentals: rentalLines,
|
||
referenceServices: referenceLines,
|
||
tasks: isRentalPackage ? [] : (source.tasks || []),
|
||
priceSource: source.price_source || source.priceSource || null
|
||
};
|
||
return scaleInstanceLines(instance, primary, geometry);
|
||
};
|
||
|
||
export const updateInstancePrimaryQuantity = (instance, value) => {
|
||
const quantity = Math.max(0, toNumber(value));
|
||
const unit = String(instance.geometry?.unit || instance.unit || '').trim().toLocaleLowerCase('da-DK');
|
||
if (['stk', 'stk.', 'sæt'].includes(unit) && (!Number.isInteger(quantity) || quantity < 1)) {
|
||
throw new Error('Mængden skal være et positivt heltal for stk. og sæt');
|
||
}
|
||
const primary = { ...instance.geometry, manualQuantity: quantity, calculatedQuantity: quantity, quantityMode: 'manual', basis: 'manual', formula: `Manuelt tilrettet: ${da(quantity)} ${instance.geometry?.unit || instance.unit || 'stk'}` };
|
||
return scaleInstanceLines({ ...instance, quantityBasis: directQuantityBasis(primary) }, primary, instance.geometryData || {});
|
||
};
|
||
|
||
export const updateInstanceSixHouseBasis = (instance, houses) => {
|
||
if (!isSixHouseBasisPackage(instance)) throw new Error('Pakken understøtter ikke seks-hus-grundlag');
|
||
const sixHouseBasis = createSixHouseBasis(houses?.houses || houses);
|
||
const primary = sixHousePrimary(instance, sixHouseBasis);
|
||
return scaleInstanceLines({
|
||
...instance,
|
||
sixHouseBasis,
|
||
quantityBasis: sixHouseQuantityBasis(instance, sixHouseBasis)
|
||
}, primary, instance.geometryData || {});
|
||
};
|
||
|
||
export const updateInstanceGeometryControl = (instance, field, value) => {
|
||
const geometry = instance.geometryData || {};
|
||
const source = {
|
||
name: instance.name,
|
||
category: instance.category,
|
||
unit: instance.geometry?.unit || instance.unit,
|
||
geometry_basis: instance.geometry?.basis,
|
||
geometry_factor: field === 'factor' ? value : instance.geometry?.factor,
|
||
default_count: field === 'count' ? value : instance.geometry?.count,
|
||
default_quantity: instance.geometry?.calculatedQuantity
|
||
};
|
||
const primary = primaryGeometry(source, geometry);
|
||
return scaleInstanceLines({ ...instance, quantityBasis: directQuantityBasis(primary) }, primary, geometry);
|
||
};
|
||
|
||
export const recalculateWorkspaceInstances = (instances, incomingGeometry) => (instances || []).map(instance => {
|
||
let geometry = incomingGeometry;
|
||
const manualNumericArea = instance.geometryData?.siteGeometryMethod === 'manual_numeric';
|
||
if (instance.geometry?.quantityMode === 'manual' && !manualNumericArea) return instance;
|
||
if (manualNumericArea) {
|
||
geometry = {
|
||
...(instance.geometryData || {}),
|
||
...(incomingGeometry || {}),
|
||
baseArea: instance.geometryData?.baseArea,
|
||
siteGeometryMethod: 'manual_numeric'
|
||
};
|
||
delete geometry.siteGeometry;
|
||
}
|
||
if (instance.sixHouseBasis) {
|
||
const basis = createSixHouseBasis(instance.sixHouseBasis.houses);
|
||
return scaleInstanceLines({
|
||
...instance,
|
||
sixHouseBasis: basis,
|
||
quantityBasis: sixHouseQuantityBasis(instance, basis)
|
||
}, sixHousePrimary(instance, basis), instance.geometryData || {});
|
||
}
|
||
const siteGeometry = manualNumericArea ? null : (geometry?.siteGeometry || instance.geometryData?.siteGeometry);
|
||
if (siteGeometry?.schema === 'site_geometry_v1') {
|
||
geometry = { ...(instance.geometryData || {}), ...geometry, siteGeometry, baseArea: siteGeometry.totals.groundAreaM2 };
|
||
}
|
||
const current = instance.geometry || {};
|
||
const primary = primaryGeometry({ name: instance.name, category: instance.category, unit: current.unit || instance.unit, geometry_basis: current.basis, geometry_factor: current.factor, default_count: current.count, default_quantity: current.calculatedQuantity }, geometry);
|
||
return scaleInstanceLines({ ...instance, quantityBasis: directQuantityBasis(primary) }, primary, geometry);
|
||
});
|
||
|
||
export const flattenWorkspaceInstances = instances => ({
|
||
materials: (instances || []).flatMap(instance => instance.materials || []),
|
||
rentals: (instances || []).flatMap(instance => instance.rentals || []),
|
||
referenceServices: (instances || []).flatMap(instance => instance.referenceServices || []),
|
||
tasks: (instances || []).flatMap(instance => instance.tasks || [])
|
||
});
|
||
|
||
export const isWorkspaceReady = instances => {
|
||
const flat = flattenWorkspaceInstances(instances);
|
||
const activePriceLines = [...flat.materials, ...flat.referenceServices, ...flat.rentals]
|
||
.filter(line => line?.active !== false && line?.isActive !== false && toNumber(line.quantity) > 0);
|
||
const activeTasks = flat.tasks.filter(task => task?.active !== false && task?.isActive !== false && toNumber(task.totalHours) > 0);
|
||
if (activePriceLines.some(line => toNumber(line.unitPrice) <= 0 || !String(line.unit || '').trim())) return false;
|
||
if (activeTasks.some(task => toNumber(task.rate) <= 0 || !String(task.timeUnit || task.unit || '').trim())) return false;
|
||
const actualMaterials = flat.materials.filter(line => line?.active !== false && line?.isActive !== false
|
||
&& line?.lineType !== 'reference_service' && !String(line?.category || '').toLocaleLowerCase('da-DK').includes('referenceydelse')
|
||
&& toNumber(line.quantity) > 0 && toNumber(line.unitPrice) > 0 && String(line.unit || '').trim());
|
||
const actualRentals = flat.rentals.filter(line => line?.active !== false && line?.isActive !== false
|
||
&& toNumber(line.quantity) > 0 && toNumber(line.unitPrice) > 0 && String(line.unit || '').trim());
|
||
const actualReferences = flat.referenceServices.filter(line => line?.active !== false && line?.isActive !== false
|
||
&& toNumber(line.quantity) > 0 && toNumber(line.unitPrice) > 0 && String(line.unit || '').trim());
|
||
if (actualMaterials.length === 0) return actualRentals.length > 0 || actualReferences.length > 0;
|
||
return activeTasks.length > 0;
|
||
};
|
||
|
||
export const workspaceTotals = instances => {
|
||
const flat = flattenWorkspaceInstances(instances);
|
||
const isReference = line => line?.lineType === 'reference_service' || String(line?.category || '').toLocaleLowerCase('da-DK').includes('referenceydelse');
|
||
const materials = round(flat.materials.filter(line => !isReference(line)).reduce((sum, line) => sum + toNumber(line.quantity) * toNumber(line.unitPrice), 0));
|
||
const references = round([...flat.referenceServices, ...flat.materials.filter(isReference)].reduce((sum, line) => sum + toNumber(line.quantity) * toNumber(line.unitPrice), 0));
|
||
const rentals = round(flat.rentals.reduce((sum, line) => sum + toNumber(line.quantity) * toNumber(line.unitPrice), 0));
|
||
const labor = round(flat.tasks.reduce((sum, task) => sum + toNumber(task.totalHours) * toNumber(task.rate), 0));
|
||
return { materials, references, rentals, labor, total: round(materials + references + rentals + labor) };
|
||
};
|