487 lines
15 KiB
JavaScript
487 lines
15 KiB
JavaScript
const logger = require('../utils/logger');
|
|
|
|
/**
|
|
* PlanningService - Håndterer planlægning og allokering af medarbejdere til projekter
|
|
* Integrerer med Ordrestyring API data for aktuelle ordrer og medarbejderinformation
|
|
*/
|
|
class PlanningService {
|
|
constructor(databaseService) {
|
|
this.db = databaseService;
|
|
this.ORDRESTYRING_DB = 'ordrestyring_local';
|
|
}
|
|
|
|
/**
|
|
* Hent alle igangværende ordrer fra Ordrestyring
|
|
* @returns {Array} Active orders with details
|
|
*/
|
|
async getActiveOrders() {
|
|
try {
|
|
const query = `
|
|
SELECT
|
|
c.id,
|
|
c.case_number,
|
|
c.yourref,
|
|
c.description,
|
|
c.creation_date,
|
|
c.status,
|
|
c.main_technician,
|
|
c.additional_technicians,
|
|
c.contact,
|
|
c.delivery_address,
|
|
c.remarks,
|
|
cs.text as status_text,
|
|
cs.colorcode as status_color,
|
|
ct.text as case_type_text,
|
|
ct.colorcode as case_type_color,
|
|
u.first_name as main_tech_first_name,
|
|
u.last_name as main_tech_last_name,
|
|
u.init as main_tech_init,
|
|
d.customer_name,
|
|
d.customer_address,
|
|
d.customer_telephone,
|
|
d.customer_email
|
|
FROM ${this.ORDRESTYRING_DB}.cases c
|
|
LEFT JOIN ${this.ORDRESTYRING_DB}.case_statuses cs ON c.status = cs.id
|
|
LEFT JOIN ${this.ORDRESTYRING_DB}.case_types ct ON c.case_type = ct.id
|
|
LEFT JOIN ${this.ORDRESTYRING_DB}.users u ON c.main_technician = u.id
|
|
LEFT JOIN ${this.ORDRESTYRING_DB}.debtors d ON c.customer_number = d.customer_number
|
|
WHERE c.status IN (99991, 999963, 999966) -- Active statuses (Åben, Åben/VIGTIG, Åben - Tag)
|
|
ORDER BY c.creation_date DESC
|
|
LIMIT 50
|
|
`;
|
|
|
|
const orders = await this.db.query(query);
|
|
|
|
// Enrich with additional technicians and hours
|
|
for (let order of orders) {
|
|
// Parse additional technicians
|
|
if (order.additional_technicians) {
|
|
try {
|
|
const techIds = JSON.parse(order.additional_technicians);
|
|
if (Array.isArray(techIds) && techIds.length > 0) {
|
|
const techQuery = `
|
|
SELECT id, first_name, last_name, init
|
|
FROM ${this.ORDRESTYRING_DB}.users
|
|
WHERE id IN (${techIds.map(() => '?').join(',')})
|
|
`;
|
|
order.additional_technician_details = await this.db.query(techQuery, techIds);
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to parse additional_technicians:', e);
|
|
}
|
|
}
|
|
|
|
// Get total hours for this case
|
|
const hoursQuery = `
|
|
SELECT
|
|
COUNT(*) as total_sessions,
|
|
SUM(CASE WHEN stop_time > 0 THEN (stop_time - start_time) / 3600 ELSE 0 END) as total_hours,
|
|
MAX(start_time) as last_activity
|
|
FROM ${this.ORDRESTYRING_DB}.hours
|
|
WHERE new_case_number = ? OR new_case_number = ?
|
|
`;
|
|
const hoursResult = await this.db.query(hoursQuery, [order.case_number, order.id]);
|
|
order.hours_summary = hoursResult[0] || { total_sessions: 0, total_hours: 0, last_activity: null };
|
|
|
|
// Get materials count
|
|
const materialsQuery = `
|
|
SELECT COUNT(*) as materials_count, SUM(CAST(sales_price as DECIMAL(10,2))) as total_value
|
|
FROM ${this.ORDRESTYRING_DB}.case_materials
|
|
WHERE case_number = ?
|
|
`;
|
|
const materialsResult = await this.db.query(materialsQuery, [order.case_number]);
|
|
order.materials_summary = materialsResult[0] || { materials_count: 0, total_value: 0 };
|
|
}
|
|
|
|
logger.info('Retrieved active orders', { count: orders.length });
|
|
return orders;
|
|
|
|
} catch (error) {
|
|
logger.error('Error fetching active orders', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hent alle medarbejdere fra Ordrestyring
|
|
* @returns {Array} All employees with their details
|
|
*/
|
|
async getEmployees() {
|
|
try {
|
|
const query = `
|
|
SELECT
|
|
u.id,
|
|
u.init,
|
|
u.first_name,
|
|
u.last_name,
|
|
u.fullName,
|
|
u.email,
|
|
u.tlf,
|
|
u.mobil_privat,
|
|
u.status,
|
|
u.department_id,
|
|
u.employee_type_id,
|
|
et.title as employee_type_title,
|
|
et.color as employee_type_color,
|
|
et.is_personal
|
|
FROM ${this.ORDRESTYRING_DB}.users u
|
|
LEFT JOIN ${this.ORDRESTYRING_DB}.employee_types et ON u.employee_type_id = et.id
|
|
WHERE u.status IN (999912, 999913) -- Active employees
|
|
ORDER BY u.first_name, u.last_name
|
|
`;
|
|
|
|
const employees = await this.db.query(query);
|
|
|
|
// Get current week allocations for each employee
|
|
const startOfWeek = this.getStartOfWeek(new Date());
|
|
const endOfFourWeeks = new Date(startOfWeek);
|
|
endOfFourWeeks.setDate(endOfFourWeeks.getDate() + 28); // 4 weeks
|
|
|
|
for (let employee of employees) {
|
|
// Get current allocations
|
|
const allocationsQuery = `
|
|
SELECT
|
|
h.id,
|
|
h.start_time,
|
|
h.stop_time,
|
|
h.remark,
|
|
h.new_case_number,
|
|
c.description as case_description,
|
|
c.yourref
|
|
FROM ${this.ORDRESTYRING_DB}.hours h
|
|
LEFT JOIN ${this.ORDRESTYRING_DB}.cases c ON h.new_case_number = c.case_number
|
|
WHERE h.emp_id = ?
|
|
AND h.start_time >= ?
|
|
AND h.start_time <= ?
|
|
ORDER BY h.start_time
|
|
`;
|
|
|
|
employee.upcoming_allocations = await this.db.query(allocationsQuery, [
|
|
employee.id,
|
|
Math.floor(startOfWeek.getTime() / 1000),
|
|
Math.floor(endOfFourWeeks.getTime() / 1000)
|
|
]);
|
|
|
|
// Calculate weekly workload
|
|
employee.weekly_hours = this.calculateWeeklyHours(employee.upcoming_allocations);
|
|
}
|
|
|
|
logger.info('Retrieved employees with allocations', { count: employees.length });
|
|
return employees;
|
|
|
|
} catch (error) {
|
|
logger.error('Error fetching employees', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hent kalender data for de næste 4 uger
|
|
* @returns {Object} Calendar data structured by week and employee
|
|
*/
|
|
async getCalendarData() {
|
|
try {
|
|
const startDate = this.getStartOfWeek(new Date());
|
|
const fourWeeksData = {};
|
|
|
|
// Generate 4 weeks of data
|
|
for (let week = 0; week < 4; week++) {
|
|
const weekStart = new Date(startDate);
|
|
weekStart.setDate(weekStart.getDate() + (week * 7));
|
|
|
|
const weekEnd = new Date(weekStart);
|
|
weekEnd.setDate(weekEnd.getDate() + 6);
|
|
|
|
const weekKey = `week_${week + 1}`;
|
|
fourWeeksData[weekKey] = {
|
|
start_date: weekStart,
|
|
end_date: weekEnd,
|
|
week_number: this.getWeekNumber(weekStart),
|
|
employees: {}
|
|
};
|
|
|
|
// Get all allocations for this week
|
|
const allocationsQuery = `
|
|
SELECT
|
|
h.id,
|
|
h.emp_id,
|
|
h.start_time,
|
|
h.stop_time,
|
|
h.remark,
|
|
h.new_case_number,
|
|
h.hour_type,
|
|
c.description as case_description,
|
|
c.yourref,
|
|
c.status as case_status,
|
|
cs.text as case_status_text,
|
|
cs.colorcode as case_status_color,
|
|
u.first_name,
|
|
u.last_name,
|
|
u.init
|
|
FROM ${this.ORDRESTYRING_DB}.hours h
|
|
LEFT JOIN ${this.ORDRESTYRING_DB}.cases c ON h.new_case_number = c.case_number
|
|
LEFT JOIN ${this.ORDRESTYRING_DB}.case_statuses cs ON c.status = cs.id
|
|
LEFT JOIN ${this.ORDRESTYRING_DB}.users u ON h.emp_id = u.id
|
|
WHERE h.start_time >= ? AND h.start_time <= ?
|
|
ORDER BY h.emp_id, h.start_time
|
|
`;
|
|
|
|
const weekAllocations = await this.db.query(allocationsQuery, [
|
|
Math.floor(weekStart.getTime() / 1000),
|
|
Math.floor(weekEnd.getTime() / 1000)
|
|
]);
|
|
|
|
// First get ALL active employees (include common status codes)
|
|
const allEmployeesQuery = `
|
|
SELECT id, first_name, last_name, init, employee_type_id, department_id
|
|
FROM ${this.ORDRESTYRING_DB}.users
|
|
WHERE status IN (1, 999912, 999913) AND employee_type_id IS NOT NULL
|
|
ORDER BY first_name, last_name
|
|
`;
|
|
|
|
const allEmployees = await this.db.query(allEmployeesQuery);
|
|
|
|
// Initialize all employees with empty allocations
|
|
allEmployees.forEach(employee => {
|
|
fourWeeksData[weekKey].employees[employee.id] = {
|
|
employee_info: {
|
|
id: employee.id,
|
|
name: `${employee.first_name} ${employee.last_name}`,
|
|
init: employee.init,
|
|
employee_type_id: employee.employee_type_id,
|
|
department_id: employee.department_id
|
|
},
|
|
allocations: [],
|
|
total_hours: 0,
|
|
status: 'ledig' // Default status
|
|
};
|
|
});
|
|
|
|
// Group allocations by employee
|
|
weekAllocations.forEach(allocation => {
|
|
const employeeId = allocation.emp_id;
|
|
|
|
if (fourWeeksData[weekKey].employees[employeeId]) {
|
|
const duration = allocation.stop_time > 0 ?
|
|
(allocation.stop_time - allocation.start_time) / 3600 : 0;
|
|
|
|
fourWeeksData[weekKey].employees[employeeId].allocations.push({
|
|
...allocation,
|
|
duration_hours: duration,
|
|
start_date: new Date(allocation.start_time * 1000),
|
|
end_date: allocation.stop_time > 0 ? new Date(allocation.stop_time * 1000) : null
|
|
});
|
|
|
|
fourWeeksData[weekKey].employees[employeeId].total_hours += duration;
|
|
fourWeeksData[weekKey].employees[employeeId].status = 'allokeret';
|
|
}
|
|
});
|
|
}
|
|
|
|
return fourWeeksData;
|
|
|
|
} catch (error) {
|
|
logger.error('Error fetching calendar data', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Opret ny medarbejder allokering
|
|
* @param {Object} allocationData - Allocation details
|
|
* @returns {Object} Created allocation
|
|
*/
|
|
async createAllocation(allocationData) {
|
|
try {
|
|
const {
|
|
employee_id,
|
|
case_number,
|
|
start_time,
|
|
end_time,
|
|
remark,
|
|
hour_type = 1 // Default work type
|
|
} = allocationData;
|
|
|
|
// Validate employee exists
|
|
const employeeCheck = await this.db.query(
|
|
`SELECT id FROM ${this.ORDRESTYRING_DB}.users WHERE id = ? AND status = 1`,
|
|
[employee_id]
|
|
);
|
|
|
|
if (employeeCheck.length === 0) {
|
|
throw new Error('Employee not found or inactive');
|
|
}
|
|
|
|
// Validate case exists
|
|
if (case_number) {
|
|
const caseCheck = await this.db.query(
|
|
`SELECT case_number FROM ${this.ORDRESTYRING_DB}.cases WHERE case_number = ?`,
|
|
[case_number]
|
|
);
|
|
|
|
if (caseCheck.length === 0) {
|
|
throw new Error('Case not found');
|
|
}
|
|
}
|
|
|
|
// Create allocation entry
|
|
const insertQuery = `
|
|
INSERT INTO ${this.ORDRESTYRING_DB}.hours (
|
|
emp_id,
|
|
start_time,
|
|
stop_time,
|
|
remark,
|
|
hour_type,
|
|
new_case_number,
|
|
exported,
|
|
created_at,
|
|
updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, -1, ?, ?)
|
|
`;
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const result = await this.db.query(insertQuery, [
|
|
employee_id,
|
|
Math.floor(new Date(start_time).getTime() / 1000),
|
|
end_time ? Math.floor(new Date(end_time).getTime() / 1000) : 0,
|
|
remark || '',
|
|
hour_type,
|
|
case_number || null,
|
|
now,
|
|
now
|
|
]);
|
|
|
|
logger.info('Created new allocation', {
|
|
id: result.insertId,
|
|
employee_id,
|
|
case_number,
|
|
start_time,
|
|
end_time
|
|
});
|
|
|
|
return {
|
|
success: true,
|
|
allocation_id: result.insertId,
|
|
message: 'Allokering oprettet succesfuldt'
|
|
};
|
|
|
|
} catch (error) {
|
|
logger.error('Error creating allocation', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Opdater eksisterende allokering
|
|
* @param {number} allocationId - ID of allocation to update
|
|
* @param {Object} updateData - Updated allocation data
|
|
* @returns {Object} Update result
|
|
*/
|
|
async updateAllocation(allocationId, updateData) {
|
|
try {
|
|
const {
|
|
start_time,
|
|
end_time,
|
|
remark,
|
|
case_number
|
|
} = updateData;
|
|
|
|
const updateQuery = `
|
|
UPDATE ${this.ORDRESTYRING_DB}.hours
|
|
SET
|
|
start_time = COALESCE(?, start_time),
|
|
stop_time = COALESCE(?, stop_time),
|
|
remark = COALESCE(?, remark),
|
|
new_case_number = COALESCE(?, new_case_number),
|
|
updated_at = ?
|
|
WHERE id = ?
|
|
`;
|
|
|
|
const now = Math.floor(Date.now() / 1000);
|
|
await this.db.query(updateQuery, [
|
|
start_time ? Math.floor(new Date(start_time).getTime() / 1000) : null,
|
|
end_time ? Math.floor(new Date(end_time).getTime() / 1000) : null,
|
|
remark || null,
|
|
case_number || null,
|
|
now,
|
|
allocationId
|
|
]);
|
|
|
|
logger.info('Updated allocation', { allocationId, updateData });
|
|
|
|
return {
|
|
success: true,
|
|
message: 'Allokering opdateret succesfuldt'
|
|
};
|
|
|
|
} catch (error) {
|
|
logger.error('Error updating allocation', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Slet allokering
|
|
* @param {number} allocationId - ID of allocation to delete
|
|
* @returns {Object} Delete result
|
|
*/
|
|
async deleteAllocation(allocationId) {
|
|
try {
|
|
await this.db.query(
|
|
`DELETE FROM ${this.ORDRESTYRING_DB}.hours WHERE id = ?`,
|
|
[allocationId]
|
|
);
|
|
|
|
logger.info('Deleted allocation', { allocationId });
|
|
|
|
return {
|
|
success: true,
|
|
message: 'Allokering slettet succesfuldt'
|
|
};
|
|
|
|
} catch (error) {
|
|
logger.error('Error deleting allocation', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hjælpe funktioner
|
|
*/
|
|
getStartOfWeek(date) {
|
|
const d = new Date(date);
|
|
const day = d.getDay();
|
|
const diff = d.getDate() - day + (day === 0 ? -6 : 1); // Mandag som start
|
|
return new Date(d.setDate(diff));
|
|
}
|
|
|
|
getWeekNumber(date) {
|
|
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
|
const dayNum = d.getUTCDay() || 7;
|
|
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
|
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
|
return Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
|
|
}
|
|
|
|
calculateWeeklyHours(allocations) {
|
|
const weeklyHours = {};
|
|
|
|
allocations.forEach(allocation => {
|
|
const startDate = new Date(allocation.start_time * 1000);
|
|
const weekKey = this.getWeekNumber(startDate);
|
|
|
|
if (!weeklyHours[weekKey]) {
|
|
weeklyHours[weekKey] = 0;
|
|
}
|
|
|
|
if (allocation.stop_time > 0) {
|
|
const hours = (allocation.stop_time - allocation.start_time) / 3600;
|
|
weeklyHours[weekKey] += hours;
|
|
}
|
|
});
|
|
|
|
return weeklyHours;
|
|
}
|
|
}
|
|
|
|
module.exports = PlanningService; |