Optimize orders import

This commit is contained in:
2026-02-09 09:03:21 -05:00
parent 7c41a7f799
commit 6aefc1b40d
197 changed files with 397 additions and 69413 deletions

View File

@@ -1,575 +0,0 @@
const express = require('express');
const { DateTime } = require('luxon');
const { getDbConnection } = require('../db/connection');
const router = express.Router();
const RANGE_BOUNDS = [
10, 20, 30, 40, 50, 60, 70, 80, 90,
100, 110, 120, 130, 140, 150, 160, 170, 180, 190, 200,
300, 400, 500, 1000, 1500, 2000
];
const FINAL_BUCKET_KEY = 'PLUS';
function buildRangeDefinitions() {
const ranges = [];
let previous = 0;
for (const bound of RANGE_BOUNDS) {
const label = `$${previous.toLocaleString()} - $${bound.toLocaleString()}`;
const key = bound.toString().padStart(5, '0');
ranges.push({
min: previous,
max: bound,
label,
key,
sort: bound
});
previous = bound;
}
// Remove the 2000+ category - all orders >2000 will go into the 2000 bucket
return ranges;
}
const RANGE_DEFINITIONS = buildRangeDefinitions();
const BUCKET_CASE = (() => {
const parts = [];
for (let i = 0; i < RANGE_BOUNDS.length; i++) {
const bound = RANGE_BOUNDS[i];
const key = bound.toString().padStart(5, '0');
if (i === RANGE_BOUNDS.length - 1) {
// For the last bucket (2000), include all orders >= 1500 (previous bound)
parts.push(`ELSE '${key}'`);
} else {
parts.push(`WHEN o.summary_subtotal <= ${bound} THEN '${key}'`);
}
}
return `CASE\n ${parts.join('\n ')}\n END`;
})();
const DEFAULT_POINT_DOLLAR_VALUE = 0.005; // 1000 points = $5, so 200 points = $1
const DEFAULTS = {
merchantFeePercent: 2.9,
fixedCostPerOrder: 1.5,
pointsPerDollar: 0,
pointsRedemptionRate: 0, // Will be calculated from actual data
pointDollarValue: DEFAULT_POINT_DOLLAR_VALUE,
};
function parseDate(value, fallback) {
if (!value) {
return fallback;
}
const parsed = DateTime.fromISO(value);
if (!parsed.isValid) {
return fallback;
}
return parsed;
}
function formatDateForSql(dt) {
return dt.toFormat('yyyy-LL-dd HH:mm:ss');
}
function getMidpoint(range) {
if (range.max == null) {
return range.min + 200; // Rough estimate for 2000+
}
return (range.min + range.max) / 2;
}
router.get('/promos', async (req, res) => {
let connection;
try {
const { connection: conn, release } = await getDbConnection();
connection = conn;
const releaseConnection = release;
const { startDate, endDate } = req.query || {};
const now = DateTime.now().endOf('day');
const defaultStart = now.minus({ years: 3 }).startOf('day');
const parsedStart = startDate ? parseDate(startDate, defaultStart).startOf('day') : defaultStart;
const parsedEnd = endDate ? parseDate(endDate, now).endOf('day') : now;
const rangeStart = parsedStart <= parsedEnd ? parsedStart : parsedEnd;
const rangeEnd = parsedEnd >= parsedStart ? parsedEnd : parsedStart;
const rangeStartSql = formatDateForSql(rangeStart);
const rangeEndSql = formatDateForSql(rangeEnd);
const sql = `
SELECT
p.promo_id AS id,
p.promo_code AS code,
p.promo_description_online AS description_online,
p.promo_description_private AS description_private,
p.date_start,
p.date_end,
COALESCE(u.usage_count, 0) AS usage_count
FROM promos p
LEFT JOIN (
SELECT
discount_code,
COUNT(DISTINCT order_id) AS usage_count
FROM order_discounts
WHERE discount_type = 10 AND discount_active = 1
GROUP BY discount_code
) u ON u.discount_code = p.promo_id
WHERE p.date_start IS NOT NULL
AND p.date_end IS NOT NULL
AND NOT (p.date_end < ? OR p.date_start > ?)
AND p.store = 1
AND p.date_start >= '2010-01-01'
ORDER BY p.promo_id DESC
LIMIT 200
`;
const [rows] = await connection.execute(sql, [rangeStartSql, rangeEndSql]);
releaseConnection();
const promos = rows.map(row => ({
id: Number(row.id),
code: row.code,
description: row.description_online || row.description_private || '',
privateDescription: row.description_private || '',
promo_description_online: row.description_online || '',
promo_description_private: row.description_private || '',
dateStart: row.date_start,
dateEnd: row.date_end,
usageCount: Number(row.usage_count || 0)
}));
res.json({ promos });
} catch (error) {
if (connection) {
try {
connection.destroy();
} catch (destroyError) {
console.error('Failed to destroy connection after error:', destroyError);
}
}
console.error('Error fetching promos:', error);
res.status(500).json({ error: 'Failed to fetch promos' });
}
});
router.post('/simulate', async (req, res) => {
const {
dateRange = {},
filters = {},
productPromo = {},
shippingPromo = {},
shippingTiers = [],
surcharges = [],
merchantFeePercent,
fixedCostPerOrder,
cogsCalculationMode = 'actual',
pointsConfig = {}
} = req.body || {};
const endDefault = DateTime.now();
const startDefault = endDefault.minus({ months: 6 });
const startDt = parseDate(dateRange.start, startDefault).startOf('day');
const endDt = parseDate(dateRange.end, endDefault).endOf('day');
const shipCountry = filters.shipCountry || 'US';
const rawPromoFilters = [
...(Array.isArray(filters.promoIds) ? filters.promoIds : []),
...(Array.isArray(filters.promoCodes) ? filters.promoCodes : []),
];
const promoCodes = Array.from(
new Set(
rawPromoFilters
.map((value) => {
if (typeof value === 'string') {
return value.trim();
}
if (typeof value === 'number') {
return String(value);
}
return '';
})
.filter((value) => value.length > 0)
)
);
const config = {
merchantFeePercent: typeof merchantFeePercent === 'number' ? merchantFeePercent : DEFAULTS.merchantFeePercent,
fixedCostPerOrder: typeof fixedCostPerOrder === 'number' ? fixedCostPerOrder : DEFAULTS.fixedCostPerOrder,
productPromo: {
type: productPromo.type || 'none',
value: Number(productPromo.value || 0),
minSubtotal: Number(productPromo.minSubtotal || 0)
},
shippingPromo: {
type: shippingPromo.type || 'none',
value: Number(shippingPromo.value || 0),
minSubtotal: Number(shippingPromo.minSubtotal || 0),
maxDiscount: Number(shippingPromo.maxDiscount || 0)
},
shippingTiers: Array.isArray(shippingTiers)
? shippingTiers
.map(tier => ({
threshold: Number(tier.threshold || 0),
mode: tier.mode === 'percentage' || tier.mode === 'flat' ? tier.mode : 'percentage',
value: Number(tier.value || 0)
}))
.filter(tier => tier.threshold >= 0 && tier.value >= 0)
.sort((a, b) => a.threshold - b.threshold)
: [],
surcharges: Array.isArray(surcharges)
? surcharges
.map(s => ({
threshold: Number(s.threshold || 0),
maxThreshold: typeof s.maxThreshold === 'number' && s.maxThreshold > 0 ? s.maxThreshold : null,
target: s.target === 'shipping' || s.target === 'order' ? s.target : 'shipping',
amount: Number(s.amount || 0)
}))
.filter(s => s.threshold >= 0 && s.amount >= 0)
.sort((a, b) => a.threshold - b.threshold)
: [],
points: {
pointsPerDollar: typeof pointsConfig.pointsPerDollar === 'number' ? pointsConfig.pointsPerDollar : null,
redemptionRate: typeof pointsConfig.redemptionRate === 'number' ? pointsConfig.redemptionRate : null,
pointDollarValue: typeof pointsConfig.pointDollarValue === 'number'
? pointsConfig.pointDollarValue
: DEFAULT_POINT_DOLLAR_VALUE
}
};
let connection;
let release;
try {
const dbConn = await getDbConnection();
connection = dbConn.connection;
release = dbConn.release;
const filteredOrdersParams = [
shipCountry,
formatDateForSql(startDt),
formatDateForSql(endDt)
];
const promoJoin = promoCodes.length > 0
? 'JOIN order_discounts od ON od.order_id = o.order_id AND od.discount_active = 1 AND od.discount_type = 10'
: '';
let promoFilterClause = '';
if (promoCodes.length > 0) {
const placeholders = promoCodes.map(() => '?').join(',');
promoFilterClause = `AND od.discount_code IN (${placeholders})`;
filteredOrdersParams.push(...promoCodes);
}
const filteredOrdersQuery = `
SELECT
o.order_id,
o.order_cid,
o.summary_subtotal,
o.summary_discount_subtotal,
o.summary_shipping,
o.ship_method_rate,
o.ship_method_cost,
o.summary_points,
${BUCKET_CASE} AS bucket_key
FROM _order o
${promoJoin}
WHERE o.summary_shipping > 0
AND o.summary_total > 0
AND o.order_status NOT IN (15)
AND o.ship_method_selected <> 'holdit'
AND o.ship_country = ?
AND o.date_placed BETWEEN ? AND ?
${promoFilterClause}
`;
const bucketParams = [
...filteredOrdersParams,
formatDateForSql(startDt),
formatDateForSql(endDt)
];
const bucketQuery = `
SELECT
f.bucket_key,
COUNT(*) AS order_count,
SUM(f.summary_subtotal) AS subtotal_sum,
SUM(f.summary_discount_subtotal) AS product_discount_sum,
SUM(f.summary_subtotal + f.summary_discount_subtotal) AS regular_subtotal_sum,
SUM(f.ship_method_rate) AS ship_rate_sum,
SUM(f.ship_method_cost) AS ship_cost_sum,
SUM(f.summary_points) AS points_awarded_sum,
SUM(COALESCE(p.points_redeemed, 0)) AS points_redeemed_sum,
SUM(COALESCE(c.total_cogs, 0)) AS cogs_sum,
AVG(f.summary_subtotal) AS avg_subtotal,
AVG(f.summary_discount_subtotal) AS avg_product_discount,
AVG(f.ship_method_rate) AS avg_ship_rate,
AVG(f.ship_method_cost) AS avg_ship_cost,
AVG(COALESCE(c.total_cogs, 0)) AS avg_cogs
FROM (
${filteredOrdersQuery}
) AS f
LEFT JOIN (
SELECT order_id, SUM(cogs_amount) AS total_cogs
FROM report_sales_data
WHERE action IN (1,2,3)
AND date_change BETWEEN ? AND ?
GROUP BY order_id
) AS c ON c.order_id = f.order_id
LEFT JOIN (
SELECT order_id, SUM(discount_amount) AS points_redeemed
FROM order_discounts
WHERE discount_type = 20 AND discount_active = 1
GROUP BY order_id
) AS p ON p.order_id = f.order_id
GROUP BY f.bucket_key
`;
const [rows] = await connection.execute(bucketQuery, bucketParams);
const totals = {
orders: 0,
subtotal: 0,
productDiscount: 0,
regularSubtotal: 0,
shipRate: 0,
shipCost: 0,
cogs: 0,
pointsAwarded: 0,
pointsRedeemed: 0
};
const rowMap = new Map();
for (const row of rows) {
const key = row.bucket_key || FINAL_BUCKET_KEY;
const parsed = {
orderCount: Number(row.order_count || 0),
subtotalSum: Number(row.subtotal_sum || 0),
productDiscountSum: Number(row.product_discount_sum || 0),
regularSubtotalSum: Number(row.regular_subtotal_sum || 0),
shipRateSum: Number(row.ship_rate_sum || 0),
shipCostSum: Number(row.ship_cost_sum || 0),
pointsAwardedSum: Number(row.points_awarded_sum || 0),
pointsRedeemedSum: Number(row.points_redeemed_sum || 0),
cogsSum: Number(row.cogs_sum || 0),
avgSubtotal: Number(row.avg_subtotal || 0),
avgProductDiscount: Number(row.avg_product_discount || 0),
avgShipRate: Number(row.avg_ship_rate || 0),
avgShipCost: Number(row.avg_ship_cost || 0),
avgCogs: Number(row.avg_cogs || 0)
};
rowMap.set(key, parsed);
totals.orders += parsed.orderCount;
totals.subtotal += parsed.subtotalSum;
totals.productDiscount += parsed.productDiscountSum;
totals.regularSubtotal += parsed.regularSubtotalSum;
totals.shipRate += parsed.shipRateSum;
totals.shipCost += parsed.shipCostSum;
totals.cogs += parsed.cogsSum;
totals.pointsAwarded += parsed.pointsAwardedSum;
totals.pointsRedeemed += parsed.pointsRedeemedSum;
}
const productDiscountRate = totals.regularSubtotal > 0
? totals.productDiscount / totals.regularSubtotal
: 0;
const pointsPerDollar = config.points.pointsPerDollar != null
? config.points.pointsPerDollar
: totals.subtotal > 0
? totals.pointsAwarded / totals.subtotal
: 0;
const pointDollarValue = config.points.pointDollarValue || DEFAULT_POINT_DOLLAR_VALUE;
// Calculate redemption rate using dollars redeemed from the matched order set
let calculatedRedemptionRate = 0;
if (config.points.redemptionRate != null) {
calculatedRedemptionRate = config.points.redemptionRate;
} else if (totals.pointsAwarded > 0 && pointDollarValue > 0) {
const totalRedeemedPoints = totals.pointsRedeemed / pointDollarValue;
if (totalRedeemedPoints > 0) {
calculatedRedemptionRate = Math.min(1, totalRedeemedPoints / totals.pointsAwarded);
}
}
const redemptionRate = calculatedRedemptionRate;
// Calculate overall average COGS percentage for 'average' mode
let overallCogsPercentage = 0;
if (cogsCalculationMode === 'average' && totals.subtotal > 0) {
overallCogsPercentage = totals.cogs / totals.subtotal;
}
const bucketResults = [];
let weightedProfitAmount = 0;
let weightedProfitPercent = 0;
for (const range of RANGE_DEFINITIONS) {
const data = rowMap.get(range.key) || {
orderCount: 0,
avgSubtotal: 0,
avgShipRate: 0,
avgShipCost: 0,
avgCogs: 0
};
const orderValue = data.avgSubtotal > 0 ? data.avgSubtotal : getMidpoint(range);
const shippingChargeBase = data.avgShipCost > 0 ? data.avgShipCost : 0;
const actualShippingCost = data.avgShipCost > 0 ? data.avgShipCost : 0;
// Calculate COGS based on the selected mode
let productCogs;
if (cogsCalculationMode === 'average') {
// Use overall average COGS percentage applied to this bucket's order value
productCogs = orderValue * overallCogsPercentage;
} else {
// Use actual COGS data from this bucket (existing behavior)
productCogs = data.avgCogs > 0 ? data.avgCogs : 0;
}
const productDiscountAmount = orderValue * productDiscountRate;
const effectiveRegularPrice = productDiscountRate < 0.99
? orderValue / (1 - productDiscountRate)
: orderValue;
let promoProductDiscount = 0;
if (config.productPromo.type === 'percentage_subtotal' && orderValue >= config.productPromo.minSubtotal) {
promoProductDiscount = Math.min(orderValue, (config.productPromo.value / 100) * orderValue);
} else if (config.productPromo.type === 'percentage_regular' && orderValue >= config.productPromo.minSubtotal) {
const targetRate = config.productPromo.value / 100;
const additionalRate = Math.max(0, targetRate - productDiscountRate);
promoProductDiscount = Math.min(orderValue, additionalRate * effectiveRegularPrice);
} else if (config.productPromo.type === 'fixed_amount' && orderValue >= config.productPromo.minSubtotal) {
promoProductDiscount = Math.min(orderValue, config.productPromo.value);
}
let shippingAfterAuto = shippingChargeBase;
for (const tier of config.shippingTiers) {
if (orderValue >= tier.threshold) {
if (tier.mode === 'percentage') {
shippingAfterAuto = shippingChargeBase * Math.max(0, 1 - tier.value / 100);
} else if (tier.mode === 'flat') {
shippingAfterAuto = tier.value;
}
}
}
let shipPromoDiscount = 0;
if (config.shippingPromo.type !== 'none' && orderValue >= config.shippingPromo.minSubtotal) {
if (config.shippingPromo.type === 'percentage') {
shipPromoDiscount = shippingAfterAuto * (config.shippingPromo.value / 100);
} else if (config.shippingPromo.type === 'fixed') {
shipPromoDiscount = config.shippingPromo.value;
}
if (config.shippingPromo.maxDiscount > 0) {
shipPromoDiscount = Math.min(shipPromoDiscount, config.shippingPromo.maxDiscount);
}
shipPromoDiscount = Math.min(shipPromoDiscount, shippingAfterAuto);
}
// Calculate surcharges
let shippingSurcharge = 0;
let orderSurcharge = 0;
for (const surcharge of config.surcharges) {
const meetsMin = orderValue >= surcharge.threshold;
const meetsMax = surcharge.maxThreshold == null || orderValue < surcharge.maxThreshold;
if (meetsMin && meetsMax) {
if (surcharge.target === 'shipping') {
shippingSurcharge += surcharge.amount;
} else if (surcharge.target === 'order') {
orderSurcharge += surcharge.amount;
}
}
}
const customerShipCost = Math.max(0, shippingAfterAuto - shipPromoDiscount + shippingSurcharge);
const customerItemCost = Math.max(0, orderValue - promoProductDiscount + orderSurcharge);
const totalRevenue = customerItemCost + customerShipCost;
const merchantFees = totalRevenue * (config.merchantFeePercent / 100);
const pointsCost = customerItemCost * pointsPerDollar * redemptionRate * pointDollarValue;
const fixedCosts = config.fixedCostPerOrder;
const totalCosts = productCogs + actualShippingCost + merchantFees + pointsCost + fixedCosts;
const profit = totalRevenue - totalCosts;
const profitPercent = totalRevenue > 0 ? (profit / totalRevenue) : 0;
const weight = totals.orders > 0 ? (data.orderCount || 0) / totals.orders : 0;
weightedProfitAmount += profit * weight;
weightedProfitPercent += profitPercent * weight;
bucketResults.push({
key: range.key,
label: range.label,
min: range.min,
max: range.max,
orderCount: data.orderCount || 0,
weight,
orderValue,
productDiscountAmount,
promoProductDiscount,
customerItemCost,
shippingChargeBase,
shippingAfterAuto,
shipPromoDiscount,
shippingSurcharge,
orderSurcharge,
customerShipCost,
actualShippingCost,
totalRevenue,
productCogs,
merchantFees,
pointsCost,
fixedCosts,
totalCosts,
profit,
profitPercent
});
}
if (release) {
release();
}
res.json({
dateRange: {
start: startDt.toISO(),
end: endDt.toISO()
},
totals: {
orders: totals.orders,
subtotal: totals.subtotal,
productDiscountRate,
pointsPerDollar,
redemptionRate,
pointDollarValue,
weightedProfitAmount,
weightedProfitPercent,
overallCogsPercentage: cogsCalculationMode === 'average' ? overallCogsPercentage : undefined
},
buckets: bucketResults
});
} catch (error) {
if (release) {
try {
release();
} catch (releaseError) {
console.error('Failed to release connection after error:', releaseError);
}
} else if (connection) {
try {
connection.destroy();
} catch (destroyError) {
console.error('Failed to destroy connection after error:', destroyError);
}
}
console.error('Error running discount simulation:', error);
res.status(500).json({ error: 'Failed to run discount simulation' });
}
});
module.exports = router;

