Files
tilbudgivern/backend/src/services/projectMaterialService.js
T
alexpolo1 c571105a31 🚀 Major System Enhancement: Complete Smart Package System & Production Ready
 New Features:
- Complete Smart Package System with 6 B7 package types
- Enhanced geometry validation with ridgeHeight support
- Automatic material quantity calculations (2.13 plates/m², 2.17m batten/m²)
- Intelligent labor hour calculations (580 DKK/hour)
- Total price calculations with VAT (realistic 800 DKK/m² for B7 roofs)

🔧 Backend Improvements:
- Enhanced PackageService with real Bygma material integration
- Improved CustomerProjects API with package management
- ProjectMaterialService with advanced calculations
- Ridge height validation (0.5-8.0m range)

🎨 Frontend Enhancements:
- Enhanced geometry input component with validation
- Improved SmartPackage components with better UX
- Real-time package selection and pricing display

📦 Deployment & Operations:
- Complete database migration scripts
- Nginx configurations for production deployment
- Build and test automation scripts
- Implementation progress documentation

🧪 System Verification:
- Full end-to-end workflow testing completed
- All calculations validated with realistic pricing
- 165m² B7 project: 132,770 DKK total (competitive market rate)
- Ready for production carpenter use!
2025-10-10 19:39:55 +00:00

511 lines
16 KiB
JavaScript

