Files
tilbudgivern/frontend/src/utils/projectReadiness.js
T
alexpolo1andClaude Sonnet 5 9b7df90385 feat: add carpenter walkthrough release gate (hydration, readiness, material link review)
Implements the plan from the carpenter walkthrough audit: make the
project flow -> final review -> PDF path measurable and safe to gate
CI on.

- projectHydrationService loads a project's geometry/labor/materials/
  rentals/packages/calculation in parallel and reports per-resource
  timing plus the slowest resource, so hydration SLA breaches are
  visible instead of silent
- projectReadiness centralizes the blocking checks final review must
  pass before a quote can be sent (name, customer, description,
  materials/labor present, zero-price lines)
- pdfSourceSignature detects when a generated PDF is stale relative to
  the current project data, so a carpenter doesn't send an outdated
  quote
- MaterialLinkReview + new customer-projects match-preview/link
  endpoints let a user review and confirm suggested master-material
  matches instead of silently auto-linking
- ProjectFlow refactored around the new hydration/readiness utilities;
  FinalReview surfaces readiness checks and unlinked material counts
- CI: the carpenter smoke test (full-roof-quote-flow.spec.js) is now
  a blocking gate on main, with the rest of the Playwright suite
  running best-effort alongside it
- docs/qa/CARPENTER_WALKTHROUGH_RELEASE_GATE.md documents the gate and
  the monthly production audit procedure

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-20 13:20:33 +02:00

