Files
tilbudgivern/backend/src/services/smartPackageManagementService.js
T

2178 lines
73 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;