684 lines
25 KiB
JavaScript
684 lines
25 KiB
JavaScript
const express = require('express');
|
|
const { DateTime } = require('luxon');
|
|
|
|
const router = express.Router();
|
|
const { getDbConnection, getPoolStatus } = require('../db/connection');
|
|
const {
|
|
getTimeRangeConditions,
|
|
_internal: timeHelpers
|
|
} = require('../utils/timeUtils');
|
|
|
|
const TIMEZONE = 'America/New_York';
|
|
|
|
// Punch types from the database
|
|
const PUNCH_TYPES = {
|
|
OUT: 0,
|
|
IN: 1,
|
|
BREAK_START: 2,
|
|
BREAK_END: 3,
|
|
};
|
|
|
|
// Standard hours for FTE calculation (40 hours per week)
|
|
const STANDARD_WEEKLY_HOURS = 40;
|
|
|
|
/**
|
|
* Calculate working hours from timeclock entries
|
|
* Groups punches by employee and date, pairs in/out punches
|
|
* Returns both total hours (with breaks, for FTE) and productive hours (without breaks, for productivity)
|
|
*/
|
|
function calculateHoursFromPunches(punches) {
|
|
// Group by employee
|
|
const byEmployee = new Map();
|
|
|
|
punches.forEach(punch => {
|
|
if (!byEmployee.has(punch.EmployeeID)) {
|
|
byEmployee.set(punch.EmployeeID, []);
|
|
}
|
|
byEmployee.get(punch.EmployeeID).push(punch);
|
|
});
|
|
|
|
const employeeHours = [];
|
|
let totalHours = 0;
|
|
let totalBreakHours = 0;
|
|
|
|
byEmployee.forEach((employeePunches, employeeId) => {
|
|
// Sort by timestamp
|
|
employeePunches.sort((a, b) => new Date(a.TimeStamp) - new Date(b.TimeStamp));
|
|
|
|
let hours = 0;
|
|
let breakHours = 0;
|
|
let currentIn = null;
|
|
let breakStart = null;
|
|
|
|
employeePunches.forEach(punch => {
|
|
const punchTime = new Date(punch.TimeStamp);
|
|
|
|
switch (punch.PunchType) {
|
|
case PUNCH_TYPES.IN:
|
|
currentIn = punchTime;
|
|
break;
|
|
case PUNCH_TYPES.OUT:
|
|
if (currentIn) {
|
|
hours += (punchTime - currentIn) / (1000 * 60 * 60); // Convert ms to hours
|
|
currentIn = null;
|
|
}
|
|
break;
|
|
case PUNCH_TYPES.BREAK_START:
|
|
breakStart = punchTime;
|
|
break;
|
|
case PUNCH_TYPES.BREAK_END:
|
|
if (breakStart) {
|
|
breakHours += (punchTime - breakStart) / (1000 * 60 * 60);
|
|
breakStart = null;
|
|
}
|
|
break;
|
|
}
|
|
});
|
|
|
|
totalHours += hours;
|
|
totalBreakHours += breakHours;
|
|
|
|
employeeHours.push({
|
|
employeeId,
|
|
hours,
|
|
breakHours,
|
|
productiveHours: hours - breakHours,
|
|
});
|
|
});
|
|
|
|
return {
|
|
employeeHours,
|
|
totalHours,
|
|
totalBreakHours,
|
|
totalProductiveHours: totalHours - totalBreakHours
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Calculate FTE (Full Time Equivalents) for a period
|
|
* @param {number} totalHours - Total hours worked
|
|
* @param {Date} startDate - Period start
|
|
* @param {Date} endDate - Period end
|
|
*/
|
|
function calculateFTE(totalHours, startDate, endDate) {
|
|
const start = new Date(startDate);
|
|
const end = new Date(endDate);
|
|
const days = Math.max(1, (end - start) / (1000 * 60 * 60 * 24));
|
|
const weeks = days / 7;
|
|
const expectedHours = weeks * STANDARD_WEEKLY_HOURS;
|
|
|
|
return expectedHours > 0 ? totalHours / expectedHours : 0;
|
|
}
|
|
|
|
// Main employee metrics endpoint
|
|
router.get('/', async (req, res) => {
|
|
const startTime = Date.now();
|
|
console.log(`[EMPLOYEE-METRICS] Starting request for timeRange: ${req.query.timeRange}`);
|
|
|
|
const timeoutPromise = new Promise((_, reject) => {
|
|
setTimeout(() => reject(new Error('Request timeout after 30 seconds')), 30000);
|
|
});
|
|
|
|
try {
|
|
const mainOperation = async () => {
|
|
const { timeRange, startDate, endDate } = req.query;
|
|
console.log(`[EMPLOYEE-METRICS] Getting DB connection...`);
|
|
const { connection, release } = await getDbConnection();
|
|
console.log(`[EMPLOYEE-METRICS] DB connection obtained in ${Date.now() - startTime}ms`);
|
|
|
|
const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate);
|
|
|
|
// Adapt where clause for timeclock table (uses TimeStamp instead of date_placed)
|
|
const timeclockWhere = whereClause.replace(/date_placed/g, 'tc.TimeStamp');
|
|
|
|
// Query for timeclock data with employee names
|
|
const timeclockQuery = `
|
|
SELECT
|
|
tc.EmployeeID,
|
|
tc.TimeStamp,
|
|
tc.PunchType,
|
|
e.firstname,
|
|
e.lastname
|
|
FROM timeclock tc
|
|
LEFT JOIN employees e ON tc.EmployeeID = e.employeeid
|
|
WHERE ${timeclockWhere}
|
|
AND e.hidden = 0
|
|
AND e.disabled = 0
|
|
ORDER BY tc.EmployeeID, tc.TimeStamp
|
|
`;
|
|
|
|
const [timeclockRows] = await connection.execute(timeclockQuery, params);
|
|
|
|
// Calculate hours (includes both total hours for FTE and productive hours for productivity)
|
|
const { employeeHours, totalHours, totalBreakHours, totalProductiveHours } = calculateHoursFromPunches(timeclockRows);
|
|
|
|
// Get employee names for the results
|
|
const employeeNames = new Map();
|
|
timeclockRows.forEach(row => {
|
|
if (!employeeNames.has(row.EmployeeID)) {
|
|
employeeNames.set(row.EmployeeID, {
|
|
firstname: row.firstname || '',
|
|
lastname: row.lastname || '',
|
|
});
|
|
}
|
|
});
|
|
|
|
// Enrich employee hours with names
|
|
const enrichedEmployeeHours = employeeHours.map(eh => ({
|
|
...eh,
|
|
name: employeeNames.has(eh.employeeId)
|
|
? `${employeeNames.get(eh.employeeId).firstname} ${employeeNames.get(eh.employeeId).lastname}`.trim()
|
|
: `Employee ${eh.employeeId}`,
|
|
})).sort((a, b) => b.hours - a.hours);
|
|
|
|
// Query for picking tickets - using subquery to avoid duplication from bucket join
|
|
// Ship-together orders: only count main orders (is_sub = 0 or NULL), not sub-orders
|
|
const pickingWhere = whereClause.replace(/date_placed/g, 'pt.createddate');
|
|
|
|
// First get picking ticket stats without the bucket join (to avoid duplication)
|
|
const pickingStatsQuery = `
|
|
SELECT
|
|
pt.createdby as employeeId,
|
|
e.firstname,
|
|
e.lastname,
|
|
COUNT(DISTINCT pt.pickingid) as ticketCount,
|
|
SUM(pt.totalpieces_picked) as piecesPicked,
|
|
SUM(TIMESTAMPDIFF(SECOND, pt.createddate, pt.closeddate)) as pickingTimeSeconds,
|
|
AVG(NULLIF(pt.picking_speed, 0)) as avgPickingSpeed
|
|
FROM picking_ticket pt
|
|
LEFT JOIN employees e ON pt.createdby = e.employeeid
|
|
WHERE ${pickingWhere}
|
|
AND pt.closeddate IS NOT NULL
|
|
GROUP BY pt.createdby, e.firstname, e.lastname
|
|
`;
|
|
|
|
// Separate query for order counts (needs bucket join for ship-together handling)
|
|
const orderCountQuery = `
|
|
SELECT
|
|
pt.createdby as employeeId,
|
|
COUNT(DISTINCT CASE WHEN ptb.is_sub = 0 OR ptb.is_sub IS NULL THEN ptb.orderid END) as ordersPicked
|
|
FROM picking_ticket pt
|
|
LEFT JOIN picking_ticket_buckets ptb ON pt.pickingid = ptb.pickingid
|
|
WHERE ${pickingWhere}
|
|
AND pt.closeddate IS NOT NULL
|
|
GROUP BY pt.createdby
|
|
`;
|
|
|
|
const [[pickingStatsRows], [orderCountRows]] = await Promise.all([
|
|
connection.execute(pickingStatsQuery, params),
|
|
connection.execute(orderCountQuery, params)
|
|
]);
|
|
|
|
// Merge the results
|
|
const orderCountMap = new Map();
|
|
orderCountRows.forEach(row => {
|
|
orderCountMap.set(row.employeeId, parseInt(row.ordersPicked || 0));
|
|
});
|
|
|
|
// Aggregate picking totals
|
|
let totalOrdersPicked = 0;
|
|
let totalPiecesPicked = 0;
|
|
let totalTickets = 0;
|
|
let totalPickingTimeSeconds = 0;
|
|
let pickingSpeedSum = 0;
|
|
let pickingSpeedCount = 0;
|
|
|
|
const pickingByEmployee = pickingStatsRows.map(row => {
|
|
const ordersPicked = orderCountMap.get(row.employeeId) || 0;
|
|
totalOrdersPicked += ordersPicked;
|
|
totalPiecesPicked += parseInt(row.piecesPicked || 0);
|
|
totalTickets += parseInt(row.ticketCount || 0);
|
|
totalPickingTimeSeconds += parseInt(row.pickingTimeSeconds || 0);
|
|
if (row.avgPickingSpeed && row.avgPickingSpeed > 0) {
|
|
pickingSpeedSum += parseFloat(row.avgPickingSpeed);
|
|
pickingSpeedCount++;
|
|
}
|
|
|
|
const empPickingHours = parseInt(row.pickingTimeSeconds || 0) / 3600;
|
|
|
|
return {
|
|
employeeId: row.employeeId,
|
|
name: `${row.firstname || ''} ${row.lastname || ''}`.trim() || `Employee ${row.employeeId}`,
|
|
ticketCount: parseInt(row.ticketCount || 0),
|
|
ordersPicked,
|
|
piecesPicked: parseInt(row.piecesPicked || 0),
|
|
pickingHours: empPickingHours,
|
|
avgPickingSpeed: row.avgPickingSpeed ? parseFloat(row.avgPickingSpeed) : null,
|
|
};
|
|
});
|
|
|
|
const totalPickingHours = totalPickingTimeSeconds / 3600;
|
|
const avgPickingSpeed = pickingSpeedCount > 0 ? pickingSpeedSum / pickingSpeedCount : 0;
|
|
|
|
// Query for shipped orders - totals
|
|
// Ship-together orders: only count main orders (order_type != 8 for sub-orders, or use parent tracking)
|
|
const shippingWhere = whereClause.replace(/date_placed/g, 'o.date_shipped');
|
|
|
|
const shippingQuery = `
|
|
SELECT
|
|
COUNT(DISTINCT CASE WHEN o.order_type != 8 OR o.order_type IS NULL THEN o.order_id END) as ordersShipped,
|
|
COALESCE(SUM(o.stats_prod_pieces), 0) as piecesShipped
|
|
FROM _order o
|
|
WHERE ${shippingWhere}
|
|
AND o.order_status IN (100, 92)
|
|
`;
|
|
|
|
const [shippingRows] = await connection.execute(shippingQuery, params);
|
|
const shipping = shippingRows[0] || { ordersShipped: 0, piecesShipped: 0 };
|
|
|
|
// Query for shipped orders by employee
|
|
const shippingByEmployeeQuery = `
|
|
SELECT
|
|
e.employeeid,
|
|
e.firstname,
|
|
e.lastname,
|
|
COUNT(DISTINCT CASE WHEN o.order_type != 8 OR o.order_type IS NULL THEN o.order_id END) as ordersShipped,
|
|
COALESCE(SUM(o.stats_prod_pieces), 0) as piecesShipped
|
|
FROM _order o
|
|
JOIN employees e ON o.stats_cid_shipped = e.cid
|
|
WHERE ${shippingWhere}
|
|
AND o.order_status IN (100, 92)
|
|
AND e.hidden = 0
|
|
AND e.disabled = 0
|
|
GROUP BY e.employeeid, e.firstname, e.lastname
|
|
ORDER BY ordersShipped DESC
|
|
`;
|
|
|
|
const [shippingByEmployeeRows] = await connection.execute(shippingByEmployeeQuery, params);
|
|
const shippingByEmployee = shippingByEmployeeRows.map(row => ({
|
|
employeeId: row.employeeid,
|
|
name: `${row.firstname || ''} ${row.lastname || ''}`.trim() || `Employee ${row.employeeid}`,
|
|
ordersShipped: parseInt(row.ordersShipped || 0),
|
|
piecesShipped: parseInt(row.piecesShipped || 0),
|
|
}));
|
|
|
|
// Calculate period dates for FTE calculation
|
|
let periodStart, periodEnd;
|
|
if (dateRange?.start) {
|
|
periodStart = new Date(dateRange.start);
|
|
} else if (params[0]) {
|
|
periodStart = new Date(params[0]);
|
|
} else {
|
|
periodStart = new Date();
|
|
periodStart.setDate(periodStart.getDate() - 30);
|
|
}
|
|
|
|
if (dateRange?.end) {
|
|
periodEnd = new Date(dateRange.end);
|
|
} else if (params[1]) {
|
|
periodEnd = new Date(params[1]);
|
|
} else {
|
|
periodEnd = new Date();
|
|
}
|
|
|
|
const fte = calculateFTE(totalHours, periodStart, periodEnd);
|
|
const activeEmployees = enrichedEmployeeHours.filter(e => e.hours > 0).length;
|
|
|
|
// Calculate weeks in period for weekly averages
|
|
const periodDays = Math.max(1, (periodEnd - periodStart) / (1000 * 60 * 60 * 24));
|
|
const weeksInPeriod = periodDays / 7;
|
|
|
|
// Get daily trend data for hours
|
|
// Use DATE_FORMAT to get date string in Eastern timezone, avoiding JS timezone conversion issues
|
|
// Business day starts at 1 AM, so subtract 1 hour before taking the date
|
|
const trendWhere = whereClause.replace(/date_placed/g, 'tc.TimeStamp');
|
|
const trendQuery = `
|
|
SELECT
|
|
DATE_FORMAT(DATE_SUB(tc.TimeStamp, INTERVAL 1 HOUR), '%Y-%m-%d') as date,
|
|
tc.EmployeeID,
|
|
tc.TimeStamp,
|
|
tc.PunchType
|
|
FROM timeclock tc
|
|
LEFT JOIN employees e ON tc.EmployeeID = e.employeeid
|
|
WHERE ${trendWhere}
|
|
AND e.hidden = 0
|
|
AND e.disabled = 0
|
|
ORDER BY date, tc.EmployeeID, tc.TimeStamp
|
|
`;
|
|
|
|
const [trendRows] = await connection.execute(trendQuery, params);
|
|
|
|
// Get daily picking data for trend
|
|
// Ship-together orders: only count main orders (is_sub = 0 or NULL)
|
|
// Use DATE_FORMAT for consistent date string format
|
|
const pickingTrendWhere = whereClause.replace(/date_placed/g, 'pt.createddate');
|
|
const pickingTrendQuery = `
|
|
SELECT
|
|
DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d') as date,
|
|
COUNT(DISTINCT CASE WHEN ptb.is_sub = 0 OR ptb.is_sub IS NULL THEN ptb.orderid END) as ordersPicked,
|
|
COALESCE(SUM(pt.totalpieces_picked), 0) as piecesPicked
|
|
FROM picking_ticket pt
|
|
LEFT JOIN picking_ticket_buckets ptb ON pt.pickingid = ptb.pickingid
|
|
WHERE ${pickingTrendWhere}
|
|
AND pt.closeddate IS NOT NULL
|
|
GROUP BY DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d')
|
|
ORDER BY date
|
|
`;
|
|
|
|
const [pickingTrendRows] = await connection.execute(pickingTrendQuery, params);
|
|
|
|
// Create a map of picking data by date
|
|
const pickingByDate = new Map();
|
|
pickingTrendRows.forEach(row => {
|
|
// Date is already a string in YYYY-MM-DD format from DATE_FORMAT
|
|
const date = String(row.date);
|
|
pickingByDate.set(date, {
|
|
ordersPicked: parseInt(row.ordersPicked || 0),
|
|
piecesPicked: parseInt(row.piecesPicked || 0),
|
|
});
|
|
});
|
|
|
|
// Group timeclock by date for trend
|
|
const byDate = new Map();
|
|
trendRows.forEach(row => {
|
|
// Date is already a string in YYYY-MM-DD format from DATE_FORMAT
|
|
const date = String(row.date);
|
|
if (!byDate.has(date)) {
|
|
byDate.set(date, []);
|
|
}
|
|
byDate.get(date).push(row);
|
|
});
|
|
|
|
// Generate all dates in the period range for complete trend data
|
|
const allDatesInRange = [];
|
|
const startDt = DateTime.fromJSDate(periodStart).setZone(TIMEZONE).startOf('day');
|
|
const endDt = DateTime.fromJSDate(periodEnd).setZone(TIMEZONE).startOf('day');
|
|
|
|
let currentDt = startDt;
|
|
while (currentDt <= endDt) {
|
|
allDatesInRange.push(currentDt.toFormat('yyyy-MM-dd'));
|
|
currentDt = currentDt.plus({ days: 1 });
|
|
}
|
|
|
|
// Build trend data for all dates in range, filling zeros for missing days
|
|
const trend = allDatesInRange.map(date => {
|
|
const punches = byDate.get(date) || [];
|
|
const { totalHours: dayHours, employeeHours: dayEmployeeHours } = calculateHoursFromPunches(punches);
|
|
const picking = pickingByDate.get(date) || { ordersPicked: 0, piecesPicked: 0 };
|
|
|
|
// Parse date string in Eastern timezone to get proper ISO timestamp
|
|
const dateDt = DateTime.fromFormat(date, 'yyyy-MM-dd', { zone: TIMEZONE });
|
|
|
|
return {
|
|
date,
|
|
timestamp: dateDt.toISO(),
|
|
hours: dayHours,
|
|
activeEmployees: dayEmployeeHours.filter(e => e.hours > 0).length,
|
|
ordersPicked: picking.ordersPicked,
|
|
piecesPicked: picking.piecesPicked,
|
|
};
|
|
});
|
|
|
|
// Get previous period data for comparison
|
|
const previousRange = getPreviousPeriodRange(timeRange, startDate, endDate);
|
|
let comparison = null;
|
|
let previousTotals = null;
|
|
|
|
if (previousRange) {
|
|
const prevTimeclockWhere = previousRange.whereClause.replace(/date_placed/g, 'tc.TimeStamp');
|
|
|
|
const [prevTimeclockRows] = await connection.execute(
|
|
`SELECT tc.EmployeeID, tc.TimeStamp, tc.PunchType
|
|
FROM timeclock tc
|
|
LEFT JOIN employees e ON tc.EmployeeID = e.employeeid
|
|
WHERE ${prevTimeclockWhere}
|
|
AND e.hidden = 0
|
|
AND e.disabled = 0
|
|
ORDER BY tc.EmployeeID, tc.TimeStamp`,
|
|
previousRange.params
|
|
);
|
|
|
|
const {
|
|
totalHours: prevTotalHours,
|
|
totalProductiveHours: prevProductiveHours,
|
|
employeeHours: prevEmployeeHours
|
|
} = calculateHoursFromPunches(prevTimeclockRows);
|
|
const prevActiveEmployees = prevEmployeeHours.filter(e => e.hours > 0).length;
|
|
|
|
// Previous picking data (ship-together fix applied)
|
|
// Use separate queries to avoid duplication from bucket join
|
|
const prevPickingWhere = previousRange.whereClause.replace(/date_placed/g, 'pt.createddate');
|
|
|
|
const [[prevPickingStatsRows], [prevOrderCountRows]] = await Promise.all([
|
|
connection.execute(
|
|
`SELECT
|
|
SUM(pt.totalpieces_picked) as piecesPicked,
|
|
SUM(TIMESTAMPDIFF(SECOND, pt.createddate, pt.closeddate)) as pickingTimeSeconds
|
|
FROM picking_ticket pt
|
|
WHERE ${prevPickingWhere}
|
|
AND pt.closeddate IS NOT NULL`,
|
|
previousRange.params
|
|
),
|
|
connection.execute(
|
|
`SELECT
|
|
COUNT(DISTINCT CASE WHEN ptb.is_sub = 0 OR ptb.is_sub IS NULL THEN ptb.orderid END) as ordersPicked
|
|
FROM picking_ticket pt
|
|
LEFT JOIN picking_ticket_buckets ptb ON pt.pickingid = ptb.pickingid
|
|
WHERE ${prevPickingWhere}
|
|
AND pt.closeddate IS NOT NULL`,
|
|
previousRange.params
|
|
)
|
|
]);
|
|
|
|
const prevPickingStats = prevPickingStatsRows[0] || { piecesPicked: 0, pickingTimeSeconds: 0 };
|
|
const prevOrderCount = prevOrderCountRows[0] || { ordersPicked: 0 };
|
|
const prevPicking = {
|
|
ordersPicked: parseInt(prevOrderCount.ordersPicked || 0),
|
|
piecesPicked: parseInt(prevPickingStats.piecesPicked || 0),
|
|
pickingTimeSeconds: parseInt(prevPickingStats.pickingTimeSeconds || 0)
|
|
};
|
|
const prevPickingHours = prevPicking.pickingTimeSeconds / 3600;
|
|
|
|
// Previous shipping data
|
|
const prevShippingWhere = previousRange.whereClause.replace(/date_placed/g, 'o.date_shipped');
|
|
const [prevShippingRows] = await connection.execute(
|
|
`SELECT
|
|
COUNT(DISTINCT CASE WHEN o.order_type != 8 OR o.order_type IS NULL THEN o.order_id END) as ordersShipped,
|
|
COALESCE(SUM(o.stats_prod_pieces), 0) as piecesShipped
|
|
FROM _order o
|
|
WHERE ${prevShippingWhere}
|
|
AND o.order_status IN (100, 92)`,
|
|
previousRange.params
|
|
);
|
|
const prevShipping = prevShippingRows[0] || { ordersShipped: 0, piecesShipped: 0 };
|
|
|
|
// Calculate previous period FTE and productivity
|
|
const prevFte = calculateFTE(prevTotalHours, previousRange.start || periodStart, previousRange.end || periodEnd);
|
|
const prevOrdersPerHour = prevProductiveHours > 0 ? parseInt(prevPicking.ordersPicked || 0) / prevProductiveHours : 0;
|
|
const prevPiecesPerHour = prevProductiveHours > 0 ? parseInt(prevPicking.piecesPicked || 0) / prevProductiveHours : 0;
|
|
|
|
previousTotals = {
|
|
hours: prevTotalHours,
|
|
productiveHours: prevProductiveHours,
|
|
activeEmployees: prevActiveEmployees,
|
|
fte: prevFte,
|
|
ordersPicked: parseInt(prevPicking.ordersPicked || 0),
|
|
piecesPicked: parseInt(prevPicking.piecesPicked || 0),
|
|
pickingHours: prevPickingHours,
|
|
ordersShipped: parseInt(prevShipping.ordersShipped || 0),
|
|
piecesShipped: parseInt(prevShipping.piecesShipped || 0),
|
|
ordersPerHour: prevOrdersPerHour,
|
|
piecesPerHour: prevPiecesPerHour,
|
|
};
|
|
|
|
// Calculate productivity metrics for comparison
|
|
const currentOrdersPerHour = totalProductiveHours > 0 ? totalOrdersPicked / totalProductiveHours : 0;
|
|
const currentPiecesPerHour = totalProductiveHours > 0 ? totalPiecesPicked / totalProductiveHours : 0;
|
|
|
|
comparison = {
|
|
hours: calculateComparison(totalHours, prevTotalHours),
|
|
productiveHours: calculateComparison(totalProductiveHours, prevProductiveHours),
|
|
activeEmployees: calculateComparison(activeEmployees, prevActiveEmployees),
|
|
fte: calculateComparison(fte, prevFte),
|
|
ordersPicked: calculateComparison(totalOrdersPicked, parseInt(prevPicking.ordersPicked || 0)),
|
|
piecesPicked: calculateComparison(totalPiecesPicked, parseInt(prevPicking.piecesPicked || 0)),
|
|
ordersShipped: calculateComparison(parseInt(shipping.ordersShipped || 0), parseInt(prevShipping.ordersShipped || 0)),
|
|
piecesShipped: calculateComparison(parseInt(shipping.piecesShipped || 0), parseInt(prevShipping.piecesShipped || 0)),
|
|
ordersPerHour: calculateComparison(currentOrdersPerHour, prevOrdersPerHour),
|
|
piecesPerHour: calculateComparison(currentPiecesPerHour, prevPiecesPerHour),
|
|
};
|
|
}
|
|
|
|
// Calculate efficiency (picking time vs productive hours)
|
|
const pickingEfficiency = totalProductiveHours > 0 ? (totalPickingHours / totalProductiveHours) * 100 : 0;
|
|
|
|
const response = {
|
|
dateRange,
|
|
totals: {
|
|
// Time metrics
|
|
hours: totalHours,
|
|
breakHours: totalBreakHours,
|
|
productiveHours: totalProductiveHours,
|
|
pickingHours: totalPickingHours,
|
|
|
|
// Employee metrics
|
|
activeEmployees,
|
|
fte,
|
|
weeksInPeriod,
|
|
|
|
// Picking metrics
|
|
ordersPicked: totalOrdersPicked,
|
|
piecesPicked: totalPiecesPicked,
|
|
ticketCount: totalTickets,
|
|
|
|
// Shipping metrics
|
|
ordersShipped: parseInt(shipping.ordersShipped || 0),
|
|
piecesShipped: parseInt(shipping.piecesShipped || 0),
|
|
|
|
// Calculated metrics - standardized to weekly
|
|
hoursPerWeek: weeksInPeriod > 0 ? totalHours / weeksInPeriod : 0,
|
|
hoursPerEmployeePerWeek: activeEmployees > 0 && weeksInPeriod > 0
|
|
? (totalHours / activeEmployees) / weeksInPeriod
|
|
: 0,
|
|
|
|
// Productivity metrics (uses productive hours - excludes breaks)
|
|
ordersPerHour: totalProductiveHours > 0 ? totalOrdersPicked / totalProductiveHours : 0,
|
|
piecesPerHour: totalProductiveHours > 0 ? totalPiecesPicked / totalProductiveHours : 0,
|
|
|
|
// Picking speed from database (more accurate, only counts picking time)
|
|
avgPickingSpeed,
|
|
|
|
// Efficiency metrics
|
|
pickingEfficiency,
|
|
},
|
|
previousTotals,
|
|
comparison,
|
|
byEmployee: {
|
|
hours: enrichedEmployeeHours,
|
|
picking: pickingByEmployee,
|
|
shipping: shippingByEmployee,
|
|
},
|
|
trend,
|
|
};
|
|
|
|
return { response, release };
|
|
};
|
|
|
|
let result;
|
|
try {
|
|
result = await Promise.race([mainOperation(), timeoutPromise]);
|
|
} catch (error) {
|
|
if (error.message.includes('timeout')) {
|
|
console.log(`[EMPLOYEE-METRICS] Request timed out in ${Date.now() - startTime}ms`);
|
|
throw error;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
const { response, release } = result;
|
|
|
|
if (release) release();
|
|
|
|
console.log(`[EMPLOYEE-METRICS] Request completed in ${Date.now() - startTime}ms`);
|
|
res.json(response);
|
|
|
|
} catch (error) {
|
|
console.error('Error in /employee-metrics:', error);
|
|
console.log(`[EMPLOYEE-METRICS] Request failed in ${Date.now() - startTime}ms`);
|
|
res.status(500).json({ error: error.message });
|
|
}
|
|
});
|
|
|
|
// Health check
|
|
router.get('/health', async (req, res) => {
|
|
try {
|
|
const { connection, release } = await getDbConnection();
|
|
await connection.execute('SELECT 1 as test');
|
|
release();
|
|
|
|
res.json({
|
|
status: 'healthy',
|
|
timestamp: new Date().toISOString(),
|
|
pool: getPoolStatus(),
|
|
});
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
status: 'unhealthy',
|
|
timestamp: new Date().toISOString(),
|
|
error: error.message,
|
|
});
|
|
}
|
|
});
|
|
|
|
// Helper functions
|
|
function calculateComparison(currentValue, previousValue) {
|
|
if (typeof previousValue !== 'number') {
|
|
return { absolute: null, percentage: null };
|
|
}
|
|
|
|
const absolute = typeof currentValue === 'number' ? currentValue - previousValue : null;
|
|
const percentage =
|
|
absolute !== null && previousValue !== 0
|
|
? (absolute / Math.abs(previousValue)) * 100
|
|
: null;
|
|
|
|
return { absolute, percentage };
|
|
}
|
|
|
|
function getPreviousPeriodRange(timeRange, startDate, endDate) {
|
|
if (timeRange && timeRange !== 'custom') {
|
|
const prevTimeRange = getPreviousTimeRange(timeRange);
|
|
if (!prevTimeRange || prevTimeRange === timeRange) {
|
|
return null;
|
|
}
|
|
return getTimeRangeConditions(prevTimeRange);
|
|
}
|
|
|
|
const hasCustomDates = (timeRange === 'custom' || !timeRange) && startDate && endDate;
|
|
if (!hasCustomDates) {
|
|
return null;
|
|
}
|
|
|
|
const start = new Date(startDate);
|
|
const end = new Date(endDate);
|
|
|
|
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
|
return null;
|
|
}
|
|
|
|
const duration = end.getTime() - start.getTime();
|
|
if (!Number.isFinite(duration) || duration <= 0) {
|
|
return null;
|
|
}
|
|
|
|
const prevEnd = new Date(start.getTime() - 1);
|
|
const prevStart = new Date(prevEnd.getTime() - duration);
|
|
|
|
return getTimeRangeConditions('custom', prevStart.toISOString(), prevEnd.toISOString());
|
|
}
|
|
|
|
function getPreviousTimeRange(timeRange) {
|
|
const map = {
|
|
today: 'yesterday',
|
|
thisWeek: 'lastWeek',
|
|
thisMonth: 'lastMonth',
|
|
last7days: 'previous7days',
|
|
last30days: 'previous30days',
|
|
last90days: 'previous90days',
|
|
yesterday: 'twoDaysAgo'
|
|
};
|
|
return map[timeRange] || timeRange;
|
|
}
|
|
|
|
module.exports = router;
|