Merge pull request #21 from alexpolo1/fix/jannick-price-import-flow
fix: make Jannick price import usable and safe
This commit is contained in:
@@ -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",...}
|
||||
|
||||
126
backend/__tests__/haandvaerkPriserImportRegression.test.js
Normal file
126
backend/__tests__/haandvaerkPriserImportRegression.test.js
Normal file
@@ -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]));
|
||||
});
|
||||
});
|
||||
@@ -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' }
|
||||
]);
|
||||
|
||||
123
backend/__tests__/haandvaerkPriserRoutes.test.js
Normal file
123
backend/__tests__/haandvaerkPriserRoutes.test.js
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Binary file not shown.
13
database/migrations/20260824_haandvaerkpriser_imports.sql
Normal file
13
database/migrations/20260824_haandvaerkpriser_imports.sql
Normal file
@@ -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;
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 (
|
||||
<Paper variant="outlined" sx={{ p: { xs: 2, md: 2.5 }, mb: 3, borderRadius: 2 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" fontWeight={700}>Håndværkpriser-reference</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Hent en skrivebeskyttet forhåndsvisning, kontrollér kategorierne og bekræft derefter importen.
|
||||
Kildepriser inkl. moms konverteres til prisgrundlag ekskl. moms.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button variant="outlined" onClick={loadPreview} disabled={loading || importing}>
|
||||
{loading ? <><CircularProgress size={18} sx={{ mr: 1 }} />Henter…</> : 'Hent forhåndsvisning'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" sx={{ mt: 2 }}>{error}</Alert>}
|
||||
|
||||
{preview && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography fontWeight={700}>{preview.totalTasks} referencepriser fundet</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', my: 1.5 }}>
|
||||
{Object.entries(preview.byCategory || {}).map(([category, count]) => (
|
||||
<Chip key={category} label={`${category}: ${count}`} />
|
||||
))}
|
||||
</Box>
|
||||
{Array.isArray(preview.sample) && preview.sample.length > 0 && (
|
||||
<Box sx={{ mb: 1.5 }}>
|
||||
<Typography variant="body2" fontWeight={700}>Eksempelpriser</Typography>
|
||||
{preview.sample.map(item => (
|
||||
<Typography key={`${item.name}-${item.unit}`} variant="body2" color="text.secondary">
|
||||
{item.name}: {formatDkk(item.avgPrice)} inkl. moms · {formatDkk(Number(item.avgPrice || 0) / 1.25)} ekskl. moms · {item.unit}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
<Alert severity="warning" sx={{ mb: 1.5 }}>
|
||||
Importen opretter nye referenceydelser og opdaterer eksakte eksisterende matches med et
|
||||
deterministisk gennemsnit. Kontrollér altid referencepriser før et fast tilbud sendes.
|
||||
</Alert>
|
||||
<FormControlLabel
|
||||
control={<Checkbox checked={confirmed} onChange={event => setConfirmed(event.target.checked)} />}
|
||||
label={`Jeg har kontrolleret forhåndsvisningen og bekræfter import af ${preview.totalTasks} referencepriser`}
|
||||
/>
|
||||
<Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
onClick={runImport}
|
||||
disabled={!confirmed || importing}
|
||||
>
|
||||
{importing ? <><CircularProgress size={18} color="inherit" sx={{ mr: 1 }} />Importerer…</> : 'Importer referencepriser'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<Alert severity="success" sx={{ mt: 2 }}>
|
||||
Import gennemført: {result.insertedPackages} oprettet, {result.updatedPackages} opdateret
|
||||
{Number.isFinite(result.matchedExistingPackages) ? `, ${result.matchedExistingPackages} matchet med eksisterende priser` : ''}.
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default HaandvaerkPriserImportPanel;
|
||||
@@ -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(<HaandvaerkPriserImportPanel onImported={onImported} />));
|
||||
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(<HaandvaerkPriserImportPanel onImported={onImported} />));
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 }) => {
|
||||
|
||||
<SmartPackageReviewQueue />
|
||||
|
||||
<HaandvaerkPriserImportPanel onImported={fetchPackages} />
|
||||
|
||||
<Paper variant="outlined" sx={{ p: { xs: 2, md: 2.5 }, mb: 3, borderRadius: 2 }}>
|
||||
<input
|
||||
ref={excelInputRef}
|
||||
|
||||
@@ -18,7 +18,9 @@ export const calculateMaterialQuantity = (material, { roofArea, width, length })
|
||||
|
||||
// Databasepakker har en eksplicit geometri-kontrakt. Enheder bruges kun
|
||||
// som fallback for gamle, allerede gemte projektlinjer.
|
||||
if (geometryMultiplier === 'area') {
|
||||
if (geometryMultiplier === 'fixed') {
|
||||
quantity = material.quantity;
|
||||
} else if (geometryMultiplier === 'area') {
|
||||
quantity = roofArea * material.quantity * wasteFactor;
|
||||
} else if (geometryMultiplier === 'length') {
|
||||
quantity = length * material.quantity * wasteFactor;
|
||||
|
||||
@@ -33,6 +33,14 @@ describe('calculateMaterialQuantity', () => {
|
||||
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);
|
||||
|
||||
80
frontend/src/utils/smartPackageSelection.js
Normal file
80
frontend/src/utils/smartPackageSelection.js
Normal file
@@ -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))
|
||||
};
|
||||
};
|
||||
66
frontend/src/utils/smartPackageSelection.test.js
Normal file
66
frontend/src/utils/smartPackageSelection.test.js
Normal file
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user