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]>
166 lines
7.0 KiB
JavaScript
166 lines
7.0 KiB
JavaScript
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
|
||
};
|
||
};
|