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}

+
+
+ + + + ${rows.map((row) => ` + + + + + + + `).join('')} + +
OpgaveGns. pris inkl. momsPrisintervalEnhed
${row.name}${row.avg} kr${row.low}${row.high} kr${row.unit}
+
+
+`; + +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