Time unification
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import express from 'express';
|
||||
import { businessTodayStr, businessDateOf, businessNow, formatDateCol } from '../../shared/business-time/index.js';
|
||||
const router = express.Router();
|
||||
|
||||
// Forecasting: summarize sales for products received in a period by brand
|
||||
@@ -15,13 +16,12 @@ router.get('/forecast', async (req, res) => {
|
||||
return res.status(400).json({ error: 'Missing required parameter: brand' });
|
||||
}
|
||||
|
||||
// Default to last 30 days if no dates provided
|
||||
const endDate = endDateStr ? new Date(endDateStr) : new Date();
|
||||
const startDate = startDateStr ? new Date(startDateStr) : new Date(endDate.getTime() - 29 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Normalize to date boundaries for consistency
|
||||
const startISO = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate())).toISOString();
|
||||
const endISO = new Date(Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth(), endDate.getUTCDate())).toISOString();
|
||||
// Default to last 30 business days if no dates provided. Params are
|
||||
// date-only business dates ('YYYY-MM-DD'); instants are converted.
|
||||
const endISO = endDateStr ? businessDateOf(endDateStr) : businessTodayStr();
|
||||
const startISO = startDateStr
|
||||
? businessDateOf(startDateStr)
|
||||
: new Date(Date.parse(endISO) - 29 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
|
||||
const sql = `
|
||||
WITH params AS (
|
||||
@@ -177,11 +177,10 @@ router.get('/forecast-v2', async (req, res) => {
|
||||
return res.status(400).json({ error: 'Missing required parameter: brand' });
|
||||
}
|
||||
|
||||
const endDate = endDateStr ? new Date(endDateStr) : new Date();
|
||||
const startDate = startDateStr ? new Date(startDateStr) : new Date(endDate.getTime() - 29 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const startISO = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate())).toISOString();
|
||||
const endISO = new Date(Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth(), endDate.getUTCDate())).toISOString();
|
||||
const endISO = endDateStr ? businessDateOf(endDateStr) : businessTodayStr();
|
||||
const startISO = startDateStr
|
||||
? businessDateOf(startDateStr)
|
||||
: new Date(Date.parse(endISO) - 29 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
|
||||
const sql = `
|
||||
WITH params AS (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import express from 'express';
|
||||
import db from '../utils/db.js';
|
||||
import { businessTodayStr, businessDateOf, businessNow, formatDateCol } from '../../shared/business-time/index.js';
|
||||
const router = express.Router();
|
||||
|
||||
// Helper function to execute queries using the connection pool
|
||||
@@ -306,15 +307,13 @@ router.get('/replenishment/metrics', async (req, res) => {
|
||||
// Reads from product_forecasts table (lifecycle-aware forecasting pipeline).
|
||||
// Falls back to velocity-based projection if forecast table is empty.
|
||||
router.get('/forecast/metrics', async (req, res) => {
|
||||
const today = new Date();
|
||||
const thirtyDaysOut = new Date(today);
|
||||
thirtyDaysOut.setDate(today.getDate() + 30);
|
||||
|
||||
const startDate = req.query.startDate ? new Date(req.query.startDate) : today;
|
||||
const endDate = req.query.endDate ? new Date(req.query.endDate) : thirtyDaysOut;
|
||||
const startISO = startDate.toISOString().split('T')[0];
|
||||
const endISO = endDate.toISOString().split('T')[0];
|
||||
const days = Math.max(1, Math.round((endDate - startDate) / (1000 * 60 * 60 * 24)));
|
||||
// Business dates (Chicago calendar — see docs/TIME.md). Inputs may be
|
||||
// date-only strings or full instants.
|
||||
const startISO = req.query.startDate ? businessDateOf(req.query.startDate) : businessTodayStr();
|
||||
const endISO = req.query.endDate
|
||||
? businessDateOf(req.query.endDate)
|
||||
: businessNow().plus({ days: 30 }).toISODate();
|
||||
const days = Math.max(1, Math.round((Date.parse(endISO) - Date.parse(startISO)) / (1000 * 60 * 60 * 24)));
|
||||
|
||||
try {
|
||||
// Check if product_forecasts has data
|
||||
@@ -331,9 +330,7 @@ router.get('/forecast/metrics', async (req, res) => {
|
||||
const { rows: [horizonRow] } = await executeQuery(
|
||||
`SELECT MAX(forecast_date) AS max_date FROM product_forecasts`
|
||||
);
|
||||
const forecastHorizonISO = horizonRow.max_date instanceof Date
|
||||
? horizonRow.max_date.toISOString().split('T')[0]
|
||||
: horizonRow.max_date;
|
||||
const forecastHorizonISO = formatDateCol(horizonRow.max_date);
|
||||
const forecastHorizon = new Date(forecastHorizonISO + 'T00:00:00');
|
||||
const clampedEndISO = endISO <= forecastHorizonISO ? endISO : forecastHorizonISO;
|
||||
const needsExtrapolation = endISO > forecastHorizonISO;
|
||||
@@ -376,7 +373,7 @@ router.get('/forecast/metrics', async (req, res) => {
|
||||
`, [startISO, clampedEndISO]);
|
||||
|
||||
const dailyForecasts = dailyRows.map(d => ({
|
||||
date: d.date instanceof Date ? d.date.toISOString().split('T')[0] : d.date,
|
||||
date: formatDateCol(d.date),
|
||||
units: parseFloat(d.units) || 0,
|
||||
revenue: parseFloat(d.revenue) || 0,
|
||||
confidence: confidenceLevel,
|
||||
@@ -475,7 +472,7 @@ router.get('/forecast/metrics', async (req, res) => {
|
||||
const estUnits = (baselineDaily * seasonal) / avgPrice + newProductDailyUnits;
|
||||
|
||||
dailyForecasts.push({
|
||||
date: d.toISOString().split('T')[0],
|
||||
date: formatDateCol(d),
|
||||
units: parseFloat(estUnits.toFixed(1)),
|
||||
revenue: parseFloat(estRevenue.toFixed(2)),
|
||||
confidence: 0, // lower confidence for extrapolated data
|
||||
@@ -539,7 +536,7 @@ router.get('/forecast/metrics', async (req, res) => {
|
||||
`, [startISO, clampedEndISO]);
|
||||
|
||||
const dailyForecastsByPhase = dailyPhaseRows.map(d => ({
|
||||
date: d.date instanceof Date ? d.date.toISOString().split('T')[0] : d.date,
|
||||
date: formatDateCol(d.date),
|
||||
preorder: parseFloat(d.preorder) || 0,
|
||||
launch: parseFloat(d.launch) || 0,
|
||||
decay: parseFloat(d.decay) || 0,
|
||||
@@ -597,11 +594,12 @@ router.get('/forecast/metrics', async (req, res) => {
|
||||
const dailyRevenue = parseFloat(totals.daily_revenue) || 0;
|
||||
|
||||
const dailyForecasts = [];
|
||||
const fallbackStart = new Date(startISO + 'T00:00:00'); // local midnight — round-trips via formatDateCol
|
||||
for (let i = 0; i < days; i++) {
|
||||
const d = new Date(startDate);
|
||||
d.setDate(startDate.getDate() + i);
|
||||
const d = new Date(fallbackStart);
|
||||
d.setDate(fallbackStart.getDate() + i);
|
||||
dailyForecasts.push({
|
||||
date: d.toISOString().split('T')[0],
|
||||
date: formatDateCol(d),
|
||||
units: parseFloat(dailyUnits.toFixed(1)),
|
||||
revenue: parseFloat(dailyRevenue.toFixed(2)),
|
||||
confidence: 0,
|
||||
@@ -776,7 +774,7 @@ router.get('/forecast/accuracy', async (req, res) => {
|
||||
`);
|
||||
|
||||
const accuracyTrend = trendRows.map(r => ({
|
||||
date: r.run_date instanceof Date ? r.run_date.toISOString().split('T')[0] : r.run_date,
|
||||
date: formatDateCol(r.run_date),
|
||||
mae: r.mae != null ? parseFloat(parseFloat(r.mae).toFixed(4)) : null,
|
||||
wmape: r.wmape != null ? parseFloat((parseFloat(r.wmape) * 100).toFixed(1)) : null,
|
||||
bias: r.bias != null ? parseFloat(parseFloat(r.bias).toFixed(4)) : null,
|
||||
@@ -796,7 +794,7 @@ router.get('/forecast/accuracy', async (req, res) => {
|
||||
`);
|
||||
|
||||
const accuracyTrendWeekly = weeklyTrendRows.map(r => ({
|
||||
date: r.run_date instanceof Date ? r.run_date.toISOString().split('T')[0] : r.run_date,
|
||||
date: formatDateCol(r.run_date),
|
||||
wmape: r.wmape != null ? parseFloat((parseFloat(r.wmape) * 100).toFixed(1)) : null,
|
||||
naiveWmape: r.naive_wmape != null ? parseFloat((parseFloat(r.naive_wmape) * 100).toFixed(1)) : null,
|
||||
fva: r.fva != null ? parseFloat(parseFloat(r.fva).toFixed(3)) : null,
|
||||
@@ -808,12 +806,8 @@ router.get('/forecast/accuracy', async (req, res) => {
|
||||
computedAt,
|
||||
daysOfHistory: parseInt(historyInfo.days_of_history) || 0,
|
||||
historyRange: {
|
||||
from: historyInfo.earliest_date instanceof Date
|
||||
? historyInfo.earliest_date.toISOString().split('T')[0]
|
||||
: historyInfo.earliest_date,
|
||||
to: historyInfo.latest_date instanceof Date
|
||||
? historyInfo.latest_date.toISOString().split('T')[0]
|
||||
: historyInfo.latest_date,
|
||||
from: formatDateCol(historyInfo.earliest_date),
|
||||
to: formatDateCol(historyInfo.latest_date),
|
||||
},
|
||||
overall: shapeOverall(overall),
|
||||
overallInclDormant: shapeOverall(overallInclDormant),
|
||||
@@ -1038,10 +1032,9 @@ router.get('/best-sellers', async (req, res) => {
|
||||
// GET /dashboard/year-revenue-estimate
|
||||
// Returns YTD actual revenue + rest-of-year forecast revenue for a full-year estimate
|
||||
router.get('/year-revenue-estimate', async (req, res) => {
|
||||
const now = new Date();
|
||||
const yearStart = `${now.getFullYear()}-01-01`;
|
||||
const todayISO = now.toISOString().split('T')[0];
|
||||
const yearEndISO = `${now.getFullYear()}-12-31`;
|
||||
const todayISO = businessTodayStr(); // business 'today', not UTC
|
||||
const yearStart = `${todayISO.slice(0, 4)}-01-01`;
|
||||
const yearEndISO = `${todayISO.slice(0, 4)}-12-31`;
|
||||
|
||||
try {
|
||||
// YTD actual revenue from orders
|
||||
@@ -1055,11 +1048,7 @@ router.get('/year-revenue-estimate', async (req, res) => {
|
||||
const { rows: [horizonRow] } = await executeQuery(
|
||||
`SELECT MAX(forecast_date) AS max_date FROM product_forecasts`
|
||||
);
|
||||
const forecastHorizonISO = horizonRow?.max_date
|
||||
? (horizonRow.max_date instanceof Date
|
||||
? horizonRow.max_date.toISOString().split('T')[0]
|
||||
: horizonRow.max_date)
|
||||
: todayISO;
|
||||
const forecastHorizonISO = horizonRow?.max_date ? formatDateCol(horizonRow.max_date) : todayISO;
|
||||
|
||||
const clampedEnd = yearEndISO <= forecastHorizonISO ? yearEndISO : forecastHorizonISO;
|
||||
|
||||
@@ -1111,13 +1100,9 @@ router.get('/year-revenue-estimate', async (req, res) => {
|
||||
// GET /dashboard/sales/metrics
|
||||
// Returns sales metrics for specified period
|
||||
router.get('/sales/metrics', async (req, res) => {
|
||||
// Default to last 30 days if no date range provided
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date(today);
|
||||
thirtyDaysAgo.setDate(today.getDate() - 30);
|
||||
|
||||
const startDate = req.query.startDate || thirtyDaysAgo.toISOString();
|
||||
const endDate = req.query.endDate || today.toISOString();
|
||||
// Default: trailing 30 business days ending now (Chicago day start).
|
||||
const startDate = req.query.startDate || businessNow().minus({ days: 30 }).startOf('day').toISO();
|
||||
const endDate = req.query.endDate || new Date().toISOString();
|
||||
|
||||
try {
|
||||
// Get daily orders and totals for the specified period
|
||||
@@ -1187,13 +1172,13 @@ router.get('/sales/metrics', async (req, res) => {
|
||||
totalCogs: parseFloat(metrics?.total_cogs) || 0,
|
||||
totalRevenue: parseFloat(metrics?.total_revenue) || 0,
|
||||
dailySales: dailyRows.map(day => ({
|
||||
date: day.sale_date,
|
||||
date: formatDateCol(day.sale_date),
|
||||
units: parseInt(day.total_units) || 0,
|
||||
revenue: parseFloat(day.total_revenue) || 0,
|
||||
cogs: parseFloat(day.total_cogs) || 0
|
||||
})),
|
||||
dailySalesByPhase: dailyPhaseRows.map(d => ({
|
||||
date: d.sale_date,
|
||||
date: formatDateCol(d.sale_date),
|
||||
preorder: parseFloat(d.preorder) || 0,
|
||||
launch: parseFloat(d.launch) || 0,
|
||||
decay: parseFloat(d.decay) || 0,
|
||||
|
||||
@@ -676,7 +676,10 @@ async function setupSshTunnel() {
|
||||
password: process.env.PROD_DB_PASSWORD,
|
||||
database: process.env.PROD_DB_NAME,
|
||||
port: process.env.PROD_DB_PORT || 3306,
|
||||
timezone: 'Z'
|
||||
// DATETIME columns store Central wall-clock literals — return them as
|
||||
// strings; parse with shared/business-time parseMySql() if instants are
|
||||
// ever needed (see docs/TIME.md).
|
||||
dateStrings: true
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import express from 'express';
|
||||
import { businessTodayStr, businessDateOf, businessNow, formatDateCol } from '../../shared/business-time/index.js';
|
||||
const router = express.Router();
|
||||
|
||||
// Get all orders with pagination, filtering, and sorting
|
||||
@@ -10,8 +11,9 @@ router.get('/', async (req, res) => {
|
||||
const offset = (page - 1) * limit;
|
||||
const search = req.query.search || '';
|
||||
const status = req.query.status || 'all';
|
||||
const fromDate = req.query.fromDate ? new Date(req.query.fromDate) : null;
|
||||
const toDate = req.query.toDate ? new Date(req.query.toDate) : null;
|
||||
// Date filters are BUSINESS dates ('YYYY-MM-DD', Chicago calendar).
|
||||
const fromDate = req.query.fromDate ? businessDateOf(req.query.fromDate) : null;
|
||||
const toDate = req.query.toDate ? businessDateOf(req.query.toDate) : null;
|
||||
const minAmount = parseFloat(req.query.minAmount) || 0;
|
||||
const maxAmount = req.query.maxAmount ? parseFloat(req.query.maxAmount) : null;
|
||||
const sortColumn = req.query.sortColumn || 'date';
|
||||
@@ -35,14 +37,14 @@ router.get('/', async (req, res) => {
|
||||
}
|
||||
|
||||
if (fromDate) {
|
||||
conditions.push(`DATE(o1.date) >= DATE($${paramCounter})`);
|
||||
params.push(fromDate.toISOString());
|
||||
conditions.push(`DATE(o1.date) >= $${paramCounter}::date`);
|
||||
params.push(fromDate);
|
||||
paramCounter++;
|
||||
}
|
||||
|
||||
if (toDate) {
|
||||
conditions.push(`DATE(o1.date) <= DATE($${paramCounter})`);
|
||||
params.push(toDate.toISOString());
|
||||
conditions.push(`DATE(o1.date) <= $${paramCounter}::date`);
|
||||
params.push(toDate);
|
||||
paramCounter++;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import express from 'express';
|
||||
import { formatDateCol } from '../../shared/business-time/index.js';
|
||||
import { PurchaseOrderStatus, ReceivingStatus } from '../types/status-codes.js';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -1003,7 +1004,7 @@ router.get('/:id/forecast', async (req, res) => {
|
||||
phase,
|
||||
method,
|
||||
forecast: rows.map(r => ({
|
||||
date: r.date instanceof Date ? r.date.toISOString().split('T')[0] : r.date,
|
||||
date: formatDateCol(r.date),
|
||||
units: parseFloat(r.units) || 0,
|
||||
revenue: parseFloat(r.revenue) || 0,
|
||||
confidenceLower: parseFloat(r.confidence_lower) || 0,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import express from 'express';
|
||||
import { businessNow } from '../../shared/business-time/index.js';
|
||||
const router = express.Router();
|
||||
|
||||
// Status code constants
|
||||
@@ -716,7 +717,7 @@ router.get('/category-analysis', async (req, res) => {
|
||||
const pool = req.app.locals.pool;
|
||||
|
||||
// Allow an optional "since" parameter or default to 1 year ago
|
||||
const since = req.query.since || new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
const since = req.query.since || businessNow().minus({ days: 365 }).toISODate();
|
||||
|
||||
const { rows: analysis } = await pool.query(`
|
||||
WITH receiving_costs AS (
|
||||
@@ -769,7 +770,7 @@ router.get('/vendor-analysis', async (req, res) => {
|
||||
const pool = req.app.locals.pool;
|
||||
|
||||
// Allow an optional "since" parameter or default to 1 year ago
|
||||
const since = req.query.since || new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||
const since = req.query.since || businessNow().minus({ days: 365 }).toISODate();
|
||||
|
||||
const { rows: metrics } = await pool.query(`
|
||||
WITH receiving_data AS (
|
||||
|
||||
@@ -5,7 +5,13 @@ const { Pool } = pg;
|
||||
let pool;
|
||||
|
||||
export function initPool(config) {
|
||||
pool = new Pool(config);
|
||||
pool = new Pool({
|
||||
// Pin the session timezone to business time (matches the
|
||||
// `ALTER DATABASE inventory_db SET timezone = 'America/Chicago'` default)
|
||||
// so a DB restore/migration can't silently revert date semantics.
|
||||
options: '-c TimeZone=America/Chicago',
|
||||
...config,
|
||||
});
|
||||
return pool;
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,10 @@ async function setupSshTunnel() {
|
||||
password: process.env.PROD_DB_PASSWORD,
|
||||
database: process.env.PROD_DB_NAME,
|
||||
port: Number(process.env.PROD_DB_PORT) || 3306,
|
||||
timezone: 'Z',
|
||||
// DATETIME columns store Central wall-clock literals — return them as
|
||||
// strings; parse with shared/business-time parseMySql() if instants are
|
||||
// ever needed (see docs/TIME.md).
|
||||
dateStrings: true,
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
Reference in New Issue
Block a user