Merge pull request #20 from alexpolo1/feat/haandvaerkpriser-import

feat: import reference task prices from haandvaerkpriser.dk
This commit is contained in:
Alex
2026-08-20 15:39:24 +02:00
committed by GitHub
3 changed files with 228 additions and 0 deletions

View File

@@ -0,0 +1,204 @@
const HaandvaerkPriserImportService = require('../src/services/haandvaerkPriserImportService');
const {
parseHaandvaerkPriserHtml,
parsePrice,
parsePriceRange,
normalizeUnit,
normalizeName
} = HaandvaerkPriserImportService;
const section = (category, rows) => `
<div class="mb-8">
<div class="flex items-baseline gap-3 mb-3">
<h2 class="text-lg font-medium text-neutral-900">${category}</h2>
</div>
<div class="bg-white rounded-xl border border-neutral-200 overflow-hidden">
<table class="w-full text-sm">
<thead><tr><th>Opgave</th><th>Gns. pris inkl. moms</th><th>Prisinterval</th><th>Enhed</th></tr></thead>
<tbody>
${rows.map((row) => `
<tr>
<td><a href="${row.href || '/foo/'}">${row.name}</a></td>
<td>${row.avg}<!-- --> kr</td>
<td>${row.low}<!-- --> <!-- -->${row.high}<!-- --> kr</td>
<td>${row.unit}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
`;
const fixtureHtml = `<html><body>
${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 &amp; 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' }
])}
</body></html>`;
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);
});
});

View File

@@ -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) => {

Binary file not shown.