Merge pull request #21 from alexpolo1/fix/jannick-price-import-flow

fix: make Jannick price import usable and safe
This commit is contained in:
Alex
2026-08-24 08:37:01 +02:00
committed by GitHub
17 changed files with 692 additions and 78 deletions

View File

@@ -128,8 +128,8 @@ The application uses a unified server (`backend/unified-server.js`) that:
```bash
git checkout main && git pull # Get the commit you want live
cd frontend && npm run build # Rebuild the served static frontend
cd ../backend && npm ci --omit=dev # Sync backend deps (skip if unchanged)
cd frontend && npm ci && npm run build # Sync frontend deps and rebuild static assets
cd ../backend && npm ci --omit=dev # Sync backend deps (skip if unchanged)
pm2 restart tilbudgivern-unified # Restart the running process
pm2 logs tilbudgivern-unified --lines 30 --nostream # Confirm a clean startup (no errors)
curl -sf http://localhost:4032/api/health # Should return {"status":"ok",...}

View File

@@ -0,0 +1,126 @@
const HaandvaerkPriserImportService = require('../src/services/haandvaerkPriserImportService');
const {
priceInclVatToExVat,
buildPriceDecision
} = HaandvaerkPriserImportService;
describe('haandvaerkpriser price contract', () => {
test('converts the source price including VAT to quote input excluding VAT', () => {
expect(priceInclVatToExVat(125)).toBe(100);
expect(priceInclVatToExVat(110)).toBe(88);
});
test('averages a stable manual baseline with the latest net source price', () => {
expect(buildPriceDecision({ sourcePriceInclVat: 125, baselinePriceExVat: 80 })).toEqual({
sourcePriceInclVat: 125,
sourcePriceExVat: 100,
baselinePriceExVat: 80,
effectivePriceExVat: 90
});
});
test('repeating an unchanged pull is deterministic', () => {
const first = buildPriceDecision({ sourcePriceInclVat: 125, baselinePriceExVat: 80 });
const second = buildPriceDecision({
sourcePriceInclVat: 125,
baselinePriceExVat: first.baselinePriceExVat
});
expect(second.effectivePriceExVat).toBe(first.effectivePriceExVat);
});
test('uses the current net source price when no manual baseline exists', () => {
expect(buildPriceDecision({ sourcePriceInclVat: 125, baselinePriceExVat: null }).effectivePriceExVat).toBe(100);
});
});
describe('haandvaerkpriser imported component shape', () => {
test('refuses to start when the database import lock is held', async () => {
const connection = {
execute: jest.fn().mockResolvedValueOnce([[{ acquired: 0 }]]),
beginTransaction: jest.fn(),
rollback: jest.fn(),
release: jest.fn()
};
const service = new HaandvaerkPriserImportService({
pool: { getConnection: jest.fn().mockResolvedValue(connection) }
});
service.fetchRows = jest.fn().mockResolvedValue([{
category: 'Maler', name: 'X', avgPrice: 125, unit: 'stk', detailUrl: 'https://x'
}]);
await expect(service.import()).rejects.toThrow('allerede i gang');
expect(connection.beginTransaction).not.toHaveBeenCalled();
expect(connection.release).toHaveBeenCalled();
});
test('always releases the pooled connection when releasing the advisory lock fails', async () => {
const connection = {
beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn(),
execute: jest.fn(async (sql) => {
if (sql.includes('SELECT GET_LOCK')) return [[{ acquired: 1 }]];
if (sql.includes('SELECT RELEASE_LOCK')) throw new Error('lock release failed');
if (sql.includes('SELECT id, name, category')) return [[]];
if (sql.includes('SELECT source_key')) return [[]];
if (sql.includes('INSERT INTO material_packages')) return [{ insertId: 901 }];
if (sql.includes('DELETE FROM smart_package_tasks')) return [{}];
if (sql.includes('INSERT INTO haandvaerkpriser_imports')) return [{}];
throw new Error(`Unexpected SQL: ${sql}`);
})
};
const service = new HaandvaerkPriserImportService({
pool: { getConnection: jest.fn().mockResolvedValue(connection) }
});
service.fetchRows = jest.fn().mockResolvedValue([{
category: 'Maler', name: 'X', avgPrice: 125, unit: 'stk',
priceRangeLow: 100, priceRangeHigh: 150, rawUnitText: 'pr. stk', detailUrl: 'https://x'
}]);
await expect(service.import()).resolves.toMatchObject({ insertedPackages: 1 });
expect(connection.release).toHaveBeenCalled();
});
test('creates a reference service package without a fake labor task', async () => {
const calls = [];
const connection = {
beginTransaction: jest.fn(),
commit: jest.fn(),
rollback: jest.fn(),
release: jest.fn(),
execute: jest.fn(async (sql, params = []) => {
calls.push({ sql, params });
if (sql.includes('SELECT GET_LOCK')) return [[{ acquired: 1 }]];
if (sql.includes('SELECT RELEASE_LOCK')) return [[{ released: 1 }]];
if (sql.includes('SELECT id, name, category')) return [[]];
if (sql.includes('SELECT source_key')) return [[]];
if (sql.includes('INSERT INTO material_packages')) return [{ insertId: 901 }];
if (sql.includes('INSERT INTO haandvaerkpriser_imports')) return [{}];
if (sql.includes('DELETE FROM smart_package_tasks')) return [{}];
throw new Error(`Unexpected SQL: ${sql}`);
})
};
const pool = {
execute: jest.fn().mockResolvedValue([{}]),
getConnection: jest.fn().mockResolvedValue(connection)
};
const service = new HaandvaerkPriserImportService({ pool });
service.fetchRows = jest.fn().mockResolvedValue([{
category: 'Maler',
name: 'Maling af lejlighed',
avgPrice: 125,
priceRangeLow: 100,
priceRangeHigh: 150,
unit: 'm²',
timeUnit: 'per_sqm',
rawUnitText: 'pr. m²',
detailUrl: 'https://haandvaerkpriser.dk/maler/x/'
}]);
const result = await service.import();
expect(result).toMatchObject({ totalRows: 1, insertedPackages: 1 });
expect(calls.some(call => call.sql.includes('INSERT INTO smart_package_tasks'))).toBe(false);
const packageInsert = calls.find(call => call.sql.includes('INSERT INTO material_packages'));
expect(packageInsert.params).toEqual(expect.arrayContaining([100]));
});
});

View File

@@ -82,7 +82,7 @@ describe('normalizeUnit', () => {
describe('normalizeName', () => {
test('strips accents and punctuation for stable matching', () => {
expect(normalizeName('Udskiftning af vinduer (2-lag)')).toBe('udskiftning af vinduer 2 lag');
expect(normalizeName('Tømrer & Snedker')).toBe('t mrer snedker');
expect(normalizeName('Tømrer & Snedker')).toBe('tomrer snedker');
});
});
@@ -119,15 +119,16 @@ describe('HaandvaerkPriserImportService.import', () => {
const executedInserts = [];
const executedUpdates = [];
const execute = jest.fn(async (sql, params) => {
if (sql.includes('SELECT GET_LOCK')) return [[{ acquired: 1 }]];
if (sql.includes('SELECT RELEASE_LOCK')) return [[{ released: 1 }]];
if (sql.includes('SELECT id, name, category, unit_price')) return [existingRows];
if (sql.includes('SELECT source_key')) return [[]];
if (sql.includes('INSERT INTO material_packages')) {
executedInserts.push({ sql, params });
return [{ insertId: 900 + executedInserts.length }];
}
if (sql.includes('INSERT INTO smart_package_tasks')) {
executedInserts.push({ sql, params });
return [{}];
}
if (sql.includes('INSERT INTO haandvaerkpriser_imports')) return [{}];
if (sql.includes('DELETE FROM smart_package_tasks')) return [{}];
if (sql.includes('UPDATE material_packages') || sql.includes('UPDATE smart_package_tasks')) {
executedUpdates.push({ sql, params });
return [{}];
@@ -138,46 +139,50 @@ describe('HaandvaerkPriserImportService.import', () => {
return { connection, executedInserts, executedUpdates };
};
test('inserts a new verified component package with one task carrying the price', async () => {
test('inserts a verified reference-service component at the net source price', async () => {
const { connection, executedInserts } = buildConnection([]);
const service = new HaandvaerkPriserImportService({ pool: { getConnection: async () => connection } });
const service = new HaandvaerkPriserImportService({ pool: { execute: jest.fn().mockResolvedValue([{}]), getConnection: async () => connection } });
service.fetchRows = jest.fn().mockResolvedValue([
{ category: 'Maler', name: 'Maling af lejlighed', avgPrice: 110, priceRangeLow: 60, priceRangeHigh: 120, unit: 'm²', timeUnit: 'per_sqm', rawUnitText: 'pr. m²', detailUrl: 'https://haandvaerkpriser.dk/maler/x/' }
]);
const result = await service.import();
expect(result).toEqual({ totalRows: 1, insertedPackages: 1, updatedPackages: 0 });
expect(result).toEqual({ totalRows: 1, insertedPackages: 1, updatedPackages: 0, matchedExistingPackages: 0 });
expect(connection.commit).toHaveBeenCalled();
const packageInsert = executedInserts.find((call) => call.sql.includes('INSERT INTO material_packages'));
expect(packageInsert.params).toEqual(expect.arrayContaining(['Maling af lejlighed', expect.any(String), 'Maler']));
expect(packageInsert.sql).toContain("'component'");
expect(packageInsert.sql).toContain("'verified'");
const taskInsert = executedInserts.find((call) => call.sql.includes('INSERT INTO smart_package_tasks'));
expect(taskInsert.params).toEqual([901, 'Maling af lejlighed', expect.any(String), 110, 'per_sqm', expect.any(String)]);
expect(packageInsert.params).toContain(88);
expect(executedInserts.some((call) => call.sql.includes('INSERT INTO smart_package_tasks'))).toBe(false);
});
test('averages the price with the existing package on a repeat import instead of duplicating', async () => {
const { connection, executedUpdates } = buildConnection([
{ id: 42, name: 'Maling af lejlighed', category: 'Maler', unit_price: '90.00' }
test('creates a separate deterministic reference averaged with an existing manual component', async () => {
const { connection, executedInserts, executedUpdates } = buildConnection([
{ id: 42, name: 'Maling af lejlighed', category: 'Maler', unit_price: '90.00', created_by: 'jannick', is_active: 1, validation_status: 'verified' }
]);
const service = new HaandvaerkPriserImportService({ pool: { getConnection: async () => connection } });
const service = new HaandvaerkPriserImportService({ pool: { execute: jest.fn().mockResolvedValue([{}]), getConnection: async () => connection } });
service.fetchRows = jest.fn().mockResolvedValue([
{ category: 'Maler', name: 'Maling af lejlighed', avgPrice: 110, priceRangeLow: 60, priceRangeHigh: 120, unit: 'm²', timeUnit: 'per_sqm', rawUnitText: 'pr. m²', detailUrl: 'https://haandvaerkpriser.dk/maler/x/' }
]);
const result = await service.import();
expect(result).toEqual({ totalRows: 1, insertedPackages: 0, updatedPackages: 1 });
const packageUpdate = executedUpdates.find((call) => call.sql.includes('UPDATE material_packages'));
// (90 + 110) / 2 = 100
expect(packageUpdate.params).toEqual(expect.arrayContaining([100]));
expect(result).toEqual({ totalRows: 1, insertedPackages: 1, updatedPackages: 0, matchedExistingPackages: 1 });
expect(executedUpdates).toHaveLength(0);
const packageInsert = executedInserts.find((call) => call.sql.includes('INSERT INTO material_packages'));
// Source 110 inkl. moms = 88 ekskl. moms; (90 + 88) / 2 = 89.
expect(packageInsert.params).toEqual(expect.arrayContaining([89]));
});
test('rolls back and rethrows on a database error', async () => {
const { connection } = buildConnection([]);
connection.execute = jest.fn().mockRejectedValue(new Error('db down'));
const service = new HaandvaerkPriserImportService({ pool: { getConnection: async () => connection } });
connection.execute = jest.fn()
.mockResolvedValueOnce([[{ acquired: 1 }]])
.mockRejectedValueOnce(new Error('db down'))
.mockResolvedValueOnce([[{ released: 1 }]]);
const service = new HaandvaerkPriserImportService({ pool: { execute: jest.fn().mockResolvedValue([{}]), getConnection: async () => connection } });
service.fetchRows = jest.fn().mockResolvedValue([
{ category: 'Maler', name: 'X', avgPrice: 100, priceRangeLow: 1, priceRangeHigh: 2, unit: 'stk', timeUnit: 'per_piece', rawUnitText: 'pr. stk', detailUrl: 'https://x' }
]);

View File

@@ -0,0 +1,123 @@
const express = require('express');
const request = require('supertest');
const jwt = require('jsonwebtoken');
process.env.JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET || 'test-access-secret';
process.env.JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'test-refresh-secret';
process.env.AUTH_USERNAME = 'jannick';
const mockPreview = jest.fn();
const mockImport = jest.fn();
jest.mock('uuid', () => ({ v4: () => 'test-corr-id-uuid' }));
jest.mock('../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), logInfo: jest.fn()
}));
jest.mock('../src/services/haandvaerkPriserImportService', () => class MockHaandvaerkPriserImportService {
preview(...args) { return mockPreview(...args); }
import(...args) { return mockImport(...args); }
});
const smartPackagesRoutes = require('../src/routes/smartPackagesRoutes');
const buildApp = () => {
const app = express();
app.use(express.json());
app.use('/api/smart-packages', smartPackagesRoutes);
return app;
};
const authHeader = () => `Bearer ${jwt.sign({ id: 1, username: 'jannick' }, process.env.JWT_ACCESS_SECRET)}`;
const otherUserHeader = () => `Bearer ${jwt.sign({ id: 2, username: 'other-user' }, process.env.JWT_ACCESS_SECRET)}`;
describe('haandvaerkpriser routes', () => {
beforeEach(() => {
mockPreview.mockReset();
mockImport.mockReset();
});
test('requires authentication for preview and import', async () => {
const app = buildApp();
expect((await request(app).get('/api/smart-packages/haandvaerkpriser-preview')).status).toBe(401);
expect((await request(app).post('/api/smart-packages/haandvaerkpriser-import').send({ confirm: true })).status).toBe(401);
});
test('restricts price operations to the configured operator account', async () => {
const app = buildApp();
expect((await request(app)
.get('/api/smart-packages/haandvaerkpriser-preview')
.set('Authorization', otherUserHeader())).status).toBe(403);
expect((await request(app)
.post('/api/smart-packages/haandvaerkpriser-import')
.set('Authorization', otherUserHeader())
.send({ confirm: true })).status).toBe(403);
expect(mockPreview).not.toHaveBeenCalled();
expect(mockImport).not.toHaveBeenCalled();
});
test('requires explicit confirmation before the write', async () => {
const response = await request(buildApp())
.post('/api/smart-packages/haandvaerkpriser-import')
.set('Authorization', authHeader())
.send({});
expect(response.status).toBe(400);
expect(response.body.error).toMatch(/bekræft/i);
expect(mockImport).not.toHaveBeenCalled();
});
test('runs a confirmed import', async () => {
mockImport.mockResolvedValue({ totalRows: 195, insertedPackages: 195, updatedPackages: 0, matchedExistingPackages: 0 });
const response = await request(buildApp())
.post('/api/smart-packages/haandvaerkpriser-import')
.set('Authorization', authHeader())
.send({ confirm: true });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({ success: true, totalRows: 195 });
expect(mockImport).toHaveBeenCalledTimes(1);
});
test('rejects overlapping imports', async () => {
let releaseImport;
mockImport.mockReturnValue(new Promise(resolve => { releaseImport = resolve; }));
const app = buildApp();
const firstRequest = request(app)
.post('/api/smart-packages/haandvaerkpriser-import')
.set('Authorization', authHeader())
.send({ confirm: true })
.then(response => response);
await new Promise(resolve => setImmediate(resolve));
const second = await request(app)
.post('/api/smart-packages/haandvaerkpriser-import')
.set('Authorization', authHeader())
.send({ confirm: true });
expect(second.status).toBe(409);
expect(second.body.error).toMatch(/allerede i gang/i);
releaseImport({ totalRows: 1, insertedPackages: 1, updatedPackages: 0, matchedExistingPackages: 0 });
expect((await firstRequest).status).toBe(200);
});
test('does not expose upstream or database errors', async () => {
mockPreview.mockRejectedValue(new Error('connect ECONNREFUSED secret-host'));
mockImport.mockRejectedValue(new Error('ER_BAD_FIELD_ERROR private_schema.secret_column'));
const app = buildApp();
const preview = await request(app)
.get('/api/smart-packages/haandvaerkpriser-preview')
.set('Authorization', authHeader());
const imported = await request(app)
.post('/api/smart-packages/haandvaerkpriser-import')
.set('Authorization', authHeader())
.send({ confirm: true });
expect(preview.status).toBe(502);
expect(preview.body.error).toBe('Kunne ikke hente referencepriser');
expect(JSON.stringify(preview.body)).not.toContain('secret-host');
expect(imported.status).toBe(500);
expect(imported.body.error).toBe('Importen kunne ikke gennemføres');
expect(JSON.stringify(imported.body)).not.toContain('private_schema');
});
});

View File

@@ -13,6 +13,15 @@ const { verifyToken } = require('../middleware/auth');
const aiValidationJobs = require('../services/aiValidationJobService');
const { SmartPackageIntegrityService } = require('../services/smartPackageIntegrityService');
const HaandvaerkPriserImportService = require('../services/haandvaerkPriserImportService');
let haandvaerkImportInProgress = false;
const requireConfiguredPriceOperator = (req, res, next) => {
const configuredUsername = String(process.env.AUTH_USERNAME || '').trim();
if (!configuredUsername || req.user?.username !== configuredUsername) {
return res.status(403).json({ success: false, error: 'Ingen adgang til prisimport' });
}
next();
};
const excelUploadDir = path.join(__dirname, '../../uploads/smart-packages/excel');
const excelMetadataPath = path.join(excelUploadDir, '.import-metadata.json');
@@ -269,26 +278,35 @@ router.get('/integrity-report', async (req, res) => {
}
});
// Referencepriser fra haandvaerkpriser.dk (support ticket #396994). Preview
// henter og parser siden uden at skrive noget - bruges til at vise en admin
// hvad en import ville medføre, før den faktisk køres.
router.get('/haandvaerkpriser-preview', verifyToken, async (req, res) => {
// Referencepriser fra haandvaerkpriser.dk (support ticket #396994). Begge
// endpoints kræver en autentificeret bruger; importen kræver desuden en
// eksplicit bekræftelse i request body.
router.get('/haandvaerkpriser-preview', verifyToken, requireConfiguredPriceOperator, async (req, res) => {
try {
const preview = await new HaandvaerkPriserImportService(global.databaseService).preview();
res.json({ success: true, ...preview });
} catch (error) {
console.error('Error previewing haandvaerkpriser.dk import:', error);
res.status(500).json({ success: false, error: 'Kunne ikke hente forhåndsvisning: ' + error.message });
res.status(502).json({ success: false, error: 'Kunne ikke hente referencepriser' });
}
});
router.post('/haandvaerkpriser-import', verifyToken, async (req, res) => {
router.post('/haandvaerkpriser-import', verifyToken, requireConfiguredPriceOperator, async (req, res) => {
if (req.body?.confirm !== true) {
return res.status(400).json({ success: false, error: 'Importen skal bekræftes eksplicit' });
}
if (haandvaerkImportInProgress) {
return res.status(409).json({ success: false, error: 'En Håndværkpriser-import er allerede i gang' });
}
haandvaerkImportInProgress = true;
try {
const result = await new HaandvaerkPriserImportService(global.databaseService).import();
res.json({ success: true, ...result });
} catch (error) {
console.error('Error importing from haandvaerkpriser.dk:', error);
res.status(500).json({ success: false, error: 'Import fejlede: ' + error.message });
res.status(500).json({ success: false, error: 'Importen kunne ikke gennemføres' });
} finally {
haandvaerkImportInProgress = false;
}
});

View File

@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS haandvaerkpriser_imports (
source_key CHAR(64) NOT NULL PRIMARY KEY,
source_category VARCHAR(100) NOT NULL,
source_name VARCHAR(255) NOT NULL,
source_url VARCHAR(1000) NULL,
source_price_incl_vat DECIMAL(12,2) NOT NULL,
source_price_excl_vat DECIMAL(12,2) NOT NULL,
matched_package_id INT NOT NULL,
baseline_price_excl_vat DECIMAL(12,2) NULL,
effective_price_excl_vat DECIMAL(12,2) NOT NULL,
imported_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_haandvaerkpriser_package (matched_package_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -129,6 +129,7 @@ export const isRentalOrServiceItem = (item = {}) => {
|| category.includes('udlej')
|| category.includes('rental')
|| category.includes('leje')
|| category.includes('referenceydelse')
|| name.includes('udlej')
|| name.includes('leje')
|| name.includes('stillads')

View File

@@ -17,6 +17,7 @@ jest.mock('../hooks/useNotification', () => ({
describe('FinalReview line classification', () => {
test('separates stillads from materials as an explicit other service', () => {
expect(isRentalOrServiceItem({ name: 'Stillads og kollektiv faldsikring', unit: 'sum' })).toBe(true);
expect(isRentalOrServiceItem({ name: 'Ekstern pris', category: 'Referenceydelse', unit: 'm²' })).toBe(true);
expect(isRentalOrServiceItem({ name: 'Betontagsten', unit: 'm²' })).toBe(false);
});
});

View File

@@ -8,6 +8,7 @@ import {
calculateTasksForGeometry,
normalizeGeometryInput
} from '../utils/smartPackageGeometryCalculations';
import { normalizeSmartPackageForSelection } from '../utils/smartPackageSelection';
const RECENT_PACKAGE_STORAGE_KEY = 'tilbudgivern:recent-smart-packages';
@@ -63,6 +64,7 @@ const InlineSmartPackage = ({
|| category.includes('udlej')
|| category.includes('rental')
|| category.includes('leje')
|| category.includes('referenceydelse')
|| name.includes('udlej')
|| name.includes('leje');
}, []);
@@ -869,54 +871,7 @@ const InlineSmartPackage = ({
const response = await fetch(`${apiBaseUrl}/api/smart-packages/${packageId}`);
const data = await response.json();
if (!response.ok || !data?.success) throw new Error(data?.error || 'Pakken kunne ikke hentes');
const source = data.package;
const sourceMaterials = Array.isArray(source.projectLines) && source.projectLines.length > 0
? source.projectLines
: (Array.isArray(source.materials) ? source.materials : []);
if (sourceMaterials.length === 0) {
throw new Error('Smart Pakken har ingen tilknyttede materialer endnu');
}
return {
id: source.id,
name: source.name,
description: source.description || '',
source: 'database',
materials: sourceMaterials.map(material => ({
id: material.id,
materialId: material.material_id,
name: material.material_name || material.name,
quantity: parseFloat(material.quantity) || 1,
unit: material.unit || 'stk',
unitPrice: parseFloat(material.unit_price ?? material.price ?? 0) || 0,
category: material.material_category || material.category || 'Materiale',
calculation: material.notes || '',
matchStatus: material.material_match_status || null,
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 => {
const perUnit = parseFloat(task.time_per_unit) || parseFloat(task.hours) || 0;
const rate = parseFloat(task.rate) || parseFloat(source.hourly_rate) || 580;
return {
id: task.id,
name: task.name,
description: task.description || '',
rate,
timePerM2: task.time_unit === 'per_sqm' ? perUnit : 0,
timePerM: task.time_unit === 'per_meter' ? perUnit : 0,
useLength: task.time_unit === 'per_meter',
fixedHours: ['per_project', 'per_piece', 'per_hour'].includes(task.time_unit)
? perUnit
: 0
};
})
};
return normalizeSmartPackageForSelection(data.package);
}, [apiBaseUrl]);
const loadDatabaseSmartPackage = useCallback(async (packageId) => {

View File

@@ -0,0 +1,126 @@
import React, { useState } from 'react';
import {
Alert,
Box,
Button,
Checkbox,
Chip,
CircularProgress,
FormControlLabel,
Paper,
Typography
} from '@mui/material';
import axios from 'axios';
const formatDkk = value => `${Number(value || 0).toLocaleString('da-DK', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})} kr.`;
const HaandvaerkPriserImportPanel = ({ onImported }) => {
const [preview, setPreview] = useState(null);
const [confirmed, setConfirmed] = useState(false);
const [loading, setLoading] = useState(false);
const [importing, setImporting] = useState(false);
const [result, setResult] = useState(null);
const [error, setError] = useState('');
const loadPreview = async () => {
setLoading(true);
setError('');
setResult(null);
setConfirmed(false);
try {
const response = await axios.get('/api/smart-packages/haandvaerkpriser-preview');
setPreview(response.data);
} catch (previewError) {
setPreview(null);
setError(previewError.response?.data?.error || 'Kunne ikke hente referencepriser');
} finally {
setLoading(false);
}
};
const runImport = async () => {
if (!preview || !confirmed) return;
setImporting(true);
setError('');
try {
const response = await axios.post('/api/smart-packages/haandvaerkpriser-import', { confirm: true });
setResult(response.data);
setConfirmed(false);
onImported?.(response.data);
} catch (importError) {
setError(importError.response?.data?.error || 'Importen kunne ikke gennemføres');
} finally {
setImporting(false);
}
};
return (
<Paper variant="outlined" sx={{ p: { xs: 2, md: 2.5 }, mb: 3, borderRadius: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
<Box>
<Typography variant="subtitle1" fontWeight={700}>Håndværkpriser-reference</Typography>
<Typography variant="body2" color="text.secondary">
Hent en skrivebeskyttet forhåndsvisning, kontrollér kategorierne og bekræft derefter importen.
Kildepriser inkl. moms konverteres til prisgrundlag ekskl. moms.
</Typography>
</Box>
<Button variant="outlined" onClick={loadPreview} disabled={loading || importing}>
{loading ? <><CircularProgress size={18} sx={{ mr: 1 }} />Henter</> : 'Hent forhåndsvisning'}
</Button>
</Box>
{error && <Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>}
{preview && (
<Box sx={{ mt: 2 }}>
<Typography fontWeight={700}>{preview.totalTasks} referencepriser fundet</Typography>
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', my: 1.5 }}>
{Object.entries(preview.byCategory || {}).map(([category, count]) => (
<Chip key={category} label={`${category}: ${count}`} />
))}
</Box>
{Array.isArray(preview.sample) && preview.sample.length > 0 && (
<Box sx={{ mb: 1.5 }}>
<Typography variant="body2" fontWeight={700}>Eksempelpriser</Typography>
{preview.sample.map(item => (
<Typography key={`${item.name}-${item.unit}`} variant="body2" color="text.secondary">
{item.name}: {formatDkk(item.avgPrice)} inkl. moms · {formatDkk(Number(item.avgPrice || 0) / 1.25)} ekskl. moms · {item.unit}
</Typography>
))}
</Box>
)}
<Alert severity="warning" sx={{ mb: 1.5 }}>
Importen opretter nye referenceydelser og opdaterer eksakte eksisterende matches med et
deterministisk gennemsnit. Kontrollér altid referencepriser før et fast tilbud sendes.
</Alert>
<FormControlLabel
control={<Checkbox checked={confirmed} onChange={event => setConfirmed(event.target.checked)} />}
label={`Jeg har kontrolleret forhåndsvisningen og bekræfter import af ${preview.totalTasks} referencepriser`}
/>
<Box>
<Button
variant="contained"
color="success"
onClick={runImport}
disabled={!confirmed || importing}
>
{importing ? <><CircularProgress size={18} color="inherit" sx={{ mr: 1 }} />Importerer</> : 'Importer referencepriser'}
</Button>
</Box>
</Box>
)}
{result && (
<Alert severity="success" sx={{ mt: 2 }}>
Import gennemført: {result.insertedPackages} oprettet, {result.updatedPackages} opdateret
{Number.isFinite(result.matchedExistingPackages) ? `, ${result.matchedExistingPackages} matchet med eksisterende priser` : ''}.
</Alert>
)}
</Paper>
);
};
export default HaandvaerkPriserImportPanel;

View File

@@ -0,0 +1,87 @@
/* eslint-disable testing-library/no-unnecessary-act */
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import axios from 'axios';
import HaandvaerkPriserImportPanel from './HaandvaerkPriserImportPanel';
jest.mock('axios');
const flush = async () => {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
};
describe('HaandvaerkPriserImportPanel', () => {
let container;
let root;
beforeEach(() => {
global.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
axios.get.mockReset();
axios.post.mockReset();
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
test('previews categories before enabling a confirmed import', async () => {
axios.get.mockResolvedValue({ data: {
success: true,
totalTasks: 195,
byCategory: { 'Tømrer & Snedker': 33, Maler: 25 },
sample: [{ name: 'Maling af facade', avgPrice: 300, unit: 'm²' }]
} });
const onImported = jest.fn();
await act(async () => root.render(<HaandvaerkPriserImportPanel onImported={onImported} />));
const previewButton = [...container.querySelectorAll('button')]
.find(button => button.textContent.includes('Hent forhåndsvisning'));
await act(async () => previewButton.click());
await flush();
expect(axios.get).toHaveBeenCalledWith('/api/smart-packages/haandvaerkpriser-preview');
expect(container.textContent).toContain('195 referencepriser');
expect(container.textContent).toContain('Tømrer & Snedker: 33');
expect(container.textContent).toContain('Maling af facade');
expect(container.textContent).toContain('300,00 kr. inkl. moms');
expect(container.textContent).toContain('240,00 kr. ekskl. moms');
const importButton = [...container.querySelectorAll('button')]
.find(button => button.textContent.includes('Importer'));
expect(importButton.disabled).toBe(true);
const checkbox = container.querySelector('input[type="checkbox"]');
await act(async () => checkbox.click());
expect(importButton.disabled).toBe(false);
});
test('sends explicit confirmation and reports the result', async () => {
axios.get.mockResolvedValue({ data: { success: true, totalTasks: 2, byCategory: { Maler: 2 }, sample: [] } });
axios.post.mockResolvedValue({ data: {
success: true,
totalRows: 2,
insertedPackages: 1,
updatedPackages: 1,
matchedExistingPackages: 1
} });
const onImported = jest.fn();
await act(async () => root.render(<HaandvaerkPriserImportPanel onImported={onImported} />));
await act(async () => [...container.querySelectorAll('button')].find(button => button.textContent.includes('Hent forhåndsvisning')).click());
await flush();
const checkbox = container.querySelector('input[type="checkbox"]');
await act(async () => checkbox.click());
await act(async () => [...container.querySelectorAll('button')].find(button => button.textContent.includes('Importer')).click());
await flush();
expect(axios.post).toHaveBeenCalledWith('/api/smart-packages/haandvaerkpriser-import', { confirm: true });
expect(container.textContent).toContain('1 oprettet');
expect(container.textContent).toContain('1 opdateret');
expect(onImported).toHaveBeenCalled();
});
});

View File

@@ -24,6 +24,7 @@ import EmptyState from '../common/EmptyState';
import ConfirmDialog from '../common/ConfirmDialog';
import PackageDetailsDialog from './PackageDetailsDialog';
import SmartPackageReviewQueue from './SmartPackageReviewQueue';
import HaandvaerkPriserImportPanel from './HaandvaerkPriserImportPanel';
// Hjælpefunktioner
import { formatCurrency, formatTime } from '../../utils/formatters';
@@ -569,6 +570,8 @@ const SmartPackages = ({ onEdit, selectedCategory = null }) => {
<SmartPackageReviewQueue />
<HaandvaerkPriserImportPanel onImported={fetchPackages} />
<Paper variant="outlined" sx={{ p: { xs: 2, md: 2.5 }, mb: 3, borderRadius: 2 }}>
<input
ref={excelInputRef}

View File

@@ -18,7 +18,9 @@ export const calculateMaterialQuantity = (material, { roofArea, width, length })
// Databasepakker har en eksplicit geometri-kontrakt. Enheder bruges kun
// som fallback for gamle, allerede gemte projektlinjer.
if (geometryMultiplier === 'area') {
if (geometryMultiplier === 'fixed') {
quantity = material.quantity;
} else if (geometryMultiplier === 'area') {
quantity = roofArea * material.quantity * wasteFactor;
} else if (geometryMultiplier === 'length') {
quantity = length * material.quantity * wasteFactor;

View File

@@ -33,6 +33,14 @@ describe('calculateMaterialQuantity', () => {
expect(result.quantity).toBe(24);
});
test('keeps an external reference quantity fixed for manual entry', () => {
const result = calculateMaterialQuantity(
{ quantity: 1, unit: 'm²', geometryMultiplier: 'fixed' },
geometry
);
expect(result.quantity).toBe(1);
});
test('falls back to the m² unit heuristic when there is no explicit geometryMultiplier', () => {
const result = calculateMaterialQuantity({ quantity: 1, unit: 'm²' }, geometry);
expect(result.quantity).toBe(100);

View File

@@ -0,0 +1,80 @@
const toNumber = value => Number.parseFloat(value) || 0;
const normalizeTask = (task, source) => {
const perUnit = toNumber(task.time_per_unit) || toNumber(task.hours);
const rate = toNumber(task.rate) || toNumber(source.hourly_rate) || 580;
return {
id: task.id,
name: task.name,
description: task.description || '',
rate,
timePerM2: task.time_unit === 'per_sqm' ? perUnit : 0,
timePerM: task.time_unit === 'per_meter' ? perUnit : 0,
useLength: task.time_unit === 'per_meter',
fixedHours: ['per_project', 'per_piece', 'per_hour'].includes(task.time_unit) ? perUnit : 0
};
};
const normalizeMaterial = (material, source) => ({
id: material.id,
materialId: material.material_id,
name: material.material_name || material.name,
quantity: toNumber(material.quantity) || 1,
unit: material.unit || 'stk',
unitPrice: toNumber(material.unit_price ?? material.price),
category: material.material_category || material.category || 'Materiale',
calculation: material.notes || '',
matchStatus: material.material_match_status || null,
geometryMultiplier: material.geometry_multiplier || null,
wasteFactor: toNumber(material.waste_factor) || 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
});
export const normalizeSmartPackageForSelection = (source = {}) => {
const sourceMaterials = Array.isArray(source.projectLines) && source.projectLines.length > 0
? source.projectLines
: (Array.isArray(source.materials) ? source.materials : []);
const sourceTasks = Array.isArray(source.tasks) ? source.tasks : [];
const isImportedReference = source.created_by === 'haandvaerkpriser-import';
let materials = isImportedReference
? []
: sourceMaterials.map(material => normalizeMaterial(material, source));
if (isImportedReference && materials.length === 0) {
const unitPrice = toNumber(source.unit_price ?? source.price_per_unit ?? source.standard_price);
if (unitPrice <= 0) throw new Error('Referenceydelsen mangler en brugbar pris');
materials = [{
id: `haandvaerkpriser-${source.id}`,
materialId: null,
name: source.name,
quantity: 1,
unit: source.unit || 'fast pris',
unitPrice,
category: 'Referenceydelse',
calculation: source.price_basis_note || '',
matchStatus: 'external_reference',
geometryMultiplier: 'fixed',
wasteFactor: 1,
sku: null,
priceSource: source.price_source || 'haandvaerkpriser.dk',
priceSourceUpdatedAt: source.validated_at || source.updated_at || null,
isRental: true
}];
}
if (materials.length === 0 && sourceTasks.length === 0) {
throw new Error('Smart Pakken har ingen tilknyttede materialer eller opgaver endnu');
}
return {
id: source.id,
name: source.name,
description: source.description || '',
source: 'database',
materials,
tasks: sourceTasks.map(task => normalizeTask(task, source))
};
};

View File

@@ -0,0 +1,66 @@
import { normalizeSmartPackageForSelection } from './smartPackageSelection';
describe('normalizeSmartPackageForSelection', () => {
test('turns an imported task-only reference into an other-service line', () => {
const result = normalizeSmartPackageForSelection({
id: 901,
name: 'Maling af lejlighed',
description: 'Ekstern referenceydelse',
category: 'Maler',
created_by: 'haandvaerkpriser-import',
unit: 'm²',
unit_price: '88.00',
price_source: 'haandvaerkpriser.dk',
price_basis_note: 'Kildepris inkl. moms: 110 kr.',
projectLines: [{
name: 'Maling af lejlighed',
material_name: 'Maling af lejlighed',
category: 'Maler',
quantity: 1,
unit: 'm²',
unit_price: 88,
isRental: false
}],
tasks: []
});
expect(result.materials).toEqual([expect.objectContaining({
name: 'Maling af lejlighed',
quantity: 1,
unit: 'm²',
unitPrice: 88,
category: 'Referenceydelse',
geometryMultiplier: 'fixed',
isRental: true
})]);
expect(result.tasks).toEqual([]);
});
test('allows an ordinary task-only component', () => {
const result = normalizeSmartPackageForSelection({
id: 13,
name: 'Kun opgave',
created_by: 'user',
projectLines: [],
materials: [],
tasks: [{ id: 1, name: 'Kontrol', hours: 2, rate: 580, time_unit: 'per_project' }]
});
expect(result.materials).toEqual([]);
expect(result.tasks).toEqual([expect.objectContaining({
name: 'Kontrol',
fixedHours: 2,
rate: 580
})]);
});
test('still rejects an ordinary empty Smart Package', () => {
expect(() => normalizeSmartPackageForSelection({
id: 12,
name: 'Tom pakke',
created_by: 'user',
projectLines: [],
materials: [],
tasks: []
})).toThrow('Smart Pakken har ingen tilknyttede materialer eller opgaver endnu');
});
});