- Created package.json for the test suite "tilbudgivern-tests" - Added scripts for running tests, watching tests, and generating coverage reports - Included Jest and Supertest as development dependencies - Configured Jest with a test environment, test match pattern, and coverage collection settings - Specified coverage output directory
879 lines
28 KiB
JavaScript
879 lines
28 KiB
JavaScript
const logger = require('../utils/logger');
|
|
|
|
/**
|
|
* PackageService - Håndterer pakker til forskellige tagtyper
|
|
* Pakker indeholder materialelister, priser og fortjenestemarginaler
|
|
*/
|
|
class PackageService {
|
|
constructor(databaseService) {
|
|
this.db = databaseService;
|
|
}
|
|
|
|
/**
|
|
* Find real materials from database based on search terms
|
|
* @param {string} searchTerm - Search term for material name
|
|
* @param {string} category - Optional category filter
|
|
* @returns {Object|null} Material with price info
|
|
*/
|
|
async findRealMaterial(searchTerm, category = null) {
|
|
try {
|
|
// First try Bygma products (most comprehensive)
|
|
let bygmaQuery = `
|
|
SELECT
|
|
bp.vareNr as varenummer,
|
|
bp.tekst as name,
|
|
bp.enhed as unit,
|
|
bp.current_netto_pris as unitPrice,
|
|
bp.varegrp as category,
|
|
'bygma' as source
|
|
FROM bygma_products bp
|
|
WHERE bp.is_active = 1
|
|
AND (bp.tekst LIKE ? OR bp.vareNr LIKE ?)
|
|
`;
|
|
|
|
const bygmaParams = [`%${searchTerm}%`, `%${searchTerm}%`];
|
|
|
|
if (category) {
|
|
bygmaQuery += ` AND bp.varegrp = ?`;
|
|
bygmaParams.push(category);
|
|
}
|
|
|
|
bygmaQuery += ` ORDER BY bp.current_netto_pris ASC LIMIT 1`;
|
|
|
|
const bygmaMaterials = await this.db.query(bygmaQuery, bygmaParams);
|
|
|
|
if (bygmaMaterials && bygmaMaterials.length > 0) {
|
|
const material = bygmaMaterials[0];
|
|
return {
|
|
name: material.name,
|
|
varenummer: material.varenummer,
|
|
unit: material.unit || 'stk',
|
|
unitPrice: parseFloat(material.unitPrice) || 0,
|
|
category: material.category || 'diverse',
|
|
source: 'bygma'
|
|
};
|
|
}
|
|
|
|
// Fallback to built-in materials
|
|
let builtInQuery = `
|
|
SELECT
|
|
CONCAT('MP', mp.id) as varenummer,
|
|
mp.name,
|
|
mp.unit,
|
|
mp.price as unitPrice,
|
|
mp.category,
|
|
'built_in' as source
|
|
FROM material_prices mp
|
|
WHERE mp.is_active = 1
|
|
AND mp.name LIKE ?
|
|
`;
|
|
|
|
const builtInParams = [`%${searchTerm}%`];
|
|
|
|
if (category) {
|
|
builtInQuery += ` AND mp.category = ?`;
|
|
builtInParams.push(category);
|
|
}
|
|
|
|
builtInQuery += ` ORDER BY mp.price ASC LIMIT 1`;
|
|
|
|
const builtInMaterials = await this.db.query(builtInQuery, builtInParams);
|
|
|
|
if (builtInMaterials && builtInMaterials.length > 0) {
|
|
const material = builtInMaterials[0];
|
|
return {
|
|
name: material.name,
|
|
varenummer: material.varenummer,
|
|
unit: material.unit || 'stk',
|
|
unitPrice: parseFloat(material.unitPrice) || 0,
|
|
category: material.category || 'diverse',
|
|
source: 'built_in'
|
|
};
|
|
}
|
|
|
|
return null;
|
|
} catch (error) {
|
|
logger.error('Error finding real material:', error);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Få pakker for en specifik tagtype
|
|
* @param {string} roofType - Tag type (Betontegl, B7, B6, Vingetegl, etc.)
|
|
* @param {number} area - Areal i m² for mængdeberegning
|
|
*/
|
|
async getPackagesForRoofType(roofType, area = 100) {
|
|
try {
|
|
// Hent pakker fra database eller brug standard pakker
|
|
const packages = await this.getStandardPackages(roofType, area);
|
|
|
|
// Beregn priser for alle pakker
|
|
const packagesWithPricing = packages.map(pkg => ({
|
|
...pkg,
|
|
pricing: this.calculatePackagePrice(pkg),
|
|
geometryArea: area
|
|
}));
|
|
|
|
logger.info('Retrieved packages for roof type', {
|
|
roofType,
|
|
area,
|
|
packageCount: packagesWithPricing.length
|
|
});
|
|
|
|
return packagesWithPricing;
|
|
} catch (error) {
|
|
logger.error('Error getting packages for roof type:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Standard pakkedefinitioner for forskellige tagtyper
|
|
*/
|
|
async getStandardPackages(roofType, area) {
|
|
const packages = [];
|
|
|
|
// Først standard pakker
|
|
switch (roofType.toLowerCase()) {
|
|
case 'betontegl':
|
|
packages.push(...await this.getBetonteglPackages(area));
|
|
break;
|
|
case 'b7':
|
|
packages.push(...await this.getB7Packages(area));
|
|
break;
|
|
case 'b6':
|
|
packages.push(...await this.getB6Packages(area));
|
|
break;
|
|
case 'vingetegl':
|
|
packages.push(...await this.getVingeteglPackages(area));
|
|
break;
|
|
case 'røde_teglsten':
|
|
packages.push(...await this.getRødeTeglstenPackages(area));
|
|
break;
|
|
default:
|
|
packages.push(...await this.getGenericRoofPackages(area));
|
|
}
|
|
|
|
// Tilføj custom packages for denne tagtype
|
|
try {
|
|
const customResult = await this.getCustomPackages();
|
|
if (customResult.success) {
|
|
const relevantCustomPackages = customResult.packages
|
|
.filter(pkg => pkg.roof_type.toLowerCase() === roofType.toLowerCase())
|
|
.map(pkg => ({
|
|
id: `custom_${pkg.id}`,
|
|
name: pkg.name,
|
|
description: pkg.description,
|
|
roofType: pkg.roof_type,
|
|
unit: pkg.unit,
|
|
materials: pkg.materials,
|
|
profitMargin: pkg.pricing.profitMargin,
|
|
isCustom: true,
|
|
pricing: pkg.pricing
|
|
}));
|
|
packages.push(...relevantCustomPackages);
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error loading custom packages:', error);
|
|
// Continue without custom packages if there's an error
|
|
}
|
|
|
|
return packages;
|
|
}
|
|
|
|
/**
|
|
* Betontegl pakker - nu med rigtige materialer fra databasen
|
|
*/
|
|
async getBetonteglPackages(area) {
|
|
// Find rigtige materialer fra databasen
|
|
const betontegl = await this.findRealMaterial('betontegl', '4840') ||
|
|
await this.findRealMaterial('tegl') ||
|
|
{ name: 'Betonteglsten (ikke fundet)', varenummer: 'FALLBACK_BT', unitPrice: 12.50, unit: 'stk', category: 'Tagbeklædning' };
|
|
|
|
const tagcentraler = await this.findRealMaterial('tagcentral', '5160') ||
|
|
await this.findRealMaterial('beslag') ||
|
|
{ name: 'Tagcentraler (ikke fundet)', varenummer: 'FALLBACK_TC', unitPrice: 85.00, unit: 'stk', category: 'Beslag' };
|
|
|
|
const undertag = await this.findRealMaterial('undertag', '4860') ||
|
|
await this.findRealMaterial('membran') ||
|
|
{ name: 'Undertag diffusionsåben (ikke fundet)', varenummer: 'FALLBACK_UT', unitPrice: 45.00, unit: 'm²', category: 'Undertag' };
|
|
|
|
const vindskeder = await this.findRealMaterial('vindskede', '1000') ||
|
|
await this.findRealMaterial('brædder') ||
|
|
{ name: 'Vindskeder 25x200mm (ikke fundet)', varenummer: 'FALLBACK_VS', unitPrice: 95.00, unit: 'løbm', category: 'Træ' };
|
|
|
|
const isolering = await this.findRealMaterial('mineraluld', '1045') ||
|
|
await this.findRealMaterial('isolering') ||
|
|
{ name: 'Mineraluld 200mm (ikke fundet)', varenummer: 'FALLBACK_IS', unitPrice: 75.00, unit: 'm²', category: 'Isolering' };
|
|
|
|
return [
|
|
{
|
|
id: 'betontegl_basic',
|
|
name: 'Betontegl Basis Pakke',
|
|
description: 'Standard betontegl installation med grundmaterialer - opdateret med rigtige materialer',
|
|
roofType: 'betontegl',
|
|
unit: 'm²',
|
|
materials: [
|
|
{
|
|
name: betontegl.name,
|
|
varenummer: betontegl.varenummer,
|
|
quantity: Math.ceil(area * 11), // 11 stk/m²
|
|
unit: betontegl.unit,
|
|
unitPrice: betontegl.unitPrice,
|
|
category: betontegl.category || 'Tagbeklædning',
|
|
source: betontegl.source || 'database'
|
|
},
|
|
{
|
|
name: tagcentraler.name,
|
|
varenummer: tagcentraler.varenummer,
|
|
quantity: Math.ceil(area * 0.8), // 0.8 stk/m²
|
|
unit: tagcentraler.unit,
|
|
unitPrice: tagcentraler.unitPrice,
|
|
category: tagcentraler.category || 'Beslag',
|
|
source: tagcentraler.source || 'database'
|
|
},
|
|
{
|
|
name: undertag.name,
|
|
varenummer: undertag.varenummer,
|
|
quantity: Math.ceil(area * 1.05), // 5% spild
|
|
unit: undertag.unit,
|
|
unitPrice: undertag.unitPrice,
|
|
category: undertag.category || 'Undertag',
|
|
source: undertag.source || 'database'
|
|
},
|
|
{
|
|
name: vindskeder.name,
|
|
varenummer: vindskeder.varenummer,
|
|
quantity: this.calculateWindboardLength(area),
|
|
unit: vindskeder.unit,
|
|
unitPrice: vindskeder.unitPrice,
|
|
category: vindskeder.category || 'Træ',
|
|
source: vindskeder.source || 'database'
|
|
}
|
|
],
|
|
profitMargin: 25, // 25% fortjeneste
|
|
complexity: 1.0
|
|
},
|
|
{
|
|
id: 'betontegl_premium',
|
|
name: 'Betontegl Premium Pakke',
|
|
description: 'Premium betontegl med ekstra isolering og kvalitetsmaterialer - opdateret med rigtige materialer',
|
|
roofType: 'betontegl',
|
|
unit: 'm²',
|
|
materials: [
|
|
{
|
|
name: betontegl.name + ' (Premium)',
|
|
varenummer: betontegl.varenummer,
|
|
quantity: Math.ceil(area * 11),
|
|
unit: betontegl.unit,
|
|
unitPrice: betontegl.unitPrice * 1.5, // Premium pris
|
|
category: betontegl.category || 'Tagbeklædning',
|
|
source: betontegl.source || 'database'
|
|
},
|
|
{
|
|
name: tagcentraler.name + ' (Rustfri)',
|
|
varenummer: tagcentraler.varenummer,
|
|
quantity: Math.ceil(area * 0.8),
|
|
unit: tagcentraler.unit,
|
|
unitPrice: tagcentraler.unitPrice * 1.4, // Premium pris
|
|
category: tagcentraler.category || 'Beslag',
|
|
source: tagcentraler.source || 'database'
|
|
},
|
|
{
|
|
name: undertag.name + ' (Premium)',
|
|
varenummer: undertag.varenummer,
|
|
quantity: Math.ceil(area * 1.05),
|
|
unit: undertag.unit,
|
|
unitPrice: undertag.unitPrice * 1.3, // Premium pris
|
|
category: undertag.category || 'Undertag',
|
|
source: undertag.source || 'database'
|
|
},
|
|
{
|
|
name: isolering.name,
|
|
varenummer: isolering.varenummer,
|
|
quantity: Math.ceil(area * 1.02),
|
|
unit: isolering.unit,
|
|
unitPrice: isolering.unitPrice,
|
|
category: isolering.category || 'Isolering',
|
|
source: isolering.source || 'database'
|
|
}
|
|
],
|
|
profitMargin: 30, // 30% fortjeneste for premium
|
|
complexity: 1.2
|
|
}
|
|
];
|
|
}
|
|
|
|
/**
|
|
* B7 tagplader pakker - nu med rigtige materialer fra databasen
|
|
*/
|
|
async getB7Packages(area) {
|
|
// Find rigtige B7 materialer fra databasen
|
|
const b7Plader = await this.findRealMaterial('B7', '4840') ||
|
|
await this.findRealMaterial('tagplade') ||
|
|
await this.findRealMaterial('eternit') ||
|
|
{ name: 'B7 tagplader 1100x2500mm (ikke fundet)', varenummer: 'FALLBACK_B7', unitPrice: 285.00, unit: 'stk', category: 'Tagbeklædning' };
|
|
|
|
const skruer = await this.findRealMaterial('skruer', '5160') ||
|
|
await this.findRealMaterial('tagskrue') ||
|
|
{ name: 'B7 Skruer 6.3x150mm (ikke fundet)', varenummer: 'FALLBACK_BS', unitPrice: 4.50, unit: 'stk', category: 'Beslag' };
|
|
|
|
const ryggesten = await this.findRealMaterial('ryggesten', '4840') ||
|
|
await this.findRealMaterial('tagryg') ||
|
|
{ name: 'Tagryggesten til B7 (ikke fundet)', varenummer: 'FALLBACK_RS', unitPrice: 145.00, unit: 'løbm', category: 'Ryg/Gavl' };
|
|
|
|
const undertag = await this.findRealMaterial('undertag', '4860') ||
|
|
await this.findRealMaterial('membran') ||
|
|
{ name: 'Undertag til B7 (ikke fundet)', varenummer: 'FALLBACK_UT2', unitPrice: 42.00, unit: 'm²', category: 'Undertag' };
|
|
|
|
const dampspærre = await this.findRealMaterial('dampspærre', '4860') ||
|
|
await this.findRealMaterial('plastfolie') ||
|
|
{ name: 'Dampspærre folie (ikke fundet)', varenummer: 'FALLBACK_DS', unitPrice: 25.50, unit: 'm²', category: 'Dampspærre' };
|
|
|
|
return [
|
|
{
|
|
id: 'b7_basic',
|
|
name: 'B7 Tagplader Basis',
|
|
description: 'Standard B7 tagpladepakke til skråtag - opdateret med rigtige materialer',
|
|
roofType: 'b7',
|
|
unit: 'm²',
|
|
materials: [
|
|
{
|
|
name: b7Plader.name,
|
|
varenummer: b7Plader.varenummer,
|
|
quantity: Math.ceil(area / 2.75), // pr. plade dækker ~2.75m²
|
|
unit: b7Plader.unit,
|
|
unitPrice: b7Plader.unitPrice,
|
|
category: b7Plader.category || 'Tagbeklædning',
|
|
source: b7Plader.source || 'database'
|
|
},
|
|
{
|
|
name: skruer.name,
|
|
varenummer: skruer.varenummer,
|
|
quantity: Math.ceil(area * 8), // 8 skruer/m²
|
|
unit: skruer.unit,
|
|
unitPrice: skruer.unitPrice,
|
|
category: skruer.category || 'Beslag',
|
|
source: skruer.source || 'database'
|
|
},
|
|
{
|
|
name: ryggesten.name,
|
|
varenummer: ryggesten.varenummer,
|
|
quantity: this.calculateRidgeLength(area),
|
|
unit: ryggesten.unit,
|
|
unitPrice: ryggesten.unitPrice,
|
|
category: ryggesten.category || 'Ryg/Gavl',
|
|
source: ryggesten.source || 'database'
|
|
},
|
|
{
|
|
name: undertag.name,
|
|
varenummer: undertag.varenummer,
|
|
quantity: Math.ceil(area * 1.05),
|
|
unit: undertag.unit,
|
|
unitPrice: undertag.unitPrice,
|
|
category: undertag.category || 'Undertag',
|
|
source: undertag.source || 'database'
|
|
}
|
|
],
|
|
profitMargin: 22, // 22% fortjeneste
|
|
complexity: 0.9
|
|
},
|
|
{
|
|
id: 'b7_insulated',
|
|
name: 'B7 Isoleret Løsning',
|
|
description: 'B7 tagplader med integreret isolering og dampspærre - opdateret med rigtige materialer',
|
|
roofType: 'b7',
|
|
unit: 'm²',
|
|
materials: [
|
|
{
|
|
name: b7Plader.name + ' (Isoleret)',
|
|
varenummer: b7Plader.varenummer,
|
|
quantity: Math.ceil(area / 2.75),
|
|
unit: b7Plader.unit,
|
|
unitPrice: b7Plader.unitPrice * 1.5, // Isoleret version er dyrere
|
|
category: b7Plader.category || 'Tagbeklædning',
|
|
source: b7Plader.source || 'database'
|
|
},
|
|
{
|
|
name: skruer.name + ' (Termo)',
|
|
varenummer: skruer.varenummer,
|
|
quantity: Math.ceil(area * 6),
|
|
unit: skruer.unit,
|
|
unitPrice: skruer.unitPrice * 1.8, // Termoskruer er dyrere
|
|
category: skruer.category || 'Beslag',
|
|
source: skruer.source || 'database'
|
|
},
|
|
{
|
|
name: dampspærre.name,
|
|
varenummer: dampspærre.varenummer,
|
|
quantity: Math.ceil(area * 1.1),
|
|
unit: dampspærre.unit,
|
|
unitPrice: dampspærre.unitPrice,
|
|
category: dampspærre.category || 'Dampspærre',
|
|
source: dampspærre.source || 'database'
|
|
}
|
|
],
|
|
profitMargin: 28,
|
|
complexity: 1.1
|
|
}
|
|
];
|
|
}
|
|
|
|
/**
|
|
* B6 tagplader pakker
|
|
*/
|
|
async getB6Packages(area) {
|
|
return [
|
|
{
|
|
id: 'b6_basic',
|
|
name: 'B6 Tagplader Standard',
|
|
description: 'Standard B6 tagpladepakke til mindre projekter',
|
|
roofType: 'b6',
|
|
unit: 'm²',
|
|
materials: [
|
|
{
|
|
name: 'B6 tagplader 900x2000mm',
|
|
varenummer: 'B6001',
|
|
quantity: Math.ceil(area / 1.8), // pr. plade dækker ~1.8m²
|
|
unit: 'stk',
|
|
unitPrice: 195.00,
|
|
category: 'Tagbeklædning'
|
|
},
|
|
{
|
|
name: 'B6 Skruer 5.5x120mm',
|
|
varenummer: 'B6002',
|
|
quantity: Math.ceil(area * 10), // flere skruer pga. mindre plader
|
|
unit: 'stk',
|
|
unitPrice: 3.75,
|
|
category: 'Beslag'
|
|
},
|
|
{
|
|
name: 'Tagryggesten til B6',
|
|
varenummer: 'B6003',
|
|
quantity: this.calculateRidgeLength(area),
|
|
unit: 'løbm',
|
|
unitPrice: 125.00,
|
|
category: 'Ryg/Gavl'
|
|
}
|
|
],
|
|
profitMargin: 20,
|
|
complexity: 0.8
|
|
}
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Vingetegl pakker - nu med rigtige materialer fra databasen
|
|
*/
|
|
async getVingeteglPackages(area) {
|
|
// Find rigtige vingetegl materialer fra databasen
|
|
const vingetegl = await this.findRealMaterial('vingetegl', '4840') ||
|
|
await this.findRealMaterial('tegl') ||
|
|
{ name: 'Vingetegl klassisk (ikke fundet)', varenummer: 'FALLBACK_VT', unitPrice: 16.50, unit: 'stk', category: 'Tagbeklædning' };
|
|
|
|
const tagcentraler = await this.findRealMaterial('tagcentral', '5160') ||
|
|
await this.findRealMaterial('beslag') ||
|
|
{ name: 'Tagcentraler til vingetegl (ikke fundet)', varenummer: 'FALLBACK_TC', unitPrice: 95.00, unit: 'stk', category: 'Beslag' };
|
|
|
|
const mørtel = await this.findRealMaterial('mørtel', '7020') ||
|
|
await this.findRealMaterial('rygningsmørtel') ||
|
|
{ name: 'Rygningsmørtel (ikke fundet)', varenummer: 'FALLBACK_RM', unitPrice: 15.00, unit: 'kg', category: 'Mørtel' };
|
|
|
|
return [
|
|
{
|
|
id: 'vingetegl_classic',
|
|
name: 'Vingetegl Klassisk',
|
|
description: 'Traditionel vingetegl med klassisk udseende - opdateret med rigtige materialer',
|
|
roofType: 'vingetegl',
|
|
unit: 'm²',
|
|
materials: [
|
|
{
|
|
name: vingetegl.name,
|
|
varenummer: vingetegl.varenummer,
|
|
quantity: Math.ceil(area * 14), // 14 stk/m²
|
|
unit: vingetegl.unit,
|
|
unitPrice: vingetegl.unitPrice,
|
|
category: vingetegl.category || 'Tagbeklædning',
|
|
source: vingetegl.source || 'database'
|
|
},
|
|
{
|
|
name: tagcentraler.name,
|
|
varenummer: tagcentraler.varenummer,
|
|
quantity: Math.ceil(area * 0.7),
|
|
unit: tagcentraler.unit,
|
|
unitPrice: tagcentraler.unitPrice,
|
|
category: tagcentraler.category || 'Beslag',
|
|
source: tagcentraler.source || 'database'
|
|
},
|
|
{
|
|
name: mørtel.name,
|
|
varenummer: mørtel.varenummer,
|
|
quantity: Math.ceil(this.calculateRidgeLength(area) * 2), // kg pr. løbm
|
|
unit: mørtel.unit,
|
|
unitPrice: mørtel.unitPrice,
|
|
category: mørtel.category || 'Mørtel',
|
|
source: mørtel.source || 'database'
|
|
}
|
|
],
|
|
profitMargin: 26,
|
|
complexity: 1.3
|
|
}
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Røde teglsten pakker
|
|
*/
|
|
async getRødeTeglstenPackages(area) {
|
|
return [
|
|
{
|
|
id: 'roed_tegl_premium',
|
|
name: 'Røde Teglsten Premium',
|
|
description: 'Klassiske røde teglsten af høj kvalitet',
|
|
roofType: 'røde_teglsten',
|
|
unit: 'm²',
|
|
materials: [
|
|
{
|
|
name: 'Røde tagsten premium',
|
|
varenummer: 'RT001',
|
|
quantity: Math.ceil(area * 12),
|
|
unit: 'stk',
|
|
unitPrice: 22.00,
|
|
category: 'Tagbeklædning'
|
|
},
|
|
{
|
|
name: 'Traditionelle tagcentraler',
|
|
varenummer: 'RT002',
|
|
quantity: Math.ceil(area * 0.9),
|
|
unit: 'stk',
|
|
unitPrice: 115.00,
|
|
category: 'Beslag'
|
|
}
|
|
],
|
|
profitMargin: 32, // Højere fortjeneste på premium produkter
|
|
complexity: 1.4
|
|
}
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Generiske tag pakker
|
|
*/
|
|
async getGenericRoofPackages(area) {
|
|
return [
|
|
{
|
|
id: 'generic_roof',
|
|
name: 'Standard Tag Pakke',
|
|
description: 'Generel tagpakke til forskellige tagtyper',
|
|
roofType: 'generisk',
|
|
unit: 'm²',
|
|
materials: [
|
|
{
|
|
name: 'Tagmateriale standard',
|
|
varenummer: 'GEN001',
|
|
quantity: area,
|
|
unit: 'm²',
|
|
unitPrice: 125.00,
|
|
category: 'Tagbeklædning'
|
|
},
|
|
{
|
|
name: 'Undertag standard',
|
|
varenummer: 'GEN002',
|
|
quantity: Math.ceil(area * 1.05),
|
|
unit: 'm²',
|
|
unitPrice: 45.00,
|
|
category: 'Undertag'
|
|
}
|
|
],
|
|
profitMargin: 20,
|
|
complexity: 1.0
|
|
}
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Beregn pakkepriser med fortjeneste
|
|
*/
|
|
calculatePackagePrice(packageData) {
|
|
let totalCostPrice = 0;
|
|
|
|
packageData.materials.forEach(material => {
|
|
totalCostPrice += material.quantity * material.unitPrice;
|
|
});
|
|
|
|
const profitAmount = totalCostPrice * (packageData.profitMargin / 100);
|
|
const listPrice = totalCostPrice + profitAmount;
|
|
|
|
return {
|
|
costPrice: Math.round(totalCostPrice * 100) / 100,
|
|
profitAmount: Math.round(profitAmount * 100) / 100,
|
|
listPrice: Math.round(listPrice * 100) / 100,
|
|
profitMargin: packageData.profitMargin,
|
|
currency: 'DKK'
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Søg i pakker
|
|
*/
|
|
async searchPackages(searchTerm, roofType = null) {
|
|
try {
|
|
const allPackages = [];
|
|
|
|
// Hvis roofType er specificeret, søg kun i den type
|
|
if (roofType) {
|
|
const packages = await this.getPackagesForRoofType(roofType, 100);
|
|
allPackages.push(...packages);
|
|
} else {
|
|
// Søg i alle tagtyper
|
|
const roofTypes = ['betontegl', 'b7', 'b6', 'vingetegl', 'røde_teglsten'];
|
|
for (const type of roofTypes) {
|
|
const packages = await this.getPackagesForRoofType(type, 100);
|
|
allPackages.push(...packages);
|
|
}
|
|
}
|
|
|
|
// Filtrer baseret på søgeterm
|
|
const filteredPackages = allPackages.filter(pkg =>
|
|
pkg.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
pkg.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
pkg.materials.some(material =>
|
|
material.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
material.varenummer.toLowerCase().includes(searchTerm.toLowerCase())
|
|
)
|
|
);
|
|
|
|
return filteredPackages;
|
|
} catch (error) {
|
|
logger.error('Error searching packages:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tilføj pakke til projekt
|
|
*/
|
|
async addPackageToProject(projectId, packageId, customArea = null) {
|
|
try {
|
|
// Hent projekt geometri for at beregne areal
|
|
const [geometryRows] = await this.db.pool.execute(
|
|
'SELECT total_area FROM roof_geometry WHERE project_id = ?',
|
|
[projectId]
|
|
);
|
|
|
|
const area = customArea || (geometryRows[0] ? geometryRows[0].total_area : 100);
|
|
|
|
// Find pakken
|
|
const packages = await this.searchPackages('', null);
|
|
const packageData = packages.find(pkg => pkg.id === packageId);
|
|
|
|
if (!packageData) {
|
|
throw new Error(`Package not found: ${packageId}`);
|
|
}
|
|
|
|
// Tilføj alle materialer fra pakken til projektet
|
|
for (const material of packageData.materials) {
|
|
await this.db.pool.execute(`
|
|
INSERT INTO project_materials (
|
|
project_id, name, material_category, unit,
|
|
quantity, unit_price, total_price, varenummer, source
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`, [
|
|
projectId,
|
|
material.name,
|
|
material.category,
|
|
material.unit,
|
|
material.quantity,
|
|
material.unitPrice,
|
|
material.quantity * material.unitPrice,
|
|
material.varenummer,
|
|
`package:${packageId}`
|
|
]);
|
|
}
|
|
|
|
logger.info('Package added to project', {
|
|
projectId,
|
|
packageId,
|
|
materialCount: packageData.materials.length,
|
|
area
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
package: packageData,
|
|
materialsAdded: packageData.materials.length,
|
|
totalCost: this.calculatePackagePrice(packageData).listPrice
|
|
};
|
|
} catch (error) {
|
|
logger.error('Error adding package to project:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hjælpefunktioner til geometriberegninger
|
|
*/
|
|
calculateWindboardLength(area) {
|
|
// Antag kvadratisk tag og beregn omkreds
|
|
const sideLength = Math.sqrt(area);
|
|
return Math.ceil(sideLength * 4); // løbm
|
|
}
|
|
|
|
calculateRidgeLength(area) {
|
|
// Antag at ryglængde er ca. 1/3 af kvadratrodens længde
|
|
const sideLength = Math.sqrt(area);
|
|
return Math.ceil(sideLength * 1.2); // løbm
|
|
}
|
|
|
|
/**
|
|
* Gem custom pakke i database
|
|
*/
|
|
async saveCustomPackage(packageData) {
|
|
try {
|
|
// Calculate cost price from materials
|
|
const costPrice = packageData.materials ?
|
|
packageData.materials.reduce((sum, material) =>
|
|
sum + (parseFloat(material.quantity || 0) * parseFloat(material.unitPrice || 0)), 0) : 0;
|
|
|
|
const [result] = await this.db.pool.execute(`
|
|
INSERT INTO custom_packages (
|
|
name, description, roof_type, materials, unit,
|
|
cost_price, profit_margin
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`, [
|
|
packageData.name,
|
|
packageData.description || '',
|
|
packageData.roofType,
|
|
JSON.stringify(packageData.materials || []),
|
|
packageData.unit || 'm2',
|
|
costPrice,
|
|
packageData.profitMargin || 20.00
|
|
]);
|
|
|
|
logger.info('Custom package saved', {
|
|
packageId: result.insertId,
|
|
name: packageData.name,
|
|
costPrice: costPrice,
|
|
unit: packageData.unit
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
id: result.insertId,
|
|
message: 'Pakke gemt succesfuldt'
|
|
};
|
|
} catch (error) {
|
|
logger.error('Error saving custom package:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async getCustomPackages() {
|
|
try {
|
|
const [rows] = await this.db.pool.execute(`
|
|
SELECT id, name, description, roof_type, materials, unit,
|
|
cost_price, list_price, profit_margin, quantity_from_geometry,
|
|
created_at, updated_at
|
|
FROM custom_packages
|
|
WHERE is_active = 1
|
|
ORDER BY created_at DESC
|
|
`);
|
|
|
|
const packages = rows.map(pkg => ({
|
|
...pkg,
|
|
materials: JSON.parse(pkg.materials || '[]'),
|
|
pricing: {
|
|
costPrice: parseFloat(pkg.cost_price || 0),
|
|
listPrice: parseFloat(pkg.list_price || 0),
|
|
profitMargin: parseFloat(pkg.profit_margin || 0)
|
|
}
|
|
}));
|
|
|
|
return {
|
|
success: true,
|
|
packages: packages
|
|
};
|
|
} catch (error) {
|
|
logger.error('Error fetching custom packages:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async updateCustomPackage(packageId, packageData) {
|
|
try {
|
|
// Calculate cost price from materials
|
|
const costPrice = packageData.materials ?
|
|
packageData.materials.reduce((sum, material) =>
|
|
sum + (parseFloat(material.quantity || 0) * parseFloat(material.unitPrice || 0)), 0) : 0;
|
|
|
|
const [result] = await this.db.pool.execute(`
|
|
UPDATE custom_packages SET
|
|
name = ?, description = ?, roof_type = ?,
|
|
materials = ?, unit = ?, cost_price = ?, profit_margin = ?,
|
|
updated_at = NOW()
|
|
WHERE id = ? AND is_active = 1
|
|
`, [
|
|
packageData.name,
|
|
packageData.description || '',
|
|
packageData.roofType,
|
|
JSON.stringify(packageData.materials || []),
|
|
packageData.unit || 'm2',
|
|
costPrice,
|
|
packageData.profitMargin || 20.00,
|
|
packageId
|
|
]);
|
|
|
|
if (result.affectedRows === 0) {
|
|
return {
|
|
success: false,
|
|
message: 'Pakke ikke fundet eller ikke aktiv'
|
|
};
|
|
}
|
|
|
|
logger.info('Custom package updated', {
|
|
packageId: packageId,
|
|
name: packageData.name,
|
|
costPrice: costPrice,
|
|
unit: packageData.unit
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
message: 'Pakke opdateret succesfuldt'
|
|
};
|
|
} catch (error) {
|
|
logger.error('Error updating custom package:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async deleteCustomPackage(packageId) {
|
|
try {
|
|
// Soft delete - set is_active = 0
|
|
const [result] = await this.db.pool.execute(`
|
|
UPDATE custom_packages SET is_active = 0, updated_at = NOW()
|
|
WHERE id = ? AND is_active = 1
|
|
`, [packageId]);
|
|
|
|
if (result.affectedRows === 0) {
|
|
return {
|
|
success: false,
|
|
message: 'Pakke ikke fundet eller allerede slettet'
|
|
};
|
|
}
|
|
|
|
logger.info('Custom package deleted', { packageId: packageId });
|
|
|
|
return {
|
|
success: true,
|
|
message: 'Pakke slettet succesfuldt'
|
|
};
|
|
} catch (error) {
|
|
logger.error('Error deleting custom package:', error);
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = PackageService; |