const logger = require('../utils/logger');
class ProjectMaterialService {
constructor(databaseService) {
this.db = databaseService;
}
// Tilføj materiale til projekt
async addProjectMaterial(projectId, materialData) {
try {
const {
materialName,
materialCategory,
quantity,
unit,
unitPrice,
supplier,
materialSource = 'manual',
notes
} = materialData;
const totalPrice = quantity * unitPrice;
const query = `
INSERT INTO project_materials (
project_id, material_name, material_category, quantity,
unit, unit_price, total_price, supplier, material_source, notes
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
const [result] = await this.db.pool.execute(query, [
projectId, materialName, materialCategory, quantity,
unit, unitPrice, totalPrice, supplier, materialSource, notes
]);
logger.info('Project material added', {
projectId,
materialName,
quantity,
totalPrice
});
return {
id: result.insertId,
materialName,
materialCategory,
quantity,
unit,
unitPrice,
totalPrice,
supplier,
materialSource
};
} catch (error) {
logger.error('Error adding project material:', error);
throw error;
}
}
// Hent alle materialer for projekt
async getProjectMaterials(projectId) {
try {
const [rows] = await this.db.pool.execute(
`SELECT * FROM project_materials
WHERE project_id = ?
ORDER BY material_category, material_name`,
[projectId]
);
// Gruppér materialer per kategori
const materialsByCategory = {};
let totalMaterialCost = 0;
rows.forEach(material => {
const category = material.material_category || 'Øvrige';
if (!materialsByCategory[category]) {
materialsByCategory[category] = [];
}
materialsByCategory[category].push(material);
totalMaterialCost += parseFloat(material.total_price);
});
return {
materials: rows,
materialsByCategory,
totalMaterialCost,
materialCount: rows.length
};
} catch (error) {
logger.error('Error getting project materials:', error);
throw error;
}
}
// Opdater materiale
async updateProjectMaterial(materialId, updateData) {
try {
const {
materialName,
materialCategory,
quantity,
unit,
unitPrice,
supplier,
notes
} = updateData;
const totalPrice = quantity * unitPrice;
const query = `
UPDATE project_materials
SET material_name = ?, material_category = ?, quantity = ?,
unit = ?, unit_price = ?, total_price = ?, supplier = ?, notes = ?
WHERE id = ?
`;
const [result] = await this.db.pool.execute(query, [
materialName, materialCategory, quantity, unit, unitPrice,
totalPrice, supplier, notes, materialId
]);
if (result.affectedRows === 0) {
throw new Error('Material not found');
}
logger.info('Project material updated', {
materialId,
materialName,
quantity,
totalPrice
});
return {
id: materialId,
materialName,
materialCategory,
quantity,
unit,
unitPrice,
totalPrice,
supplier
};
} catch (error) {
logger.error('Error updating project material:', error);
throw error;
}
}
// Slet materiale
async deleteProjectMaterial(materialId) {
try {
const [result] = await this.db.pool.execute(
'DELETE FROM project_materials WHERE id = ?',
[materialId]
);
if (result.affectedRows === 0) {
throw new Error('Material not found');
}
logger.info('Project material deleted', { materialId });
return true;
} catch (error) {
logger.error('Error deleting project material:', error);
throw error;
}
}
// Importer materialer fra eksisterende database
async importMaterialsFromDatabase(projectId, searchCriteria) {
try {
const { category, searchTerm, limit = 50 } = searchCriteria;
let query = `
SELECT DISTINCT name, material_category, unit, unit_price, supplier_name
FROM ocr_materials
WHERE 1=1
`;
let params = [];
if (category) {
query += ' AND material_category = ?';
params.push(category);
}
if (searchTerm) {
query += ' AND (name LIKE ? OR supplier_name LIKE ?)';
params.push(`%${searchTerm}%`, `%${searchTerm}%`);
}
query += ' ORDER BY name LIMIT ?';
params.push(limit);
const [rows] = await this.db.pool.execute(query, params);
logger.info('Materials found in database', {
projectId,
foundCount: rows.length,
searchCriteria
});
return rows.map(row => ({
materialName: row.name,
materialCategory: row.material_category,
unit: row.unit,
unitPrice: row.unit_price,
supplier: row.supplier_name,
materialSource: 'database'
}));
} catch (error) {
logger.error('Error importing materials from database:', error);
throw error;
}
}
// Bulk tilføj materialer
async bulkAddMaterials(projectId, materials) {
try {
const addedMaterials = [];
let totalCost = 0;
for (const material of materials) {
const result = await this.addProjectMaterial(projectId, material);
addedMaterials.push(result);
totalCost += result.totalPrice;
}
logger.info('Bulk materials added', {
projectId,
materialCount: addedMaterials.length,
totalCost
});
return {
addedMaterials,
totalCost,
count: addedMaterials.length
};
} catch (error) {
logger.error('Error bulk adding materials:', error);
throw error;
}
}
// Hent material kategorier fra database
async getMaterialCategories() {
try {
const [rows] = await this.db.pool.execute(`
SELECT DISTINCT material_category as category, COUNT(*) as count
FROM ocr_materials
WHERE material_category IS NOT NULL AND material_category != ''
GROUP BY material_category
ORDER BY count DESC, material_category ASC
`);
return rows;
} catch (error) {
logger.error('Error getting material categories:', error);
throw error;
}
}
// Beregn materialeomkostninger for projekt
async calculateMaterialCosts(projectId) {
try {
const [rows] = await this.db.pool.execute(`
SELECT
SUM(total_price) as total_cost,
COUNT(*) as material_count,
material_category,
SUM(total_price) as category_cost
FROM project_materials
WHERE project_id = ?
GROUP BY material_category
`, [projectId]);
const [totalRows] = await this.db.pool.execute(`
SELECT
SUM(total_price) as total_cost,
COUNT(*) as material_count
FROM project_materials
WHERE project_id = ?
`, [projectId]);
const categoryBreakdown = rows.map(row => ({
category: row.material_category || 'Øvrige',
cost: parseFloat(row.category_cost),
materialCount: parseInt(row.material_count)
}));
return {
totalMaterialCost: totalRows[0]?.total_cost ? parseFloat(totalRows[0].total_cost) : 0,
materialCount: totalRows[0]?.material_count ? parseInt(totalRows[0].material_count) : 0,
categoryBreakdown
};
} catch (error) {
logger.error('Error calculating material costs:', error);
throw error;
}
}
// Tag materiale forslag baseret på tag type og areal
async suggestRoofMaterials(projectId) {
try {
// Hent geometri data
const [geometryRows] = await this.db.pool.execute(
'SELECT * FROM roof_geometry WHERE project_id = ?',
[projectId]
);
if (geometryRows.length === 0) {
throw new Error('No geometry data found for project');
}
const geometry = geometryRows[0];
const suggestions = [];
// Hent rigtige materialer fra database
const [roofMaterials] = await this.db.pool.execute(`
SELECT name, category, price, unit
FROM material_prices
WHERE category IN ('Tagmateriale', 'Tag', 'Tagmaterialer', 'Tagpap', 'Tagplader')
ORDER BY price ASC
LIMIT 10
`);
const [woodMaterials] = await this.db.pool.execute(`
SELECT name, category, price, unit
FROM material_prices
WHERE category IN ('Træ', 'Trælast', 'Konstruktionstræ')
ORDER BY price ASC
LIMIT 8
`);
const [insulationMaterials] = await this.db.pool.execute(`
SELECT name, category, price, unit
FROM material_prices
WHERE category IN ('Isolering', 'Isolation')
ORDER BY price ASC
LIMIT 5
`);
const [fastenerMaterials] = await this.db.pool.execute(`
SELECT name, category, price, unit
FROM material_prices
WHERE category IN ('Beslag', 'Befæstning', 'Skruer')
ORDER BY price ASC
LIMIT 6
`);
// Basis materialer for tag arbejde - nu med database integration
const roofMaterialSuggestions = {
'fladt_tag': [
{ name: 'EPDM tagfolie', unit: 'm²', estimatedQuantity: geometry.total_area * 1.1, price: 165.00 },
{ name: 'Tagpap', unit: 'm²', estimatedQuantity: geometry.total_area * 1.1, price: 85.00 },
{ name: 'Isolering EPS', unit: 'm²', estimatedQuantity: geometry.total_area, price: 95.00 },
{ name: 'Træbeton', unit: 'm²', estimatedQuantity: geometry.total_area, price: 125.00 },
{ name: 'Tagrender', unit: 'løbende m', estimatedQuantity: geometry.perimeter, price: 185.00 },
{ name: 'Tætningslister', unit: 'løbende m', estimatedQuantity: geometry.perimeter * 1.1, price: 15.00 }
],
'skraat_tag': [
{ name: 'Tagsten', unit: 'm²', estimatedQuantity: geometry.total_area * 1.15, price: 195.00 },
{ name: 'Undertag', unit: 'm²', estimatedQuantity: geometry.total_area, price: 65.00 },
{ name: 'Tagcentraler', unit: 'stk', estimatedQuantity: Math.ceil(geometry.total_area / 10), price: 85.00 },
{ name: 'Mineraluld 200mm', unit: 'm²', estimatedQuantity: geometry.total_area, price: 135.00 },
{ name: 'Spær 45x195mm', unit: 'stk', estimatedQuantity: Math.ceil(geometry.length / 0.6), price: 125.00 },
{ name: 'Lægte 38x63mm', unit: 'løbende m', estimatedQuantity: geometry.total_area * 2.5, price: 18.50 },
{ name: 'Vindskeder', unit: 'løbende m', estimatedQuantity: geometry.perimeter, price: 95.00 },
{ name: 'Tagrender Ø150mm', unit: 'løbende m', estimatedQuantity: geometry.length * 2, price: 185.00 },
{ name: 'Nedløb Ø100mm', unit: 'stk', estimatedQuantity: Math.max(2, Math.ceil(geometry.length / 8)), price: 125.00 }
],
'mansard': [
{ name: 'Tagsten premium', unit: 'm²', estimatedQuantity: geometry.total_area * 1.2, price: 245.00 },
{ name: 'Undertag diffusionsåben', unit: 'm²', estimatedQuantity: geometry.total_area, price: 75.00 },
{ name: 'Tagcentraler forstærket', unit: 'stk', estimatedQuantity: Math.ceil(geometry.total_area / 8), price: 95.00 },
{ name: 'Mineraluld 250mm', unit: 'm²', estimatedQuantity: geometry.total_area, price: 165.00 },
{ name: 'Spær forstærket', unit: 'stk', estimatedQuantity: Math.ceil(geometry.length / 0.5), price: 145.00 },
{ name: 'Specialbeslag', unit: 'sæt', estimatedQuantity: Math.ceil(geometry.total_area / 15), price: 185.00 }
],
'komplekst': [
{ name: 'Premium tagsten', unit: 'm²', estimatedQuantity: geometry.total_area * 1.25, price: 295.00 },
{ name: 'Højkvalitets undertag', unit: 'm²', estimatedQuantity: geometry.total_area, price: 95.00 },
{ name: 'Specialbeslag', unit: 'sæt', estimatedQuantity: Math.ceil(geometry.total_area / 20), price: 245.00 },
{ name: 'Mineraluld premium', unit: 'm²', estimatedQuantity: geometry.total_area, price: 195.00 },
{ name: 'Ventilationssystem', unit: 'sæt', estimatedQuantity: Math.ceil(geometry.total_area / 50), price: 385.00 }
]
};
let materialList = roofMaterialSuggestions[geometry.roof_type] || roofMaterialSuggestions['skraat_tag'];
// Tilføj database materialer hvis tilgængelige
if (roofMaterials.length > 0) {
roofMaterials.forEach(material => {
materialList.push({
name: material.name,
unit: material.unit,
estimatedQuantity: geometry.total_area * 1.1,
price: parseFloat(material.price),
category: 'Tagmateriale'
});
});
}
if (woodMaterials.length > 0) {
woodMaterials.forEach(material => {
materialList.push({
name: material.name,
unit: material.unit,
estimatedQuantity: material.unit.includes('m²') ? geometry.total_area : geometry.length * 2,
price: parseFloat(material.price),
category: 'Træ'
});
});
}
if (insulationMaterials.length > 0) {
insulationMaterials.forEach(material => {
materialList.push({
name: material.name,
unit: material.unit,
estimatedQuantity: geometry.total_area,
price: parseFloat(material.price),
category: 'Isolering'
});
});
}
if (fastenerMaterials.length > 0) {
fastenerMaterials.forEach(material => {
materialList.push({
name: material.name,
unit: material.unit,
estimatedQuantity: material.unit === 'pakke' ? 1 : Math.ceil(geometry.total_area * 6),
price: parseFloat(material.price),
category: 'Beslag'
});
});
}
// Tilføj ekstra materialer for specielle forhold
if (geometry.has_dormers) {
materialList.push({
name: 'Kvist materialer ekstra',
unit: 'sæt',
estimatedQuantity: geometry.dormer_count || 1,
price: 485.00,
category: 'Tagmaterialer'
});
materialList.push({
name: 'Kvist blinklister',
unit: 'løbende m',
estimatedQuantity: (geometry.dormer_count || 1) * 8,
price: 45.00,
category: 'Tætning'
});
}
if (geometry.has_skylights) {
materialList.push({
name: 'Ovenlys inddækning',
unit: 'stk',
estimatedQuantity: 2,
price: 185.00,
category: 'Specialmaterialer'
});
}
// Tilføj standard tilbehør baseret på projekt størrelse
if (geometry.total_area > 100) {
materialList.push({
name: 'Tagstiger galvaniseret',
unit: 'stk',
estimatedQuantity: 1,
price: 850.00,
category: 'Tilbehør'
});
}
if (geometry.total_area > 50) {
materialList.push({
name: 'Snefang system',
unit: 'løbende m',
estimatedQuantity: geometry.length,
price: 75.00,
category: 'Tilbehør'
});
}
// Konverter til suggestions format med forbedret kategorisering
materialList.forEach(material => {
suggestions.push({
name: material.name,
unit: material.unit,
estimatedQuantity: Math.ceil(material.estimatedQuantity),
unitPrice: material.price,
category: material.category || 'Tagmaterialer',
calculation: `${material.estimatedQuantity.toFixed(1)} ${material.unit} baseret på ${geometry.total_area}m² tagareal`,
source: 'database_enhanced'
});
});
return suggestions;
} catch (error) {
logger.error('Error suggesting roof materials:', error);
throw error;
}
}
}
module.exports = ProjectMaterialService;