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;