2178 lines
73 KiB
JavaScript
2178 lines
73 KiB
JavaScript
/**
|
||
* 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;
|