Add repeat orders page
This commit is contained in:
@@ -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;
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user