Files
tilbudgivern/frontend/src/utils/smartPackageWorkspace.js
T
f37adae2cb
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: deliver auditable Smart Pakke quote flow and free site geometry (#31)
* 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]>
2026-09-26 22:39:18 +02:00

630 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) };
};