CI - Test & Build / Lint & Type Check (push) Canceled after 0s
CI - Test & Build / Backend Unit Tests (push) Canceled after 0s
CI - Test & Build / Frontend Build (push) Canceled after 0s
CI - Test & Build / Security Scan (push) Canceled after 0s
CI - Test & Build / E2E Tests (Playwright) (push) Canceled after 0s
CI - Test & Build / CI Summary (push) Canceled after 0s
* feat: move login credentials to a DB-backed users table with an admin management page Replaces the hardcoded AUTH_USERNAME/AUTH_PASSWORD login check with a new auth_accounts table (bcrypt-hashed passwords, admin/user roles). Adds admin-only /api/users CRUD routes and a "Brugere" admin page in the frontend for managing logins without redeploying. Removes the unused, unmounted duplicate login route in src/routes/auth.js. * docs: add architecture codemaps with diagrams for the whole system Adds codemaps/architecture.md, backend.md, frontend.md, and data.md — Mermaid-diagrammed design documentation verified against the live codebase and database rather than assumed from CLAUDE.md. Covers the unified-server.js request flow (mounted routers + ~183 inline routes), 68 backend services grouped by domain, the frontend's state-driven view-switch (no React Router in practice despite BrowserRouter being present), and the full 122-table DB schema with the auth_accounts vs unrelated users table naming trap flagged explicitly. Links added from the root README. Co-Authored-By: Claude Sonnet 5 <[email protected]> * feat: ship canonical roof quote workflow * fix: keep migration dry-run idempotent * [verified] feat: complete Smart Pakker management * [verified] fix: ignore blank task dependencies * [verified] fix: align package duplication with schema * [verified] fix: enforce Discord status limits * [verified] fix: link Smart Pakke materials safely * [verified] fix: harden material link review * [verified] feat: improve material matching * fix: scope pitch validation to roof packages * fix: support canonical snapshots on production schema * [verified] fix: hide internal package metadata from PDF * [verified] feat: deliver sales-ready customer PDF * [verified] feat: ship sales-ready PDF with AI overview * [verified] fix: authenticate project list requests * [verified] fix: refresh project-list authentication * [verified] fix: open existing project details * [verified] fix: keep roof components searchable in builder * [verified] fix: expose all Smart Package categories * [verified] fix: authenticate project creation * [verified] feat: make Smart Pakker the universal project flow * [verified] feat: preview Smart Package contents * [verified] test: keep generic release isolated from downpipe work * feat: add first-class Smart Pakke rentals * [verified] feat: add gutter and downpipe smart packages * [verified] fix: prepare six-house gutter quote flow * [verified] fix: open generic quotes without roof geometry * [verified] fix: review generic quotes with authenticated APIs * [verified] fix: calculate generic Smart Package quotes * [verified] fix: return generic calculation breakdown * feat: checkpoint generic signed snapshot validation with red-green tests * feat: complete fail-closed generic quote approval and customer PDF flow * feat: use generic signed snapshot in final review * feat: redesign generic quote final review * fix: harden generic review summaries * feat: add auditable six-house package basis * [verified] feat: finish auditable Smart Pakke UI * [verified] fix: bind auditable quantity and price bases * [verified] fix: keep six-house basis across package versions * [verified] fix: complete smart package discovery management * [verified] fix: simplify composition and generic scope * [verified] test: keep explicit roof contracts fail closed * [verified] fix: harden generic quote snapshots * fix: make generic quote delivery customer safe * [verified] fix: secure package catalog reads * [verified] fix: close workspace provenance blockers * fix: harden customer document language boundary * [verified] fix: secure smart package internal reads * fix: version package child mutations atomically * feat: add generic customer quote text flow * [verified] fix: allow manual customer numbers * [verified] fix: expose optional roof geometry * [verified] fix: rebase hydrated packages after geometry edits * [verified] feat: add free editable site area map * [verified] fix: harden map recovery and geocoding gate * fix: bind map quantities to authoritative geometry * fix: release geocoder lock before dispatch * fix: separate roof and site geometry provenance * fix: revoke stale admin authorization * fix: migrate task geometry basis * fix: make backend CI dependency-complete * ci: seed isolated e2e login account * fix: allow clean database bootstrap * fix: skip indexes for optional tables * test: use canonical mansard geometry in e2e * [verified] fix(auth): enforce live operator boundary * fix: fail close Ordrestyring offer transport * fix(frontend): authenticate customer project requests * fix: align canonical roof type contract * [verified] fix: reconcile legacy package labor safely * [verified] fix: audit site geometry deletion * docs: add PR 31 reviewer guide * docs: synchronize Obsidian vault * docs: sync integrated reviewer guide to Obsidian * ci: seed isolated auth account explicitly * fix: close offer bootstrap and service readiness gaps * fix: authenticate protected package callers * fix: provision initial admin and disable generic send * [verified] fix: close final quote release blockers * [verified] fix: seed gutter packages before deployment --------- Co-authored-by: alexpolo1 <[email protected]> Co-authored-by: Claude Sonnet 5 <[email protected]>
655 lines
41 KiB
JavaScript
655 lines
41 KiB
JavaScript
'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 };
|