166 lines
7.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const normalizeText = (value) => String(value || '').toLowerCase();
const daysSince = (value) => {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return Math.floor((Date.now() - date.getTime()) / 86400000);
};
export const buildQuoteReadinessChecks = ({
project,
geometry,
materials = [],
rentals = [],
laborTasks = [],
projectDescription = '',
orderSuggestions,
ordrestyringMetadata
}) => {
const checks = [];
const allText = normalizeText([
project?.project_name,
project?.name,
project?.description,
project?.project_description,
projectDescription,
geometry?.roofType,
geometry?.roof_type,
...materials.map(item => `${item.name || item.material_name || ''} ${item.category || ''}`),
...laborTasks.map(item => `${item.name || ''} ${item.description || ''}`)
].join(' '));
const roofType = normalizeText(geometry?.roofType || geometry?.roof_type);
const add = (id, severity, title, message, source = 'final_review') => {
checks.push({ id, severity, title, message, source });
};
if (!project?.project_name && !project?.name) {
add('missing_project_name', 'blocking', 'Projekt mangler navn', 'Giv projektet et navn før tilbuddet sendes.');
}
if (!project?.customer_name) {
add('missing_customer', 'blocking', 'Kunde mangler', 'Kundenavn skal være udfyldt før afsendelse.');
}
if (!projectDescription || !String(projectDescription).trim()) {
add('missing_description', 'blocking', 'Beskrivelse mangler', 'Skriv kundevendt beskrivelse af arbejdet.');
}
if (!materials.length && !rentals.length) {
add('missing_materials', 'blocking', 'Materialer mangler', 'Tilføj materialer eller udlejning før afsendelse.');
}
if (!laborTasks.length) {
add('missing_labor', 'blocking', 'Timer/opgaver mangler', 'Tilføj arbejdsopgaver og timer før afsendelse.');
}
const zeroPriceMaterials = materials.filter(item => (
Number(item.quantity) > 0 && Number(item.unitPrice ?? item.unit_price ?? 0) <= 0
));
if (zeroPriceMaterials.length > 0) {
add('zero_price_materials', 'blocking', 'Materialepris mangler', `${zeroPriceMaterials.length} materialelinje(r) har 0 kr. Ret priserne før tilbuddet sendes.`);
}
const zeroPriceRentals = rentals.filter(item => (
Number(item.quantity) > 0 && Number(item.unitPrice ?? item.unit_price ?? 0) <= 0
));
if (zeroPriceRentals.length > 0) {
add('zero_price_rentals', 'blocking', 'Udlejningspris mangler', `${zeroPriceRentals.length} udlejningslinje(r) har 0 kr. Ret priserne før tilbuddet sendes.`);
}
const unlinkedMaterials = materials.filter(item => !Number(item.materialId ?? item.material_id));
if (unlinkedMaterials.length > 0) {
add('unlinked_materials', 'blocking', 'Materialer mangler databasekobling', `${unlinkedMaterials.length} materialelinje(r) mangler materiale-id. Vælg rigtige materialer fra materialedatabasen.`);
}
const inconsistentLines = [...materials, ...rentals].filter(item => {
const storedTotal = Number(item.total ?? item.totalPrice ?? item.total_price);
if (!Number.isFinite(storedTotal)) return false;
const calculatedTotal = Number(item.quantity || 0) * Number(item.unitPrice ?? item.unit_price ?? 0);
return Math.abs(storedTotal - calculatedTotal) > 0.01;
});
if (inconsistentLines.length > 0) {
add('inconsistent_line_totals', 'blocking', 'Linjetotal stemmer ikke', `${inconsistentLines.length} linje(r) stemmer ikke med antal × enhedspris.`);
}
const hasKvistData = Boolean(
geometry?.numberOfKviste || geometry?.number_of_kviste
|| geometry?.kvistWidth || geometry?.kvist_width
|| geometry?.kvistLength || geometry?.kvist_length
);
if (roofType === 'tag_med_kviste' && !hasKvistData) {
add('missing_dormer_data', 'blocking', 'Kvistdata mangler', 'Angiv antal og mål på kviste før tilbuddet sendes.');
}
const priceSyncAge = daysSince(ordrestyringMetadata?.last_price_list_synced_at);
if (priceSyncAge !== null && priceSyncAge > 14) {
add('stale_price_list', 'warning', 'Prislisten er gammel', `Sidste prissync er ${priceSyncAge} dage gammel. Dobbelttjek materialepriser.`, 'ordrestyring');
} else if (!ordrestyringMetadata?.last_price_list_synced_at) {
add('missing_price_sync', 'warning', 'Prislistesync ukendt', 'Der mangler dato for prislistesync. Dobbelttjek priser før fast tilbud.', 'ordrestyring');
}
if ((orderSuggestions?.recommendation?.confidence || '') === 'low') {
add('low_history_confidence', 'warning', 'Svag historisk reference', 'Ordrestyring matcher ikke stærkt nok. Gennemgå timer og materialer manuelt.', 'ordrestyring');
}
if (orderSuggestions?.matches?.length > 0 && (orderSuggestions?.materialsDraft?.length || 0) === 0 && !materials.length) {
add('missing_order_materials', 'warning', 'Ingen materialelinjer fra historik', 'Ordrestyring gav historik, men ikke materialer. Materialelisten skal kontrolleres.', 'ordrestyring');
}
if ((allText.includes('b7') || allText.includes('eternit')) && !allText.includes('asbest')) {
add('missing_asbestos_check', 'warning', 'Asbest ikke afklaret', 'B7/eternit bør have asbest og bortskaffelse afklaret før endelig pris.');
}
if ((roofType === 'fladt_tag' || roofType === 'koebenhavnertag') && !/(afløb|afloeb|fald|tagbrønd|tagbroend|nedløb|nedloeb)/i.test(allText)) {
add('missing_flat_roof_drainage', 'warning', 'Fald/afløb ikke afklaret', 'Flade tage skal have fald, afløb og opkanter kontrolleret.');
}
return checks;
};
export const getProjectReadiness = ({
project,
geometry,
packageData,
materials,
rentals = [],
laborTasks = [],
projectDescription = '',
orderSuggestions,
ordrestyringMetadata,
persistedCompletedSteps = 0,
additionalBlockingChecks = [],
additionalAttentionChecks = []
} = {}) => {
const canonicalMaterials = Array.isArray(materials) ? materials : (packageData?.materials || []);
const completedSteps = Math.max(
[
project ? 1 : 0,
geometry ? 2 : 0,
packageData ? 3 : 0,
project && geometry && packageData ? 4 : 0
].reduce((highest, step) => Math.max(highest, step), 0),
persistedCompletedSteps
);
const totalSteps = 4;
const flowComplete = completedSteps >= totalSteps;
const checks = buildQuoteReadinessChecks({
project,
geometry,
materials: canonicalMaterials,
rentals,
laborTasks,
projectDescription,
orderSuggestions,
ordrestyringMetadata
});
const blockingChecks = [
...checks.filter(check => check.severity === 'blocking'),
...additionalBlockingChecks
];
const attentionChecks = [
...checks.filter(check => check.severity !== 'blocking'),
...additionalAttentionChecks
];
return {
completedSteps,
totalSteps,
flowComplete,
readyForReview: flowComplete,
readyToSend: flowComplete && blockingChecks.length === 0,
blockingChecks,
attentionChecks,
unlinkedMaterialCount: canonicalMaterials.filter(item => !Number(item.materialId ?? item.material_id)).length
};
};