Add repeat orders page

This commit is contained in:
2026-04-09 10:36:52 -04:00
parent c276f165f4
commit 338f829eb6
5 changed files with 1254 additions and 0 deletions

View File

@@ -0,0 +1,391 @@
const express = require('express');
const router = express.Router();
// Stale PO statuses to exclude from our counting — these are the "still active"
// statuses as defined in scripts/metrics-new/update_product_metrics.sql. POs that
// are canceled or done are excluded separately.
const OPEN_PO_STATUSES = [
'created',
'ordered',
'preordered',
'electronically_sent',
'electronically_ready_send',
'receiving_started'
];
// GET /api/repeat-orders
// Finds products that are repeatedly appearing on small POs to a given supplier,
// indicating the auto-PO pattern where we keep buying MOQ instead of batching.
//
// Design notes on dedup between POs and receivings:
// - We count PO line appearances (not unit totals) as the frequency signal, since
// that is exactly the user action we are trying to detect (repeated small orders).
// - Canceled POs are filtered out so unreceived garbage cannot inflate the count.
// - Receivings are joined for read-only context (units actually arrived) and are
// NOT added to the frequency count, so there is no double-counting between POs
// and their matching receivings.
// - Stock / on-order / replenishment are pulled from product_metrics, which uses
// the canonical FIFO logic in update_product_metrics.sql to avoid double-counting
// units between POs and their receivings.
//
// Query params:
// supplierId (int, default 92 = Notions)
// windowDays (int, default 30, range 1..180)
// minPoCount (int, default 3)
// maxAvgQty (number, default 10 — filters out legitimate large batch orders)
router.get('/', async (req, res) => {
const supplierId = parseInt(req.query.supplierId, 10);
const windowDays = Math.min(180, Math.max(1, parseInt(req.query.windowDays, 10) || 30));
const minPoCount = Math.max(2, parseInt(req.query.minPoCount, 10) || 3);
const maxAvgQtyRaw = parseFloat(req.query.maxAvgQty);
const maxAvgQty = Number.isFinite(maxAvgQtyRaw) && maxAvgQtyRaw > 0 ? maxAvgQtyRaw : 10;
if (!Number.isFinite(supplierId)) {
return res.status(400).json({ error: 'supplierId query param is required' });
}
try {
const pool = req.app.locals.pool;
const { rows } = await pool.query(
`
WITH po_window AS (
SELECT
po.pid,
po.po_id,
po.date,
po.ordered,
po.po_cost_price,
po.status
FROM purchase_orders po
WHERE po.supplier_id = $1
AND po.date >= NOW() - ($2::int * INTERVAL '1 day')
AND po.status <> 'canceled'
),
po_agg AS (
SELECT
pid,
COUNT(*)::int AS po_line_count,
COUNT(DISTINCT date::date)::int AS po_days_active,
SUM(ordered)::int AS po_total_units,
AVG(ordered::numeric)::numeric(10,2) AS po_avg_qty,
MIN(ordered)::int AS po_min_qty,
MAX(ordered)::int AS po_max_qty,
MIN(date)::date AS first_po_date,
MAX(date)::date AS last_po_date,
SUM(ordered * po_cost_price)::numeric(14,2) AS po_total_cost,
-- Count of PO lines still in an open status (not yet received / closed)
COUNT(*) FILTER (WHERE status = ANY($5::text[]))::int AS po_open_line_count
FROM po_window
GROUP BY pid
HAVING COUNT(*) >= $3
AND AVG(ordered::numeric) <= $4
),
recv_window AS (
SELECT
pid,
COUNT(*)::int AS recv_line_count,
COUNT(DISTINCT received_date::date)::int AS recv_days_active,
SUM(qty_each)::int AS recv_total_units,
MAX(received_date)::date AS last_recv_date
FROM receivings
WHERE supplier_id = $1
AND received_date >= NOW() - ($2::int * INTERVAL '1 day')
AND status IN ('partial_received', 'full_received', 'paid')
GROUP BY pid
),
forecast_agg AS (
SELECT
pid,
SUM(forecast_units) FILTER (
WHERE forecast_date >= CURRENT_DATE
AND forecast_date < CURRENT_DATE + INTERVAL '30 days'
)::numeric(10,2) AS forecast_30d,
SUM(forecast_units) FILTER (
WHERE forecast_date >= CURRENT_DATE
AND forecast_date < CURRENT_DATE + INTERVAL '60 days'
)::numeric(10,2) AS forecast_60d,
MAX(lifecycle_phase) AS lifecycle_phase
FROM product_forecasts
WHERE pid IN (SELECT pid FROM po_agg)
GROUP BY pid
)
SELECT
pm.pid,
pm.sku,
pm.title,
pm.brand,
pm.vendor,
pm.image_url,
pm.is_visible,
pm.is_replenishable,
pm.moq,
pm.current_stock,
pm.notions_inv_count,
pm.on_order_qty,
pm.sales_30d,
pm.sales_velocity_daily::numeric(10,3) AS velocity,
pm.stock_cover_in_days::numeric(10,1) AS cover_days,
pm.replenishment_units,
pm.to_order_units,
pm.avg_lead_time_days,
pm.lifecycle_phase,
pm.earliest_expected_date,
po.po_line_count,
po.po_days_active,
po.po_total_units,
po.po_avg_qty,
po.po_min_qty,
po.po_max_qty,
po.first_po_date,
po.last_po_date,
po.po_total_cost,
po.po_open_line_count,
COALESCE(r.recv_line_count, 0) AS recv_line_count,
COALESCE(r.recv_days_active, 0) AS recv_days_active,
COALESCE(r.recv_total_units, 0) AS recv_total_units,
r.last_recv_date,
f.forecast_30d,
f.forecast_60d
FROM po_agg po
JOIN product_metrics pm ON pm.pid = po.pid
LEFT JOIN recv_window r ON r.pid = po.pid
LEFT JOIN forecast_agg f ON f.pid = po.pid
ORDER BY po.po_line_count DESC, pm.sales_30d DESC NULLS LAST
LIMIT 500
`,
[supplierId, windowDays, minPoCount, maxAvgQty, OPEN_PO_STATUSES]
);
// Supplier-level summary for the filters in effect — independent of the
// minPoCount / maxAvgQty filters so the user can see the full baseline.
const { rows: summaryRows } = await pool.query(
`
SELECT
COUNT(*)::int AS total_po_lines,
COUNT(DISTINCT po_id)::int AS distinct_po_ids,
COUNT(DISTINCT pid)::int AS distinct_products,
COUNT(DISTINCT date::date)::int AS distinct_days,
SUM(ordered)::int AS total_units,
AVG(ordered::numeric)::numeric(10,2) AS avg_qty_per_line
FROM purchase_orders
WHERE supplier_id = $1
AND date >= NOW() - ($2::int * INTERVAL '1 day')
AND status <> 'canceled'
`,
[supplierId, windowDays]
);
const results = rows.map((row) => {
const poAvg = parseFloat(row.po_avg_qty) || 0;
const velocity = parseFloat(row.velocity) || 0;
const forecast30 = parseFloat(row.forecast_30d) || 0;
const poCount = parseInt(row.po_line_count, 10) || 0;
const poDays = parseInt(row.po_days_active, 10) || 0;
// Repeat score: higher = more consolidation opportunity.
// - poCount boosts the score
// - inversely weighted by avg qty (small buys = bigger problem)
// - boosted by velocity (real demand, not noise)
const repeatScore = poAvg > 0
? Number(((poCount * Math.max(velocity, 0.1)) / poAvg).toFixed(2))
: 0;
return {
pid: row.pid,
sku: row.sku,
title: row.title,
brand: row.brand,
vendor: row.vendor,
imageUrl: row.image_url,
isVisible: row.is_visible,
isReplenishable: row.is_replenishable,
moq: row.moq,
currentStock: row.current_stock,
notionsInvCount: row.notions_inv_count,
onOrderQty: row.on_order_qty,
sales30d: row.sales_30d,
velocity,
coverDays: row.cover_days !== null ? parseFloat(row.cover_days) : null,
replenishmentUnits: row.replenishment_units,
toOrderUnits: row.to_order_units,
avgLeadTimeDays: row.avg_lead_time_days,
lifecyclePhase: row.lifecycle_phase,
earliestExpectedDate: row.earliest_expected_date,
poLineCount: poCount,
poDaysActive: poDays,
poTotalUnits: row.po_total_units,
poAvgQty: poAvg,
poMinQty: row.po_min_qty,
poMaxQty: row.po_max_qty,
firstPoDate: row.first_po_date,
lastPoDate: row.last_po_date,
poTotalCost: parseFloat(row.po_total_cost) || 0,
poOpenLineCount: row.po_open_line_count,
recvLineCount: row.recv_line_count,
recvDaysActive: row.recv_days_active,
recvTotalUnits: row.recv_total_units,
lastRecvDate: row.last_recv_date,
forecast30d: forecast30,
forecast60d: parseFloat(row.forecast_60d) || 0,
repeatScore,
};
});
const summary = summaryRows[0] || {};
res.json({
params: {
supplierId,
windowDays,
minPoCount,
maxAvgQty,
},
supplierSummary: {
totalPoLines: summary.total_po_lines || 0,
distinctPoIds: summary.distinct_po_ids || 0,
distinctProducts: summary.distinct_products || 0,
distinctDays: summary.distinct_days || 0,
totalUnits: summary.total_units || 0,
avgQtyPerLine: parseFloat(summary.avg_qty_per_line) || 0,
},
matchSummary: {
matchCount: results.length,
totalSmallPoLines: results.reduce((sum, r) => sum + r.poLineCount, 0),
totalSmallPoUnits: results.reduce((sum, r) => sum + (r.poTotalUnits || 0), 0),
totalSales30d: results.reduce((sum, r) => sum + (r.sales30d || 0), 0),
totalSuggestedUnits: results.reduce((sum, r) => sum + (r.replenishmentUnits || 0), 0),
},
results,
});
} catch (error) {
console.error('Error fetching repeat orders:', error);
res.status(500).json({ error: 'Failed to fetch repeat orders' });
}
});
// GET /api/repeat-orders/suppliers
// Returns the list of suppliers that have had non-canceled PO activity recently,
// so the UI can offer a supplier dropdown without an expensive scan.
router.get('/suppliers', async (req, res) => {
const windowDays = Math.min(365, Math.max(7, parseInt(req.query.windowDays, 10) || 90));
try {
const pool = req.app.locals.pool;
const { rows } = await pool.query(
`
SELECT
supplier_id,
MAX(vendor) AS vendor_name,
COUNT(*)::int AS line_count,
COUNT(DISTINCT po_id)::int AS po_count,
MAX(date)::date AS last_po_date
FROM purchase_orders
WHERE supplier_id IS NOT NULL
AND date >= NOW() - ($1::int * INTERVAL '1 day')
AND status <> 'canceled'
GROUP BY supplier_id
HAVING COUNT(*) >= 5
ORDER BY line_count DESC
`,
[windowDays]
);
res.json({
suppliers: rows.map((r) => ({
supplierId: r.supplier_id,
vendorName: r.vendor_name,
lineCount: r.line_count,
poCount: r.po_count,
lastPoDate: r.last_po_date,
})),
});
} catch (error) {
console.error('Error fetching repeat-order suppliers:', error);
res.status(500).json({ error: 'Failed to fetch suppliers' });
}
});
// GET /api/repeat-orders/:pid/history
// Returns the full PO + receiving history within the window for a single product,
// for drill-down detail when the user clicks a row.
router.get('/:pid/history', async (req, res) => {
const pid = parseInt(req.params.pid, 10);
const supplierId = parseInt(req.query.supplierId, 10);
const windowDays = Math.min(365, Math.max(1, parseInt(req.query.windowDays, 10) || 30));
if (!Number.isFinite(pid) || !Number.isFinite(supplierId)) {
return res.status(400).json({ error: 'pid and supplierId are required' });
}
try {
const pool = req.app.locals.pool;
const { rows: poRows } = await pool.query(
`
SELECT
po_id,
date,
expected_date,
ordered,
po_cost_price,
status,
notes
FROM purchase_orders
WHERE pid = $1
AND supplier_id = $2
AND date >= NOW() - ($3::int * INTERVAL '1 day')
ORDER BY date DESC
`,
[pid, supplierId, windowDays]
);
const { rows: recvRows } = await pool.query(
`
SELECT
receiving_id,
received_date,
qty_each,
cost_each,
status
FROM receivings
WHERE pid = $1
AND supplier_id = $2
AND received_date >= NOW() - ($3::int * INTERVAL '1 day')
ORDER BY received_date DESC
`,
[pid, supplierId, windowDays]
);
res.json({
pid,
supplierId,
purchaseOrders: poRows.map((r) => ({
poId: r.po_id,
date: r.date,
expectedDate: r.expected_date,
ordered: r.ordered,
costPrice: parseFloat(r.po_cost_price) || 0,
status: r.status,
notes: r.notes,
})),
receivings: recvRows.map((r) => ({
receivingId: r.receiving_id,
receivedDate: r.received_date,
qtyEach: r.qty_each,
costEach: parseFloat(r.cost_each) || 0,
status: r.status,
})),
});
} catch (error) {
console.error('Error fetching repeat-order history:', error);
res.status(500).json({ error: 'Failed to fetch product history' });
}
});
module.exports = router;

View File

@@ -28,6 +28,7 @@ const importAuditLogRouter = require('./routes/import-audit-log');
const productEditorAuditLogRouter = require('./routes/product-editor-audit-log');
const newsletterRouter = require('./routes/newsletter');
const linesAggregateRouter = require('./routes/linesAggregate');
const repeatOrdersRouter = require('./routes/repeat-orders');
// Get the absolute path to the .env file
const envPath = '/var/www/html/inventory/.env';
@@ -140,6 +141,7 @@ async function startServer() {
app.use('/api/product-editor-audit-log', productEditorAuditLogRouter);
app.use('/api/newsletter', newsletterRouter);
app.use('/api/lines-aggregate', linesAggregateRouter);
app.use('/api/repeat-orders', repeatOrdersRouter);
// Basic health check route
app.get('/health', (req, res) => {