View File

@@ -1,683 +0,0 @@
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;

File diff suppressed because it is too large Load Diff

View File

@@ -1,484 +0,0 @@
const express = require('express');
const { DateTime } = require('luxon');
const router = express.Router();
const { getDbConnection, getPoolStatus } = require('../db/connection');
const {
getTimeRangeConditions,
} = require('../utils/timeUtils');
const TIMEZONE = 'America/New_York';
// Main operations metrics endpoint - focused on picking and shipping
router.get('/', async (req, res) => {
const startTime = Date.now();
console.log(`[OPERATIONS-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(`[OPERATIONS-METRICS] Getting DB connection...`);
const { connection, release } = await getDbConnection();
console.log(`[OPERATIONS-METRICS] DB connection obtained in ${Date.now() - startTime}ms`);
const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate);
// 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)
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
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();
}
// Calculate productivity (orders/pieces per picking hour)
const ordersPerHour = totalPickingHours > 0 ? totalOrdersPicked / totalPickingHours : 0;
const piecesPerHour = totalPickingHours > 0 ? totalPiecesPicked / totalPickingHours : 0;
// Get daily trend data for picking
// Use DATE_FORMAT to get date string in Eastern timezone
// Business day starts at 1 AM, so subtract 1 hour before taking the date
const pickingTrendWhere = whereClause.replace(/date_placed/g, 'pt.createddate');
const pickingTrendQuery = `
SELECT
pt_agg.date,
COALESCE(order_counts.ordersPicked, 0) as ordersPicked,
pt_agg.piecesPicked
FROM (
SELECT
DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d') as date,
COALESCE(SUM(pt.totalpieces_picked), 0) as piecesPicked
FROM picking_ticket pt
WHERE ${pickingTrendWhere}
AND pt.closeddate IS NOT NULL
GROUP BY DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d')
) pt_agg
LEFT JOIN (
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
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_counts ON pt_agg.date = order_counts.date
ORDER BY pt_agg.date
`;
// Get shipping trend data
const shippingTrendWhere = whereClause.replace(/date_placed/g, 'o.date_shipped');
const shippingTrendQuery = `
SELECT
DATE_FORMAT(DATE_SUB(o.date_shipped, INTERVAL 1 HOUR), '%Y-%m-%d') as date,
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 ${shippingTrendWhere}
AND o.order_status IN (100, 92)
GROUP BY DATE_FORMAT(DATE_SUB(o.date_shipped, INTERVAL 1 HOUR), '%Y-%m-%d')
ORDER BY date
`;
const [[pickingTrendRows], [shippingTrendRows]] = await Promise.all([
connection.execute(pickingTrendQuery, [...params, ...params]),
connection.execute(shippingTrendQuery, params),
]);
// Create maps for trend data
const pickingByDate = new Map();
pickingTrendRows.forEach(row => {
const date = String(row.date);
pickingByDate.set(date, {
ordersPicked: parseInt(row.ordersPicked || 0),
piecesPicked: parseInt(row.piecesPicked || 0),
});
});
const shippingByDate = new Map();
shippingTrendRows.forEach(row => {
const date = String(row.date);
shippingByDate.set(date, {
ordersShipped: parseInt(row.ordersShipped || 0),
piecesShipped: parseInt(row.piecesShipped || 0),
});
});
// 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
const trend = allDatesInRange.map(date => {
const picking = pickingByDate.get(date) || { ordersPicked: 0, piecesPicked: 0 };
const shippingData = shippingByDate.get(date) || { ordersShipped: 0, piecesShipped: 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(),
ordersPicked: picking.ordersPicked,
piecesPicked: picking.piecesPicked,
ordersShipped: shippingData.ordersShipped,
piecesShipped: shippingData.piecesShipped,
};
});
// Get previous period data for comparison
const previousRange = getPreviousPeriodRange(timeRange, startDate, endDate);
let comparison = null;
let previousTotals = null;
if (previousRange) {
// Previous picking data
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 productivity
const prevOrdersPerHour = prevPickingHours > 0 ? parseInt(prevPicking.ordersPicked || 0) / prevPickingHours : 0;
const prevPiecesPerHour = prevPickingHours > 0 ? parseInt(prevPicking.piecesPicked || 0) / prevPickingHours : 0;
previousTotals = {
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,
};
comparison = {
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(ordersPerHour, prevOrdersPerHour),
piecesPerHour: calculateComparison(piecesPerHour, prevPiecesPerHour),
};
}
const response = {
dateRange,
totals: {
// Picking metrics
ordersPicked: totalOrdersPicked,
piecesPicked: totalPiecesPicked,
ticketCount: totalTickets,
pickingHours: totalPickingHours,
// Shipping metrics
ordersShipped: parseInt(shipping.ordersShipped || 0),
piecesShipped: parseInt(shipping.piecesShipped || 0),
// Productivity metrics
ordersPerHour,
piecesPerHour,
avgPickingSpeed,
},
previousTotals,
comparison,
byEmployee: {
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(`[OPERATIONS-METRICS] Request timed out in ${Date.now() - startTime}ms`);
throw error;
}
throw error;
}
const { response, release } = result;
if (release) release();
console.log(`[OPERATIONS-METRICS] Request completed in ${Date.now() - startTime}ms`);
res.json(response);
} catch (error) {
console.error('Error in /operations-metrics:', error);
console.log(`[OPERATIONS-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;

View File

@@ -1,505 +0,0 @@
const express = require('express');
const { DateTime } = require('luxon');
const router = express.Router();
const { getDbConnection, getPoolStatus } = require('../db/connection');
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 overtime calculation (40 hours per week)
const STANDARD_WEEKLY_HOURS = 40;
// Reference pay period start date (January 25, 2026 is a Sunday, first day of a pay period)
const PAY_PERIOD_REFERENCE = DateTime.fromObject(
{ year: 2026, month: 1, day: 25 },
{ zone: TIMEZONE }
);
/**
* Calculate the pay period that contains a given date
* Pay periods are 14 days starting on Sunday
* @param {DateTime} date - The date to find the pay period for
* @returns {{ start: DateTime, end: DateTime, week1: { start: DateTime, end: DateTime }, week2: { start: DateTime, end: DateTime } }}
*/
function getPayPeriodForDate(date) {
const dt = DateTime.isDateTime(date) ? date : DateTime.fromJSDate(date, { zone: TIMEZONE });
// Calculate days since reference
const daysSinceReference = Math.floor(dt.diff(PAY_PERIOD_REFERENCE, 'days').days);
// Find which pay period this falls into (can be negative for dates before reference)
const payPeriodIndex = Math.floor(daysSinceReference / 14);
// Calculate the start of this pay period
const start = PAY_PERIOD_REFERENCE.plus({ days: payPeriodIndex * 14 }).startOf('day');
const end = start.plus({ days: 13 }).endOf('day');
// Week 1: Sunday through Saturday
const week1Start = start;
const week1End = start.plus({ days: 6 }).endOf('day');
// Week 2: Sunday through Saturday
const week2Start = start.plus({ days: 7 }).startOf('day');
const week2End = end;
return {
start,
end,
week1: { start: week1Start, end: week1End },
week2: { start: week2Start, end: week2End },
};
}
/**
* Get the current pay period
*/
function getCurrentPayPeriod() {
return getPayPeriodForDate(DateTime.now().setZone(TIMEZONE));
}
/**
* Navigate to previous or next pay period
* @param {DateTime} currentStart - Current pay period start
* @param {number} offset - Number of pay periods to move (negative for previous)
*/
function navigatePayPeriod(currentStart, offset) {
const newStart = currentStart.plus({ days: offset * 14 });
return getPayPeriodForDate(newStart);
}
/**
* Calculate working hours from timeclock entries, broken down by week
* @param {Array} punches - Timeclock punch entries
* @param {Object} payPeriod - Pay period with week boundaries
*/
function calculateHoursByWeek(punches, payPeriod) {
// Group by employee
const byEmployee = new Map();
punches.forEach(punch => {
if (!byEmployee.has(punch.EmployeeID)) {
byEmployee.set(punch.EmployeeID, {
employeeId: punch.EmployeeID,
firstname: punch.firstname || '',
lastname: punch.lastname || '',
punches: [],
});
}
byEmployee.get(punch.EmployeeID).punches.push(punch);
});
const employeeResults = [];
let totalHours = 0;
let totalBreakHours = 0;
let totalOvertimeHours = 0;
let totalRegularHours = 0;
let week1TotalHours = 0;
let week1TotalOvertime = 0;
let week2TotalHours = 0;
let week2TotalOvertime = 0;
byEmployee.forEach((employeeData) => {
// Sort punches by timestamp
employeeData.punches.sort((a, b) => new Date(a.TimeStamp) - new Date(b.TimeStamp));
// Calculate hours for each week
const week1Punches = employeeData.punches.filter(p => {
const dt = DateTime.fromJSDate(new Date(p.TimeStamp), { zone: TIMEZONE });
return dt >= payPeriod.week1.start && dt <= payPeriod.week1.end;
});
const week2Punches = employeeData.punches.filter(p => {
const dt = DateTime.fromJSDate(new Date(p.TimeStamp), { zone: TIMEZONE });
return dt >= payPeriod.week2.start && dt <= payPeriod.week2.end;
});
const week1Hours = calculateHoursFromPunches(week1Punches);
const week2Hours = calculateHoursFromPunches(week2Punches);
// Calculate overtime per week (anything over 40 hours)
const week1Overtime = Math.max(0, week1Hours.hours - STANDARD_WEEKLY_HOURS);
const week2Overtime = Math.max(0, week2Hours.hours - STANDARD_WEEKLY_HOURS);
const week1Regular = week1Hours.hours - week1Overtime;
const week2Regular = week2Hours.hours - week2Overtime;
const employeeTotal = week1Hours.hours + week2Hours.hours;
const employeeBreaks = week1Hours.breakHours + week2Hours.breakHours;
const employeeOvertime = week1Overtime + week2Overtime;
const employeeRegular = employeeTotal - employeeOvertime;
totalHours += employeeTotal;
totalBreakHours += employeeBreaks;
totalOvertimeHours += employeeOvertime;
totalRegularHours += employeeRegular;
week1TotalHours += week1Hours.hours;
week1TotalOvertime += week1Overtime;
week2TotalHours += week2Hours.hours;
week2TotalOvertime += week2Overtime;
employeeResults.push({
employeeId: employeeData.employeeId,
name: `${employeeData.firstname} ${employeeData.lastname}`.trim() || `Employee ${employeeData.employeeId}`,
week1Hours: week1Hours.hours,
week1BreakHours: week1Hours.breakHours,
week1Overtime,
week1Regular,
week2Hours: week2Hours.hours,
week2BreakHours: week2Hours.breakHours,
week2Overtime,
week2Regular,
totalHours: employeeTotal,
totalBreakHours: employeeBreaks,
overtimeHours: employeeOvertime,
regularHours: employeeRegular,
});
});
// Sort by total hours descending
employeeResults.sort((a, b) => b.totalHours - a.totalHours);
return {
byEmployee: employeeResults,
totals: {
hours: totalHours,
breakHours: totalBreakHours,
overtimeHours: totalOvertimeHours,
regularHours: totalRegularHours,
activeEmployees: employeeResults.filter(e => e.totalHours > 0).length,
},
byWeek: [
{
week: 1,
start: payPeriod.week1.start.toISODate(),
end: payPeriod.week1.end.toISODate(),
hours: week1TotalHours,
overtime: week1TotalOvertime,
regular: week1TotalHours - week1TotalOvertime,
},
{
week: 2,
start: payPeriod.week2.start.toISODate(),
end: payPeriod.week2.end.toISODate(),
hours: week2TotalHours,
overtime: week2TotalOvertime,
regular: week2TotalHours - week2TotalOvertime,
},
],
};
}
/**
* Calculate hours from a set of punches
*/
function calculateHoursFromPunches(punches) {
let hours = 0;
let breakHours = 0;
let currentIn = null;
let breakStart = null;
punches.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);
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;
}
});
return { hours, breakHours };
}
/**
* Calculate FTE for a pay period (based on 80 hours = 1 FTE for 2-week period)
* @param {number} totalHours - Total hours worked
* @param {number} elapsedFraction - Fraction of the period elapsed (0-1). Defaults to 1 for complete periods.
*/
function calculateFTE(totalHours, elapsedFraction = 1) {
const fullTimePeriodHours = STANDARD_WEEKLY_HOURS * 2; // 80 hours for 2 weeks
const proratedHours = fullTimePeriodHours * elapsedFraction;
return proratedHours > 0 ? totalHours / proratedHours : 0;
}
// Main payroll metrics endpoint
router.get('/', async (req, res) => {
const startTime = Date.now();
console.log(`[PAYROLL-METRICS] Starting request`);
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Request timeout after 30 seconds')), 30000);
});
try {
const mainOperation = async () => {
const { payPeriodStart, navigate } = req.query;
let payPeriod;
if (payPeriodStart) {
// Parse the provided start date
const startDate = DateTime.fromISO(payPeriodStart, { zone: TIMEZONE });
if (!startDate.isValid) {
return res.status(400).json({ error: 'Invalid payPeriodStart date format' });
}
payPeriod = getPayPeriodForDate(startDate);
} else {
// Default to current pay period
payPeriod = getCurrentPayPeriod();
}
// Handle navigation if requested
if (navigate) {
const offset = parseInt(navigate, 10);
if (!isNaN(offset)) {
payPeriod = navigatePayPeriod(payPeriod.start, offset);
}
}
console.log(`[PAYROLL-METRICS] Getting DB connection...`);
const { connection, release } = await getDbConnection();
console.log(`[PAYROLL-METRICS] DB connection obtained in ${Date.now() - startTime}ms`);
// Build query for the pay period
const periodStart = payPeriod.start.toJSDate();
const periodEnd = payPeriod.end.toJSDate();
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 tc.TimeStamp >= ? AND tc.TimeStamp <= ?
AND e.hidden = 0
AND e.disabled = 0
ORDER BY tc.EmployeeID, tc.TimeStamp
`;
const [timeclockRows] = await connection.execute(timeclockQuery, [periodStart, periodEnd]);
// Calculate hours with week breakdown
const hoursData = calculateHoursByWeek(timeclockRows, payPeriod);
// Calculate FTE — prorate for in-progress periods so the value reflects
// the pace employees are on rather than raw hours / 80
let elapsedFraction = 1;
if (isCurrentPayPeriod(payPeriod)) {
const now = DateTime.now().setZone(TIMEZONE);
const elapsedDays = Math.max(1, Math.ceil(now.diff(payPeriod.start, 'days').days));
elapsedFraction = Math.min(1, elapsedDays / 14);
}
const fte = calculateFTE(hoursData.totals.hours, elapsedFraction);
const activeEmployees = hoursData.totals.activeEmployees;
const avgHoursPerEmployee = activeEmployees > 0 ? hoursData.totals.hours / activeEmployees : 0;
// Get previous pay period data for comparison
const prevPayPeriod = navigatePayPeriod(payPeriod.start, -1);
const [prevTimeclockRows] = await connection.execute(timeclockQuery, [
prevPayPeriod.start.toJSDate(),
prevPayPeriod.end.toJSDate(),
]);
const prevHoursData = calculateHoursByWeek(prevTimeclockRows, prevPayPeriod);
const prevFte = calculateFTE(prevHoursData.totals.hours);
// Calculate comparisons
const comparison = {
hours: calculateComparison(hoursData.totals.hours, prevHoursData.totals.hours),
overtimeHours: calculateComparison(hoursData.totals.overtimeHours, prevHoursData.totals.overtimeHours),
fte: calculateComparison(fte, prevFte),
activeEmployees: calculateComparison(hoursData.totals.activeEmployees, prevHoursData.totals.activeEmployees),
};
const response = {
payPeriod: {
start: payPeriod.start.toISODate(),
end: payPeriod.end.toISODate(),
label: formatPayPeriodLabel(payPeriod),
week1: {
start: payPeriod.week1.start.toISODate(),
end: payPeriod.week1.end.toISODate(),
label: formatWeekLabel(payPeriod.week1),
},
week2: {
start: payPeriod.week2.start.toISODate(),
end: payPeriod.week2.end.toISODate(),
label: formatWeekLabel(payPeriod.week2),
},
isCurrent: isCurrentPayPeriod(payPeriod),
},
totals: {
hours: hoursData.totals.hours,
breakHours: hoursData.totals.breakHours,
overtimeHours: hoursData.totals.overtimeHours,
regularHours: hoursData.totals.regularHours,
activeEmployees,
fte,
avgHoursPerEmployee,
},
previousTotals: {
hours: prevHoursData.totals.hours,
overtimeHours: prevHoursData.totals.overtimeHours,
activeEmployees: prevHoursData.totals.activeEmployees,
fte: prevFte,
},
comparison,
byEmployee: hoursData.byEmployee,
byWeek: hoursData.byWeek,
};
return { response, release };
};
let result;
try {
result = await Promise.race([mainOperation(), timeoutPromise]);
} catch (error) {
if (error.message.includes('timeout')) {
console.log(`[PAYROLL-METRICS] Request timed out in ${Date.now() - startTime}ms`);
throw error;
}
throw error;
}
const { response, release } = result;
if (release) release();
console.log(`[PAYROLL-METRICS] Request completed in ${Date.now() - startTime}ms`);
res.json(response);
} catch (error) {
console.error('Error in /payroll-metrics:', error);
console.log(`[PAYROLL-METRICS] Request failed in ${Date.now() - startTime}ms`);
res.status(500).json({ error: error.message });
}
});
// Get pay period info endpoint (for navigation without full data)
router.get('/period-info', async (req, res) => {
try {
const { payPeriodStart, navigate } = req.query;
let payPeriod;
if (payPeriodStart) {
const startDate = DateTime.fromISO(payPeriodStart, { zone: TIMEZONE });
if (!startDate.isValid) {
return res.status(400).json({ error: 'Invalid payPeriodStart date format' });
}
payPeriod = getPayPeriodForDate(startDate);
} else {
payPeriod = getCurrentPayPeriod();
}
if (navigate) {
const offset = parseInt(navigate, 10);
if (!isNaN(offset)) {
payPeriod = navigatePayPeriod(payPeriod.start, offset);
}
}
res.json({
payPeriod: {
start: payPeriod.start.toISODate(),
end: payPeriod.end.toISODate(),
label: formatPayPeriodLabel(payPeriod),
week1: {
start: payPeriod.week1.start.toISODate(),
end: payPeriod.week1.end.toISODate(),
label: formatWeekLabel(payPeriod.week1),
},
week2: {
start: payPeriod.week2.start.toISODate(),
end: payPeriod.week2.end.toISODate(),
label: formatWeekLabel(payPeriod.week2),
},
isCurrent: isCurrentPayPeriod(payPeriod),
},
});
} catch (error) {
console.error('Error in /payroll-metrics/period-info:', error);
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 formatPayPeriodLabel(payPeriod) {
const startStr = payPeriod.start.toFormat('MMM d');
const endStr = payPeriod.end.toFormat('MMM d, yyyy');
return `${startStr} ${endStr}`;
}
function formatWeekLabel(week) {
const startStr = week.start.toFormat('MMM d');
const endStr = week.end.toFormat('MMM d');
return `${startStr} ${endStr}`;
}
function isCurrentPayPeriod(payPeriod) {
const now = DateTime.now().setZone(TIMEZONE);
return now >= payPeriod.start && now <= payPeriod.end;
}
module.exports = router;

View File

@@ -1,57 +0,0 @@
const express = require('express');
const router = express.Router();
const { getDbConnection, getCachedQuery } = require('../db/connection');
// Test endpoint to count orders
router.get('/order-count', async (req, res) => {
try {
const { connection } = await getDbConnection();
// Simple query to count orders from _order table
const queryFn = async () => {
const [rows] = await connection.execute('SELECT COUNT(*) as count FROM _order');
return rows[0].count;
};
const cacheKey = 'order-count';
const count = await getCachedQuery(cacheKey, 'default', queryFn);
res.json({
success: true,
data: {
orderCount: count,
timestamp: new Date().toISOString()
}
});
} catch (error) {
console.error('Error fetching order count:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
// Test connection endpoint
router.get('/test-connection', async (req, res) => {
try {
const { connection } = await getDbConnection();
// Test the connection with a simple query
const [rows] = await connection.execute('SELECT 1 as test');
res.json({
success: true,
message: 'Database connection successful',
data: rows[0]
});
} catch (error) {
console.error('Error testing connection:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
module.exports = router;