Files
tilbudgivern/backend/src/services/packageService.js
T

1354 lines
48 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const logger = require('../utils/logger');
/**
* PackageService - Håndterer pakker til forskellige tagtyper
* Pakker indeholder materialelister, priser og fortjenestemarginaler
*
* PERFORMANCE: Added in-memory caching for material lookups
*/
class PackageService {
constructor(databaseService) {
this.db = databaseService;
// Standard 20% avance på alle materialer som default
this.DEFAULT_PROFIT_MARGIN = 20;
// PERFORMANCE: In-memory cache for material lookups (5 minute TTL)
this.materialCache = new Map();
this.CACHE_TTL = 5 * 60 * 1000; // 5 minutes
}
/**
* PERFORMANCE: Clear expired cache entries
*/
_cleanupCache() {
const now = Date.now();
for (const [key, value] of this.materialCache.entries()) {
if (now > value.expiresAt) {
this.materialCache.delete(key);
}
}
}
/**
* 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
*
* PERFORMANCE: Added caching to reduce database queries
*/
async findRealMaterial(searchTerm, category = null) {
try {
// PERFORMANCE: Check cache first
const cacheKey = `material:${searchTerm}:${category || 'all'}`;
const cached = this.materialCache.get(cacheKey);
if (cached && Date.now() < cached.expiresAt) {
logger.debug('Cache hit for material:', { searchTerm, category });
return cached.value;
}
// Cleanup expired entries periodically (every 100 requests)
if (Math.random() < 0.01) {
this._cleanupCache();
}
// 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];
const result = {
name: material.name,
varenummer: material.varenummer,
unit: material.unit || 'stk',
unitPrice: parseFloat(material.unitPrice) || 0,
category: material.category || 'diverse',
source: 'bygma'
};
// PERFORMANCE: Cache the result
this.materialCache.set(cacheKey, {
value: result,
expiresAt: Date.now() + this.CACHE_TTL
});
return result;
}
// 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];
const result = {
name: material.name,
varenummer: material.varenummer,
unit: material.unit || 'stk',
unitPrice: parseFloat(material.unitPrice) || 0,
category: material.category || 'diverse',
source: 'built_in'
};
// PERFORMANCE: Cache the result
this.materialCache.set(cacheKey, {
value: result,
expiresAt: Date.now() + this.CACHE_TTL
});
return result;
}
// PERFORMANCE: Cache null results to avoid repeated failed lookups
this.materialCache.set(cacheKey, {
value: null,
expiresAt: Date.now() + this.CACHE_TTL
});
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 tagrende pakker til alle tagtyper (beregnet baseret på perimeter)
const estimatedPerimeter = Math.sqrt(area) * 4; // Estimeret perimeter baseret på kvadratisk areal
packages.push(...await this.getTagrendePakker(estimatedPerimeter));
// Tilføj nedløb pakker (standard 4 nedløb for de fleste huse)
packages.push(...await this.getNedløbPakker(4));
// Tilføj spær opretning pakke (estimeret husstørrelse baseret på areal)
const estimatedHusLængde = Math.sqrt(area * 1.5);
const estimatedHusBredde = Math.sqrt(area / 1.5);
packages.push(...await this.getSpærOpretningPakker(estimatedHusLængde, estimatedHusBredde));
// Tilføj B7 rygning pakke hvis det er B7 tagtype
if (roofType.toLowerCase() === 'b7') {
const estimatedRygLængde = Math.max(estimatedHusLængde, estimatedHusBredde);
packages.push(...await this.getB7RygningPakker(estimatedRygLængde));
}
// 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 - opdateret med rigtige varenumre fra Google Docs
*/
async getB7Packages(area) {
// Find B7 materialer med specifikke varenumre fra dokumentet
const b7Plader = await this.findRealMaterial('147072', null) ||
{ name: 'B7 Tagplader 1100x2500mm', varenummer: '147072', unitPrice: 285.00, unit: 'stk', category: 'Tagbeklædning' };
const lægter = await this.findRealMaterial('1632038073', null) ||
{ name: 'Lægter 38x63mm C18', varenummer: '1632038073', unitPrice: 35.00, unit: 'løbm', category: 'Konstruktionstræ' };
const tagskruer = await this.findRealMaterial('119229', null) ||
{ name: 'Tagskruer rustfrie 400 stk', varenummer: '119229', unitPrice: 165.00, unit: 'pakke', category: 'Beslag' };
const ringsøm = await this.findRealMaterial('101933', null) ||
{ name: 'Ringsøm galvaniserede 2880 stk', varenummer: '101933', unitPrice: 125.00, unit: 'pakke', category: 'Beslag' };
const epdmBånd = await this.findRealMaterial('029218', null) ||
{ name: 'EPDM tætningsbånd 30m', varenummer: '029218', unitPrice: 245.00, unit: 'rulle', category: 'Tætning' };
// Beregn mængder baseret på specifikationer fra Google Docs
const b7AntalPlader = Math.ceil(area * 2.13); // 2,13 plader pr m²
const lægterLøbm = Math.ceil(area * 2.17); // 2,17 m pr m² ved 46cm afstand
const skruePakkerBehov = Math.ceil((area * 4.25) / 400); // 4,25 skruer pr m², 400 stk/pakke
const sømpakkerBehov = Math.ceil((area * 15) / 2880); // 15 søm pr m², 2880 stk/pakke
const epdmBåndBehov = Math.ceil((area * 2.17) / 30); // 2,17 m pr m², 30m/rulle
return [
{
id: 'b7_inkl_lægter',
name: 'B7 Montering inkl. Lægter',
description: `Komplet B7 installation med lægter efter specifikationer: 2,13 plader/m², lægteafstand 46cm, 4,25 skruer/m², 15 søm/m², 2,17m EPDM/m². Arbejdstid: 0,5 time/m².`,
roofType: 'b7',
unit: 'm²',
baseQuantity: area,
materials: [
{
name: b7Plader.name,
varenummer: b7Plader.varenummer,
quantity: b7AntalPlader,
unit: b7Plader.unit,
unitPrice: b7Plader.unitPrice,
category: b7Plader.category,
description: `${b7AntalPlader} stk - 2,13 plader pr m²`,
source: b7Plader.source || 'database'
},
{
name: lægter.name,
varenummer: lægter.varenummer,
quantity: lægterLøbm,
unit: lægter.unit,
unitPrice: lægter.unitPrice,
category: lægter.category,
description: `${lægterLøbm} løbm - 2,17 m pr m² (46cm afstand)`,
source: lægter.source || 'database'
},
{
name: tagskruer.name,
varenummer: tagskruer.varenummer,
quantity: skruePakkerBehov,
unit: tagskruer.unit,
unitPrice: tagskruer.unitPrice,
category: tagskruer.category,
description: `${skruePakkerBehov} pakker - ${Math.ceil(area * 4.25)} skruer (4,25/m²)`,
source: tagskruer.source || 'database'
},
{
name: ringsøm.name,
varenummer: ringsøm.varenummer,
quantity: sømpakkerBehov,
unit: ringsøm.unit,
unitPrice: ringsøm.unitPrice,
category: ringsøm.category,
description: `${sømpakkerBehov} pakker - ${Math.ceil(area * 15)} søm (15/m²)`,
source: ringsøm.source || 'database'
},
{
name: epdmBånd.name,
varenummer: epdmBånd.varenummer,
quantity: epdmBåndBehov,
unit: epdmBånd.unit,
unitPrice: epdmBånd.unitPrice,
category: epdmBånd.category,
description: `${epdmBåndBehov} ruller - ${Math.ceil(area * 2.17)}m (2,17m/m²)`,
source: epdmBånd.source || 'database'
}
],
laborHours: area * 0.5, // 0,5 time pr m²
laborDescription: `${area * 0.5} timer total - 0,5 time pr m²`,
profitMargin: this.DEFAULT_PROFIT_MARGIN, // Standard avance
complexity: 1.0
}
];
}
/**
* 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
}
];
}
/**
* Get profit margin for package - returns default or specified margin
* @param {number|undefined} customMargin - Custom margin to use instead of default
* @returns {number} Profit margin percentage
*/
getProfitMargin(customMargin = null) {
return customMargin !== null ? customMargin : this.DEFAULT_PROFIT_MARGIN;
}
/**
* 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 {
let allPackages = [];
// Hvis roofType er specificeret, søg kun i den type
if (roofType) {
const packages = await this.getPackagesForRoofType(roofType, 100);
allPackages.push(...packages);
} else {
// PERFORMANCE: Parallelize async calls instead of sequential loop
const roofTypes = ['betontegl', 'b7', 'b6', 'vingetegl', 'røde_teglsten'];
const packagePromises = roofTypes.map(type => this.getPackagesForRoofType(type, 100));
const packageArrays = await Promise.all(packagePromises);
allPackages = packageArrays.flat();
}
// 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}`);
}
// PERFORMANCE: Batch insert all materials in a single query instead of sequential inserts
if (packageData.materials.length > 0) {
const values = packageData.materials.map(material => [
projectId,
material.name,
material.category,
material.unit,
material.quantity,
material.unitPrice,
material.quantity * material.unitPrice,
material.varenummer,
`package:${packageId}`
]);
const placeholders = values.map(() => '(?, ?, ?, ?, ?, ?, ?, ?, ?)').join(', ');
const flatValues = values.flat();
await this.db.pool.execute(`
INSERT INTO project_materials (
project_id, name, material_category, unit,
quantity, unit_price, total_price, varenummer, source
) VALUES ${placeholders}
`, flatValues);
}
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;
}
}
/**
* Tagrende pakke med Lindab materialer - baseret på Google Docs specifikation
*/
async getTagrendePakker(perimeter = 40) {
// Find Lindab materialer fra database
const tagrende = await this.findRealMaterial('077424', null) ||
{ name: 'Lindab Tagrende 3m', varenummer: '077424', unitPrice: 185.00, unit: 'stk', category: 'Tagrende' };
const endeBund = await this.findRealMaterial('107744', null) ||
{ name: 'Lindab Ende bund', varenummer: '107744', unitPrice: 35.00, unit: 'stk', category: 'Tagrende' };
const samlestykke = await this.findRealMaterial('077419', null) ||
{ name: 'Lindab Samlestykke', varenummer: '077419', unitPrice: 28.00, unit: 'stk', category: 'Tagrende' };
const konsoljern = await this.findRealMaterial('077407', null) ||
{ name: 'Lindab Konsoljern', varenummer: '077407', unitPrice: 45.00, unit: 'stk', category: 'Tagrende' };
const hjørneIndvendig = await this.findRealMaterial('077426', null) ||
{ name: 'Lindab Hjørne indvendig', varenummer: '077426', unitPrice: 65.00, unit: 'stk', category: 'Tagrende' };
const hjørneUdvendig = await this.findRealMaterial('077431', null) ||
{ name: 'Lindab Hjørne udvendig', varenummer: '077431', unitPrice: 65.00, unit: 'stk', category: 'Tagrende' };
// Beregn mængder baseret på perimeter (facadelængde)
const tagrendeAntal = Math.ceil(perimeter / 3); // 3m pr. stykke
const samlestykkeBehov = Math.max(0, tagrendeAntal - 1); // Samling mellem hver 3m stykke
const konsolBehov = Math.ceil(perimeter / 0.6); // Max 60cm mellem konsoller
return [
{
id: 'tagrende_lindab_steel',
name: 'Tagrende Lindab Stål Komplet',
description: 'Komplet tagrende system med Lindab materialer i stål. Inkluderer tagrende, konsoller, samlestykker og afslutninger. 10 meter tager 5 timer arbejde.',
roofType: 'generisk',
unit: 'løbm',
baseQuantity: perimeter,
materials: [
{
name: tagrende.name,
varenummer: tagrende.varenummer,
quantity: tagrendeAntal,
unit: tagrende.unit,
unitPrice: tagrende.unitPrice,
category: tagrende.category,
description: `${tagrendeAntal} stk á 3m = ${tagrendeAntal * 3}m tagrende`,
source: tagrende.source || 'database'
},
{
name: endeBund.name,
varenummer: endeBund.varenummer,
quantity: 4, // Generisk 4 stk som specificeret
unit: endeBund.unit,
unitPrice: endeBund.unitPrice,
category: endeBund.category,
description: 'Standard 4 stk ende bunde til afslutning',
source: endeBund.source || 'database'
},
{
name: samlestykke.name,
varenummer: samlestykke.varenummer,
quantity: samlestykkeBehov,
unit: samlestykke.unit,
unitPrice: samlestykke.unitPrice,
category: samlestykke.category,
description: `${samlestykkeBehov} stk - 1 pr 3m tagrende`,
source: samlestykke.source || 'database'
},
{
name: konsoljern.name,
varenummer: konsoljern.varenummer,
quantity: konsolBehov,
unit: konsoljern.unit,
unitPrice: konsoljern.unitPrice,
category: konsoljern.category,
description: `${konsolBehov} stk - maks 60cm mellem konsoller`,
source: konsoljern.source || 'database'
},
{
name: hjørneIndvendig.name,
varenummer: hjørneIndvendig.varenummer,
quantity: 1,
unit: hjørneIndvendig.unit,
unitPrice: hjørneIndvendig.unitPrice,
category: hjørneIndvendig.category,
description: '1 stk til huskeliste',
source: hjørneIndvendig.source || 'database'
},
{
name: hjørneUdvendig.name,
varenummer: hjørneUdvendig.varenummer,
quantity: 1,
unit: hjørneUdvendig.unit,
unitPrice: hjørneUdvendig.unitPrice,
category: hjørneUdvendig.category,
description: '1 stk til huskeliste',
source: hjørneUdvendig.source || 'database'
}
],
laborHours: Math.ceil(perimeter / 10 * 5), // 10m = 5 timer
laborDescription: `${Math.ceil(perimeter / 10 * 5)} timer total - 10 løbende meter tager 5 timer`,
profitMargin: this.DEFAULT_PROFIT_MARGIN, // Standard avance
complexity: 1.0
}
];
}
/**
* Nedløb pakke med automatisk tilbehørsberegning - baseret på Google Docs specifikation
*/
async getNedløbPakker(nedløbAntal = 4) {
// Find Lindab nedløb materialer fra database
const nedløb = await this.findRealMaterial('077438', null) ||
{ name: 'Lindab Nedløb 3m', varenummer: '077438', unitPrice: 165.00, unit: 'stk', category: 'Nedløb' };
const rørholder = await this.findRealMaterial('184937', null) ||
{ name: 'Lindab Rørholder', varenummer: '184937', unitPrice: 25.00, unit: 'stk', category: 'Nedløb' };
const tudstykke = await this.findRealMaterial('122214', null) ||
{ name: 'Lindab Tudstykke', varenummer: '122214', unitPrice: 45.00, unit: 'stk', category: 'Nedløb' };
const silikone = await this.findRealMaterial('050043', null) ||
{ name: 'Silikone tætning', varenummer: '050043', unitPrice: 35.00, unit: 'stk', category: 'Nedløb' };
const brøndkrave = await this.findRealMaterial('061637', null) ||
{ name: 'Brøndkrave', varenummer: '061637', unitPrice: 85.00, unit: 'stk', category: 'Nedløb' };
const bøjning = await this.findRealMaterial('076623', null) ||
{ name: 'Lindab Bøjning 87°', varenummer: '076623', unitPrice: 55.00, unit: 'stk', category: 'Nedløb' };
return [
{
id: 'nedløb_lindab_komplet',
name: 'Nedløb Lindab Komplet System',
description: `Komplet nedløb installation for ${nedløbAntal} stk nedløb. Inkluderer alle nødvendige tilbehør og beslag. 1 nedløb tager 1 time arbejde.`,
roofType: 'generisk',
unit: 'stk',
baseQuantity: nedløbAntal,
materials: [
{
name: nedløb.name,
varenummer: nedløb.varenummer,
quantity: nedløbAntal,
unit: nedløb.unit,
unitPrice: nedløb.unitPrice,
category: nedløb.category,
description: `${nedløbAntal} stk nedløb á 3m`,
source: nedløb.source || 'database'
},
{
name: rørholder.name,
varenummer: rørholder.varenummer,
quantity: nedløbAntal * 2, // 2 stk per nedløb
unit: rørholder.unit,
unitPrice: rørholder.unitPrice,
category: rørholder.category,
description: `${nedløbAntal * 2} stk - 2 pr nedløb`,
source: rørholder.source || 'database'
},
{
name: tudstykke.name,
varenummer: tudstykke.varenummer,
quantity: nedløbAntal,
unit: tudstykke.unit,
unitPrice: tudstykke.unitPrice,
category: tudstykke.category,
description: `${nedløbAntal} stk - 1 pr nedløb`,
source: tudstykke.source || 'database'
},
{
name: silikone.name,
varenummer: silikone.varenummer,
quantity: nedløbAntal,
unit: silikone.unit,
unitPrice: silikone.unitPrice,
category: silikone.category,
description: `${nedløbAntal} stk - 1 pr nedløb`,
source: silikone.source || 'database'
},
{
name: brøndkrave.name,
varenummer: brøndkrave.varenummer,
quantity: nedløbAntal,
unit: brøndkrave.unit,
unitPrice: brøndkrave.unitPrice,
category: brøndkrave.category,
description: `${nedløbAntal} stk - 1 pr nedløb`,
source: brøndkrave.source || 'database'
},
{
name: bøjning.name,
varenummer: bøjning.varenummer,
quantity: nedløbAntal * 2, // 2 stk per nedløb
unit: bøjning.unit,
unitPrice: bøjning.unitPrice,
category: bøjning.category,
description: `${nedløbAntal * 2} stk - 2 pr nedløb`,
source: bøjning.source || 'database'
}
],
laborHours: nedløbAntal * 1, // 1 time pr nedløb
laborDescription: `${nedløbAntal} timer total - 1 time pr nedløb`,
profitMargin: this.DEFAULT_PROFIT_MARGIN, // Standard avance
complexity: 1.0
}
];
}
/**
* Spær opretning pakke med LBM beregning - baseret på Google Docs specifikation
*/
async getSpærOpretningPakker(husLængde = 12, husBredde = 8) {
// Find spær materialer fra database
const spærTræ = await this.findRealMaterial('146304720010450', null) ||
{ name: 'Spær træ 200mm C24', varenummer: '146304720010450', unitPrice: 125.00, unit: 'løbm', category: 'Konstruktionstræ' };
const spånskruer = await this.findRealMaterial('310615', null) ||
{ name: 'Spånskruer 5,0x100mm', varenummer: '310615', unitPrice: 285.00, unit: 'pakke', category: 'Beslag' };
const muresnor = await this.findRealMaterial('195408', null) ||
{ name: 'Muresnor 50m', varenummer: '195408', unitPrice: 45.00, unit: 'stk', category: 'Værktøj' };
const bits = await this.findRealMaterial('224770', null) ||
{ name: 'Bits TX20', varenummer: '224770', unitPrice: 25.00, unit: 'stk', category: 'Værktøj' };
const snorvatterpas = await this.findRealMaterial('064736', null) ||
{ name: 'Snorvatterpas 100cm', varenummer: '064736', unitPrice: 165.00, unit: 'stk', category: 'Værktøj' };
const rundsavsklinge = await this.findRealMaterial('182788', null) ||
{ name: 'Rundsavsklinge 190mm', varenummer: '182788', unitPrice: 125.00, unit: 'stk', category: 'Værktøj' };
const stiksavsklinger = await this.findRealMaterial('147761', null) ||
{ name: 'Stiksavsklinger T101B', varenummer: '147761', unitPrice: 85.00, unit: 'pakke', category: 'Værktøj' };
// Beregn LBM: (Længde + 1) × Bredde = LBM på spær
const lbmSpær = (husLængde + 1) * husBredde;
const skrueBehov = Math.ceil(lbmSpær * 5); // 5 skruer pr LBM
const skruePakker = Math.ceil(skrueBehov / 200); // 200 stk pr pakke
return [
{
id: 'spær_opretning_komplet',
name: 'Spær Opretning Komplet',
description: `Komplet spær opretning for ${husLængde}x${husBredde}m hus. LBM beregning: (${husLængde}+1) × ${husBredde} = ${lbmSpær} løbende meter spær. 5 meter tager 0,5 time.`,
roofType: 'generisk',
unit: 'løbm',
baseQuantity: lbmSpær,
materials: [
{
name: spærTræ.name,
varenummer: spærTræ.varenummer,
quantity: lbmSpær,
unit: spærTræ.unit,
unitPrice: spærTræ.unitPrice,
category: spærTræ.category,
description: `${lbmSpær} løbm - beregnet fra geometri`,
source: spærTræ.source || 'database'
},
{
name: spånskruer.name,
varenummer: spånskruer.varenummer,
quantity: skruePakker,
unit: spånskruer.unit,
unitPrice: spånskruer.unitPrice,
category: spånskruer.category,
description: `${skruePakker} pakker - ${skrueBehov} skruer (5 pr LBM)`,
source: spånskruer.source || 'database'
},
{
name: muresnor.name,
varenummer: muresnor.varenummer,
quantity: 1,
unit: muresnor.unit,
unitPrice: muresnor.unitPrice,
category: muresnor.category,
description: '1 stk pr sag',
source: muresnor.source || 'database'
},
{
name: bits.name,
varenummer: bits.varenummer,
quantity: 1,
unit: bits.unit,
unitPrice: bits.unitPrice,
category: bits.category,
description: '1 stk pr sag',
source: bits.source || 'database'
},
{
name: snorvatterpas.name,
varenummer: snorvatterpas.varenummer,
quantity: 2,
unit: snorvatterpas.unit,
unitPrice: snorvatterpas.unitPrice,
category: snorvatterpas.category,
description: '2 stk pr sag',
source: snorvatterpas.source || 'database'
},
{
name: rundsavsklinge.name,
varenummer: rundsavsklinge.varenummer,
quantity: 1,
unit: rundsavsklinge.unit,
unitPrice: rundsavsklinge.unitPrice,
category: rundsavsklinge.category,
description: '1 stk pr sag',
source: rundsavsklinge.source || 'database'
},
{
name: stiksavsklinger.name,
varenummer: stiksavsklinger.varenummer,
quantity: 1,
unit: stiksavsklinger.unit,
unitPrice: stiksavsklinger.unitPrice,
category: stiksavsklinger.category,
description: '1 pakke pr sag',
source: stiksavsklinger.source || 'database'
}
],
laborHours: Math.ceil(lbmSpær / 5 * 0.5), // 5 meter = 0.5 time
laborDescription: `${Math.ceil(lbmSpær / 5 * 0.5)} timer total - 5 meter tager 0,5 time`,
profitMargin: this.DEFAULT_PROFIT_MARGIN, // Standard avance
complexity: 1.1
}
];
}
/**
* B7 Rygning pakke - separat pakke baseret på Google Docs specifikation
*/
async getB7RygningPakker(rygLængde = 12) {
// Find B7 rygning materialer fra database
const rygning = await this.findRealMaterial('057437', null) ||
{ name: 'B7 Rygning eternit', varenummer: '057437', unitPrice: 185.00, unit: 'løbm', category: 'Ryg/Gavl' };
const rygningselement = await this.findRealMaterial('038218', null) ||
{ name: 'B7 Rygningselement', varenummer: '038218', unitPrice: 45.00, unit: 'stk', category: 'Ryg/Gavl' };
const tagskruerRyg = await this.findRealMaterial('144855', null) ||
{ name: 'Tagskruer til rygning 100 stk', varenummer: '144855', unitPrice: 125.00, unit: 'pakke', category: 'Beslag' };
const epdmBånd = await this.findRealMaterial('029218', null) ||
{ name: 'EPDM tætningsbånd 30m', varenummer: '029218', unitPrice: 245.00, unit: 'rulle', category: 'Tætning' };
// Beregn mængder: 1 stk pr meter, 4 skruer pr meter, 0,4m EPDM pr meter
const rygningsElementer = Math.ceil(rygLængde); // 1 stk pr meter
const skruePakker = Math.ceil((rygLængde * 4) / 100); // 4 skruer pr m, 100 stk/pakke
const epdmBåndRuller = Math.ceil((rygLængde * 0.4) / 30); // 0,4m pr meter, 30m/rulle
return [
{
id: 'b7_rygning_komplet',
name: 'B7 Rygning Komplet',
description: `B7 rygning installation for ${rygLængde} løbende meter. 1 rygning/m, 1 element/m, 4 skruer/m, 0,4m EPDM/m.`,
roofType: 'b7',
unit: 'løbm',
baseQuantity: rygLængde,
materials: [
{
name: rygning.name,
varenummer: rygning.varenummer,
quantity: rygLængde,
unit: rygning.unit,
unitPrice: rygning.unitPrice,
category: rygning.category,
description: `${rygLængde} løbm - 1 stk pr meter`,
source: rygning.source || 'database'
},
{
name: rygningselement.name,
varenummer: rygningselement.varenummer,
quantity: rygningsElementer,
unit: rygningselement.unit,
unitPrice: rygningselement.unitPrice,
category: rygningselement.category,
description: `${rygningsElementer} stk - 1 stk pr meter`,
source: rygningselement.source || 'database'
},
{
name: tagskruerRyg.name,
varenummer: tagskruerRyg.varenummer,
quantity: skruePakker,
unit: tagskruerRyg.unit,
unitPrice: tagskruerRyg.unitPrice,
category: tagskruerRyg.category,
description: `${skruePakker} pakker - ${rygLængde * 4} skruer (4 pr meter)`,
source: tagskruerRyg.source || 'database'
},
{
name: epdmBånd.name,
varenummer: epdmBånd.varenummer,
quantity: epdmBåndRuller,
unit: epdmBånd.unit,
unitPrice: epdmBånd.unitPrice,
category: epdmBånd.category,
description: `${epdmBåndRuller} ruller - ${rygLængde * 0.4}m (0,4m pr meter)`,
source: epdmBånd.source || 'database'
}
],
laborHours: rygLængde * 0.3, // Estimeret arbejdstid
laborDescription: `${rygLængde * 0.3} timer total - rygning installation`,
profitMargin: this.DEFAULT_PROFIT_MARGIN, // Standard avance
complexity: 1.2
}
];
}
}
module.exports = PackageService;