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) }; };