1354 lines
48 KiB
JavaScript
1354 lines
48 KiB
JavaScript
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; |