Files
tilbudgivern/backend/src/services/genericQuoteSnapshotService.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

655 lines
41 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.
'use strict';
const crypto = require('crypto');
const { stableStringify } = require('./roofQuoteSnapshotService');
const { calculateQuoteEconomics } = require('./quoteEconomicsService');
const { assertCustomerDocumentLanguage } = require('./customerDocumentLanguage');
const { normalizeGeometry } = require('./siteGeometryService');
const SCHEMA = 'generic_quote_snapshot_v1';
const WORKSPACE_OVERRIDE_SOURCE = 'workspace_server';
const MAX_SOURCE_AGE_DAYS = 30;
const DAY_MS = 86400000;
const MAX_GENERIC_QUOTE_TEXT_LENGTH = 5000;
const fail = (code, message, status = 422, details = {}) => {
throw Object.assign(new Error(message), { code, status, ...details });
};
const positive = value => typeof value === 'number' && Number.isFinite(value) && value > 0 && value <= 1e9;
const text = value => typeof value === 'string' && value.trim().length > 0;
const money = value => Math.round(value * 100) / 100;
const sign = artifact => {
const serializedArtifact = stableStringify(artifact);
return { artifact: JSON.parse(serializedArtifact), serializedArtifact,
signature: crypto.createHash('sha256').update(serializedArtifact).digest('hex') };
};
const active = line => ![false, 0, '0', 'false'].includes(line.active)
&& ![false, 0, '0', 'false'].includes(line.isActive ?? line.is_active);
const sameNumber = (left, right) => Math.abs(Number(left) - Number(right)) <= 0.001;
const sameSiteArea = (left, right) => typeof left === 'number' && Number.isFinite(left)
&& typeof right === 'number' && Number.isFinite(right) && Math.abs(left - right) <= 0.000001;
const sameThousandth = (left, right) => Math.round(Number(left) * 1000) === Math.round(Number(right) * 1000);
const lineId = line => String(line?.sourceLineId ?? line?.source_line_id ?? line?.id ?? '').trim();
const sourcePackageId = line => Number(line?.package_id ?? line?.sourcePackageId ?? line?.source_package_id);
const lineName = line => line?.name ?? line?.material_name ?? line?.task_name;
const sourceKind = line => line?.kind;
const packageCatalogKey = source => String(source?.catalog_key || source?.price_source_value || '').trim();
const isBaseAreaBasis = value => ['base_area', 'ground_area'].includes(String(value || '').trim().toLowerCase());
const storedSiteGeometry = row => {
if (!row) return null;
try {
return typeof row.geometry_json === 'string' ? JSON.parse(row.geometry_json) : row.geometry_json;
} catch (_error) {
return null;
}
};
const validateSiteGeometryTrust = (instance, projectId, source, sourceLines, authoritativeRow) => {
const trust = instance?.siteGeometryTrust;
const embedded = instance?.geometryData?.siteGeometry;
const lines = ['materials', 'tasks', 'rentals', 'referenceServices'].flatMap(kind => instance?.[kind] || []);
const lineProvenances = line => [line?.quantityProvenance, line?.quantity_provenance]
.filter(value => value && typeof value === 'object' && !Array.isArray(value));
const lineClaims = lines.filter(line => lineProvenances(line).some(provenance => (
provenance.source === 'user_drawn' || provenance.geometryEngine === 'siteGeometry'
|| provenance.provider === 'openstreetmap'
|| provenance.siteGeometryRevision != null || provenance.siteGeometrySignature != null
|| provenance.siteGeometryProjectId != null
)) || line?.siteGeometryRevision != null || line?.siteGeometrySignature != null
|| line?.siteGeometryProjectId != null);
const requiresSiteGeometry = isBaseAreaBasis(source?.geometry_basis)
|| sourceLines.some(line => isBaseAreaBasis(line.geometry_basis ?? line.geometry_multiplier));
const stale = () => fail('STALE_SITE_GEOMETRY', 'Persisted site geometry provenance is stale or inconsistent', 409);
if (!trust && !embedded && lineClaims.length === 0
&& instance?.geometryData?.siteGeometryMethod === 'manual_numeric' && requiresSiteGeometry) {
const requestedArea = instance.geometryData.baseArea;
const baselineArea = instance.quantityBasis?.mode === 'direct' ? instance.quantityBasis.quantity : null;
if (!positive(requestedArea) || !positive(baselineArea)) stale();
return { area: baselineArea, trust: null, manualArea: true, requestedArea };
}
const authoritative = storedSiteGeometry(authoritativeRow);
if (requiresSiteGeometry || trust || embedded || lineClaims.length) {
const rowRevision = Number(authoritativeRow?.revision);
if (!authoritative || Number(authoritativeRow?.project_id) !== Number(projectId)
|| !Number.isSafeInteger(rowRevision) || rowRevision < 1
|| rowRevision !== Number(authoritative.revision)
|| authoritative.schema !== 'site_geometry_v1' || !text(authoritative.signature)) stale();
let recomputedAuthoritative;
try { recomputedAuthoritative = normalizeGeometry({ geometry: authoritative.areas?.[0]?.geometry }); }
catch (_error) { stale(); }
const recalculatedArea = recomputedAuthoritative?.totals?.groundAreaM2;
const recalculatedPerimeter = recomputedAuthoritative?.totals?.perimeterM;
if (!sameSiteArea(recalculatedArea, authoritative.totals?.groundAreaM2)
|| !sameSiteArea(recalculatedArea, authoritative.areas?.[0]?.groundAreaM2)
|| !sameSiteArea(recalculatedPerimeter, authoritative.totals?.perimeterM)
|| !sameSiteArea(recalculatedPerimeter, authoritative.areas?.[0]?.perimeterM)) stale();
}
if (!trust && !embedded && lineClaims.length === 0) {
if (requiresSiteGeometry) stale();
return null;
}
if (trust?.source !== 'project_site_geometry' || Number(trust.projectId) !== Number(projectId)
|| embedded?.schema !== 'site_geometry_v1'
|| embedded.projectId !== undefined && Number(embedded.projectId) !== Number(projectId)
|| trust.revision !== embedded.revision || trust.signature !== embedded.signature
|| instance.geometryData?.baseArea !== undefined && !sameSiteArea(instance.geometryData.baseArea, trust.groundAreaM2)
|| instance.siteGeometryRevision !== undefined && instance.siteGeometryRevision !== trust.revision
|| instance.siteGeometrySignature !== undefined && instance.siteGeometrySignature !== trust.signature
|| instance.siteGeometryProjectId !== undefined && Number(instance.siteGeometryProjectId) !== Number(projectId)
|| Number(authoritativeRow.revision) !== Number(trust.revision)
|| authoritative.signature !== trust.signature
|| stableStringify(authoritative) !== stableStringify(embedded)) stale();
let recomputed;
try { recomputed = normalizeGeometry({ geometry: embedded.areas?.[0]?.geometry }); }
catch (_error) { stale(); }
const area = recomputed?.totals?.groundAreaM2;
if (!sameSiteArea(area, embedded.totals?.groundAreaM2)
|| !sameSiteArea(area, embedded.areas?.[0]?.groundAreaM2)
|| !sameSiteArea(area, trust.groundAreaM2)) stale();
const factor = Number(source?.geometry_factor);
const expectedBasis = area * (Number.isFinite(factor) && factor > 0 ? factor : 1);
if (isBaseAreaBasis(source?.geometry_basis)
&& (instance.quantityBasis?.mode !== 'direct' || !sameSiteArea(instance.quantityBasis.quantity, expectedBasis))) stale();
for (const line of lineClaims) {
const provenances = lineProvenances(line);
const projects = [line.siteGeometryProjectId,
...provenances.flatMap(provenance => [provenance.projectId, provenance.siteGeometryProjectId])]
.filter(value => value != null);
const revisions = [line.geometryVersion, line.siteGeometryRevision,
...provenances.flatMap(provenance => [provenance.geometryVersion, provenance.siteGeometryRevision])]
.filter(value => value != null);
const signatures = [line.geometrySignature, line.siteGeometrySignature,
...provenances.flatMap(provenance => [provenance.geometrySignature, provenance.siteGeometrySignature])]
.filter(value => value != null);
if (projects.some(value => Number(value) !== Number(projectId))
|| !revisions.length || revisions.some(value => value !== trust.revision)
|| !signatures.length || signatures.some(value => value !== trust.signature)) stale();
}
return { area, trust: {
source: 'project_site_geometry', projectId: Number(projectId), revision: trust.revision,
signature: trust.signature, groundAreaM2: area
} };
};
const assertFreshSourceDate = (value, now, code = 'STALE_MATERIAL_PRICE') => {
const timestamp = value instanceof Date ? value.getTime() : Date.parse(value);
const current = now instanceof Date ? now.getTime() : Date.parse(now);
if (!Number.isFinite(timestamp) || !Number.isFinite(current) || timestamp > current
|| current - timestamp > MAX_SOURCE_AGE_DAYS * DAY_MS) {
fail(code, `Price source date must be present, non-future and at most ${MAX_SOURCE_AGE_DAYS} days old`);
}
return new Date(timestamp).toISOString();
};
const manualAudit = (line, now, serverAudit, requestedValue) => {
const provenance = line?.quantityProvenance ?? line?.quantity_provenance ?? line?.overrideProvenance ?? {};
const reason = provenance.reason ?? line?.overrideReason ?? line?.override_reason;
const audit = serverAudit || line?.manualOverride || {};
const author = serverAudit?.author ?? audit.author;
const timestamp = serverAudit?.timestamp ?? audit.timestamp;
const parsedTimestamp = Date.parse(timestamp);
const currentTimestamp = now instanceof Date ? now.getTime() : Date.parse(now);
const serverOwned = Boolean(serverAudit) || audit.source === WORKSPACE_OVERRIDE_SOURCE
&& provenance.source === WORKSPACE_OVERRIDE_SOURCE
&& audit.reason === provenance.reason
&& audit.author === provenance.author
&& audit.timestamp === provenance.timestamp
&& sameNumber(audit.requestedValue, provenance.requestedValue)
&& sameNumber(audit.requestedValue, requestedValue);
return serverOwned && text(reason) && text(author) && text(timestamp) && Number.isFinite(parsedTimestamp)
&& Number.isFinite(currentTimestamp) && parsedTimestamp <= currentTimestamp
? {
source: WORKSPACE_OVERRIDE_SOURCE,
reason: reason.trim(),
requestedValue: Number(requestedValue),
author: author.trim(),
timestamp: new Date(timestamp).toISOString()
}
: null;
};
const assertSixHouseBasis = (source, instance, normalizedBasis) => {
const key = packageCatalogKey(source);
const metric = key.startsWith('gutter-')
? { field: 'gutterLength', unit: 'løbende m' }
: key.startsWith('downpipe-') ? { field: 'downpipeCount', unit: 'stk' } : null;
if (!metric) return;
const basis = instance.sixHouseBasis;
if (normalizedBasis.mode !== 'per_house_breakdown' || normalizedBasis.houses.length !== 6
|| basis?.kind !== 'six_house_gutter_downpipe' || basis?.version !== 1 || !Array.isArray(basis.houses)
|| basis.houses.length !== 6 || normalizedBasis.unit !== metric.unit) {
fail('INVALID_SIX_HOUSE_BASIS', 'Gutter and downpipe packages require exactly six named houses');
}
const ids = new Set(); const names = new Set();
let total = 0;
basis.houses.forEach((house, index) => {
const id = String(house?.id || '').trim();
const name = String(house?.name || '').trim();
const value = house?.[metric.field];
const normalizedId = id.toLocaleLowerCase('da-DK');
const normalizedName = name.toLocaleLowerCase('da-DK');
const quantityHouse = normalizedBasis.houses[index];
if (!id || !name || ids.has(normalizedId) || names.has(normalizedName) || !Number.isFinite(value)
|| value < 0 || metric.field === 'downpipeCount' && (!Number.isInteger(value)
|| !Number.isInteger(quantityHouse.quantity) || quantityHouse.quantity !== value)
|| quantityHouse.id !== id || quantityHouse.name !== name
|| metric.field !== 'downpipeCount' && !sameThousandth(quantityHouse.quantity, value)) {
fail('INVALID_SIX_HOUSE_BASIS', 'Six-house identities, names and package quantities must match exactly');
}
ids.add(normalizedId); names.add(normalizedName); total += value;
});
const sourceTotal = basis.subtotals?.[metric.field];
if (!positive(total) || !sameThousandth(total, sourceTotal) || !sameThousandth(total, normalizedBasis.total)
|| metric.field === 'downpipeCount' && (!Number.isInteger(sourceTotal) || !Number.isInteger(normalizedBasis.total))) {
fail('INVALID_SIX_HOUSE_BASIS', 'Six-house package totals must reconcile exactly');
}
};
const canonicalizeLines = ({ instance, source, sourcePackageLines, materialPrices, now, overrideAudit, siteGeometry }) => {
const authoritative = sourcePackageLines.filter(line => sourcePackageId(line) === Number(source.id));
const submitted = new Map();
for (const kind of ['materials', 'tasks', 'rentals', 'referenceServices']) {
for (const line of (instance[kind] || []).filter(active)) {
if (line.packageInstanceId !== undefined && line.packageInstanceId !== instance.instanceId
|| line.sourcePackageId !== undefined && Number(line.sourcePackageId) !== Number(instance.sourcePackageId)
|| line.sourcePackageVersion !== undefined && Number(line.sourcePackageVersion) !== Number(instance.sourcePackageVersion)) {
fail('INVALID_LINE_SOURCE', 'Line source does not match its workspace instance');
}
const key = `${kind}:${lineId(line)}`;
if (!lineId(line) || submitted.has(key)) fail('STALE_PACKAGE_CHILDREN', 'Package child identities must be unique and current');
submitted.set(key, line);
}
}
const authoritativeKeys = new Set(authoritative.map(line => `${sourceKind(line)}:${lineId(line)}`));
if (authoritative.length !== submitted.size || [...submitted.keys()].some(key => !authoritativeKeys.has(key))) {
fail('STALE_PACKAGE_CHILDREN', 'Package children changed or no longer exist; reload the workspace');
}
const result = { materials: [], tasks: [], rentals: [], references: [] };
const sixHousePackage = /^(gutter|downpipe)-/.test(packageCatalogKey(source));
const instanceBasisQuantity = instance.quantityBasis?.mode === 'per_house_breakdown'
? instance.quantityBasis.total : instance.quantityBasis?.mode === 'per_house'
? instance.quantityBasis.houseCount * instance.quantityBasis.quantityPerHouse : instance.quantityBasis?.quantity;
const primaryUsesSiteArea = isBaseAreaBasis(source.geometry_basis);
authoritative.forEach(sourceLine => {
const kind = sourceKind(sourceLine);
if (!['materials', 'tasks', 'rentals', 'referenceServices'].includes(kind)) {
fail('STALE_PACKAGE_CHILDREN', 'Unknown authoritative package child type');
}
const workspaceLine = submitted.get(`${kind}:${lineId(sourceLine)}`);
if (!workspaceLine) fail('STALE_PACKAGE_CHILDREN', 'A current package child is missing from the workspace');
const labor = kind === 'tasks';
const timeUnit = sourceLine.time_unit || 'total';
const explicitSiteArea = isBaseAreaBasis(sourceLine.geometry_basis ?? sourceLine.geometry_multiplier);
const lineScalesFromPrimary = labor
? ['per_meter', 'per_m2', 'per_sqm', 'per_piece'].includes(timeUnit)
: Boolean(sourceLine.geometry_multiplier && sourceLine.geometry_multiplier !== 'fixed');
const usesSiteArea = Boolean(siteGeometry && (explicitSiteArea || primaryUsesSiteArea && lineScalesFromPrimary));
const basisQuantity = explicitSiteArea ? siteGeometry?.area : instanceBasisQuantity;
const sourceQuantity = labor
? Number(['per_meter', 'per_m2', 'per_sqm', 'per_piece'].includes(timeUnit)
? sourceLine.time_per_unit ?? sourceLine.hours ?? sourceLine.estimated_hours
: sourceLine.hours ?? sourceLine.estimated_hours ?? sourceLine.time_per_unit)
: Number(sourceLine.base_quantity ?? sourceLine.quantity);
const sourceUnitPrice = labor ? Number(sourceLine.rate)
: kind === 'materials' ? Number(workspaceLine.unitPrice) : Number(sourceLine.unit_price);
const submittedQuantity = labor ? workspaceLine.totalHours : workspaceLine.quantity;
const submittedPrice = labor ? workspaceLine.rate : workspaceLine.unitPrice;
if (!positive(sourceQuantity) || !positive(sourceUnitPrice) || !positive(submittedQuantity) || !positive(submittedPrice)) {
fail('INVALID_LINE', 'Authoritative package children require valid quantity and price data');
}
let rawData = {};
if (kind === 'materials') {
try { rawData = typeof sourceLine.excel_raw_data === 'string' ? JSON.parse(sourceLine.excel_raw_data) : sourceLine.excel_raw_data || {}; }
catch (_error) { fail('STALE_PACKAGE_CHILDREN', 'Authoritative package child conversion data is invalid'); }
}
const physicalPerPrimary = Number(rawData.physicalQuantityPerPrimary);
const piecesPerUnit = Number(rawData.piecesPerPurchaseUnit);
const scaleFromBasis = primaryBasis => {
if (labor && ['per_meter', 'per_m2', 'per_sqm', 'per_piece'].includes(timeUnit) && positive(primaryBasis)) {
return sourceQuantity * primaryBasis;
}
if (kind === 'materials' && sixHousePackage && positive(primaryBasis)) {
return positive(physicalPerPrimary) && positive(piecesPerUnit)
? Math.ceil(primaryBasis * physicalPerPrimary / piecesPerUnit)
: sourceQuantity * primaryBasis * Number(sourceLine.waste_factor || 1);
}
if (!labor && sourceLine.geometry_multiplier && sourceLine.geometry_multiplier !== 'fixed' && positive(primaryBasis)) {
return sourceQuantity * primaryBasis * Number(sourceLine.waste_factor || 1);
}
return sourceQuantity;
};
let quantity = scaleFromBasis(basisQuantity);
let override = null;
let effectiveBasisQuantity = basisQuantity;
if (siteGeometry?.manualArea && usesSiteArea) {
const factor = Number(source.geometry_factor);
const requestedBasisQuantity = explicitSiteArea
? siteGeometry.requestedArea
: siteGeometry.requestedArea * (Number.isFinite(factor) && factor > 0 ? factor : 1);
const requestedQuantity = scaleFromBasis(requestedBasisQuantity);
if (!sameNumber(submittedQuantity, requestedQuantity)) {
fail('INVALID_MANUAL_OVERRIDE', 'Manual site-area lines must match the requested noncanonical area');
}
override = manualAudit(workspaceLine, now, overrideAudit, submittedQuantity);
if (!override) fail('INVALID_MANUAL_OVERRIDE', 'Manual quantity/hour overrides require a reason and server-owned audit');
quantity = Number(submittedQuantity);
effectiveBasisQuantity = requestedBasisQuantity;
} else if (!sameNumber(submittedQuantity, quantity)) {
override = manualAudit(workspaceLine, now, overrideAudit, submittedQuantity);
if (!override) fail('INVALID_MANUAL_OVERRIDE', 'Manual quantity/hour overrides require a reason and server-owned audit');
quantity = Number(submittedQuantity);
}
if (labor && !sameNumber(submittedPrice, sourceUnitPrice)) {
fail('INVALID_MANUAL_OVERRIDE', 'Labor rates are server-owned and cannot be overridden');
}
if (!labor && kind !== 'materials' && !sameNumber(submittedPrice, sourceUnitPrice)) {
fail('STALE_SOURCE_PRICE', 'Rental and reference prices are server-owned and must be current');
}
let unitPrice = sourceUnitPrice;
let priceVersion = null;
let sourceDate = sourceLine.source_date;
let unit = sourceLine.unit;
let priceSource = sourceLine.price_source || null;
if (kind === 'materials') {
const current = materialPrices.find(row => Number(row.material_id) === Number(sourceLine.material_id));
if (!current || !positive(current.price_version) || !positive(Number(current.current_unit_price))
|| !text(current.current_unit) || workspaceLine.priceVersion != null
&& Number(workspaceLine.priceVersion) !== Number(current.price_version)) {
fail('STALE_MATERIAL_PRICE', 'Material price version must be current');
}
sourceDate = current.source_date;
unitPrice = Number(current.current_unit_price);
priceVersion = Number(current.price_version);
unit = current.current_unit;
priceSource = current.price_source || current.supplier_name || null;
if (!sameNumber(submittedPrice, unitPrice) || workspaceLine.materialId != null
&& Number(workspaceLine.materialId) !== Number(sourceLine.material_id)) {
fail('STALE_MATERIAL_PRICE', 'Workspace material price or identity is stale');
}
}
if (!labor && workspaceLine.unit !== unit) {
fail(kind === 'materials' ? 'STALE_MATERIAL_PRICE' : 'STALE_SOURCE_PRICE',
'Workspace pricing unit is stale');
}
const priceSourceUpdatedAt = assertFreshSourceDate(sourceDate, now,
kind === 'materials' ? 'STALE_MATERIAL_PRICE' : 'STALE_SOURCE_PRICE');
const geometryBasis = sixHousePackage
? (packageCatalogKey(source).startsWith('gutter-') ? 'six_house_gutter_length' : 'six_house_downpipe_count')
: sourceLine.geometry_basis || sourceLine.geometry_multiplier
|| (labor && timeUnit !== 'total' ? instance.quantityBasis.mode : 'fixed');
const basisInherited = sixHousePackage || labor && timeUnit !== 'total'
|| Boolean(sourceLine.geometry_multiplier && sourceLine.geometry_multiplier !== 'fixed');
const physicalQuantity = positive(physicalPerPrimary) ? effectiveBasisQuantity * physicalPerPrimary : null;
const calculatedProvenance = {
source: 'authoritative_package_child', sourceLineId: lineId(sourceLine),
sourcePackageId: Number(source.id), sourcePackageVersion: Number(source.version),
...(usesSiteArea && siteGeometry.trust ? {
siteGeometryProjectId: siteGeometry.trust.projectId,
siteGeometryRevision: siteGeometry.trust.revision,
siteGeometrySignature: siteGeometry.trust.signature
} : {})
};
const canonical = {
id: /^\d+$/.test(lineId(sourceLine)) ? Number(lineId(sourceLine)) : lineId(sourceLine),
name: lineName(sourceLine), packageInstanceId: instance.instanceId,
sourcePackageId: instance.sourcePackageId, sourcePackageVersion: instance.sourcePackageVersion,
...(labor
? { totalHours: quantity, rate: unitPrice, timeUnit, lineTotal: money(quantity * unitPrice) }
: { quantity, unit, unitPrice, priceSource, priceSourceUpdatedAt, lineTotal: money(quantity * unitPrice) }),
...(sourceLine.material_id != null ? { materialId: Number(sourceLine.material_id), priceVersion } : {}),
geometryBasis, measuredValue: positive(effectiveBasisQuantity) ? effectiveBasisQuantity : sourceQuantity,
measuredUnit: instance.quantityBasis.unit, baseQuantity: sourceQuantity, basisInherited,
wasteFactor: Number(sourceLine.waste_factor || 1),
...(labor ? { timeBasis: sourceQuantity } : {}),
...(positive(physicalPerPrimary) && positive(piecesPerUnit) ? {
physicalQuantityPerPrimary: physicalPerPrimary, piecesPerPurchaseUnit: piecesPerUnit,
physicalQuantity, physicalUnit: rawData.physicalUnit || 'stk'
} : {}),
...(rawData.componentType ? { componentType: rawData.componentType } : {}),
...(positive(Number(rawData.lengthPerPieceMeters)) ? { lengthPerPieceMeters: Number(rawData.lengthPerPieceMeters) } : {}),
quantityMode: override ? 'manual' : 'calculated',
quantityProvenance: override ? { source: 'manual', ...override } : calculatedProvenance,
formula: positive(physicalPerPrimary) && positive(piecesPerUnit)
? `${effectiveBasisQuantity} × ${physicalPerPrimary} = ${physicalQuantity} ${rawData.physicalUnit || 'stk'}; ${quantity} × ${piecesPerUnit} pr. indkøbsenhed`
: positive(effectiveBasisQuantity) && !sameNumber(effectiveBasisQuantity, 1)
? `${effectiveBasisQuantity} × ${sourceQuantity} = ${quantity}` : `Fast mængde: ${quantity}`,
...(override ? { manualOverride: override } : {})
};
result[kind === 'referenceServices' ? 'references' : kind].push(canonical);
});
return result;
};
const canonicalizeWorkspaceInstance = ({ instance, source, sourcePackageLines, materialPrices,
now = new Date(), overrideAudit, siteGeometry = null }) => {
const quantityBasis = normalizeQuantityBasis(instance.quantityBasis);
assertSixHouseBasis(source, instance, quantityBasis);
const canonical = canonicalizeLines({ instance: { ...instance, quantityBasis }, source,
sourcePackageLines, materialPrices, now, overrideAudit, siteGeometry });
return { ...instance, quantityBasis, materials: canonical.materials, tasks: canonical.tasks,
rentals: canonical.rentals, referenceServices: canonical.references };
};
const assertFiniteTree = value => {
if (typeof value === 'number' && !Number.isFinite(value)) fail('INVALID_NUMBER', 'Signed values must be finite');
if (value && typeof value === 'object') Object.values(value).forEach(assertFiniteTree);
};
const roofList = value => {
if (value == null || value === '') return [];
const parsed = parseStored(value, []);
if (!Array.isArray(parsed)) fail('INVALID_SOURCE_PACKAGE', 'Invalid source compatibility data');
return parsed;
};
const normalizeQuantityBasis = basis => {
if (!basis || !text(basis.unit)) fail('INVALID_QUANTITY_BASIS', 'A direct or per-house quantity basis is required');
if (basis.mode === 'direct' && positive(basis.quantity)) {
return { mode: 'direct', quantity: basis.quantity, unit: basis.unit.trim() };
}
if (basis.mode === 'per_house' && Number.isInteger(basis.houseCount) && positive(basis.houseCount)
&& positive(basis.quantityPerHouse) && positive(basis.houseCount * basis.quantityPerHouse)) {
return { mode: 'per_house', houseCount: basis.houseCount, quantityPerHouse: basis.quantityPerHouse, unit: basis.unit.trim() };
}
if (basis.mode === 'per_house_breakdown' && Array.isArray(basis.houses) && basis.houses.length > 0) {
const ids = new Set();
const houses = basis.houses.map(house => {
const id = String(house?.id || '').trim();
const name = String(house?.name || '').trim();
const quantity = house?.quantity;
const normalizedId = id.toLocaleLowerCase('da-DK');
if (!id || !name || ids.has(normalizedId)
|| typeof quantity !== 'number' || !Number.isFinite(quantity) || quantity < 0 || quantity > 1e9) {
fail('INVALID_QUANTITY_BASIS', 'Every house needs a unique identity, name and non-negative quantity');
}
ids.add(normalizedId);
return { id, name, quantity };
});
const total = houses.reduce((sum, house) => sum + house.quantity, 0);
if (!positive(total) || !positive(basis.total) || Math.abs(total - basis.total) > 0.001) {
fail('INVALID_QUANTITY_BASIS', 'Per-house quantities must reconcile exactly to the total');
}
return { mode: 'per_house_breakdown', houses, total: basis.total, unit: basis.unit.trim() };
}
fail('INVALID_QUANTITY_BASIS', 'A direct or per-house quantity basis is required');
};
function buildSnapshot(input = {}) {
assertFiniteTree(input);
const { project, workspace, sourcePackages = [], sourcePackageLines = [], materialPrices = [], quoteText,
reservations = [], now = new Date(), authoritativeSiteGeometry = null } = input;
const missingCustomerText = !text(quoteText);
const otherIncomplete = !positive(project?.id) || !text(project?.customer_number) || !text(project?.customer_name)
|| workspace?.projectId !== project.id || !Number.isInteger(workspace?.version) || workspace.version < 1
|| !Array.isArray(workspace.instances) || !workspace.instances.length
|| !Array.isArray(reservations) || reservations.some(value => !text(value));
if (otherIncomplete) {
fail('GENERIC_QUOTE_INCOMPLETE', 'Project, customer, workspace and customer text are required');
}
const lines = { materials: [], tasks: [], rentals: [], references: [] };
const usedSources = new Map();
const ids = new Set();
const instances = workspace.instances.map(instance => {
if (!text(instance.instanceId) || ids.has(instance.instanceId)) fail('INVALID_INSTANCE', 'Unique workspace instance required');
ids.add(instance.instanceId);
const source = sourcePackages.find(row => Number(row.id) === instance.sourcePackageId);
if (!source || Number(source.is_active) !== 1 || source.validation_status !== 'verified'
|| !Number.isInteger(instance.sourcePackageVersion) || instance.sourcePackageVersion < 1
|| Number(source.version) !== instance.sourcePackageVersion) {
fail('STALE_PACKAGE_VERSION', 'Source package must be active, verified and current');
}
if (source.package_type === 'complete_offer'
|| source.replacement_scope || roofList(source.compatible_roof_materials).length || roofList(source.allowed_roof_forms).length) {
fail('ROOF_PACKAGE_REQUIRES_ROOF_SNAPSHOT', 'Roof packages require the strict roof snapshot');
}
usedSources.set(source.id, source);
for (const kind of ['materials', 'tasks', 'rentals', 'referenceServices']) {
if (instance[kind] !== undefined && !Array.isArray(instance[kind])) fail('INVALID_LINES', 'Lines must be arrays');
}
const authoritativeLines = sourcePackageLines.filter(line => sourcePackageId(line) === Number(source.id));
const siteGeometry = validateSiteGeometryTrust(instance, project.id, source, authoritativeLines,
authoritativeSiteGeometry);
const normalized = canonicalizeWorkspaceInstance({ instance, source, sourcePackageLines, materialPrices, now, siteGeometry });
const canonical = { materials: normalized.materials, tasks: normalized.tasks, rentals: normalized.rentals,
references: normalized.referenceServices };
Object.keys(lines).forEach(kind => lines[kind].push(...canonical[kind]));
return normalized;
});
if (!Object.values(lines).some(rows => rows.length)) fail('EMPTY_QUOTE', 'Quote needs active lines');
const sum = rows => money(rows.reduce((total, line) => total + line.lineTotal, 0));
const economics = calculateQuoteEconomics({ materialTotal: sum(lines.materials), laborTotal: sum(lines.tasks),
rentalTotal: sum(lines.rentals), referenceTotal: sum(lines.references) });
if (missingCustomerText) {
fail('GENERIC_QUOTE_INCOMPLETE', 'Project, customer, workspace and customer text are required', 422,
{ reason: 'MISSING_CUSTOMER_TEXT' });
}
return sign({ schema: SCHEMA, customerProject: project, workspace: { ...workspace, instances },
sourcePackages: [...usedSources.values()], lines, economics, quoteText, reservations });
}
const parseStored = (value, fallback) => {
if (value == null || value === '') return fallback;
if (typeof value === 'object') return value;
try { return JSON.parse(value); } catch (_error) { fail('INVALID_STORED_QUOTE', 'Stored quote data is invalid'); }
};
class GenericQuoteSnapshotService {
constructor({ databaseService } = {}) { this.db = databaseService; }
async transaction(projectId, action) {
const id = Number(projectId);
if (!Number.isInteger(id) || id <= 0) fail('INVALID_PROJECT_ID', 'Invalid project id', 400);
if (!this.db?.pool?.getConnection) fail('GENERIC_SNAPSHOT_UNAVAILABLE', 'Snapshot storage unavailable', 503);
const connection = await this.db.pool.getConnection();
try {
await connection.beginTransaction();
const result = await action(connection, id);
await connection.commit();
return result;
} catch (error) {
await connection.rollback();
throw error;
} finally { connection.release(); }
}
async load(connection, id) {
const [projects] = await connection.execute(`SELECT id, project_name, customer_name, customer_number,
customer_email, customer_phone, customer_address, project_description, created_at, selected_packages, input_normalization_log
FROM customer_projects WHERE id = ? FOR UPDATE`, [id]);
if (!projects.length) fail('PROJECT_NOT_FOUND', 'Project not found', 404);
const { input_normalization_log: log, ...project } = projects[0];
const normalizationLog = parseStored(log, {});
const [rows] = await connection.execute(`SELECT version, workspace_json FROM project_smart_package_workspaces
WHERE project_id = ? FOR UPDATE`, [id]);
const stored = parseStored(rows[0]?.workspace_json, {});
const workspace = { ...stored, projectId: id, version: Number(rows[0]?.version) };
if (!Array.isArray(workspace.instances)) fail('INVALID_STORED_QUOTE', 'Workspace instances are required');
const instanceIds = new Set(workspace.instances.map(instance => instance.instanceId));
const selected = parseStored(project.selected_packages, []);
const selectedItems = Array.isArray(selected) ? selected : [selected];
if (selectedItems.some(item => {
const sourceId = typeof item === 'object' && item ? item.sourcePackageId ?? item.id : item;
const version = typeof item === 'object' && item ? item.sourcePackageVersion ?? item.version : null;
return !workspace.instances.some(instance => instance.sourcePackageId === Number(sourceId)
&& (version == null || instance.sourcePackageVersion === Number(version)));
})) fail('UNBOUND_SOURCE_PACKAGE', 'Selected source is not bound to the workspace');
for (const table of ['project_materials', 'project_rentals', 'project_labor']) {
const [projectRows] = await connection.execute(`SELECT * FROM ${table} WHERE project_id = ? FOR UPDATE`, [id]);
const projectedLines = table === 'project_labor'
? projectRows.flatMap(row => parseStored(row.work_breakdown, [])) : projectRows;
if (projectedLines.filter(active).some(line => !instanceIds.has(line.packageInstanceId ?? line.package_instance_id))) {
fail('UNBOUND_PROJECT_LINES', 'Move standalone project lines into the workspace before creating a generic quote');
}
if (table === 'project_labor' && projectRows.some(row => Number(row.total_work_hours) > 0
&& !parseStored(row.work_breakdown, []).length)) {
fail('UNBOUND_PROJECT_LINES', 'Labor needs a workspace line breakdown');
}
}
let quoteText = normalizationLog.genericQuoteText;
if (!text(quoteText)) {
const [quotes] = await connection.execute(`SELECT quote_text FROM generated_quotes
WHERE project_id = ? ORDER BY created_at DESC, id DESC LIMIT 1 FOR UPDATE`, [id]);
quoteText = quotes[0]?.quote_text;
}
const sourceIds = [...new Set((workspace.instances || []).map(instance => instance.sourcePackageId))];
const [sourcePackages] = sourceIds.length ? await connection.execute(`SELECT id, catalog_key, version, is_active,
validation_status, validated_at, updated_at, category, name, created_by, package_type, replacement_scope,
compatible_roof_materials, allowed_roof_forms, unit, unit_price, price_per_unit, standard_price,
total_estimated_price, hourly_rate, geometry_basis, geometry_factor, price_source, price_source_value
FROM material_packages WHERE id IN (${sourceIds.map(() => '?').join(',')}) FOR UPDATE`, sourceIds) : [[]];
const [packageMaterials] = sourceIds.length ? await connection.execute(`SELECT pm.id, pm.package_id,
'materials' AS kind, pm.material_id, pm.material_name AS name, pm.quantity, pm.unit,
pm.geometry_multiplier, pm.base_quantity, pm.waste_factor, pm.excel_raw_data
FROM package_materials pm WHERE pm.package_id IN (${sourceIds.map(() => '?').join(',')})
ORDER BY pm.package_id, pm.id FOR UPDATE`, sourceIds) : [[]];
const [packageTasks] = sourceIds.length ? await connection.execute(`SELECT task.id, task.package_id,
'tasks' AS kind, task.name, task.hours, COALESCE(NULLIF(task.rate, 0), pkg.hourly_rate) AS rate,
task.time_unit, task.time_per_unit, task.geometry_basis,
pkg.validated_at AS source_date
FROM smart_package_tasks task JOIN material_packages pkg ON pkg.id = task.package_id
WHERE task.package_id IN (${sourceIds.map(() => '?').join(',')})
ORDER BY task.package_id, task.id FOR UPDATE`, sourceIds) : [[]];
const syntheticPrices = sourcePackages.filter(row => row.package_type === 'rental_service'
|| row.created_by === 'haandvaerkpriser-import').map(row => {
const reference = row.created_by === 'haandvaerkpriser-import'
|| /håndværker|haandvaerk|reference/i.test(`${row.price_source || ''} ${row.category || ''}`);
const unitPrice = [row.unit_price, row.price_per_unit, row.standard_price, row.total_estimated_price]
.map(Number).find(positive);
return { id: reference ? `haandvaerkpriser-${row.id}` : `rental-package-${row.id}`, package_id: row.id,
kind: reference ? 'referenceServices' : 'rentals', name: row.name, quantity: 1, unit: row.unit,
unit_price: unitPrice, price_source: row.price_source, source_date: row.validated_at };
});
const sourcePackageLines = [...packageMaterials, ...packageTasks, ...syntheticPrices];
const [siteGeometryRows] = await connection.execute(`SELECT project_id, revision, geometry_json
FROM project_site_geometry WHERE project_id = ? FOR UPDATE`, [id]);
const authoritativeSiteGeometry = siteGeometryRows[0] || null;
const materialIds = [...new Set(packageMaterials.map(line => line.material_id).filter(value => value != null))];
const [materialPrices] = materialIds.length ? await connection.execute(`SELECT mp.material_id,
mp.id AS price_version, mp.price AS current_unit_price, m.unit AS current_unit,
COALESCE(mp.document_date, mp.valid_from) AS source_date, mp.supplier_name AS price_source
FROM material_prices mp JOIN materials m ON m.id = mp.material_id
WHERE mp.material_id IN (${materialIds.map(() => '?').join(',')}) AND mp.is_active = 1
AND mp.id = (SELECT latest.id FROM material_prices latest WHERE latest.material_id = mp.material_id
AND latest.is_active = 1 ORDER BY latest.valid_from DESC, latest.id DESC LIMIT 1) FOR UPDATE`, materialIds) : [[]];
const snapshot = buildSnapshot({ project, workspace, sourcePackages, sourcePackageLines, materialPrices,
authoritativeSiteGeometry,
quoteText, reservations: stored.reservations || [] });
const approval = normalizationLog.genericQuoteApproval || null;
const approved = approval?.signature === snapshot.signature && text(approval?.approvedBy) && text(approval?.approvedAt);
return { ...snapshot, approved: Boolean(approved), approval,
realityCheck: { mode: 'generic_deterministic', ready: true, blockers: [] }, readyForSubmission: true };
}
assertExpectedSignature(snapshot, expected) {
if (typeof expected !== 'string' || !/^[a-f0-9]{64}$/.test(expected)
|| sign(snapshot.artifact).signature !== expected) {
fail('GENERIC_QUOTE_SIGNATURE_MISMATCH', 'Quote changed; reload before continuing', 409);
}
return snapshot;
}
buildFromProject(projectId) {
return this.transaction(projectId, (connection, id) => this.load(connection, id));
}
saveCustomerText(projectId, genericQuoteText) {
return this.transaction(projectId, async (connection, id) => {
if (typeof genericQuoteText !== 'string' || !genericQuoteText.trim()
|| genericQuoteText.trim().length > MAX_GENERIC_QUOTE_TEXT_LENGTH) {
fail('INVALID_GENERIC_QUOTE_TEXT', `Customer text must be between 1 and ${MAX_GENERIC_QUOTE_TEXT_LENGTH} characters`, 400);
}
const normalizedText = genericQuoteText.trim();
assertCustomerDocumentLanguage([normalizedText]);
const [result] = await connection.execute(`UPDATE customer_projects SET input_normalization_log = JSON_SET(
JSON_REMOVE(COALESCE(NULLIF(input_normalization_log, ''), JSON_OBJECT()), '$.genericQuoteApproval'),
'$.genericQuoteText', JSON_EXTRACT(?, '$')) WHERE id = ?`, [JSON.stringify(normalizedText), id]);
if (result.affectedRows !== 1) fail('PROJECT_NOT_FOUND', 'Project not found', 404);
return { projectId: id, genericQuoteText: normalizedText, approvalInvalidated: true };
});
}
async withApprovedSnapshot(projectId, expected, action) {
const snapshot = await this.transaction(projectId, async (connection, id) => {
const current = this.assertExpectedSignature(await this.load(connection, id), expected);
if (!current.approved) fail('GENERIC_QUOTE_APPROVAL_REQUIRED', 'Approve the current quote first', 409);
return current;
});
return action(snapshot);
}
approveSnapshot(projectId, expected, approvedBy) {
return this.transaction(projectId, async (connection, id) => {
if (!text(approvedBy)) fail('INVALID_APPROVER', 'Authenticated approver required', 400);
const snapshot = this.assertExpectedSignature(await this.load(connection, id), expected);
const approval = { signature: snapshot.signature, approvedBy, approvedAt: new Date().toISOString() };
const [result] = await connection.execute(`UPDATE customer_projects SET input_normalization_log = JSON_SET(
COALESCE(NULLIF(input_normalization_log, ''), JSON_OBJECT()), '$.genericQuoteApproval', JSON_EXTRACT(?, '$'))
WHERE id = ?`, [JSON.stringify(approval), id]);
if (result.affectedRows !== 1) fail('APPROVAL_NOT_PERSISTED', 'Approval could not be saved', 409);
const approved = this.assertExpectedSignature(await this.load(connection, id), expected);
if (!approved.approved) fail('APPROVAL_NOT_PERSISTED', 'Approval could not be verified', 409);
return approved;
});
}
}
module.exports = { SCHEMA, MAX_SOURCE_AGE_DAYS, MAX_GENERIC_QUOTE_TEXT_LENGTH, buildSnapshot,
canonicalizeWorkspaceInstance, GenericQuoteSnapshotService };