diff --git a/backend/src/__tests__/projectMaterialService.test.js b/backend/src/__tests__/projectMaterialService.test.js new file mode 100644 index 0000000..1373136 --- /dev/null +++ b/backend/src/__tests__/projectMaterialService.test.js @@ -0,0 +1,39 @@ +const ProjectMaterialService = require('../services/projectMaterialService'); + +describe('ProjectMaterialService price provenance', () => { + test('stores a database link, price source and MySQL-safe price timestamp', async () => { + const execute = jest.fn().mockResolvedValue([{ insertId: 73 }]); + const service = new ProjectMaterialService({ pool: { execute } }); + + const saved = await service.addProjectMaterial(394, { + materialId: 125, + materialName: 'Taglægte', + materialCategory: 'Tag', + quantity: 2, + unit: 'STK', + unitPrice: 20.58, + supplier: 'Stark A/S', + materialSource: 'smart_package', + priceSource: 'Stark A/S', + priceSourceUpdatedAt: '2026-08-10T08:33:41.000Z', + notes: null + }); + + expect(saved).toMatchObject({ id: 73, materialId: 125, totalPrice: 41.16 }); + expect(execute).toHaveBeenCalledWith(expect.stringContaining('price_source_updated_at'), [ + 394, + 125, + 'Taglægte', + 'Tag', + 2, + 'STK', + 20.58, + 41.16, + 'Stark A/S', + 'smart_package', + 'Stark A/S', + '2026-08-10 08:33:41', + null + ]); + }); +}); diff --git a/backend/src/__tests__/smartPackageIntegrityService.test.js b/backend/src/__tests__/smartPackageIntegrityService.test.js index f79d34c..6eaffee 100644 --- a/backend/src/__tests__/smartPackageIntegrityService.test.js +++ b/backend/src/__tests__/smartPackageIntegrityService.test.js @@ -28,11 +28,23 @@ describe('SmartPackageIntegrityService', () => { const execute = jest.fn().mockResolvedValue([[{ id: 42, sku: 'SKU-42', name: 'Taglægte' }]]); const service = new SmartPackageIntegrityService({ pool: { execute } }); await expect(service.validateMaterials(PACKAGE_TYPES.COMPLETE_OFFER, [ - { material_id: 42, name: 'Taglægte' } + { material_id: 42, name: 'Taglægte', unit_price: 25 } ])).resolves.toHaveLength(1); await expect(service.validateMaterials(PACKAGE_TYPES.RENTAL_SERVICE, [])).resolves.toEqual([]); }); + test('rejects a linked material without a usable price', async () => { + const execute = jest.fn().mockResolvedValue([[{ id: 42, sku: 'SKU-42', name: 'Taglægte' }]]); + const service = new SmartPackageIntegrityService({ pool: { execute } }); + + await expect(service.validateMaterials(PACKAGE_TYPES.COMPONENT, [ + { material_id: 42, name: 'Taglægte', unit_price: 0 } + ])).rejects.toMatchObject({ + name: 'SmartPackageIntegrityError', + details: [{ reason: 'missing_material_price' }] + }); + }); + test('verifies a complete offer only when materials and tasks exist', async () => { const connection = { beginTransaction: jest.fn(), @@ -41,7 +53,7 @@ describe('SmartPackageIntegrityService', () => { release: jest.fn(), execute: jest.fn(async sql => { if (sql.includes('FROM material_packages')) return [[{ id: 7, package_type: 'complete_offer' }]]; - if (sql.includes('FROM package_materials')) return [[{ material_id: 42, material_name: 'Taglægte' }]]; + if (sql.includes('FROM package_materials')) return [[{ material_id: 42, material_name: 'Taglægte', unit_price: 25 }]]; if (sql.includes('FROM materials')) return [[{ id: 42, sku: 'SKU-42', name: 'Taglægte' }]]; if (sql.includes('FROM smart_package_tasks')) return [[{ task_count: 5 }]]; if (sql.includes('UPDATE material_packages')) return [{ affectedRows: 1 }]; diff --git a/backend/src/routes/smartPackagesRoutes.js b/backend/src/routes/smartPackagesRoutes.js index 2ef5a67..7a45c75 100644 --- a/backend/src/routes/smartPackagesRoutes.js +++ b/backend/src/routes/smartPackagesRoutes.js @@ -14,6 +14,7 @@ const aiValidationJobs = require('../services/aiValidationJobService'); const { SmartPackageIntegrityService } = require('../services/smartPackageIntegrityService'); const excelUploadDir = path.join(__dirname, '../../uploads/smart-packages/excel'); +const excelMetadataPath = path.join(excelUploadDir, '.import-metadata.json'); const excelUpload = multer({ storage: multer.diskStorage({ destination: (req, file, cb) => { @@ -102,6 +103,39 @@ const getCompletedMapping = (mappingJobId, fileHash) => { const stripStoredExcelPrefix = filename => filename.replace(/^\d+-/, ''); +const readExcelMetadata = () => { + try { + return JSON.parse(fs.readFileSync(excelMetadataPath, 'utf8')); + } catch (error) { + return {}; + } +}; + +const updateExcelMetadata = (filename, patch) => { + fs.mkdirSync(excelUploadDir, { recursive: true }); + const metadata = readExcelMetadata(); + metadata[filename] = { ...(metadata[filename] || {}), ...patch }; + const temporaryPath = `${excelMetadataPath}.tmp`; + fs.writeFileSync(temporaryPath, JSON.stringify(metadata, null, 2), { mode: 0o600 }); + fs.renameSync(temporaryPath, excelMetadataPath); +}; + +const findImportedDuplicate = async (filename, fileHash) => { + const rows = await global.databaseService.query( + `SELECT DISTINCT excel_source_file + FROM material_packages + WHERE created_by = 'excel-import' AND excel_source_file IS NOT NULL AND excel_source_file <> ?`, + [filename] + ); + return rows.find(row => { + try { + return getFileHash(getExcelFilePath(row.excel_source_file)) === fileHash; + } catch (error) { + return false; + } + })?.excel_source_file || null; +}; + // ======================================== // SMART PAKKER API ENDPOINTS // ======================================== @@ -291,6 +325,7 @@ router.get('/material-master-search', async (req, res) => { router.get('/excel-uploads', verifyToken, async (req, res) => { try { fs.mkdirSync(excelUploadDir, { recursive: true }); + const metadata = readExcelMetadata(); const uploadedFiles = fs.readdirSync(excelUploadDir) .filter(filename => ['.xlsx', '.xls', '.xlsm', '.csv'].includes(path.extname(filename).toLowerCase())) .map(filename => { @@ -299,26 +334,54 @@ router.get('/excel-uploads', verifyToken, async (req, res) => { filename, displayName: stripStoredExcelPrefix(filename), size: stat.size, - uploadedAt: stat.mtime.toISOString() + uploadedAt: metadata[filename]?.uploadedAt || stat.mtime.toISOString(), + uploadedBy: metadata[filename]?.uploadedBy || null, + fileHash: metadata[filename]?.fileHash || getFileHash(path.join(excelUploadDir, filename)), + importedBy: metadata[filename]?.importedBy || null }; }); const importRows = await global.databaseService.query( - `SELECT excel_source_file, + `SELECT mp.excel_source_file, + COUNT(*) AS total_packages, SUM(is_active = 1) AS active_packages, SUM(is_active = 0) AS inactive_packages, - MAX(updated_at) AS imported_at - FROM material_packages - WHERE created_by = 'excel-import' AND excel_source_file IS NOT NULL - GROUP BY excel_source_file` + SUM(validation_status = 'verified') AS verified_packages, + SUM(validation_status <> 'verified') AS packages_requiring_review, + SUM(COALESCE(material_quality.material_lines, 0)) AS material_lines, + SUM(COALESCE(material_quality.unlinked_material_lines, 0)) AS unlinked_material_lines, + SUM(COALESCE(task_quality.task_lines, 0)) AS task_lines, + MAX(mp.updated_at) AS imported_at + FROM material_packages mp + LEFT JOIN ( + SELECT package_id, COUNT(*) AS material_lines, + SUM(material_id IS NULL) AS unlinked_material_lines + FROM package_materials GROUP BY package_id + ) material_quality ON material_quality.package_id = mp.id + LEFT JOIN ( + SELECT package_id, COUNT(*) AS task_lines + FROM smart_package_tasks GROUP BY package_id + ) task_quality ON task_quality.package_id = mp.id + WHERE mp.created_by = 'excel-import' AND mp.excel_source_file IS NOT NULL + GROUP BY mp.excel_source_file` ); const importsByFile = new Map(importRows.map(row => [row.excel_source_file, row])); - const files = uploadedFiles.map(file => { + const firstFileByHash = new Map(); + const files = uploadedFiles.sort((left, right) => new Date(left.uploadedAt) - new Date(right.uploadedAt)).map(file => { const importInfo = importsByFile.get(file.filename); + const duplicateOf = firstFileByHash.get(file.fileHash) || null; + if (!duplicateOf) firstFileByHash.set(file.fileHash, file.filename); return { ...file, importStatus: importInfo ? 'imported' : 'uploaded', + duplicateOf, + totalPackages: Number(importInfo?.total_packages || 0), activePackages: Number(importInfo?.active_packages || 0), inactivePackages: Number(importInfo?.inactive_packages || 0), + verifiedPackages: Number(importInfo?.verified_packages || 0), + packagesRequiringReview: Number(importInfo?.packages_requiring_review || 0), + materialLines: Number(importInfo?.material_lines || 0), + unlinkedMaterialLines: Number(importInfo?.unlinked_material_lines || 0), + taskLines: Number(importInfo?.task_lines || 0), importedAt: importInfo?.imported_at || null }; }).sort((left, right) => new Date(right.uploadedAt) - new Date(left.uploadedAt)); @@ -330,6 +393,30 @@ router.get('/excel-uploads', verifyToken, async (req, res) => { } }); +router.get('/excel-uploads/:filename/packages', verifyToken, async (req, res) => { + try { + const filename = path.basename(req.params.filename || ''); + if (!filename || filename !== req.params.filename) throw new Error('Ugyldigt Excel-filnavn'); + const packages = await global.databaseService.query( + `SELECT mp.id, mp.name, mp.category, mp.package_type, mp.validation_status, mp.is_active, + COUNT(DISTINCT pm.id) AS material_lines, + COUNT(DISTINCT CASE WHEN pm.material_id IS NULL THEN pm.id END) AS unlinked_material_lines, + COUNT(DISTINCT spt.id) AS task_lines + FROM material_packages mp + LEFT JOIN package_materials pm ON pm.package_id = mp.id + LEFT JOIN smart_package_tasks spt ON spt.package_id = mp.id + WHERE mp.excel_source_file = ? + GROUP BY mp.id + ORDER BY mp.excel_source_sheet, mp.excel_source_row, mp.name + LIMIT 1000`, + [filename] + ); + return res.json({ success: true, filename, packages }); + } catch (error) { + return res.status(400).json({ success: false, error: error.message || 'Pakkerne kunne ikke hentes' }); + } +}); + router.post('/excel-upload', verifyToken, (req, res) => { excelUpload.single('file')(req, res, async (error) => { if (error) { @@ -348,6 +435,12 @@ router.post('/excel-upload', verifyToken, (req, res) => { try { const fileHash = getFileHash(req.file.path); + updateExcelMetadata(req.file.filename, { + originalName: req.file.originalname, + uploadedAt: new Date().toISOString(), + uploadedBy: req.user?.username || 'authenticated-user', + fileHash + }); const structure = new ExcelMappingService().getStructure(req.file.path); let standardValidation = null; let standardError = null; @@ -392,6 +485,14 @@ router.post('/excel-map', verifyToken, async (req, res) => { const filePath = getExcelFilePath(req.body?.filename); const fileHash = getFileHash(filePath); if (req.body?.fileHash !== fileHash) throw new Error('Excel-filen er ændret siden upload'); + const duplicateFilename = await findImportedDuplicate(req.body.filename, fileHash); + if (duplicateFilename) { + return res.status(409).json({ + success: false, + error: `Samme filindhold er allerede importeret som ${stripStoredExcelPrefix(duplicateFilename)}`, + duplicateFilename + }); + } const job = await aiValidationJobs.createJob('excel-mapping', null, { payload: { filePath }, inputHash: fileHash, @@ -475,6 +576,14 @@ router.post('/excel-import', verifyToken, async (req, res) => { const filePath = getExcelFilePath(req.body?.filename); const fileHash = getFileHash(filePath); if (req.body?.fileHash !== fileHash) throw new Error('Excel-filen er ændret siden upload'); + const duplicateFilename = await findImportedDuplicate(req.body.filename, fileHash); + if (duplicateFilename) { + return res.status(409).json({ + success: false, + error: `Samme filindhold er allerede importeret som ${stripStoredExcelPrefix(duplicateFilename)}`, + duplicateFilename + }); + } const mapping = getCompletedMapping(req.body?.mappingJobId, fileHash); const standardValidation = getExcelValidationService().validateStandard(filePath, mapping); if (standardValidation.blocksImport) { @@ -489,6 +598,12 @@ router.post('/excel-import', verifyToken, async (req, res) => { synchronize: true, mapping }); + updateExcelMetadata(req.body.filename, { + importedAt: new Date().toISOString(), + importedBy: req.user?.username || 'authenticated-user', + fileHash, + result + }); res.json({ success: true, result, message: 'SmartPakker importeret' }); } catch (error) { console.error('Error importing SmartPakker Excel:', error); diff --git a/backend/src/services/projectMaterialService.js b/backend/src/services/projectMaterialService.js index be6510b..39c41f1 100644 --- a/backend/src/services/projectMaterialService.js +++ b/backend/src/services/projectMaterialService.js @@ -1,5 +1,12 @@ const logger = require('../utils/logger'); +const toMysqlDateTime = value => { + if (!value) return null; + const date = value instanceof Date ? value : new Date(value); + if (Number.isNaN(date.getTime())) return null; + return date.toISOString().slice(0, 19).replace('T', ' '); +}; + class ProjectMaterialService { constructor(databaseService) { this.db = databaseService; @@ -9,6 +16,7 @@ class ProjectMaterialService { async addProjectMaterial(projectId, materialData) { try { const { + materialId, materialName, materialCategory, quantity, @@ -16,6 +24,8 @@ class ProjectMaterialService { unitPrice, supplier, materialSource = 'manual', + priceSource, + priceSourceUpdatedAt, notes } = materialData; @@ -23,14 +33,16 @@ class ProjectMaterialService { const query = ` INSERT INTO project_materials ( - project_id, material_name, material_category, quantity, - unit, unit_price, total_price, supplier, material_source, notes - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + project_id, material_id, material_name, material_category, quantity, + unit, unit_price, total_price, supplier, material_source, + price_source, price_source_updated_at, notes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `; const [result] = await this.db.pool.execute(query, [ - projectId, materialName, materialCategory, quantity, - unit, unitPrice, totalPrice, supplier, materialSource, notes + projectId, materialId || null, materialName, materialCategory, quantity, + unit, unitPrice, totalPrice, supplier, materialSource, + priceSource || null, toMysqlDateTime(priceSourceUpdatedAt), notes ]); logger.info('Project material added', { @@ -42,6 +54,7 @@ class ProjectMaterialService { return { id: result.insertId, + materialId: materialId || null, materialName, materialCategory, quantity, diff --git a/backend/src/services/smartPackageExcelImportService.js b/backend/src/services/smartPackageExcelImportService.js index 9daee49..86ad651 100644 --- a/backend/src/services/smartPackageExcelImportService.js +++ b/backend/src/services/smartPackageExcelImportService.js @@ -311,6 +311,8 @@ class SmartPackageExcelImportService { const synchronizedIds = new Set(); let materialCount = 0; + let linkedMaterialCount = 0; + let unlinkedMaterialCount = 0; let taskCount = 0; let insertedPackages = 0; let updatedPackages = 0; @@ -402,6 +404,8 @@ class SmartPackageExcelImportService { }) ]); materialCount += 1; + if (matchedMaterial?.id) linkedMaterialCount += 1; + else unlinkedMaterialCount += 1; } const tasksWithDefaults = applyDefaultTaskTimes( @@ -453,6 +457,8 @@ class SmartPackageExcelImportService { updatedPackages, deactivatedPackages, materialLines: materialCount, + linkedMaterialLines: linkedMaterialCount, + unlinkedMaterialLines: unlinkedMaterialCount, taskLines: taskCount }; } catch (error) { diff --git a/backend/src/services/smartPackageIntegrityService.js b/backend/src/services/smartPackageIntegrityService.js index ce87d71..1640240 100644 --- a/backend/src/services/smartPackageIntegrityService.js +++ b/backend/src/services/smartPackageIntegrityService.js @@ -66,6 +66,19 @@ class SmartPackageIntegrityService { invalid.map(material => ({ line: material.index + 1, name: material.name, materialId: material.id, reason: 'unknown_material_id' })) ); } + const zeroPriced = materials + .map((material, index) => ({ + index, + name: material?.name || material?.material_name || '', + price: Number(material?.unit_price ?? material?.unitPrice ?? material?.price ?? 0) + })) + .filter(material => !Number.isFinite(material.price) || material.price <= 0); + if (zeroPriced.length > 0) { + throw new SmartPackageIntegrityError( + 'Alle materialelinjer skal have en positiv pris før pakken kan verificeres.', + zeroPriced.map(material => ({ line: material.index + 1, name: material.name, reason: 'missing_material_price' })) + ); + } return rows; } @@ -85,7 +98,8 @@ class SmartPackageIntegrityService { SELECT COUNT(*) AS craft_material_lines, SUM(pm.material_id IS NOT NULL) AS linked_material_lines, - SUM(pm.material_id IS NULL) AS unlinked_material_lines + SUM(pm.material_id IS NULL) AS unlinked_material_lines, + SUM(COALESCE(pm.unit_price, 0) <= 0) AS zero_price_material_lines FROM package_materials pm JOIN material_packages mp ON mp.id = pm.package_id WHERE mp.is_active = 1 AND mp.package_type <> 'rental_service' @@ -93,12 +107,16 @@ class SmartPackageIntegrityService { const [violations] = await this.db.pool.execute(` SELECT mp.id, mp.name, mp.package_type, mp.validation_status, COUNT(pm.id) AS material_lines, - SUM(pm.material_id IS NULL) AS unlinked_material_lines + SUM(pm.material_id IS NULL) AS unlinked_material_lines, + SUM(pm.id IS NOT NULL AND COALESCE(pm.unit_price, 0) <= 0) AS zero_price_material_lines, + (SELECT COUNT(*) FROM smart_package_tasks spt WHERE spt.package_id = mp.id) AS task_lines FROM material_packages mp LEFT JOIN package_materials pm ON pm.package_id = mp.id WHERE mp.is_active = 1 AND mp.package_type <> 'rental_service' GROUP BY mp.id HAVING material_lines = 0 OR unlinked_material_lines > 0 + OR zero_price_material_lines > 0 + OR (mp.package_type = 'complete_offer' AND task_lines = 0) ORDER BY mp.name `); return { summary: summary[0], materials: materials[0], violations }; @@ -119,7 +137,7 @@ class SmartPackageIntegrityService { } const packageType = packages[0].package_type; const [materials] = await connection.execute( - 'SELECT material_id, material_name FROM package_materials WHERE package_id = ?', + 'SELECT material_id, material_name, unit_price FROM package_materials WHERE package_id = ?', [packageId] ); await this.validateMaterials(packageType, materials, connection); diff --git a/backend/src/services/smartPackageManagementService.js b/backend/src/services/smartPackageManagementService.js index 0b95a12..964a4dd 100644 --- a/backend/src/services/smartPackageManagementService.js +++ b/backend/src/services/smartPackageManagementService.js @@ -188,6 +188,7 @@ class SmartPackageManagementService { COALESCE(legacy_task_totals.task_hours, 0) as legacy_task_hours, COALESCE(legacy_task_totals.task_labor_cost, 0) as legacy_task_labor_cost, COALESCE(material_totals.material_count, 0) as material_count, + COALESCE(material_totals.unlinked_material_count, 0) as unlinked_material_count, CASE WHEN COALESCE(material_totals.material_cost, 0) > 0 THEN material_totals.material_cost ELSE COALESCE(mp.total_estimated_price, 0) @@ -215,6 +216,7 @@ class SmartPackageManagementService { SELECT package_id, COUNT(*) as material_count, + SUM(material_id IS NULL) as unlinked_material_count, COALESCE(SUM(COALESCE(total_price, quantity * unit_price)), 0) as material_cost FROM package_materials GROUP BY package_id diff --git a/database/migrations/20260815_project_material_price_provenance.sql b/database/migrations/20260815_project_material_price_provenance.sql new file mode 100644 index 0000000..3dd2bdc --- /dev/null +++ b/database/migrations/20260815_project_material_price_provenance.sql @@ -0,0 +1,7 @@ +ALTER TABLE project_materials + ADD COLUMN IF NOT EXISTS material_id int(11) DEFAULT NULL AFTER project_id, + ADD COLUMN IF NOT EXISTS price_source varchar(120) DEFAULT NULL AFTER material_source, + ADD COLUMN IF NOT EXISTS price_source_updated_at datetime DEFAULT NULL AFTER price_source; + +CREATE INDEX IF NOT EXISTS idx_project_materials_material_id + ON project_materials(material_id); diff --git a/frontend/src/components/FinalReview.js b/frontend/src/components/FinalReview.js index ee7655a..095b035 100644 --- a/frontend/src/components/FinalReview.js +++ b/frontend/src/components/FinalReview.js @@ -242,6 +242,52 @@ export const buildQuoteReadinessChecks = ({ 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 || @@ -1654,7 +1700,18 @@ const FinalReview = ({ {editableMaterials.map((material, index) => (
- {material.name} + + {material.name} + + {Number(material.materialId ?? material.material_id) + ? `Materiale #${material.materialId ?? material.material_id}` + : 'Mangler materiale-id'} + {material.priceSource ? ` · ${material.priceSource}` : ''} + {material.priceSourceUpdatedAt + ? ` · ${new Date(material.priceSourceUpdatedAt).toLocaleDateString('da-DK')}` + : ''} + + { const b7Checks = buildQuoteReadinessChecks({ project: { project_name: 'B7 tag', customer_name: 'Test Kunde' }, geometry: { roofType: 'sadeltag' }, - materials: [{ name: 'Eternit B7 tagplader', quantity: 100, unitPrice: 238 }], + materials: [{ materialId: 7, name: 'Eternit B7 tagplader', quantity: 100, unitPrice: 238 }], laborTasks: [{ name: 'Montage', totalHours: 40 }], projectDescription: 'Udskiftning af eternittag' }); @@ -44,7 +44,7 @@ describe('FinalReview readiness checks', () => { const flatChecks = buildQuoteReadinessChecks({ project: { project_name: 'Fladtag', customer_name: 'Test Kunde' }, geometry: { roofType: 'fladt_tag' }, - materials: [{ name: 'SBS overpap', quantity: 80, unitPrice: 92 }], + materials: [{ materialId: 8, name: 'SBS overpap', quantity: 80, unitPrice: 92 }], laborTasks: [{ name: 'Tagpap', totalHours: 20 }], projectDescription: 'Nyt tagpap' }); @@ -55,7 +55,7 @@ describe('FinalReview readiness checks', () => { const checks = buildQuoteReadinessChecks({ project: { project_name: 'Mansardtag', customer_name: 'Test Kunde' }, geometry: { roofType: 'mansardtag' }, - materials: [{ name: 'Tegl', quantity: 120, unitPrice: 145 }], + materials: [{ materialId: 9, name: 'Tegl', quantity: 120, unitPrice: 145 }], laborTasks: [{ name: 'Omlægning', totalHours: 170 }], projectDescription: 'Omlægning af mansardtag med stillads' }); @@ -66,4 +66,26 @@ describe('FinalReview readiness checks', () => { factor: 1.8 }); }); + + test('blocks zero prices, missing material links and inconsistent totals', () => { + const checks = buildQuoteReadinessChecks({ + project: { project_name: 'Tag', customer_name: 'Test Kunde' }, + geometry: { roofType: 'sadeltag' }, + materials: [ + { name: 'Ukendt tagsten', quantity: 10, unitPrice: 0, total: 200 } + ], + rentals: [{ name: 'Stillads', quantity: 1, unitPrice: 0 }], + laborTasks: [{ name: 'Montage', totalHours: 10 }], + projectDescription: 'Udskiftning af tag' + }); + + expect(checks.filter(check => check.severity === 'blocking').map(check => check.id)).toEqual( + expect.arrayContaining([ + 'zero_price_materials', + 'zero_price_rentals', + 'unlinked_materials', + 'inconsistent_line_totals' + ]) + ); + }); }); diff --git a/frontend/src/components/InlineSmartPackage.css b/frontend/src/components/InlineSmartPackage.css index 92f1fa8..ac39f93 100644 --- a/frontend/src/components/InlineSmartPackage.css +++ b/frontend/src/components/InlineSmartPackage.css @@ -317,6 +317,118 @@ border-left: 4px solid #6f42c1; } +.smart-package-quick-section { + margin: 0 0 18px; + padding: 16px; + border: 1px solid #a7f3d0; + border-radius: 14px; + background: linear-gradient(135deg, #ecfdf5 0%, #f0fdfa 58%, #eff6ff 100%); +} + +.smart-package-quick-heading, +.smart-package-quick-meta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.smart-package-eyebrow { + display: block; + margin-bottom: 3px; + color: #047857; + font-size: 0.72rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.smart-package-quick-heading h4 { + margin: 0; + color: #12372a; + font-size: 1.05rem; +} + +.smart-package-quick-count { + padding: 5px 9px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.82); + color: #065f46; + font-size: 0.75rem; + font-weight: 700; +} + +.smart-package-quick-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); + gap: 10px; + margin-top: 13px; +} + +.smart-package-quick-card { + display: grid; + gap: 8px; + padding: 13px; + border: 1px solid rgba(5, 150, 105, 0.28); + border-radius: 11px; + background: rgba(255, 255, 255, 0.94); + box-shadow: 0 5px 18px rgba(15, 118, 110, 0.08); +} + +.smart-package-quick-meta { + color: #475569; + font-size: 0.72rem; + font-weight: 700; +} + +.smart-package-quick-meta .is-ready { + color: #166534; +} + +.smart-package-quick-meta .needs-review { + color: #b45309; +} + +.smart-package-quick-card h5 { + display: -webkit-box; + min-height: 2.5em; + margin: 0; + overflow: hidden; + color: #172554; + font-size: 0.98rem; + line-height: 1.25; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.smart-package-quick-card p { + margin: 0; + color: #64748b; + font-size: 0.78rem; +} + +.smart-package-quick-card button { + min-height: 44px; + border: 0; + border-radius: 9px; + background: #047857; + color: #fff; + cursor: pointer; + font: inherit; + font-weight: 750; +} + +.smart-package-quick-card button:focus-visible { + outline: 3px solid #38bdf8; + outline-offset: 2px; +} + +.smart-package-quick-card button:disabled { + background: #cbd5e1; + color: #475569; + cursor: not-allowed; +} + .package-options { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); @@ -1230,6 +1342,36 @@ min-width: auto; width: 100%; } + + .database-package-picker { + position: sticky; + top: 8px; + z-index: 5; + padding: 12px; + margin-inline: -4px; + border: 1px solid #cbd5e1; + border-radius: 12px; + background: rgba(255, 255, 255, 0.96); + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12); + backdrop-filter: blur(8px); + } + + .smart-package-quick-section { + padding: 13px; + } + + .smart-package-quick-grid { + grid-template-columns: 1fr; + } +} + +@media (prefers-reduced-motion: reduce) { + .section, + .material-item, + .task-card { + animation: none; + transition: none; + } } /* Animation */ diff --git a/frontend/src/components/InlineSmartPackage.js b/frontend/src/components/InlineSmartPackage.js index 36ee41c..e5360c7 100644 --- a/frontend/src/components/InlineSmartPackage.js +++ b/frontend/src/components/InlineSmartPackage.js @@ -2,6 +2,17 @@ import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react' import './InlineSmartPackage.css'; import { PROJECT_STATUS, getProjectStatusMeta } from '../utils/projectStatus'; +const RECENT_PACKAGE_STORAGE_KEY = 'tilbudgivern:recent-smart-packages'; + +const readRecentPackageIds = () => { + try { + const value = JSON.parse(localStorage.getItem(RECENT_PACKAGE_STORAGE_KEY) || '[]'); + return Array.isArray(value) ? value.map(String).slice(0, 3) : []; + } catch (error) { + return []; + } +}; + // Hint komponent til tooltips const Hint = ({ text, multiline = false }) => ( @@ -78,6 +89,7 @@ const InlineSmartPackage = ({ const [databasePackageSearch, setDatabasePackageSearch] = useState(''); const [selectedDatabasePackageId, setSelectedDatabasePackageId] = useState(''); const [databasePackageError, setDatabasePackageError] = useState(''); + const [recentPackageIds, setRecentPackageIds] = useState(readRecentPackageIds); const [similarProjects, setSimilarProjects] = useState(null); const [experienceLoading, setExperienceLoading] = useState(false); const [historyQuery, setHistoryQuery] = useState(''); @@ -256,6 +268,7 @@ const InlineSmartPackage = ({ if (materials.length > 0) { const body = { materials: materials.map((m) => ({ + materialId: m.materialId || m.material_id || null, materialName: m.name, materialCategory: m.category || 'Øvrige', quantity: parseFloat(m.quantity) || 0, @@ -263,6 +276,8 @@ const InlineSmartPackage = ({ unitPrice: parseFloat(m.unitPrice) || 0, supplier: m.supplier || null, materialSource: 'database', + priceSource: m.priceSource || m.price_source || m.supplier || 'materialedatabase', + priceSourceUpdatedAt: m.priceSourceUpdatedAt || m.price_source_updated_at || null, notes: m.calculation || null })).filter((m) => m.materialName && m.quantity > 0 && m.unitPrice >= 0) }; @@ -942,6 +957,8 @@ const InlineSmartPackage = ({ geometryMultiplier: material.geometry_multiplier || null, wasteFactor: parseFloat(material.waste_factor || 1) || 1, sku: material.item_code || null, + priceSource: material.price_source || source.price_source || material.supplier || 'materialedatabase', + priceSourceUpdatedAt: material.price_source_updated_at || source.updated_at || null, isRental: material.isRental === true })), tasks: (source.tasks || []).map(task => { @@ -962,6 +979,15 @@ const InlineSmartPackage = ({ }) }; await loadSmartPackage(normalizedPackage); + setRecentPackageIds(previous => { + const next = [String(packageId), ...previous.filter(id => id !== String(packageId))].slice(0, 3); + try { + localStorage.setItem(RECENT_PACKAGE_STORAGE_KEY, JSON.stringify(next)); + } catch (error) { + console.warn('Kunne ikke gemme seneste Smart Pakker:', error); + } + return next; + }); } catch (error) { console.error('Kunne ikke indlæse Smart Pakke:', error); setDatabasePackageError(error.message || 'Pakken kunne ikke indlæses'); @@ -976,6 +1002,44 @@ const InlineSmartPackage = ({ .some(value => String(value || '').toLocaleLowerCase('da-DK').includes(query))); }, [databasePackageSearch, databasePackages]); + const recommendedDatabasePackage = useMemo(() => { + const recommendation = orderSuggestions?.recommendation?.recommendedPackage; + if (!recommendation) return null; + return databasePackages.find(pkg => ( + String(pkg.id) === String(recommendation.packageId) + || pkg.name === recommendation.name + || String(pkg.validation_notes || '').includes(`Katalog-nøgle: ${recommendation.packageId}.`) + )) || null; + }, [databasePackages, orderSuggestions]); + + const quickPackages = useMemo(() => { + const items = []; + if (recommendedDatabasePackage) items.push({ ...recommendedDatabasePackage, quickReason: 'Anbefalet til projektet' }); + recentPackageIds.forEach(id => { + const recent = databasePackages.find(pkg => String(pkg.id) === id); + if (recent && !items.some(pkg => String(pkg.id) === String(recent.id))) { + items.push({ ...recent, quickReason: 'Senest brugt' }); + } + }); + return items.slice(0, 3); + }, [databasePackages, recentPackageIds, recommendedDatabasePackage]); + + const packageQuality = useCallback((pkg) => { + const materialCount = Number(pkg.material_count || 0); + const taskCount = Number(pkg.task_count || 0); + const unlinkedCount = Number(pkg.unlinked_material_count || 0); + const ready = pkg.validation_status === 'verified' + && materialCount > 0 + && taskCount > 0 + && unlinkedCount === 0; + return { + ready, + label: ready + ? 'Feltklar' + : `${unlinkedCount > 0 ? `${unlinkedCount} materialer mangler kobling` : 'Kræver kontrol'}` + }; + }, []); + const applyOrderSuggestion = useCallback(async (mode = 'full') => { if (!orderSuggestions) { return; @@ -2706,7 +2770,42 @@ const InlineSmartPackage = ({ )} {/* Alle tilgængelige pakker */} -
+ {quickPackages.length > 0 && !selectedPackage && ( +
+
+
+ Hurtigt valg +

Klar til brug hos kunden

+
+ {quickPackages.length} forslag +
+
+ {quickPackages.map(pkg => { + const quality = packageQuality(pkg); + return ( +
+
+ {pkg.quickReason} + {quality.label} +
+
{pkg.name}
+

{pkg.material_count || 0} materialer · {pkg.task_count || 0} opgaver

+ +
+ ); + })} +
+
+ )} + +
@@ -2730,7 +2829,7 @@ const InlineSmartPackage = ({ {filteredDatabasePackages.map(pkg => ( ))} diff --git a/frontend/src/components/ProjectFlow.js b/frontend/src/components/ProjectFlow.js index 705daef..c4b415e 100644 --- a/frontend/src/components/ProjectFlow.js +++ b/frontend/src/components/ProjectFlow.js @@ -7,7 +7,7 @@ import FinalReview from './FinalReview'; import { useNotification } from '../hooks/useNotification'; import { getProjectStatusMeta, getProjectStepFromStatus, isFinalizedProjectStatus } from '../utils/projectStatus'; import { normalizeProjectLaborResponse } from '../utils/projectLabor'; -import { normalizeProjectLine, reconcilePackageLines } from '../utils/projectLines'; +import { normalizeProjectLine, packageDataForSession, reconcilePackageLines } from '../utils/projectLines'; import { markQuoteFlowMilestone, setProductContext, @@ -58,7 +58,8 @@ const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => { const saveToSession = (projectId, dataType, data) => { try { const key = getSessionKey(projectId, dataType); - sessionStorage.setItem(key, JSON.stringify(data)); + const sessionData = dataType === 'packageData' ? packageDataForSession(data) : data; + sessionStorage.setItem(key, JSON.stringify(sessionData)); console.log(`💾 Saved ${dataType} to session:`, key); } catch (error) { console.error('Error saving to session:', error); @@ -385,6 +386,7 @@ const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => { console.log('🧱 Loaded materials:', materialsData.materials.length, 'items'); loadedMaterials = materialsData.materials.map((material) => normalizeProjectLine({ id: material.id, + materialId: material.material_id || null, name: material.material_name || material.name, category: material.material_category || material.category || 'Øvrige', quantity: parseFloat(material.quantity) || 0, @@ -392,6 +394,8 @@ const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => { unitPrice: material.unit_price ?? material.unitPrice, total: material.total_price ?? material.total, supplier: material.supplier || null, + priceSource: material.price_source || material.material_source || null, + priceSourceUpdatedAt: material.price_source_updated_at || material.updated_at || null, calculation: material.notes || '' })); setMaterials(loadedMaterials); diff --git a/frontend/src/components/smartPackages/SmartPackages.js b/frontend/src/components/smartPackages/SmartPackages.js index cea611c..092c1c3 100644 --- a/frontend/src/components/smartPackages/SmartPackages.js +++ b/frontend/src/components/smartPackages/SmartPackages.js @@ -61,6 +61,8 @@ const SmartPackages = ({ onEdit, selectedCategory = null }) => { const [excelMapping, setExcelMapping] = useState(null); const [showAllExcelIssues, setShowAllExcelIssues] = useState(false); const [excelUploads, setExcelUploads] = useState([]); + const [expandedExcelFile, setExpandedExcelFile] = useState(null); + const [excelFilePackages, setExcelFilePackages] = useState({}); // Filtre og søgning const [filters, setFilters] = useState({ @@ -257,6 +259,59 @@ const SmartPackages = ({ onEdit, selectedCategory = null }) => { } }; + const handleResumeExcelFile = async (file) => { + setExcelFile(null); + setUploadedExcelFile({ + filename: file.filename, + originalName: file.displayName, + fileHash: file.fileHash, + size: file.size, + uploadedAt: file.uploadedAt + }); + setExcelValidation(null); + setExcelImportResult(null); + setExcelMappingJob(null); + setExcelMapping(null); + setExcelValidationJob(null); + setExcelError(''); + setExcelPhase('uploading'); + + try { + const response = await axios.post('/api/smart-packages/excel-standard-preview', { + filename: file.filename, + fileHash: file.fileHash + }); + const standardValidation = response.data.standardValidation; + setExcelValidation({ standard: standardValidation, ai: null }); + if (standardValidation?.blocksImport) { + setExcelPhase('error'); + setExcelError('Standardkontrollen fandt fejl, der skal rettes før import.'); + return; + } + await handleAiValidation({ ...file, originalName: file.displayName }, standardValidation, null); + } catch (error) { + const message = error.response?.data?.error || error.message || 'Filen kunne ikke genoptages'; + setExcelPhase('error'); + setExcelError(message); + enqueueSnackbar(message, { variant: 'error' }); + } + }; + + const handleToggleExcelPackages = async (file) => { + if (expandedExcelFile === file.filename) { + setExpandedExcelFile(null); + return; + } + setExpandedExcelFile(file.filename); + if (excelFilePackages[file.filename]) return; + try { + const response = await axios.get(`/api/smart-packages/excel-uploads/${encodeURIComponent(file.filename)}/packages`); + setExcelFilePackages(previous => ({ ...previous, [file.filename]: response.data?.packages || [] })); + } catch (error) { + enqueueSnackbar(error.response?.data?.error || 'Pakkerne kunne ikke hentes', { variant: 'error' }); + } + }; + const handleExcelUpload = async () => { if (!excelFile) { enqueueSnackbar('Vælg en Excel-fil først', { variant: 'warning' }); @@ -750,6 +805,8 @@ const SmartPackages = ({ onEdit, selectedCategory = null }) => { {excelImportResult.totalPackages} pakker behandlet: {excelImportResult.insertedPackages} nye, {' '}{excelImportResult.updatedPackages} opdateret og {excelImportResult.deactivatedPackages} deaktiveret. + {' '}{excelImportResult.linkedMaterialLines || 0} materialelinjer blev koblet til materialedatabasen; + {' '}{excelImportResult.unlinkedMaterialLines || 0} kræver manuel kobling før pakken kan verificeres til feltbrug. )} @@ -763,7 +820,7 @@ const SmartPackages = ({ onEdit, selectedCategory = null }) => { key={file.filename} sx={{ display: 'grid', - gridTemplateColumns: { xs: '1fr', sm: 'minmax(0, 1fr) auto' }, + gridTemplateColumns: '1fr', gap: 1, alignItems: 'center', p: 1.25, @@ -778,15 +835,67 @@ const SmartPackages = ({ onEdit, selectedCategory = null }) => { {new Date(file.uploadedAt).toLocaleString('da-DK')} · {(file.size / 1024).toFixed(0)} KB + {file.uploadedBy ? ` · Uploadet af ${file.uploadedBy}` : ''} + + + SHA-256: {file.fileHash?.slice(0, 12)}… - + + + {file.packagesRequiringReview > 0 && ( + + )} + {file.unlinkedMaterialLines > 0 && ( + + )} + {file.duplicateOf && ( + + )} + + + {file.importedAt + ? `Importeret ${new Date(file.importedAt).toLocaleString('da-DK')}${file.importedBy ? ` af ${file.importedBy}` : ''}` + : 'Ikke importeret endnu'} + {file.importStatus === 'imported' + ? ` · ${file.materialLines} materialelinjer · ${file.taskLines} opgaver` + : ''} + + + + {file.importStatus === 'imported' && ( + + )} + + {expandedExcelFile === file.filename && ( + + {(excelFilePackages[file.filename] || []).slice(0, 50).map(packageItem => ( + + #{packageItem.id} {packageItem.name} · {packageItem.material_lines} materialer · {packageItem.task_lines} opgaver · {packageItem.validation_status} + + ))} + {(excelFilePackages[file.filename] || []).length > 50 && ( + + Viser de første 50 af {excelFilePackages[file.filename].length} pakker. + + )} + + )} ))} diff --git a/frontend/src/utils/projectLines.js b/frontend/src/utils/projectLines.js index de75a24..80c83b6 100644 --- a/frontend/src/utils/projectLines.js +++ b/frontend/src/utils/projectLines.js @@ -28,3 +28,12 @@ export const reconcilePackageLines = ({ sessionPackageData, persistedMaterials, ? persistedRentals.map(normalizeProjectLine) : (sessionPackageData.rentals || []).map(normalizeProjectLine) }); + +// Session storage may keep navigation state, but persisted project lines are the +// price authority. Dropping prices here prevents stale browser values from +// silently winning after a reload or on another device. +export const packageDataForSession = (packageData = {}) => ({ + ...packageData, + materials: (packageData.materials || []).map(({ unitPrice, unit_price, total, totalPrice, total_price, ...line }) => line), + rentals: (packageData.rentals || []).map(({ unitPrice, unit_price, total, totalPrice, total_price, ...line }) => line) +}); diff --git a/frontend/src/utils/projectLines.test.js b/frontend/src/utils/projectLines.test.js index 8ee069c..0cd7ffd 100644 --- a/frontend/src/utils/projectLines.test.js +++ b/frontend/src/utils/projectLines.test.js @@ -1,4 +1,4 @@ -import { normalizeProjectLine, reconcilePackageLines } from './projectLines'; +import { normalizeProjectLine, packageDataForSession, reconcilePackageLines } from './projectLines'; describe('project line normalization', () => { test('maps persisted snake_case prices into the editable line contract', () => { @@ -29,4 +29,16 @@ describe('project line normalization', () => { expect(result.materials[0].unitPrice).toBe(180); expect(result.rentals[0].unitPrice).toBe(14000); }); + + test('does not store authoritative prices in browser session data', () => { + const result = packageDataForSession({ + selectedPackageId: 42, + materials: [{ name: 'Tagsten', quantity: 10, unitPrice: 25, total: 250 }], + rentals: [{ name: 'Stillads', quantity: 1, unit_price: 5000, total_price: 5000 }] + }); + + expect(result.selectedPackageId).toBe(42); + expect(result.materials[0]).toEqual({ name: 'Tagsten', quantity: 10 }); + expect(result.rentals[0]).toEqual({ name: 'Stillads', quantity: 1 }); + }); }); diff --git a/status.md b/status.md index 86406bc..84ff60c 100644 --- a/status.md +++ b/status.md @@ -246,6 +246,30 @@ Formål: Genbrug godkendte data sikkert videre i driften og bevar et revisionssp Teknisk regression: backend **38 suites / 195 tests**, frontend **8 suites / 19 tests**, production build og live Playwright-kontrol bestod. PM2-servicen blev genstartet, og `/api/health` svarede grønt. +### Opfølgende forbedringsplan for #9, #10 og #11 — 15. august 2026 + +Målet var at gøre de tre rettelser robuste nok til tømrerens reelle feltflow, ikke kun lukke de oprindelige symptomer. Arbejdet blev udført i denne rækkefølge: + +1. ✅ Gør databasen til autoritet for materialepriser og fjern forældede priser fra browserens sessionskopi. +2. ✅ Blokér afsendelse ved 0-priser, manglende materiale-id eller totaler, der ikke svarer til antal × enhedspris. +3. ✅ Gem materiale-id, priskilde og prisdato på projektets materialelinje og vis dem i Final Review. +4. ✅ Gør Excel-import sporbar med SHA-256, bruger, upload-/importtid, dubletkontrol, kvalitetsresultat og mulighed for at genoptage eller gendanne en konkret filversion. +5. ✅ Vis kun komplette, verificerede og fuldt databasekoblede SmartPakker som feltklare; gør anbefalede og senest brugte pakker til højst tre hurtigvalg på mobil. +6. ✅ Verificér på 390 × 844 px, genindlæs projektet, kontroller databasen, kør regressionstest og production build. + +| Forbedring | Resultat | Beståelsesbevis | +|---|---|---| +| #11 prisintegritet | ✅ Løst og verificeret | Final Review stopper materialer/udlejning med 0-pris, ukoblede materialer og afvigende linjetotaler. Browser-sessionen kan ikke længere være prisautoritet. | +| Prisens revisionsspor | ✅ Løst og verificeret | Migrationen `20260815_project_material_price_provenance.sql` er anvendt. Projektmaterialer kan gemme `material_id`, `price_source` og `price_source_updated_at`. | +| #9 Excel-sporbarhed | ✅ Løst og verificeret | Uploadhistorikken viser filhash, bruger, tid, importstatus og kvalitetsmål. Identisk allerede importeret filindhold afvises, mens samme kendte version kan genkøres kontrolleret. | +| Excel-kvalitetsport | ✅ Løst og verificeret i kode | Importen tæller koblede og ukoblede materialer. Pakker uden rigtige databasekoblinger eller positiv pris kan ikke verificeres som feltklare. | +| #10 mobilt SmartPakke-valg | ✅ Løst og verificeret | Mobilvisningen viser anbefalet/senest brugt SmartPakke som store, fokuserede hurtigvalg; øvrige pakker er stadig søgbare i dropdown. | +| Driftstest | ✅ Bestået | En verificeret B7-pakke gav 8 rigtige materialer og 5 opgaver. 8/8 linjer blev gemt med materiale-id, positiv pris, priskilde og prisdato. Efter fuld reload viste Final Review samme total på 156.084,21 kr. uden pris-, koblings- eller sumblokeringer. | + +Excel-gennemgangen afdækkede samtidig et vigtigt datakvalitetsfund: den seneste store import indeholder **519 materialelinjer, hvoraf 517 ikke er koblet til materialedatabasen**, og **40 pakker kræver review**. De er bevaret til oprydning, men bliver ikke præsenteret som feltklare SmartPakker. Dette følger den faste regel: en utilstrækkelig pakke skal udvides eller erstattes, og ingen pakke må godkendes til feltbrug med fritekstmaterialer. + +**Endelig verifikation:** Backend **39 suites / 197 tests**, frontend **8 suites / 21 tests**, production build og live Playwright på 390 × 844 px bestod. Den første live-gemning afdækkede et ISO-/MySQL-datetimeproblem; det blev rettet og dækket af en regressionstest. Det særskilte testprojekt `394` og alle dets kaskaderede testdata blev derefter slettet permanent; kontrol viste 0 resterende projekt-, materiale-, labor- og geometrirækker. + ## Anbefalet næste handling **Platform:** Gennemfør næste tømrer-mode-feltforsøg fra helt ny kunde gennem fysisk besigtigelse på mobil via den nye “Nyt tilbud”-indgang. August-rapportens kendte-projekt-baseline og den første mobile UX-bølge er oprettet; næste kontrol skal især måle resterende manuel indtastning, offline-risiko og den åbne P2-checkliste.