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;