From 81b6e1ea21864bb003f1bc66e144f26c1debcc82 Mon Sep 17 00:00:00 2001 From: alexpolo1 Date: Mon, 24 Aug 2026 08:31:55 +0200 Subject: [PATCH] fix: make Jannick price import usable and safe --- CLAUDE.md | 4 +- .../haandvaerkPriserImportRegression.test.js | 126 ++++++++++++++++++ .../haandvaerkPriserImportService.test.js | 45 ++++--- .../__tests__/haandvaerkPriserRoutes.test.js | 123 +++++++++++++++++ backend/src/routes/smartPackagesRoutes.js | 32 ++++- .../services/haandvaerkPriserImportService.js | Bin 9223 -> 13141 bytes .../20260824_haandvaerkpriser_imports.sql | 13 ++ frontend/src/components/FinalReview.js | 1 + frontend/src/components/FinalReview.test.js | 1 + frontend/src/components/InlineSmartPackage.js | 51 +------ .../HaandvaerkPriserImportPanel.js | 126 ++++++++++++++++++ .../HaandvaerkPriserImportPanel.test.js | 87 ++++++++++++ .../components/smartPackages/SmartPackages.js | 3 + .../utils/smartPackageGeometryCalculations.js | 4 +- .../smartPackageGeometryCalculations.test.js | 8 ++ frontend/src/utils/smartPackageSelection.js | 80 +++++++++++ .../src/utils/smartPackageSelection.test.js | 66 +++++++++ 17 files changed, 692 insertions(+), 78 deletions(-) create mode 100644 backend/__tests__/haandvaerkPriserImportRegression.test.js create mode 100644 backend/__tests__/haandvaerkPriserRoutes.test.js create mode 100644 database/migrations/20260824_haandvaerkpriser_imports.sql create mode 100644 frontend/src/components/smartPackages/HaandvaerkPriserImportPanel.js create mode 100644 frontend/src/components/smartPackages/HaandvaerkPriserImportPanel.test.js create mode 100644 frontend/src/utils/smartPackageSelection.js create mode 100644 frontend/src/utils/smartPackageSelection.test.js 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 41955b9b347a221b4b0bdf936efb5571dd985161..e9278a65f883f62e03a1116d574f560a49bb0c1a 100644 GIT binary patch literal 13141 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 VxnkEhjIDjuw;MR2dU^ine*hJ1L2m#6 delta 3240 zcmb7GQEVJn6=hR9AeTZMT&E66dcASFyUy;cHz`$Z$D~QKZbITDY+^+sr0eIMx4V;> zomt+z-DH)T{Xhs%B`AI32hqPahz|s+`KSaE5{3^dRH+}p2Y>wVfj|O2kdQ!}J2Shs z>(Yvqy*u;XoqNwc_n!OaxAm`GeC2cB_FgfcN-f@@0)>t(eQF9trDw*9MRJ9fjX*h4 zkwe1Md1_(5>)_8L$8{w+t#+WaRFdlVsq9HtE60Vo^gMjO;0e`|A@Ule$|E8+A|EGM%S%U4)w2$_XjE zjwS3V>4h?v&N1MiO&ZW_sqt%vvHOA>_*w~9(~Xu;ny&W+perN^PvX$>If*<-Wo ztavVB!I0Sw9ak|$qyx|O9lt?7GXs(%f*A@d8!~7~M8X3A9J+#`E0n{w%*e=hM<0w4 zk$G)oF12Ca9yylW|7m0%_eV#MCinBBr*PjK9qfNKy8p@OKh59vKiMzN&W_P6RiqDw zg$iZC6O9GBcyr|nIli3-JF4UA4gxc4LAxQkvY@seMr?LK+$FyOfWFjR7PoleJT;}t zv(s?vjVN1J_@p`AkgrcC9Ne>bfI!%j zvNzcL@%U6f_RNkq#_$zYl0jb@$WtL1^h;bb1Zy$%SaIr34dv97sRgk0K~<)pdu=(; z3QL94(dv`YcE|%-*eAV6evRef;$NGxPIL=PX)A^MKhlyYjbH z2erpeu?Vqx4m3CzC?x7ksxQfPAgMfp+DID&M_fm(qag}dv_jvxqLT+=hYis}jZksc zm*11|yl^6Q16e(Q_R$e-wnfdwU|mXPTP-YPRd6pyOP{6NL+@)d53p#} zl7`v%h?$=H^hDA(fQik-*8JyC+1!1iX#O!dnPRg-Kh6yi2azHKRzvEnD|m>*h|U7JZdxkn_;xm%jb_ZU7YIGf#_ITvuA-Z2eaQT!muN0WCmE+D zx{b(lj*4_Dx5MeeuI*S`T!NUsCS(<^!YL538|}1Ye7m$nnYL8b@Jp=>kVV(6QyE$y zX3{F{wZs6HYTGuxTcC^%wD2-HQ#v+nzIF7R`NPrW{$+041)6z~i23TdmGXu)s4?SP zptb*D^xho0!gJ95PT*e+cyI#vP}k^Y9GXobe7h^8aw8!sYs7XfGVkUlZ)Lr$w|sCo z=s_GFtPY*SaUUNr0r!41_l7ozCe{TMZDkg>Tbc*(pc%}+iWzhE_$SQ0Cznq|Bv-j~ zz;-qdUoBZ>Zj+@Fx14z=Gif&HjQQp9$7f?ZoC6NWw5yQ^ESB=bmtL=`u+CQIwHY$M zri11$^ymyFRNx5+c!{oFyOqst_cPN<=JIs$5svrG!XfkX=?U}4+?4t2^rQXAbJPpF zpOCvcH%}jOzoYf&|FJdSNsmu)XW`{UI36ZFLM0Q#a?IQ5!{%di$s~^w!B=MW`h{~h z%M|i^%V2s`wFupiZj^7*fTZ`MJWiK6jfV{8Z!~B$r@)uB(t5^>^nPh z-u!*~>3s)KC)9cS&rF}lrb_0ylN08}6P1MW{r$n@*+c3MhkHrTAB9e%4H8rp$|({YMgdFEP6~r#hat96yvwU$Pg?zl$?a z%i+i0uaMbSXAXVA!Rr;j+wid()|As`xudKobu~~pYGYx901IIx^^P&`QX9XLV*Xa4 zUMH;jm>1^HGjtmD8kUSNb`H?BJl!6GkI7&p=AB%ju$PgY#Yuvl=U5m`=BL>?qpdkJ zUOb(8V18rG#G|o-BPyEGq2)=xp_?>#2f`5)zsbF6o!LhlX03QEI(5Ha{LHJR!<*^S Lg_IdN{rUd_4Ur5- 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'); + }); +});