diff --git a/backend/__tests__/haandvaerkPriserImportService.test.js b/backend/__tests__/haandvaerkPriserImportService.test.js
new file mode 100644
index 0000000..b84f65c
--- /dev/null
+++ b/backend/__tests__/haandvaerkPriserImportService.test.js
@@ -0,0 +1,204 @@
+const HaandvaerkPriserImportService = require('../src/services/haandvaerkPriserImportService');
+const {
+ parseHaandvaerkPriserHtml,
+ parsePrice,
+ parsePriceRange,
+ normalizeUnit,
+ normalizeName
+} = HaandvaerkPriserImportService;
+
+const section = (category, rows) => `
+
+
+
${category}
+
+
+
+ | Opgave | Gns. pris inkl. moms | Prisinterval | Enhed |
+
+ ${rows.map((row) => `
+
+ | ${row.name} |
+ ${row.avg} kr |
+ ${row.low} – ${row.high} kr |
+ ${row.unit} |
+
+ `).join('')}
+
+
+
+
+`;
+
+const fixtureHtml = `
+ ${section('Maler', [
+ { name: 'Maling af lejlighed (vægge og lofter)', avg: '110', low: '60', high: '120', unit: 'pr. m² vægflade', href: '/maler/maling/' }
+ ])}
+ ${section('Tømrer & Snedker', [
+ { name: 'Udskiftning af vinduer', avg: '10.000', low: '5.000', high: '15.000', unit: 'pr. stk' }
+ ])}
+ ${section('Elektriker', [
+ { name: 'Skift af gruppetavle', avg: '5.000', low: '3.000', high: '7.000', unit: 'fast pris' }
+ ])}
+`;
+
+describe('parsePrice', () => {
+ test.each([
+ ['110\n kr', 110],
+ ['10.000 kr', 10000],
+ ['', 0],
+ [null, 0]
+ ])('parses %p as %p', (input, expected) => {
+ expect(parsePrice(input)).toBe(expected);
+ });
+});
+
+describe('parsePriceRange', () => {
+ test('splits a Danish en-dash range', () => {
+ expect(parsePriceRange('60 – 120 kr')).toEqual({ low: 60, high: 120 });
+ });
+
+ test('returns nulls for malformed ranges', () => {
+ expect(parsePriceRange('n/a')).toEqual({ low: null, high: null });
+ });
+});
+
+describe('normalizeUnit', () => {
+ test.each([
+ ['pr. m² vægflade', { unit: 'm²', timeUnit: 'per_sqm' }],
+ ['pr. løb. m', { unit: 'løbende m', timeUnit: 'per_meter' }],
+ ['pr. stk', { unit: 'stk', timeUnit: 'per_piece' }],
+ ['pr. time', { unit: 'time', timeUnit: 'per_hour' }]
+ ])('maps %p', (input, expected) => {
+ expect(normalizeUnit(input)).toEqual(expected);
+ });
+
+ test('falls back to a fixed price for long-tail units', () => {
+ expect(normalizeUnit('pr. lejl.')).toEqual({ unit: 'pr. lejl.', timeUnit: 'per_project' });
+ expect(normalizeUnit('')).toEqual({ unit: 'fast pris', timeUnit: 'per_project' });
+ });
+});
+
+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');
+ });
+});
+
+describe('parseHaandvaerkPriserHtml', () => {
+ test('only keeps rows from allowed categories, decoding HTML entities in category names', () => {
+ const rows = parseHaandvaerkPriserHtml(fixtureHtml);
+ expect(rows).toHaveLength(2);
+ expect(rows.map((row) => row.category)).toEqual(['Maler', 'Tømrer & Snedker']);
+ expect(rows.find((row) => row.category === 'Elektriker')).toBeUndefined();
+ });
+
+ test('extracts price, range, unit and an absolute detail URL per row', () => {
+ const rows = parseHaandvaerkPriserHtml(fixtureHtml);
+ expect(rows[0]).toMatchObject({
+ name: 'Maling af lejlighed (vægge og lofter)',
+ avgPrice: 110,
+ priceRangeLow: 60,
+ priceRangeHigh: 120,
+ unit: 'm²',
+ timeUnit: 'per_sqm',
+ detailUrl: 'https://haandvaerkpriser.dk/maler/maling/'
+ });
+ });
+
+ test('respects a custom allow-list', () => {
+ const rows = parseHaandvaerkPriserHtml(fixtureHtml, { categories: new Set(['Elektriker']) });
+ expect(rows).toHaveLength(1);
+ expect(rows[0].name).toBe('Skift af gruppetavle');
+ });
+});
+
+describe('HaandvaerkPriserImportService.import', () => {
+ const buildConnection = (existingRows) => {
+ const executedInserts = [];
+ const executedUpdates = [];
+ const execute = jest.fn(async (sql, params) => {
+ if (sql.includes('SELECT id, name, category, unit_price')) return [existingRows];
+ 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('UPDATE material_packages') || sql.includes('UPDATE smart_package_tasks')) {
+ executedUpdates.push({ sql, params });
+ return [{}];
+ }
+ throw new Error(`Unexpected SQL: ${sql}`);
+ });
+ const connection = { execute, beginTransaction: jest.fn(), commit: jest.fn(), rollback: jest.fn(), release: jest.fn() };
+ return { connection, executedInserts, executedUpdates };
+ };
+
+ test('inserts a new verified component package with one task carrying the price', async () => {
+ const { connection, executedInserts } = buildConnection([]);
+ const service = new HaandvaerkPriserImportService({ pool: { 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(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)]);
+ });
+
+ 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' }
+ ]);
+ const service = new HaandvaerkPriserImportService({ pool: { 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]));
+ });
+
+ 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 } });
+ 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' }
+ ]);
+
+ await expect(service.import()).rejects.toThrow('db down');
+ expect(connection.rollback).toHaveBeenCalled();
+ });
+});
+
+describe('HaandvaerkPriserImportService.preview', () => {
+ test('summarizes counts per category without writing anything', async () => {
+ const service = new HaandvaerkPriserImportService({ pool: {} });
+ service.fetchRows = jest.fn().mockResolvedValue([
+ { category: 'Maler', name: 'A' },
+ { category: 'Maler', name: 'B' },
+ { category: 'Murer', name: 'C' }
+ ]);
+
+ const result = await service.preview();
+ expect(result.totalTasks).toBe(3);
+ expect(result.byCategory).toEqual({ Maler: 2, Murer: 1 });
+ expect(result.sample).toHaveLength(3);
+ });
+});
diff --git a/backend/src/routes/smartPackagesRoutes.js b/backend/src/routes/smartPackagesRoutes.js
index 824c1fe..06dacc7 100644
--- a/backend/src/routes/smartPackagesRoutes.js
+++ b/backend/src/routes/smartPackagesRoutes.js
@@ -12,6 +12,7 @@ const AiFeatureFlagService = require('../services/aiFeatureFlagService');
const { verifyToken } = require('../middleware/auth');
const aiValidationJobs = require('../services/aiValidationJobService');
const { SmartPackageIntegrityService } = require('../services/smartPackageIntegrityService');
+const HaandvaerkPriserImportService = require('../services/haandvaerkPriserImportService');
const excelUploadDir = path.join(__dirname, '../../uploads/smart-packages/excel');
const excelMetadataPath = path.join(excelUploadDir, '.import-metadata.json');
@@ -268,6 +269,29 @@ 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) => {
+ 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 });
+ }
+});
+
+router.post('/haandvaerkpriser-import', verifyToken, async (req, res) => {
+ 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 });
+ }
+});
+
// Pakker som integritetskontrollen har arkiveret (blokeret + inaktiv) og som
// venter på manuel materialekobling, med forslag til match fra materialedatabasen.
router.get('/review-queue', verifyToken, async (req, res) => {
diff --git a/backend/src/services/haandvaerkPriserImportService.js b/backend/src/services/haandvaerkPriserImportService.js
new file mode 100644
index 0000000..41955b9
Binary files /dev/null and b/backend/src/services/haandvaerkPriserImportService.js differ