/** * Smart Package Management Service * * Håndterer alle CRUD-operationer på smart pakker, tasks og montagevejledninger. * Inkluderer funktionalitet til at beregne materiale-mængder baseret på geometri, * generere arbejdsbeskrivelser og estimere tid for opgaver. * * @author Tilbudgivern * @version 2.0 */ const logger = require('../utils/logger'); const { applyDefaultTaskTimes } = require('./smartPackageDefaultsService'); const { PACKAGE_TYPES, VALIDATION_STATUSES, SmartPackageIntegrityService } = require('./smartPackageIntegrityService'); class SmartPackageManagementService { constructor(databaseService) { this.db = databaseService; } normalizeSqlParams(params = []) { return params.map((param) => (param === undefined ? null : param)); } buildHoursMetadata(packageRow = {}, taskHours = 0, legacyTaskHours = 0) { const parsedTaskHours = parseFloat(taskHours || 0); const parsedLegacyTaskHours = parseFloat(legacyTaskHours || 0); const parsedEstimatedHours = parseFloat(packageRow.estimated_hours || 0); const parsedTimePerSqm = parseFloat(packageRow.time_per_sqm || 0); const parsedTimePerUnit = parseFloat(packageRow.time_per_unit || 0); if (packageRow.hours_unit === 'per_m2' && parsedTimePerSqm > 0) { return { effectiveEstimatedHours: parsedTimePerSqm, hoursSource: 'time_per_sqm' }; } if (['per_meter', 'per_piece'].includes(packageRow.hours_unit) && parsedTimePerUnit > 0) { return { effectiveEstimatedHours: parsedTimePerUnit, hoursSource: 'time_per_unit' }; } if (parsedTaskHours > 0) { return { effectiveEstimatedHours: parsedTaskHours, hoursSource: 'tasks' }; } if (parsedLegacyTaskHours > 0) { return { effectiveEstimatedHours: parsedLegacyTaskHours, hoursSource: 'legacy_tasks' }; } return { effectiveEstimatedHours: parsedEstimatedHours, hoursSource: 'package_estimate' }; } parseExcelQuantity(value) { const match = String(value || '').replace(',', '.').match(/\d+(?:\.\d+)?/); const quantity = match ? parseFloat(match[0]) : 0; return quantity > 0 ? quantity : 0; } inferExcelMaterialUnit(quantityText, fallbackUnit) { const normalized = String(quantityText || '').toLocaleLowerCase('da-DK'); if (!this.parseExcelQuantity(quantityText)) return 'stk.'; if (normalized.includes('stk')) return normalized.includes('/m') ? 'stk./m²' : 'stk.'; if (normalized.includes('lbm')) return normalized.includes('/m') ? 'lbm/m²' : 'lbm'; if (normalized.includes('m²') || normalized.includes('m2')) return 'm²'; return fallbackUnit || 'stk.'; } isRentalPackage(packageData = {}) { const sheet = String(packageData.excel_source_sheet || '').toLocaleLowerCase('da-DK'); const name = String(packageData.name || '').toLocaleLowerCase('da-DK'); const unit = String(packageData.unit || '').toLocaleLowerCase('da-DK'); return sheet === 'udlejning' || name.startsWith('leje ') || name.startsWith('leje af ') || ['dag', 'uge', 'måned', 'maaned'].some(value => unit.includes(value)); } buildProjectLines(packageData, materials) { const packagePrice = [ packageData.standard_price, packageData.unit_price, packageData.price_per_unit, packageData.total_estimated_price ].map(value => parseFloat(value || 0)).find(value => value > 0) || 0; if (this.isRentalPackage(packageData)) { return [{ name: packageData.name, material_name: packageData.name, material_category: packageData.category || 'Udlejning', category: packageData.category || 'Udlejning', quantity: 1, unit: packageData.unit || 'dag', price: packagePrice, unit_price: packagePrice, unitPrice: packagePrice, isRental: true, notes: packageData.price_basis_note || `Fra Smart Pakke: ${packageData.name}` }]; } if (materials.length === 0) { return packagePrice > 0 ? [{ name: packageData.name, material_name: packageData.name, material_category: packageData.category || 'Smart Pakke', category: packageData.category || 'Smart Pakke', quantity: 1, unit: packageData.unit || 'stk.', price: packagePrice, unit_price: packagePrice, unitPrice: packagePrice, isRental: false, notes: packageData.price_basis_note || `Fra Smart Pakke: ${packageData.name}` }] : []; } return materials.map(material => { const parsedQuantity = parseFloat(material.quantity || 0); const excelQuantity = this.parseExcelQuantity(material.quantity_text); return { ...material, quantity: parsedQuantity > 0 ? parsedQuantity : (excelQuantity || 1), unit: parsedQuantity > 0 ? (material.unit || 'stk.') : this.inferExcelMaterialUnit(material.quantity_text, material.unit), isRental: false, notes: material.quantity_text ? `${material.notes || material.material_name} (${material.quantity_text})` : (material.notes || '') }; }); } /** * Hent alle pakker med filtrering * @param {Object} filters - Filteringsparametre * @returns {Array} Pakker */ async getPackages(filters = {}) { try { let query = ` SELECT mp.id, mp.name, mp.description, mp.category, mp.estimated_hours, mp.hourly_rate, mp.installation_notes, mp.area_based, mp.time_per_sqm, mp.time_per_unit, mp.hours_unit, mp.auto_calculated, mp.is_template, mp.popularity_score, mp.created_by, mp.created_at, mp.updated_at, mp.excel_source_sheet, mp.unit, mp.package_type, mp.validation_status, mp.validation_notes, mp.validated_at, mp.version, COALESCE(task_totals.task_count, 0) as smart_task_count, COALESCE(task_totals.task_hours, 0) as smart_task_hours, COALESCE(task_totals.task_labor_cost, 0) as smart_task_labor_cost, COALESCE(legacy_task_totals.task_count, 0) as legacy_task_count, COALESCE(legacy_task_totals.task_hours, 0) as legacy_task_hours, COALESCE(legacy_task_totals.task_labor_cost, 0) as legacy_task_labor_cost, COALESCE(material_totals.material_count, 0) as material_count, COALESCE(material_totals.unlinked_material_count, 0) as unlinked_material_count, CASE WHEN COALESCE(material_totals.material_cost, 0) > 0 THEN material_totals.material_cost ELSE COALESCE(mp.total_estimated_price, 0) END as material_cost FROM material_packages mp LEFT JOIN ( SELECT package_id, COUNT(*) as task_count, COALESCE(SUM(hours), 0) as task_hours, COALESCE(SUM(hours * COALESCE(NULLIF(rate, 0), 580)), 0) as task_labor_cost FROM smart_package_tasks GROUP BY package_id ) task_totals ON mp.id = task_totals.package_id LEFT JOIN ( SELECT package_id, COUNT(*) as task_count, COALESCE(SUM(estimated_hours), 0) as task_hours, COALESCE(SUM(estimated_hours * 580), 0) as task_labor_cost FROM package_tasks GROUP BY package_id ) legacy_task_totals ON mp.id = legacy_task_totals.package_id LEFT JOIN ( SELECT package_id, COUNT(*) as material_count, SUM(material_id IS NULL) as unlinked_material_count, COALESCE(SUM(COALESCE(total_price, quantity * unit_price)), 0) as material_cost FROM package_materials GROUP BY package_id ) material_totals ON mp.id = material_totals.package_id WHERE mp.is_active = 1 `; const params = []; if (filters.category) { // Smart kategori matching: "Tag" skal matche alle tag-relaterede kategorier if (filters.category.toLowerCase() === 'tag') { query += ` AND ( mp.category LIKE '%Tag%' OR mp.category LIKE '%tag%' OR mp.category = 'Tagrenovering' OR mp.category = 'Tagudskiftning' OR mp.category = 'Tagmaterialer' OR mp.category = 'Tagrende' )`; } else { query += ' AND mp.category = ?'; params.push(filters.category); } } if (filters.search) { query += ` AND ( mp.name LIKE ? OR mp.description LIKE ? OR mp.category LIKE ? )`; const searchTerm = `%${filters.search}%`; params.push(searchTerm, searchTerm, searchTerm); } if (filters.isTemplate !== undefined) { query += ' AND mp.is_template = ?'; params.push(filters.isTemplate ? 1 : 0); } if (filters.packageType) { query += ' AND mp.package_type = ?'; params.push(filters.packageType); } if (filters.validationStatus) { query += ' AND mp.validation_status = ?'; params.push(filters.validationStatus); } // Sorting if (filters.sortBy) { const sortDirection = filters.sortDir === 'desc' ? 'DESC' : 'ASC'; let sortField = 'mp.created_at'; switch (filters.sortBy) { case 'name': sortField = 'mp.name'; break; case 'popularity': sortField = 'mp.popularity_score'; break; case 'price': sortField = `( COALESCE(material_totals.material_cost, COALESCE(mp.total_estimated_price, 0)) + ( COALESCE( NULLIF(task_totals.task_labor_cost, 0), NULLIF(legacy_task_totals.task_labor_cost, 0), 0 ) ) )`; break; case 'hours': sortField = `COALESCE( NULLIF(task_totals.task_hours, 0), NULLIF(legacy_task_totals.task_hours, 0), CASE WHEN mp.area_based = 1 AND mp.time_per_sqm > 0 THEN mp.time_per_sqm ELSE mp.estimated_hours END )`; break; default: sortField = 'mp.created_at'; } query += ` ORDER BY ${sortField} ${sortDirection}`; } else { query += ' ORDER BY mp.created_at DESC'; } if (filters.limit) { query += ' LIMIT ?'; params.push(parseInt(filters.limit, 10)); } if (filters.offset) { query += ' OFFSET ?'; params.push(parseInt(filters.offset, 10)); } const [rows] = await this.db.pool.execute(query, params); return rows.map((row) => ({ ...row, task_count: parseInt(row.smart_task_count, 10) > 0 ? parseInt(row.smart_task_count, 10) : parseInt(row.legacy_task_count, 10) || 0, task_hours: parseFloat(row.smart_task_hours || 0) > 0 ? parseFloat(row.smart_task_hours || 0) : parseFloat(row.legacy_task_hours || 0), labor_cost: parseFloat(row.smart_task_labor_cost || 0) > 0 ? parseFloat(row.smart_task_labor_cost || 0) : parseFloat(row.legacy_task_labor_cost || 0), total_package_price: ( parseFloat(row.material_cost || 0) + ( parseFloat(row.smart_task_labor_cost || 0) > 0 ? parseFloat(row.smart_task_labor_cost || 0) : parseFloat(row.legacy_task_labor_cost || 0) ) ), estimated_hours: this.buildHoursMetadata(row, row.smart_task_hours, row.legacy_task_hours).effectiveEstimatedHours, ...this.buildHoursMetadata(row, row.smart_task_hours, row.legacy_task_hours) })); } catch (error) { logger.error('Error getting smart packages:', error); throw error; } } /** * Opret en ny smart pakke */ async createPackage(packageData) { try { const { name, description, category, package_type, validation_status, estimated_hours, hourly_rate, hours_unit, time_per_sqm, installation_notes, is_template, materials, tasks, created_by } = packageData; const packageType = package_type || PACKAGE_TYPES.COMPONENT; const validationStatus = packageType === PACKAGE_TYPES.RENTAL_SERVICE ? VALIDATION_STATUSES.VERIFIED : VALIDATION_STATUSES.NEEDS_REVIEW; const taskTimeTotal = hours_unit === 'per_m2' ? (parseFloat(time_per_sqm) || parseFloat(estimated_hours) || 0) : (parseFloat(estimated_hours) || 0); const normalizedTasks = applyDefaultTaskTimes(tasks || [], taskTimeTotal, hours_unit); // Beregn estimeret materialepris const totalMaterialPrice = materials && materials.length > 0 ? materials.reduce((sum, material) => sum + ( (parseFloat(material.quantity) || 0) * (parseFloat(material.custom_price ?? material.unitPrice ?? material.unit_price ?? material.price) || 0) ), 0) : 0; // Start en transaction const connection = await this.db.pool.getConnection(); await connection.beginTransaction(); try { await new SmartPackageIntegrityService(this.db).validateMaterials(packageType, materials || [], connection); // Indsæt pakke const [packageResult] = await connection.execute(` INSERT INTO material_packages ( name, description, category, package_type, validation_status, validated_at, estimated_hours, hourly_rate, hours_unit, time_per_sqm, installation_notes, is_template, total_estimated_price, created_by ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ name, description, category, packageType, validationStatus, validationStatus === VALIDATION_STATUSES.VERIFIED ? new Date() : null, estimated_hours || 0, hourly_rate || 580, hours_unit || 'total', time_per_sqm || 0, installation_notes || '', is_template ? 1 : 0, totalMaterialPrice, created_by || 'system' ]); const packageId = packageResult.insertId; // Indsæt materialer if (materials && materials.length > 0) { for (const material of materials) { // Support both camelCase (unitPrice) and snake_case (unit_price) from frontend const unitPrice = material.custom_price ?? material.unitPrice ?? material.unit_price ?? material.price ?? 0; const qty = material.quantity ?? 0; const materialTotal = parseFloat(qty) * parseFloat(unitPrice); await connection.execute(` INSERT INTO package_materials ( package_id, material_id, material_match_status, material_match_score, material_name, material_category, quantity, unit, unit_price, total_price, supplier, notes ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ packageId, material.material_id || material.materialId || null, material.material_match_status || ((material.material_id || material.materialId) ? 'manual' : null), material.material_match_score || ((material.material_id || material.materialId) ? 1 : null), material.name || material.material_name || '', material.category || '', parseFloat(qty) || 0, material.unit || 'stk', parseFloat(unitPrice) || 0, isNaN(materialTotal) ? 0 : materialTotal, material.supplier || '', material.notes || '' ]); } } // Indsæt tasks if (normalizedTasks.length > 0) { for (let i = 0; i < normalizedTasks.length; i++) { const task = normalizedTasks[i]; const [taskResult] = await connection.execute(` INSERT INTO smart_package_tasks ( package_id, name, description, task_order, hours, rate, skills, tools, optional, depends_on ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ packageId, task.name, task.description || '', i + 1, // Task order based on array position task.hours || 0, task.rate || packageData.hourly_rate || 580, task.skills && typeof task.skills !== 'string' ? JSON.stringify(task.skills) : (task.skills || null), task.tools && typeof task.tools !== 'string' ? JSON.stringify(task.tools) : (task.tools || null), task.optional ? 1 : 0, task.dependsOn || null ]); // Indsæt steps hvis der er nogen if (task.steps && task.steps.length > 0) { const taskId = taskResult.insertId; for (let j = 0; j < task.steps.length; j++) { const step = task.steps[j]; await connection.execute(` INSERT INTO smart_package_steps ( task_id, step_order, title, description, minutes, image, video, safety, quality, tips ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ taskId, j + 1, // Step order based on array position step.title || '', step.description || '', step.minutes || 0, step.image || null, step.video || null, step.safety || null, step.quality || null, step.tips || null ]); } } } } // Commit transaktionen await connection.commit(); logger.info('Smart package created successfully', { packageId, name }); return { id: packageId, name, description, category, estimated_hours, hourly_rate, materials_count: materials ? materials.length : 0, tasks_count: tasks ? tasks.length : 0, material_cost: totalMaterialPrice, labor_cost: estimated_hours * hourly_rate, created_by }; } catch (error) { // Rollback transaktionen ved fejl await connection.rollback(); throw error; } finally { connection.release(); } } catch (error) { logger.error('Error creating smart package:', error); throw error; } } /** * Hent pakke med komplet indhold (tasks, steps, materialer) */ async getPackageDetails(packageId) { try { // Hent pakke info const [packageRows] = await this.db.pool.execute(` SELECT * FROM material_packages WHERE id = ? AND is_active = 1 `, [packageId]); if (packageRows.length === 0) { const error = new Error('Package not found'); error.status = 404; throw error; } const packageData = packageRows[0]; // Hent tasks med steps const tasks = await this.getTasksForPackage(packageId); // Hent materialer const [materialsRows] = await this.db.pool.execute(` SELECT * FROM package_materials WHERE package_id = ? ORDER BY material_category, material_name `, [packageId]); // Map materialer til det format frontend forventer const materials = materialsRows.map(m => ({ ...m, name: m.material_name, materialId: m.material_id, quantity: parseFloat(m.quantity), // Fjern unødvendige decimaler price: parseFloat(m.unit_price || 0), unitPrice: parseFloat(m.unit_price || 0), custom_price: null, material_description: m.notes || '' })); const projectLines = this.buildProjectLines(packageData, materials); // Hent installation manuals hvis der er nogen koblet const [manualsRows] = await this.db.pool.execute(` SELECT im.* FROM installation_manuals im JOIN smart_package_tasks spt ON im.id = spt.installation_manual_id WHERE spt.package_id = ? GROUP BY im.id `, [packageId]); // Hent brugstatistik const [usageRows] = await this.db.pool.execute(` SELECT COUNT(*) as usage_count FROM smart_package_usage WHERE package_id = ? `, [packageId]); // Beregn totaler for prissammenfatning const calculatedMaterialPrice = materials.reduce((sum, m) => { const price = parseFloat(m.custom_price || m.price || 0); const quantity = parseFloat(m.quantity || 0); return sum + (price * quantity); }, 0); const totalHours = tasks.reduce((sum, t) => { return sum + parseFloat(t.hours || 0); }, 0); const effectiveHourlyRate = parseFloat(packageData.hourly_rate || 0) > 0 ? parseFloat(packageData.hourly_rate) : 580; const laborCost = totalHours * effectiveHourlyRate; const storedPackagePrice = parseFloat(packageData.total_estimated_price || 0); const hasCalculatedPrice = calculatedMaterialPrice > 0 || laborCost > 0; const totalMaterialPrice = calculatedMaterialPrice > 0 ? calculatedMaterialPrice : (hasCalculatedPrice ? 0 : storedPackagePrice); const totalPrice = hasCalculatedPrice ? calculatedMaterialPrice + laborCost : storedPackagePrice; const hoursMetadata = this.buildHoursMetadata(packageData, totalHours, totalHours); return { ...packageData, estimated_hours: hoursMetadata.effectiveEstimatedHours, tasks, materials: materials, projectLines, installation_manuals: manualsRows, usage_count: usageRows[0]?.usage_count || 0, totalMaterialPrice, totalHours, totalLaborCost: laborCost, totalPrice, ...hoursMetadata }; } catch (error) { if (error.status === 404) { throw error; } logger.error('Error getting package details:', error); throw error; } } /** * Hent tasks for en pakke */ async getTasksForPackage(packageId) { try { const [taskRows] = await this.db.pool.execute(` SELECT * FROM smart_package_tasks WHERE package_id = ? ORDER BY task_order `, [packageId]); if (taskRows.length === 0) { const [legacyTaskRows] = await this.db.pool.execute(` SELECT * FROM package_tasks WHERE package_id = ? ORDER BY FIELD(task_phase, 'forberedelse', 'hovedarbejde', 'afslutning', 'inspektion'), task_order, id `, [packageId]); return legacyTaskRows.map((task) => ({ id: task.id, package_id: task.package_id, name: task.task_name || '', description: task.task_description || '', hours: parseFloat(task.estimated_hours || 0), rate: 580, optional: 0, depends_on: task.depends_on_task_id || null, task_order: task.task_order || 0, task_phase: task.task_phase || 'hovedarbejde', required_skills: [], required_tools: [], steps: [], source: 'legacy' })); } let effectiveTaskRows = taskRows; const hasStoredTaskTime = taskRows.some(task => ( (parseFloat(task.hours) || 0) > 0 || (parseFloat(task.time_per_unit) || 0) > 0 )); if (!hasStoredTaskTime) { const [packageRows] = await this.db.pool.execute(` SELECT estimated_hours, time_per_unit, time_per_sqm, hours_unit FROM material_packages WHERE id = ? `, [packageId]); const packageRow = packageRows[0] || {}; const totalTime = parseFloat( packageRow.time_per_unit || packageRow.time_per_sqm || packageRow.estimated_hours || 0 ); const defaults = applyDefaultTaskTimes(taskRows, totalTime, packageRow.hours_unit); effectiveTaskRows = taskRows.map((task, index) => ({ ...task, hours: defaults[index]?.hours || 0, time_per_unit: defaults[index]?.timePerUnit || 0, time_unit: defaults[index]?.timeUnit || task.time_unit, default_time_applied: totalTime > 0 })); } const tasks = []; for (const task of effectiveTaskRows) { // Hent steps for hver task const [stepRows] = await this.db.pool.execute(` SELECT * FROM smart_package_steps WHERE task_id = ? ORDER BY step_order `, [task.id]); // Parse JSON felter const requiredSkills = task.required_skills ? JSON.parse(task.required_skills) : []; const requiredTools = task.required_tools ? JSON.parse(task.required_tools) : []; tasks.push({ ...task, required_skills: requiredSkills, required_tools: requiredTools, steps: stepRows }); } return tasks; } catch (error) { logger.error('Error getting tasks for package:', error); throw error; } } /** * Opdater en pakke */ async updatePackage(packageId, updateData) { try { const connection = await this.db.pool.getConnection(); await connection.beginTransaction(); try { // Opdater pakke info const { name, description, category, package_type, validation_status, validation_notes, difficulty_level, estimated_hours, hourly_rate, hours_unit, time_per_sqm, installation_notes, is_template, materials, tasks } = updateData; const normalizedMaterials = Array.isArray(materials) ? materials.filter(Boolean) : null; const taskTimeTotal = hours_unit === 'per_m2' ? (parseFloat(time_per_sqm) || parseFloat(estimated_hours) || 0) : (parseFloat(estimated_hours) || 0); const normalizedTasks = Array.isArray(tasks) ? applyDefaultTaskTimes(tasks.filter(Boolean), taskTimeTotal, hours_unit) : null; const [currentRows] = await connection.execute( 'SELECT package_type, validation_status FROM material_packages WHERE id = ? AND is_active = 1', [packageId] ); if (currentRows.length === 0) { const error = new Error('Package not found'); error.status = 404; throw error; } const effectivePackageType = package_type || currentRows[0].package_type || PACKAGE_TYPES.COMPONENT; if (normalizedMaterials) { await new SmartPackageIntegrityService(this.db).validateMaterials( effectivePackageType, normalizedMaterials, connection ); } const contentChanged = Boolean(normalizedMaterials || normalizedTasks || package_type); const effectiveValidationStatus = effectivePackageType === PACKAGE_TYPES.RENTAL_SERVICE ? VALIDATION_STATUSES.VERIFIED : (contentChanged ? VALIDATION_STATUSES.NEEDS_REVIEW : currentRows[0].validation_status); if (name || description || category || package_type || contentChanged || validation_notes !== undefined || difficulty_level || estimated_hours !== undefined || hourly_rate !== undefined || hours_unit !== undefined || time_per_sqm !== undefined || installation_notes || is_template !== undefined) { let updateQuery = 'UPDATE material_packages SET updated_at = NOW()'; const params = []; if (name) { updateQuery += ', name = ?'; params.push(name); } if (description !== undefined) { updateQuery += ', description = ?'; params.push(description); } if (category) { updateQuery += ', category = ?'; params.push(category); } if (package_type) { updateQuery += ', package_type = ?'; params.push(package_type); } if (contentChanged || effectivePackageType === PACKAGE_TYPES.RENTAL_SERVICE) { updateQuery += ', validation_status = ?, validated_at = ?'; params.push( effectiveValidationStatus, effectiveValidationStatus === VALIDATION_STATUSES.VERIFIED ? new Date() : null ); } if (validation_notes !== undefined) { updateQuery += ', validation_notes = ?'; params.push(validation_notes); } updateQuery += ', version = version + 1'; if (difficulty_level) { updateQuery += ', difficulty_level = ?'; params.push(difficulty_level); } if (estimated_hours !== undefined) { updateQuery += ', estimated_hours = ?'; params.push(estimated_hours); } if (hourly_rate !== undefined) { updateQuery += ', hourly_rate = ?'; params.push(hourly_rate); } if (hours_unit !== undefined) { updateQuery += ', hours_unit = ?'; params.push(hours_unit); } if (time_per_sqm !== undefined) { updateQuery += ', time_per_sqm = ?'; params.push(time_per_sqm); } if (installation_notes !== undefined) { updateQuery += ', installation_notes = ?'; params.push(installation_notes); } if (is_template !== undefined) { updateQuery += ', is_template = ?'; params.push(is_template ? 1 : 0); } updateQuery += ' WHERE id = ? AND is_active = 1'; params.push(packageId); await connection.execute(updateQuery, this.normalizeSqlParams(params)); } // Opdater materialer hvis det er inkluderet if (normalizedMaterials) { // Slet nuværende materialer await connection.execute('DELETE FROM package_materials WHERE package_id = ?', [packageId]); // Tilføj nye materialer let totalMaterialPrice = 0; for (const material of normalizedMaterials) { // Support both camelCase (unitPrice) and snake_case (unit_price) from frontend const unitPrice = material.custom_price ?? material.unitPrice ?? material.unit_price ?? material.price ?? 0; const qty = material.quantity ?? 0; const materialTotal = parseFloat(qty) * parseFloat(unitPrice); totalMaterialPrice += isNaN(materialTotal) ? 0 : materialTotal; await connection.execute(` INSERT INTO package_materials ( package_id, material_id, material_match_status, material_match_score, material_name, material_category, quantity, unit, unit_price, total_price, supplier, notes ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, this.normalizeSqlParams([ packageId, material.material_id ?? material.materialId ?? null, material.material_match_status ?? ((material.material_id || material.materialId) ? 'manual' : null), material.material_match_score ?? ((material.material_id || material.materialId) ? 1 : null), material.name ?? material.material_name ?? '', material.category ?? '', parseFloat(qty) || 0, material.unit ?? 'stk', parseFloat(unitPrice) || 0, isNaN(materialTotal) ? 0 : materialTotal, material.supplier ?? '', material.notes ?? '' ])); } // Opdater total materialepris await connection.execute(` UPDATE material_packages SET total_estimated_price = ? WHERE id = ? `, [totalMaterialPrice, packageId]); } // Opdater tasks hvis det er inkluderet if (normalizedTasks) { // Slet nuværende tasks og steps const [existingTasks] = await connection.execute('SELECT id FROM smart_package_tasks WHERE package_id = ?', [packageId]); for (const task of existingTasks) { await connection.execute('DELETE FROM smart_package_steps WHERE task_id = ?', [task.id]); } await connection.execute('DELETE FROM smart_package_tasks WHERE package_id = ?', [packageId]); // Tilføj nye tasks og steps for (let i = 0; i < normalizedTasks.length; i++) { const task = normalizedTasks[i]; const [taskResult] = await connection.execute(` INSERT INTO smart_package_tasks ( package_id, name, description, task_order, hours, rate, skills, tools, optional, depends_on ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, this.normalizeSqlParams([ packageId, task.name ?? '', task.description ?? '', i + 1, task.hours ?? 0, task.rate ?? updateData.hourly_rate ?? 580, task.skills && typeof task.skills !== 'string' ? JSON.stringify(task.skills) : (task.skills || null), task.tools && typeof task.tools !== 'string' ? JSON.stringify(task.tools) : (task.tools || null), task.optional ? 1 : 0, null // Vi håndterer dependencies efter alle tasks er oprettet ])); // Tilføj steps if (Array.isArray(task.steps) && task.steps.length > 0) { const taskId = taskResult.insertId; for (let j = 0; j < task.steps.length; j++) { const step = task.steps[j]; await connection.execute(` INSERT INTO smart_package_steps ( task_id, step_order, title, description, minutes, image, video, safety, quality, tips ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, this.normalizeSqlParams([ taskId, j + 1, step?.title ?? '', step?.description ?? '', step?.minutes ?? 0, step?.image ?? null, step?.video ?? null, step?.safety ?? null, step?.quality ?? null, step?.tips ?? null ])); } } } } // Commit transaktionen await connection.commit(); logger.info('Smart package updated successfully', { packageId }); return { success: true }; } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } } catch (error) { logger.error('Error updating smart package:', error); throw error; } } /** * Slet en pakke (soft delete) */ async deletePackage(packageId) { try { await this.db.pool.execute(` UPDATE material_packages SET is_active = 0, updated_at = NOW() WHERE id = ? `, [packageId]); logger.info('Smart package deleted (soft delete)', { packageId }); return { success: true }; } catch (error) { logger.error('Error deleting smart package:', error); throw error; } } /** * Tilføj en task til en pakke */ async addTaskToPackage(packageId, taskData) { try { // Tjek om pakken eksisterer const [packageCheck] = await this.db.pool.execute(` SELECT id FROM material_packages WHERE id = ? AND is_active = 1 `, [packageId]); if (packageCheck.length === 0) { throw new Error('Package not found or inactive'); } // Find næste task_order værdi const [orderResult] = await this.db.pool.execute(` SELECT COALESCE(MAX(task_order), 0) + 1 as next_order FROM smart_package_tasks WHERE package_id = ? `, [packageId]); const nextOrder = orderResult[0].next_order; // Indsæt task const [taskResult] = await this.db.pool.execute(` INSERT INTO smart_package_tasks ( package_id, name, description, task_order, hours, rate, skills, tools, optional, depends_on, installation_manual_id, time_unit, time_per_unit, is_custom, material_varenr ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ packageId, taskData.name, taskData.description || '', nextOrder, taskData.hours || 0, taskData.rate || 580, taskData.skills ? JSON.stringify(taskData.skills) : null, taskData.tools ? JSON.stringify(taskData.tools) : null, taskData.optional ? 1 : 0, taskData.dependsOn || null, taskData.installationManualId || null, taskData.timeUnit || 'per_project', taskData.timePerUnit || 0, taskData.isCustom ? 1 : 0, taskData.materialVarenr || null ]); const taskId = taskResult.insertId; // Opdater total estimated_hours for pakken await this.db.pool.execute(` UPDATE material_packages SET estimated_hours = COALESCE(estimated_hours, 0) + ?, updated_at = NOW() WHERE id = ? `, [taskData.hours || 0, packageId]); logger.info('Task added to package', { packageId, taskId }); return { taskId, order: nextOrder }; } catch (error) { logger.error('Error adding task to package:', error); throw error; } } /** * Opdater en task */ async updateTask(taskId, taskData) { try { const [taskCheck] = await this.db.pool.execute(` SELECT package_id, estimated_hours FROM smart_package_tasks WHERE id = ? `, [taskId]); if (taskCheck.length === 0) { throw new Error('Task not found'); } const packageId = taskCheck[0].package_id; const oldHours = taskCheck[0].estimated_hours || 0; const newHours = taskData.hours || 0; const hoursDiff = newHours - oldHours; // Opdater task await this.db.pool.execute(` UPDATE smart_package_tasks SET task_name = ?, task_description = ?, estimated_hours = ?, hourly_rate = ?, required_skills = ?, required_tools = ?, is_optional = ?, depends_on_task_id = ?, installation_manual_id = ?, time_unit = ?, time_per_unit = ?, is_custom = ?, material_varenr = ? WHERE id = ? `, [ taskData.name, taskData.description || '', newHours, taskData.rate || 580, taskData.skills ? JSON.stringify(taskData.skills) : null, taskData.tools ? JSON.stringify(taskData.tools) : null, taskData.optional ? 1 : 0, taskData.dependsOn || null, taskData.installationManualId || null, taskData.timeUnit || 'per_project', taskData.timePerUnit || 0, taskData.isCustom ? 1 : 0, taskData.materialVarenr || null, taskId ]); // Opdater pakkens samlede timer if (hoursDiff !== 0) { await this.db.pool.execute(` UPDATE material_packages SET estimated_hours = GREATEST(0, COALESCE(estimated_hours, 0) + ?), updated_at = NOW() WHERE id = ? `, [hoursDiff, packageId]); } logger.info('Task updated', { taskId, packageId }); return { success: true }; } catch (error) { logger.error('Error updating task:', error); throw error; } } /** * Opret en custom/manuel installation opgave * @param {Object} customTaskData - Data til custom task * @returns {Object} - Oprettet task info */ async createCustomTask(customTaskData) { try { const { packageId, name, description, timeUnit, // 'per_meter', 'per_sqm', 'per_piece', 'per_project' timePerUnit, // Timer pr. enhed (fx 0.17 = 10 min) hourlyRate, materialVarenr, tools, skills, optional } = customTaskData; // Validering if (!packageId || !name || !timeUnit || timePerUnit === undefined) { throw new Error('packageId, name, timeUnit og timePerUnit er påkrævet'); } // Tjek at pakken eksisterer const [packageCheck] = await this.db.pool.execute(` SELECT id FROM material_packages WHERE id = ? AND is_active = 1 `, [packageId]); if (packageCheck.length === 0) { throw new Error('Package not found or inactive'); } // Find næste task_order const [orderResult] = await this.db.pool.execute(` SELECT COALESCE(MAX(task_order), 0) + 1 as next_order FROM smart_package_tasks WHERE package_id = ? `, [packageId]); const nextOrder = orderResult[0].next_order; // Indsæt custom task const [taskResult] = await this.db.pool.execute(` INSERT INTO smart_package_tasks ( package_id, name, description, task_order, time_unit, time_per_unit, hourly_rate, required_tools, required_skills, is_optional, is_custom, material_varenr, estimated_hours ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ packageId, name, description || '', nextOrder, timeUnit, timePerUnit, hourlyRate || 580, tools ? JSON.stringify(tools) : null, skills ? JSON.stringify(skills) : null, optional ? 1 : 0, 1, // is_custom = true materialVarenr || null, 0 // estimated_hours sættes til 0, da det beregnes dynamisk ]); const taskId = taskResult.insertId; logger.info('Custom task created', { taskId, packageId, name }); return { success: true, taskId, order: nextOrder, message: `Custom task '${name}' oprettet med ${timePerUnit} timer pr. ${timeUnit}` }; } catch (error) { logger.error('Error creating custom task:', error); throw error; } } /** * Hent alle custom tasks * @returns {Array} - Custom tasks */ async getCustomTasks(filters = {}) { try { let query = ` SELECT t.*, p.name as package_name, m.material_name, CASE WHEN t.time_unit = 'per_meter' THEN CONCAT(ROUND(t.time_per_unit * 60, 0), ' min pr. løbende meter') WHEN t.time_unit = 'per_sqm' THEN CONCAT(ROUND(t.time_per_unit * 60, 0), ' min pr. m²') WHEN t.time_unit = 'per_piece' THEN CONCAT(ROUND(t.time_per_unit * 60, 0), ' min pr. styk') WHEN t.time_unit = 'per_hour' THEN CONCAT(t.time_per_unit, ' timer pr. time') ELSE CONCAT(t.estimated_hours, ' timer totalt') END as time_display FROM smart_package_tasks t LEFT JOIN material_packages p ON t.package_id = p.id LEFT JOIN materials m ON t.material_varenr = m.varenr WHERE t.is_custom = 1 `; const params = []; if (filters.packageId) { query += ' AND t.package_id = ?'; params.push(filters.packageId); } if (filters.materialVarenr) { query += ' AND t.material_varenr = ?'; params.push(filters.materialVarenr); } query += ' ORDER BY t.created_at DESC'; const [tasks] = await this.db.pool.execute(query, params); // Parse JSON felter const parsedTasks = tasks.map(task => ({ ...task, required_tools: task.required_tools ? JSON.parse(task.required_tools) : [], required_skills: task.required_skills ? JSON.parse(task.required_skills) : [] })); return parsedTasks; } catch (error) { logger.error('Error fetching custom tasks:', error); throw error; } } /** * Slet en task */ async deleteTask(taskId) { try { // Få information om tasken før sletning const [taskInfo] = await this.db.pool.execute(` SELECT package_id, estimated_hours FROM smart_package_tasks WHERE id = ? `, [taskId]); if (taskInfo.length === 0) { throw new Error('Task not found'); } const packageId = taskInfo[0].package_id; const hours = taskInfo[0].estimated_hours || 0; // Start en transaction const connection = await this.db.pool.getConnection(); await connection.beginTransaction(); try { // Slet steps først await connection.execute(` DELETE FROM smart_package_steps WHERE task_id = ? `, [taskId]); // Slet task await connection.execute(` DELETE FROM smart_package_tasks WHERE id = ? `, [taskId]); // Opdater pakkens samlede timer await connection.execute(` UPDATE material_packages SET estimated_hours = GREATEST(0, COALESCE(estimated_hours, 0) - ?), updated_at = NOW() WHERE id = ? `, [hours, packageId]); // Reorder tasks await connection.execute(` SET @order = 0; UPDATE smart_package_tasks SET task_order = (@order := @order + 1) WHERE package_id = ? ORDER BY task_order; `, [packageId]); // Commit transaktionen await connection.commit(); logger.info('Task deleted', { taskId, packageId }); return { success: true }; } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } } catch (error) { logger.error('Error deleting task:', error); throw error; } } /** * Ændre rækkefølgen af tasks */ async reorderTasks(packageId, taskOrderings) { try { const connection = await this.db.pool.getConnection(); await connection.beginTransaction(); try { for (const item of taskOrderings) { await connection.execute(` UPDATE smart_package_tasks SET task_order = ? WHERE id = ? AND package_id = ? `, [item.order, item.taskId, packageId]); } await connection.commit(); logger.info('Tasks reordered', { packageId, taskCount: taskOrderings.length }); return { success: true }; } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } } catch (error) { logger.error('Error reordering tasks:', error); throw error; } } /** * Tilføj et step til en task */ async addStepToTask(taskId, stepData) { try { // Tjek om task eksisterer const [taskCheck] = await this.db.pool.execute(` SELECT id, package_id FROM smart_package_tasks WHERE id = ? `, [taskId]); if (taskCheck.length === 0) { throw new Error('Task not found'); } // Find næste step_order værdi const [orderResult] = await this.db.pool.execute(` SELECT COALESCE(MAX(step_order), 0) + 1 as next_order FROM smart_package_steps WHERE task_id = ? `, [taskId]); const nextOrder = orderResult[0].next_order; // Indsæt step const [stepResult] = await this.db.pool.execute(` INSERT INTO smart_package_steps ( task_id, step_order, title, description, minutes, image, video, safety, quality, tips ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ taskId, nextOrder, stepData.title, stepData.description || '', stepData.minutes || 0, stepData.image || null, stepData.video || null, stepData.safety || null, stepData.quality || null, stepData.tips || null ]); logger.info('Step added to task', { taskId, stepId: stepResult.insertId, packageId: taskCheck[0].package_id }); return { stepId: stepResult.insertId, order: nextOrder }; } catch (error) { logger.error('Error adding step to task:', error); throw error; } } /** * Opdater et step */ async updateStep(stepId, stepData) { try { await this.db.pool.execute(` UPDATE smart_package_steps SET title = ?, description = ?, minutes = ?, image = ?, video = ?, safety = ?, quality = ?, tips = ? WHERE id = ? `, [ stepData.title, stepData.description || '', stepData.minutes || 0, stepData.image || null, stepData.video || null, stepData.safety || null, stepData.quality || null, stepData.tips || null, stepId ]); logger.info('Step updated', { stepId }); return { success: true }; } catch (error) { logger.error('Error updating step:', error); throw error; } } /** * Slet et step */ async deleteStep(stepId) { try { // Få information om step før sletning const [stepInfo] = await this.db.pool.execute(` SELECT task_id FROM smart_package_steps WHERE id = ? `, [stepId]); if (stepInfo.length === 0) { throw new Error('Step not found'); } const taskId = stepInfo[0].task_id; // Start en transaction const connection = await this.db.pool.getConnection(); await connection.beginTransaction(); try { // Slet step await connection.execute(` DELETE FROM smart_package_steps WHERE id = ? `, [stepId]); // Reorder steps await connection.execute(` SET @order = 0; UPDATE smart_package_steps SET step_order = (@order := @order + 1) WHERE task_id = ? ORDER BY step_order; `, [taskId]); // Commit transaktionen await connection.commit(); logger.info('Step deleted', { stepId, taskId }); return { success: true }; } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } } catch (error) { logger.error('Error deleting step:', error); throw error; } } /** * Ændre rækkefølgen af steps */ async reorderSteps(taskId, stepOrderings) { try { const connection = await this.db.pool.getConnection(); await connection.beginTransaction(); try { for (const item of stepOrderings) { await connection.execute(` UPDATE smart_package_steps SET step_order = ? WHERE id = ? AND task_id = ? `, [item.order, item.stepId, taskId]); } await connection.commit(); logger.info('Steps reordered', { taskId, stepCount: stepOrderings.length }); return { success: true }; } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } } catch (error) { logger.error('Error reordering steps:', error); throw error; } } /** * Tilføj pakke til project */ async addPackageToProject(projectId, packageId, options = {}) { try { // Hent pakke med detaljer const packageData = await this.getPackageDetails(packageId); if (!packageData) { throw new Error('Package not found'); } // Start en transaction const connection = await this.db.pool.getConnection(); await connection.beginTransaction(); try { // Log brugen af pakken await connection.execute(` INSERT INTO smart_package_usage ( package_id, project_id, used_by ) VALUES (?, ?, ?) `, [ packageId, projectId, options.userId || 'system' ]); // Opdater popularity_score og last_used_at await connection.execute(` UPDATE material_packages SET popularity_score = popularity_score + 1, last_used_at = NOW() WHERE id = ? `, [packageId]); // Tilføj materialer til projektet for (const material of packageData.materials) { // Beregn mængde baseret på geometri hvis relevant let quantity = parseFloat(material.quantity); if (options.geometry) { // Anvend geometri multiplier hvis specificeret if (material.geometry_multiplier === 'area' && options.geometry.total_area) { quantity = quantity * options.geometry.total_area; } else if (material.geometry_multiplier === 'length' && options.geometry.length_main) { quantity = quantity * options.geometry.length_main; } } // Anvend bruger-specificeret multiplier if (options.multiplier) { quantity = quantity * parseFloat(options.multiplier); } // Tilføj materiale til projektet await connection.execute(` INSERT INTO project_materials ( project_id, material_name, material_category, quantity, unit, unit_price, total_price, supplier, material_source, notes ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ projectId, material.material_name, material.material_category, quantity, material.unit, material.unit_price, quantity * material.unit_price, material.supplier, `Smart Pakke: ${packageData.name}`, material.notes || '' ]); } // Tilføj arbejdsopgaver til projektet if (packageData.tasks && packageData.tasks.length > 0) { for (const task of packageData.tasks) { // Beregn timer baseret på geometri hvis relevant let hours = parseFloat(task.estimated_hours); if (options.geometry && task.geometry_multiplier) { if (task.geometry_multiplier === 'area' && options.geometry.total_area) { hours = hours * options.geometry.total_area; } else if (task.geometry_multiplier === 'length' && options.geometry.length_main) { hours = hours * options.geometry.length_main; } } // Anvend bruger-specificeret multiplier if (options.multiplier) { hours = hours * parseFloat(options.multiplier); } // Tilføj opgave til projektet await connection.execute(` INSERT INTO project_tasks ( project_id, task_name, task_description, estimated_hours, hourly_rate, task_source ) VALUES (?, ?, ?, ?, ?, ?) `, [ projectId, task.task_name, task.task_description, hours, task.hourly_rate, `Smart Pakke: ${packageData.name}` ]); } } // Commit transaktionen await connection.commit(); logger.info('Package added to project', { projectId, packageId, materialCount: packageData.materials.length, taskCount: packageData.tasks ? packageData.tasks.length : 0 }); return { success: true, materials_added: packageData.materials.length, tasks_added: packageData.tasks ? packageData.tasks.length : 0, total_material_cost: packageData.total_estimated_price, total_labor_cost: packageData.estimated_hours * packageData.hourly_rate }; } catch (error) { await connection.rollback(); throw error; } finally { connection.release(); } } catch (error) { logger.error('Error adding package to project:', error); throw error; } } /** * Generer arbejdsbeskrivelse for en pakke */ async generateWorkDescription(packageId) { try { const packageData = await this.getPackageDetails(packageId); if (!packageData) { throw new Error('Package not found'); } let description = `# ${packageData.name}\n\n`; if (packageData.description) { description += `${packageData.description}\n\n`; } if (packageData.installation_notes) { description += `## Generelle installationsnoter\n${packageData.installation_notes}\n\n`; } // Tilføj arbejdsopgaver if (packageData.tasks && packageData.tasks.length > 0) { description += `## Arbejdsopgaver\n\n`; for (let i = 0; i < packageData.tasks.length; i++) { const task = packageData.tasks[i]; description += `### ${i+1}. ${task.task_name}\n`; if (task.task_description) { description += `${task.task_description}\n\n`; } if (task.estimated_hours) { description += `**Estimeret tidsforbrug:** ${task.estimated_hours} timer\n\n`; } if (task.required_skills && task.required_skills.length > 0) { description += `**Krævede færdigheder:** ${task.required_skills.join(', ')}\n\n`; } if (task.required_tools && task.required_tools.length > 0) { description += `**Nødvendige værktøjer:** ${task.required_tools.join(', ')}\n\n`; } // Tilføj steps hvis der er nogen if (task.steps && task.steps.length > 0) { description += `#### Fremgangsmåde:\n`; for (let j = 0; j < task.steps.length; j++) { const step = task.steps[j]; description += `${j+1}. **${step.title}** `; description += `(${step.minutes} min) - ${step.description}\n`; if (step.safety) { description += ` ⚠️ *Sikkerhed: ${step.safety}*\n`; } if (step.tips) { description += ` 💡 *Tip: ${step.tips}*\n`; } } description += `\n`; } } } // Tilføj materialeliste if (packageData.materials && packageData.materials.length > 0) { description += `## Materialeliste\n\n`; description += `| Materiale | Mængde | Enhed | Enhedspris |\n`; description += `|-----------|--------|-------|------------|\n`; for (const material of packageData.materials) { description += `| ${material.material_name} | ${material.quantity} | ${material.unit} | ${material.unit_price} kr |\n`; } description += `\n**Total materialepris:** ${packageData.total_estimated_price} kr\n\n`; } // Tilføj total pris const laborCost = packageData.estimated_hours * packageData.hourly_rate; description += `## Samlet oversigt\n\n`; description += `- Materialepris: ${packageData.total_estimated_price} kr\n`; description += `- Arbejdsløn: ${packageData.estimated_hours} timer × ${packageData.hourly_rate} kr/time = ${laborCost} kr\n`; description += `- **Total pris:** ${packageData.total_estimated_price + laborCost} kr\n`; return description; } catch (error) { logger.error('Error generating work description:', error); throw error; } } /** * Hent pakke kategorier og typer */ async getPackageCategories() { try { const [categoryRows] = await this.db.pool.execute(` SELECT DISTINCT category FROM material_packages WHERE is_active = 1 AND category IS NOT NULL ORDER BY category `); // Return only categories as work areas (Tag, Gulv, Væg, etc.) return { categories: categoryRows.map(row => row.category).filter(Boolean) }; } catch (error) { logger.error('Error getting package categories:', error); throw error; } } /** * Få relaterede pakker baseret på type, kategori, etc. */ async getRelatedPackages(packageId, limit = 5) { try { // Hent info om den valgte pakke const [packageRows] = await this.db.pool.execute(` SELECT package_type, category FROM material_packages WHERE id = ? AND is_active = 1 `, [packageId]); if (packageRows.length === 0) { const error = new Error('Package not found'); error.status = 404; throw error; } const packageType = packageRows[0].package_type; const category = packageRows[0].category; // Find relaterede pakker const [relatedRows] = await this.db.pool.execute(` SELECT mp.id, mp.name, mp.description, mp.category, mp.package_type, mp.estimated_hours, mp.total_estimated_price, mp.popularity_score FROM material_packages mp WHERE mp.is_active = 1 AND mp.id != ? AND (mp.package_type = ? OR mp.category = ?) ORDER BY CASE WHEN mp.package_type = ? AND mp.category = ? THEN 1 WHEN mp.package_type = ? THEN 2 WHEN mp.category = ? THEN 3 ELSE 4 END, mp.popularity_score DESC LIMIT ? `, [packageId, packageType, category, packageType, category, packageType, category, limit]); return relatedRows; } catch (error) { logger.error('Error getting related packages:', error); throw error; } } /** * Hent alle tasks på tværs af pakker */ async getAllTasks() { try { const [taskRows] = await this.db.pool.execute(` SELECT spt.*, mp.name as package_name, mp.category as package_category FROM smart_package_tasks spt LEFT JOIN material_packages mp ON spt.package_id = mp.id WHERE mp.is_active = 1 ORDER BY mp.category, mp.name, spt.task_order `); const tasks = []; for (const task of taskRows) { // Hent steps for hver task const [stepRows] = await this.db.pool.execute(` SELECT * FROM smart_package_steps WHERE task_id = ? ORDER BY step_order `, [task.id]); // Parse JSON felter const requiredSkills = task.required_skills ? JSON.parse(task.required_skills) : []; const requiredTools = task.required_tools ? JSON.parse(task.required_tools) : []; tasks.push({ ...task, required_skills: requiredSkills, required_tools: requiredTools, steps: stepRows }); } return tasks; } catch (error) { logger.error('Error getting all tasks:', error); throw error; } } /** * Evaluer formel med geometri-data * Understøtter formler som: "facadelængde/3", "(huslængde+1)*husbredde", "tagflade_m2*2.13" */ evaluateFormula(formula, geometry) { try { // Fjern "FORMULA:" prefix hvis det findes let expr = formula.replace(/^FORMULA:/i, '').trim(); // Erstatter danske matematiske tegn med JavaScript operatorer expr = expr.replace(/÷/g, '/'); // Division expr = expr.replace(/×/g, '*'); // Multiplikation expr = expr.replace(/,/g, '.'); // Komma til punktum for decimaler console.log('🧮 [FORMULA] Original:', formula); console.log('🧮 [FORMULA] Cleaned:', expr); logger.info('Evaluating formula:', { original: formula, cleaned: expr }); // Map geometri-værdier til variable const variables = { facadelængde: geometry.length_main || 0, huslængde: geometry.length_main || 0, husbredde: geometry.width_main || 0, tagflade_m2: geometry.total_area || 0, tagflade: geometry.total_area || 0, rygning_længde: geometry.length_main || 0, nedløb_antal: geometry.nedløb_antal || 1, m2: geometry.total_area || 0, stern_til_kip: geometry.stern_to_ridge_height || 0, højde_stern_til_kip: geometry.stern_to_ridge_height || 0, taghøjde: geometry.roof_height || 0 }; console.log('🧮 [FORMULA] Geometry data:', { length_main: geometry.length_main, width_main: geometry.width_main, total_area: geometry.total_area, stern_to_ridge_height: geometry.stern_to_ridge_height, roof_height: geometry.roof_height }); // Erstat variable i formlen for (const [key, value] of Object.entries(variables)) { const regex = new RegExp(key, 'gi'); expr = expr.replace(regex, value); } console.log('🧮 [FORMULA] After substitution:', expr); logger.info('After variable substitution:', { expr, geometry: { length: geometry.length_main, width: geometry.width_main, area: geometry.total_area } }); // Evaluer matematisk udtryk via sikker parser const { Parser } = require('expr-eval'); const parser = new Parser({}); const parsed = parser.parse(String(expr || '0')); const result = Number(parsed.evaluate(variables)) || 0; console.log('✅ [FORMULA] Result:', result); logger.info('Formula result:', { result, expr }); return Math.max(0, result); // Ingen negative værdier } catch (error) { console.log('❌ [FORMULA] Error:', error.message); logger.error('Error evaluating formula:', { formula, error: error.message }); return 1; // Fallback } } /** * Beregn materialer med formler baseret på geometri * Bruges når SmartPackage tilføjes til projekt * @param {number} packageId - ID på SmartPackage * @param {object} geometry - Geometri data (length_main, width_main, total_area) * @param {number} markupPercent - Material markup procent (standard 20%) */ async calculatePackageMaterialsWithGeometry(packageId, geometry, markupPercent = 20) { try { // Hent pakke med beskrivelse der indeholder formler const [packageRows] = await this.db.pool.execute(` SELECT * FROM material_packages WHERE id = ? `, [packageId]); if (packageRows.length === 0) { throw new Error('Package not found'); } const pkg = packageRows[0]; const [materialRows] = await this.db.pool.execute(` SELECT pm.*, m.sku, m.name AS master_name, COALESCE(latest.price, stark.price, bygma.current_netto_pris, pm.unit_price, 0) AS current_price FROM package_materials pm JOIN materials m ON m.id = pm.material_id LEFT JOIN material_prices latest ON latest.id = ( SELECT price.id FROM material_prices price WHERE price.material_id = m.id AND price.is_active = 1 ORDER BY price.valid_from DESC, price.id DESC LIMIT 1 ) LEFT JOIN stark_materials_cache stark ON stark.product_id = m.sku LEFT JOIN bygma_materials_mapping bmm ON bmm.material_id = m.id LEFT JOIN bygma_products bygma ON bygma.id = bmm.bygma_product_id AND bygma.is_active = 1 WHERE pm.package_id = ? ORDER BY pm.id `, [packageId]); logger.info('Processing materials for package:', { packageId, packageName: pkg.name, materialCount: materialRows.length, markupPercent, geometry: { length: geometry.length_main, width: geometry.width_main, area: geometry.total_area } }); // Beregn quantities const calculatedMaterials = materialRows.map(item => { const baseQuantity = parseFloat(item.base_quantity ?? item.quantity ?? 0) || 0; const wasteFactor = parseFloat(item.waste_factor ?? 1) || 1; let measurement = 1; if (item.geometry_multiplier === 'area') measurement = parseFloat(geometry.total_area || 0); if (item.geometry_multiplier === 'length') measurement = parseFloat(geometry.length_main || 0); if (item.geometry_multiplier === 'width') measurement = parseFloat(geometry.width_main || 0); const rawQuantity = baseQuantity * measurement * wasteFactor; const wholeUnit = /^(stk|rl|pk|ps|box|krt|bdt|kor)$/i.test(String(item.unit || '').trim()); const quantity = wholeUnit ? Math.ceil(rawQuantity) : Math.ceil(rawQuantity * 100) / 100; const markupMultiplier = 1 + (markupPercent / 100); const basePrice = parseFloat(item.current_price || 0); const priceWithMarkup = basePrice * markupMultiplier; logger.info('Calculated material:', { name: item.master_name, quantity, basePrice, markupPercent, priceWithMarkup, geometryMultiplier: item.geometry_multiplier }); return { materialId: item.material_id, varenr: item.sku, name: item.master_name, quantity, unit: item.unit, base_price: basePrice, price: priceWithMarkup, markup_percent: markupPercent, formula_used: `${item.geometry_multiplier || 'fixed'} × ${baseQuantity} × spild ${wasteFactor}` }; }); const [taskRows] = await this.db.pool.execute(` SELECT time_unit, time_per_unit, hours FROM smart_package_tasks WHERE package_id = ? `, [packageId]); const laborHours = taskRows.reduce((sum, task) => { const time = parseFloat(task.time_per_unit || task.hours || 0); if (task.time_unit === 'per_sqm') return sum + (time * parseFloat(geometry.total_area || 0)); if (task.time_unit === 'per_meter') return sum + (time * parseFloat(geometry.length_main || 0)); return sum + time; }, 0) || ( pkg.hours_unit === 'per_m2' ? parseFloat(pkg.time_per_sqm || pkg.estimated_hours || 0) * parseFloat(geometry.total_area || 0) : parseFloat(pkg.estimated_hours || 0) ); return { package: pkg, materials: calculatedMaterials, laborHours: Math.round(laborHours * 100) / 100, geometry_used: geometry, markup_percent: markupPercent }; } catch (error) { logger.error('Error calculating materials with geometry:', error); throw error; } } /** * Parse formler fra pakke-beskrivelse * Format: "• 077424 - Tagrende 3m (beregnes fra facadelængde ÷ 3)" */ parseFormulasFromDescription(description) { const materials = []; const lines = description.split('\n'); for (const line of lines) { // Match varenummer og navn - både med og uden punkt-tegn const match = line.match(/[•·]\s*(\d+)\s*-\s*([^(]+)(?:\(([^)]+)\))?/); if (match) { const [, varenr, name, details] = match; // Udtræk formel hvis den findes let formula = null; let fixedQuantity = null; if (details) { // Match forskellige formelformater const patterns = [ /(?:antal|beregnes):\s*([^,)]+)/i, // "antal: facadelængde÷3" /(?:beregnes fra|baseret på)\s+([^,)]+)/i, // "beregnes fra facadelængde ÷ 3" /(\d+(?:\.\d+)?)\s+(?:pr|per)\s+(?:m²|m2)/i, // "2,13 pr m²" /(\d+(?:\.\d+)?)\s+(?:pr|per)\s+(?:meter|m)/i, // "4 pr meter" /(\d+)\s+stk/i // "4 stk" ]; for (const pattern of patterns) { const formulaMatch = details.match(pattern); if (formulaMatch) { if (pattern.source.includes('stk')) { // Fast antal fixedQuantity = parseInt(formulaMatch[1]); } else { formula = formulaMatch[1].trim(); } break; } } } materials.push({ varenr: varenr.trim(), name: name.trim(), formula: formula ? `FORMULA:${formula}` : null, fixedQuantity, price: 0 // Skal hentes fra materials tabel }); } } logger.info('Parsed materials from description:', { count: materials.length, materials }); return materials; } /** * Beregn arbejdstid med formel fra beskrivelse * Format: "10 meter = 5 timer" eller "0,5 time pr m²" */ calculateLaborHours(description, geometry) { try { // Match forskellige formater const patterns = [ /(\d+(?:\.\d+)?)\s*(?:meter|m)\s*=\s*(\d+(?:\.\d+)?)\s*time/i, // "10m = 5 timer" /(\d+(?:,\d+)?)\s*time\s*pr\s*(?:m²|m2)/i, // "0,5 time pr m²" /(\d+(?:,\d+)?)\s*time\s*pr\s*(?:meter|m)/i // "0,5 time pr meter" ]; for (const pattern of patterns) { const match = description.match(pattern); if (match) { if (pattern.source.includes('=')) { // Format: "10m = 5 timer" const meters = parseFloat(match[1]); const hours = parseFloat(match[2]); const timePerMeter = hours / meters; const totalMeters = geometry.length_main || 0; return totalMeters * timePerMeter; } else if (pattern.source.includes('m²|m2')) { // Format: "0,5 time pr m²" const hoursPerM2 = parseFloat(match[1].replace(',', '.')); return (geometry.total_area || 0) * hoursPerM2; } else { // Format: "0,5 time pr meter" const hoursPerMeter = parseFloat(match[1].replace(',', '.')); return (geometry.length_main || 0) * hoursPerMeter; } } } // Fallback: returner estimated_hours fra pakken return 0; } catch (error) { logger.error('Error calculating labor hours:', error); return 0; } } } module.exports = SmartPackageManagementService;