Merge pull request #20 from alexpolo1/feat/haandvaerkpriser-import
feat: import reference task prices from haandvaerkpriser.dk
This commit is contained in:
204
backend/__tests__/haandvaerkPriserImportService.test.js
Normal file
204
backend/__tests__/haandvaerkPriserImportService.test.js
Normal 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 & 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);
|
||||
});
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
BIN
backend/src/services/haandvaerkPriserImportService.js
Normal file
BIN
backend/src/services/haandvaerkPriserImportService.js
Normal file
Binary file not shown.
Reference in New Issue
Block a user