feat: harden smart package field workflow
This commit is contained in:
@@ -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 }];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user