feat: harden smart package field workflow

This commit is contained in:
alexpolo1
2026-08-15 00:34:28 +02:00
parent 3ab5e8cf5e
commit f9c0b7bc57
17 changed files with 724 additions and 34 deletions
@@ -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
]);
});
});
@@ -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 }];
+122 -7
View File
@@ -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);
+18 -5
View File
@@ -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,
@@ -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) {
@@ -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);
@@ -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
@@ -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);
+58 -1
View File
@@ -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 = ({
</div>
{editableMaterials.map((material, index) => (
<div key={index} className="table-row editable-row">
<span className="material-name">{material.name}</span>
<span className="material-name">
{material.name}
<small style={{ display: 'block', color: Number(material.materialId ?? material.material_id) ? '#166534' : '#b91c1c' }}>
{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')}`
: ''}
</small>
</span>
<input
type="number"
step="0.1"
+25 -3
View File
@@ -35,7 +35,7 @@ describe('FinalReview readiness checks', () => {
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'
])
);
});
});
@@ -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 */
+101 -2
View File
@@ -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 }) => (
<span className="hint-icon">
@@ -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 */}
<div style={{marginBottom: '16px'}}>
{quickPackages.length > 0 && !selectedPackage && (
<div className="smart-package-quick-section" aria-labelledby="quick-package-heading">
<div className="smart-package-quick-heading">
<div>
<span className="smart-package-eyebrow">Hurtigt valg</span>
<h4 id="quick-package-heading">Klar til brug hos kunden</h4>
</div>
<span className="smart-package-quick-count">{quickPackages.length} forslag</span>
</div>
<div className="smart-package-quick-grid">
{quickPackages.map(pkg => {
const quality = packageQuality(pkg);
return (
<article className="smart-package-quick-card" key={pkg.id}>
<div className="smart-package-quick-meta">
<span>{pkg.quickReason}</span>
<span className={quality.ready ? 'is-ready' : 'needs-review'}>{quality.label}</span>
</div>
<h5 title={pkg.name}>{pkg.name}</h5>
<p>{pkg.material_count || 0} materialer · {pkg.task_count || 0} opgaver</p>
<button
type="button"
onClick={() => loadDatabaseSmartPackage(pkg.id)}
disabled={!quality.ready || databasePackagesLoading}
aria-label={`Vælg ${pkg.name}`}
>
{quality.ready ? 'Vælg pakke' : 'Kræver kontrol'}
</button>
</article>
);
})}
</div>
</div>
)}
<div className="database-package-picker" style={{marginBottom: '16px'}}>
<label htmlFor="database-smart-package" style={{display: 'block', fontWeight: 600, marginBottom: '6px'}}>
Gemte Smart Pakker ({databasePackages.length})
</label>
@@ -2730,7 +2829,7 @@ const InlineSmartPackage = ({
</option>
{filteredDatabasePackages.map(pkg => (
<option key={pkg.id} value={pkg.id}>
{pkg.name} · {pkg.material_count || 0} materialer · {pkg.task_count || 0} opgaver
{pkg.name} · {pkg.material_count || 0} materialer · {pkg.task_count || 0} opgaver · {packageQuality(pkg).label}
</option>
))}
</select>
+6 -2
View File
@@ -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);
@@ -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 }) => {
<Alert severity="success" sx={{ mt: 2 }}>
{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.
</Alert>
)}
@@ -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 }) => {
</Typography>
<Typography variant="caption" color="text.secondary">
{new Date(file.uploadedAt).toLocaleString('da-DK')} · {(file.size / 1024).toFixed(0)} KB
{file.uploadedBy ? ` · Uploadet af ${file.uploadedBy}` : ''}
</Typography>
<Typography variant="caption" color="text.secondary" display="block" sx={{ fontFamily: 'monospace' }}>
SHA-256: {file.fileHash?.slice(0, 12)}…
</Typography>
</Box>
<Chip
size="small"
color={file.importStatus === 'imported' ? 'success' : 'info'}
label={file.importStatus === 'imported'
? `${file.activePackages} aktive pakker`
: 'Uploadet – ikke importeret'}
/>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, alignItems: 'center' }}>
<Chip
size="small"
color={file.importStatus === 'imported' ? 'success' : 'info'}
label={file.importStatus === 'imported'
? `${file.activePackages} aktive pakker`
: 'Uploadet – ikke importeret'}
/>
{file.packagesRequiringReview > 0 && (
<Chip size="small" color="warning" label={`${file.packagesRequiringReview} kræver review`} />
)}
{file.unlinkedMaterialLines > 0 && (
<Chip size="small" color="error" label={`${file.unlinkedMaterialLines} materialer ikke koblet`} />
)}
{file.duplicateOf && (
<Chip size="small" variant="outlined" label="Dubletindhold" />
)}
</Box>
<Typography variant="caption" color="text.secondary">
{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`
: ''}
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
<Button
size="small"
variant={file.importStatus === 'imported' ? 'outlined' : 'contained'}
onClick={() => handleResumeExcelFile(file)}
disabled={Boolean(file.duplicateOf && file.importStatus !== 'imported')}
>
{file.importStatus === 'imported' ? 'Gendan denne version' : 'Fortsæt kontrol'}
</Button>
{file.importStatus === 'imported' && (
<Button size="small" onClick={() => handleToggleExcelPackages(file)}>
{expandedExcelFile === file.filename ? 'Skjul pakker' : 'Se importerede pakker'}
</Button>
)}
</Box>
{expandedExcelFile === file.filename && (
<Box sx={{ maxHeight: 240, overflow: 'auto', bgcolor: 'grey.50', borderRadius: 1, p: 1 }}>
{(excelFilePackages[file.filename] || []).slice(0, 50).map(packageItem => (
<Typography key={packageItem.id} variant="caption" display="block" sx={{ py: 0.4 }}>
#{packageItem.id} {packageItem.name} · {packageItem.material_lines} materialer · {packageItem.task_lines} opgaver · {packageItem.validation_status}
</Typography>
))}
{(excelFilePackages[file.filename] || []).length > 50 && (
<Typography variant="caption" display="block" fontWeight={700} sx={{ pt: 1 }}>
Viser de første 50 af {excelFilePackages[file.filename].length} pakker.
</Typography>
)}
</Box>
)}
</Box>
))}
</Box>
+9
View File
@@ -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)
});
+13 -1
View File
@@ -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 });
});
});
+24
View File
@@ -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.