diff --git a/CLAUDE.md b/CLAUDE.md
index d4fcc00..a27413a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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",...}
diff --git a/backend/__tests__/haandvaerkPriserImportRegression.test.js b/backend/__tests__/haandvaerkPriserImportRegression.test.js
new file mode 100644
index 0000000..5c36b04
--- /dev/null
+++ b/backend/__tests__/haandvaerkPriserImportRegression.test.js
@@ -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]));
+ });
+});
diff --git a/backend/__tests__/haandvaerkPriserImportService.test.js b/backend/__tests__/haandvaerkPriserImportService.test.js
index b84f65c..8a71044 100644
--- a/backend/__tests__/haandvaerkPriserImportService.test.js
+++ b/backend/__tests__/haandvaerkPriserImportService.test.js
@@ -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' }
]);
diff --git a/backend/__tests__/haandvaerkPriserRoutes.test.js b/backend/__tests__/haandvaerkPriserRoutes.test.js
new file mode 100644
index 0000000..123d3f6
--- /dev/null
+++ b/backend/__tests__/haandvaerkPriserRoutes.test.js
@@ -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');
+ });
+});
diff --git a/backend/src/routes/smartPackagesRoutes.js b/backend/src/routes/smartPackagesRoutes.js
index 06dacc7..b7f0741 100644
--- a/backend/src/routes/smartPackagesRoutes.js
+++ b/backend/src/routes/smartPackagesRoutes.js
@@ -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;
}
});
diff --git a/backend/src/services/haandvaerkPriserImportService.js b/backend/src/services/haandvaerkPriserImportService.js
index 41955b9..e9278a6 100644
Binary files a/backend/src/services/haandvaerkPriserImportService.js and b/backend/src/services/haandvaerkPriserImportService.js differ
diff --git a/database/migrations/20260824_haandvaerkpriser_imports.sql b/database/migrations/20260824_haandvaerkpriser_imports.sql
new file mode 100644
index 0000000..c40211c
--- /dev/null
+++ b/database/migrations/20260824_haandvaerkpriser_imports.sql
@@ -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;
diff --git a/frontend/src/components/FinalReview.js b/frontend/src/components/FinalReview.js
index 8bc46cf..4d6f12b 100644
--- a/frontend/src/components/FinalReview.js
+++ b/frontend/src/components/FinalReview.js
@@ -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')
diff --git a/frontend/src/components/FinalReview.test.js b/frontend/src/components/FinalReview.test.js
index d6be8a3..dbc9af4 100644
--- a/frontend/src/components/FinalReview.test.js
+++ b/frontend/src/components/FinalReview.test.js
@@ -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);
});
});
diff --git a/frontend/src/components/InlineSmartPackage.js b/frontend/src/components/InlineSmartPackage.js
index f7bd32c..4f40402 100644
--- a/frontend/src/components/InlineSmartPackage.js
+++ b/frontend/src/components/InlineSmartPackage.js
@@ -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) => {
diff --git a/frontend/src/components/smartPackages/HaandvaerkPriserImportPanel.js b/frontend/src/components/smartPackages/HaandvaerkPriserImportPanel.js
new file mode 100644
index 0000000..d52209c
--- /dev/null
+++ b/frontend/src/components/smartPackages/HaandvaerkPriserImportPanel.js
@@ -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 (
+
+
+
+ Håndværkpriser-reference
+
+ Hent en skrivebeskyttet forhåndsvisning, kontrollér kategorierne og bekræft derefter importen.
+ Kildepriser inkl. moms konverteres til prisgrundlag ekskl. moms.
+
+
+
+
+
+ {error && {error}}
+
+ {preview && (
+
+ {preview.totalTasks} referencepriser fundet
+
+ {Object.entries(preview.byCategory || {}).map(([category, count]) => (
+
+ ))}
+
+ {Array.isArray(preview.sample) && preview.sample.length > 0 && (
+
+ Eksempelpriser
+ {preview.sample.map(item => (
+
+ {item.name}: {formatDkk(item.avgPrice)} inkl. moms · {formatDkk(Number(item.avgPrice || 0) / 1.25)} ekskl. moms · {item.unit}
+
+ ))}
+
+ )}
+
+ Importen opretter nye referenceydelser og opdaterer eksakte eksisterende matches med et
+ deterministisk gennemsnit. Kontrollér altid referencepriser før et fast tilbud sendes.
+
+ setConfirmed(event.target.checked)} />}
+ label={`Jeg har kontrolleret forhåndsvisningen og bekræfter import af ${preview.totalTasks} referencepriser`}
+ />
+
+
+
+
+ )}
+
+ {result && (
+
+ Import gennemført: {result.insertedPackages} oprettet, {result.updatedPackages} opdateret
+ {Number.isFinite(result.matchedExistingPackages) ? `, ${result.matchedExistingPackages} matchet med eksisterende priser` : ''}.
+
+ )}
+
+ );
+};
+
+export default HaandvaerkPriserImportPanel;
diff --git a/frontend/src/components/smartPackages/HaandvaerkPriserImportPanel.test.js b/frontend/src/components/smartPackages/HaandvaerkPriserImportPanel.test.js
new file mode 100644
index 0000000..6577c3b
--- /dev/null
+++ b/frontend/src/components/smartPackages/HaandvaerkPriserImportPanel.test.js
@@ -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());
+ 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());
+ 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();
+ });
+});
diff --git a/frontend/src/components/smartPackages/SmartPackages.js b/frontend/src/components/smartPackages/SmartPackages.js
index 7c2371e..774567a 100644
--- a/frontend/src/components/smartPackages/SmartPackages.js
+++ b/frontend/src/components/smartPackages/SmartPackages.js
@@ -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 }) => {
+
+
{
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);
diff --git a/frontend/src/utils/smartPackageSelection.js b/frontend/src/utils/smartPackageSelection.js
new file mode 100644
index 0000000..5698dd5
--- /dev/null
+++ b/frontend/src/utils/smartPackageSelection.js
@@ -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))
+ };
+};
diff --git a/frontend/src/utils/smartPackageSelection.test.js b/frontend/src/utils/smartPackageSelection.test.js
new file mode 100644
index 0000000..34b29c6
--- /dev/null
+++ b/frontend/src/utils/smartPackageSelection.test.js
@@ -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');
+ });
+});