✨ 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!
511 lines
16 KiB
JavaScript
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;
|