424 lines
14 KiB
JavaScript
424 lines
14 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
|
|
// Status code constants
|
|
const STATUS = {
|
|
CANCELED: 0,
|
|
CREATED: 1,
|
|
ELECTRONICALLY_READY_SEND: 10,
|
|
ORDERED: 11,
|
|
PREORDERED: 12,
|
|
ELECTRONICALLY_SENT: 13,
|
|
RECEIVING_STARTED: 15,
|
|
DONE: 50
|
|
};
|
|
|
|
const RECEIVING_STATUS = {
|
|
CANCELED: 0,
|
|
CREATED: 1,
|
|
PARTIAL_RECEIVED: 30,
|
|
FULL_RECEIVED: 40,
|
|
PAID: 50
|
|
};
|
|
|
|
// Get all purchase orders with summary metrics
|
|
router.get('/', async (req, res) => {
|
|
try {
|
|
const pool = req.app.locals.pool;
|
|
const { search, status, vendor, startDate, endDate, page = 1, limit = 100, sortColumn = 'date', sortDirection = 'desc' } = req.query;
|
|
|
|
let whereClause = '1=1';
|
|
const params = [];
|
|
|
|
if (search) {
|
|
whereClause += ' AND (po.po_id LIKE ? OR po.vendor LIKE ?)';
|
|
params.push(`%${search}%`, `%${search}%`);
|
|
}
|
|
|
|
if (status && status !== 'all') {
|
|
whereClause += ' AND po.status = ?';
|
|
params.push(Number(status));
|
|
}
|
|
|
|
if (vendor && vendor !== 'all') {
|
|
whereClause += ' AND po.vendor = ?';
|
|
params.push(vendor);
|
|
}
|
|
|
|
if (startDate) {
|
|
whereClause += ' AND po.date >= ?';
|
|
params.push(startDate);
|
|
}
|
|
|
|
if (endDate) {
|
|
whereClause += ' AND po.date <= ?';
|
|
params.push(endDate);
|
|
}
|
|
|
|
// Get filtered summary metrics
|
|
const [summary] = await pool.query(`
|
|
WITH po_totals AS (
|
|
SELECT
|
|
po_id,
|
|
SUM(ordered) as total_ordered,
|
|
SUM(received) as total_received,
|
|
CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_cost
|
|
FROM purchase_orders po
|
|
WHERE ${whereClause}
|
|
GROUP BY po_id
|
|
)
|
|
SELECT
|
|
COUNT(DISTINCT po_id) as order_count,
|
|
SUM(total_ordered) as total_ordered,
|
|
SUM(total_received) as total_received,
|
|
ROUND(
|
|
SUM(total_received) / NULLIF(SUM(total_ordered), 0), 3
|
|
) as fulfillment_rate,
|
|
CAST(SUM(total_cost) AS DECIMAL(15,3)) as total_value,
|
|
CAST(AVG(total_cost) AS DECIMAL(15,3)) as avg_cost
|
|
FROM po_totals
|
|
`, params);
|
|
|
|
// Get total count for pagination
|
|
const [countResult] = await pool.query(`
|
|
SELECT COUNT(DISTINCT po_id) as total
|
|
FROM purchase_orders po
|
|
WHERE ${whereClause}
|
|
`, params);
|
|
|
|
const total = countResult[0].total;
|
|
const offset = (page - 1) * limit;
|
|
const pages = Math.ceil(total / limit);
|
|
|
|
// Get recent purchase orders
|
|
const [orders] = await pool.query(`
|
|
WITH po_totals AS (
|
|
SELECT
|
|
po_id,
|
|
vendor,
|
|
date,
|
|
status,
|
|
receiving_status,
|
|
COUNT(DISTINCT pid) as total_items,
|
|
SUM(ordered) as total_quantity,
|
|
CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_cost,
|
|
SUM(received) as total_received,
|
|
ROUND(
|
|
SUM(received) / NULLIF(SUM(ordered), 0), 3
|
|
) as fulfillment_rate
|
|
FROM purchase_orders po
|
|
WHERE ${whereClause}
|
|
GROUP BY po_id, vendor, date, status, receiving_status
|
|
)
|
|
SELECT
|
|
po_id as id,
|
|
vendor as vendor_name,
|
|
DATE_FORMAT(date, '%Y-%m-%d') as order_date,
|
|
status,
|
|
receiving_status,
|
|
total_items,
|
|
total_quantity,
|
|
total_cost,
|
|
total_received,
|
|
fulfillment_rate
|
|
FROM po_totals
|
|
ORDER BY
|
|
CASE
|
|
WHEN ? = 'order_date' THEN date
|
|
WHEN ? = 'vendor_name' THEN vendor
|
|
WHEN ? = 'total_cost' THEN CAST(total_cost AS DECIMAL(15,3))
|
|
WHEN ? = 'total_received' THEN CAST(total_received AS DECIMAL(15,3))
|
|
WHEN ? = 'total_items' THEN CAST(total_items AS SIGNED)
|
|
WHEN ? = 'total_quantity' THEN CAST(total_quantity AS SIGNED)
|
|
WHEN ? = 'fulfillment_rate' THEN CAST(fulfillment_rate AS DECIMAL(5,3))
|
|
WHEN ? = 'status' THEN status
|
|
ELSE date
|
|
END ${sortDirection === 'desc' ? 'DESC' : 'ASC'}
|
|
LIMIT ? OFFSET ?
|
|
`, [...params, sortColumn, sortColumn, sortColumn, sortColumn, sortColumn, sortColumn, sortColumn, sortColumn, Number(limit), offset]);
|
|
|
|
// Get unique vendors for filter options
|
|
const [vendors] = await pool.query(`
|
|
SELECT DISTINCT vendor
|
|
FROM purchase_orders
|
|
WHERE vendor IS NOT NULL AND vendor != ''
|
|
ORDER BY vendor
|
|
`);
|
|
|
|
// Get unique statuses for filter options
|
|
const [statuses] = await pool.query(`
|
|
SELECT DISTINCT status
|
|
FROM purchase_orders
|
|
WHERE status IS NOT NULL
|
|
ORDER BY status
|
|
`);
|
|
|
|
// Parse numeric values
|
|
const parsedOrders = orders.map(order => ({
|
|
id: order.id,
|
|
vendor_name: order.vendor_name,
|
|
order_date: order.order_date,
|
|
status: Number(order.status),
|
|
receiving_status: Number(order.receiving_status),
|
|
total_items: Number(order.total_items) || 0,
|
|
total_quantity: Number(order.total_quantity) || 0,
|
|
total_cost: Number(order.total_cost) || 0,
|
|
total_received: Number(order.total_received) || 0,
|
|
fulfillment_rate: Number(order.fulfillment_rate) || 0
|
|
}));
|
|
|
|
// Parse summary metrics
|
|
const parsedSummary = {
|
|
order_count: Number(summary[0].order_count) || 0,
|
|
total_ordered: Number(summary[0].total_ordered) || 0,
|
|
total_received: Number(summary[0].total_received) || 0,
|
|
fulfillment_rate: Number(summary[0].fulfillment_rate) || 0,
|
|
total_value: Number(summary[0].total_value) || 0,
|
|
avg_cost: Number(summary[0].avg_cost) || 0
|
|
};
|
|
|
|
res.json({
|
|
orders: parsedOrders,
|
|
summary: parsedSummary,
|
|
pagination: {
|
|
total,
|
|
pages,
|
|
page: Number(page),
|
|
limit: Number(limit)
|
|
},
|
|
filters: {
|
|
vendors: vendors.map(v => v.vendor),
|
|
statuses: statuses.map(s => Number(s.status))
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Error fetching purchase orders:', error);
|
|
res.status(500).json({ error: 'Failed to fetch purchase orders' });
|
|
}
|
|
});
|
|
|
|
// Get vendor performance metrics
|
|
router.get('/vendor-metrics', async (req, res) => {
|
|
try {
|
|
const pool = req.app.locals.pool;
|
|
|
|
const [metrics] = await pool.query(`
|
|
WITH delivery_metrics AS (
|
|
SELECT
|
|
vendor,
|
|
po_id,
|
|
ordered,
|
|
received,
|
|
cost_price,
|
|
CASE
|
|
WHEN status >= ${STATUS.RECEIVING_STARTED} AND receiving_status >= ${RECEIVING_STATUS.PARTIAL_RECEIVED}
|
|
AND received_date IS NOT NULL AND date IS NOT NULL
|
|
THEN DATEDIFF(received_date, date)
|
|
ELSE NULL
|
|
END as delivery_days
|
|
FROM purchase_orders
|
|
WHERE vendor IS NOT NULL AND vendor != ''
|
|
AND status != ${STATUS.CANCELED} -- Exclude canceled orders
|
|
)
|
|
SELECT
|
|
vendor as vendor_name,
|
|
COUNT(DISTINCT po_id) as total_orders,
|
|
SUM(ordered) as total_ordered,
|
|
SUM(received) as total_received,
|
|
ROUND(
|
|
SUM(received) / NULLIF(SUM(ordered), 0), 3
|
|
) as fulfillment_rate,
|
|
CAST(ROUND(
|
|
SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2
|
|
) AS DECIMAL(15,3)) as avg_unit_cost,
|
|
CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_spend,
|
|
ROUND(
|
|
AVG(NULLIF(delivery_days, 0)), 1
|
|
) as avg_delivery_days
|
|
FROM delivery_metrics
|
|
GROUP BY vendor
|
|
HAVING total_orders > 0
|
|
ORDER BY total_spend DESC
|
|
`);
|
|
|
|
// Parse numeric values
|
|
const parsedMetrics = metrics.map(vendor => ({
|
|
id: vendor.vendor_name,
|
|
vendor_name: vendor.vendor_name,
|
|
total_orders: Number(vendor.total_orders) || 0,
|
|
total_ordered: Number(vendor.total_ordered) || 0,
|
|
total_received: Number(vendor.total_received) || 0,
|
|
fulfillment_rate: Number(vendor.fulfillment_rate) || 0,
|
|
avg_unit_cost: Number(vendor.avg_unit_cost) || 0,
|
|
total_spend: Number(vendor.total_spend) || 0,
|
|
avg_delivery_days: vendor.avg_delivery_days === null ? null : Number(vendor.avg_delivery_days)
|
|
}));
|
|
|
|
res.json(parsedMetrics);
|
|
} catch (error) {
|
|
console.error('Error fetching vendor metrics:', error);
|
|
res.status(500).json({ error: 'Failed to fetch vendor metrics' });
|
|
}
|
|
});
|
|
|
|
// Get cost analysis
|
|
router.get('/cost-analysis', async (req, res) => {
|
|
try {
|
|
const pool = req.app.locals.pool;
|
|
|
|
const [analysis] = await pool.query(`
|
|
WITH category_costs AS (
|
|
SELECT
|
|
c.name as category,
|
|
po.pid,
|
|
po.cost_price,
|
|
po.ordered,
|
|
po.received,
|
|
po.status,
|
|
po.receiving_status
|
|
FROM purchase_orders po
|
|
JOIN product_categories pc ON po.pid = pc.pid
|
|
JOIN categories c ON pc.cat_id = c.cat_id
|
|
WHERE po.status != ${STATUS.CANCELED} -- Exclude canceled orders
|
|
)
|
|
SELECT
|
|
category,
|
|
COUNT(DISTINCT pid) as unique_products,
|
|
CAST(AVG(cost_price) AS DECIMAL(15,3)) as avg_cost,
|
|
CAST(MIN(cost_price) AS DECIMAL(15,3)) as min_cost,
|
|
CAST(MAX(cost_price) AS DECIMAL(15,3)) as max_cost,
|
|
CAST(STDDEV(cost_price) AS DECIMAL(15,3)) as cost_variance,
|
|
CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_spend
|
|
FROM category_costs
|
|
GROUP BY category
|
|
ORDER BY total_spend DESC
|
|
`);
|
|
|
|
// Parse numeric values
|
|
const parsedAnalysis = {
|
|
categories: analysis.map(cat => ({
|
|
category: cat.category,
|
|
unique_products: Number(cat.unique_products) || 0,
|
|
avg_cost: Number(cat.avg_cost) || 0,
|
|
min_cost: Number(cat.min_cost) || 0,
|
|
max_cost: Number(cat.max_cost) || 0,
|
|
cost_variance: Number(cat.cost_variance) || 0,
|
|
total_spend: Number(cat.total_spend) || 0
|
|
}))
|
|
};
|
|
|
|
res.json(parsedAnalysis);
|
|
} catch (error) {
|
|
console.error('Error fetching cost analysis:', error);
|
|
res.status(500).json({ error: 'Failed to fetch cost analysis' });
|
|
}
|
|
});
|
|
|
|
// Get receiving status metrics
|
|
router.get('/receiving-status', async (req, res) => {
|
|
try {
|
|
const pool = req.app.locals.pool;
|
|
|
|
const [status] = await pool.query(`
|
|
WITH po_totals AS (
|
|
SELECT
|
|
po_id,
|
|
status,
|
|
receiving_status,
|
|
SUM(ordered) as total_ordered,
|
|
SUM(received) as total_received,
|
|
CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_cost
|
|
FROM purchase_orders
|
|
WHERE status != ${STATUS.CANCELED}
|
|
GROUP BY po_id, status, receiving_status
|
|
)
|
|
SELECT
|
|
COUNT(DISTINCT po_id) as order_count,
|
|
SUM(total_ordered) as total_ordered,
|
|
SUM(total_received) as total_received,
|
|
ROUND(
|
|
SUM(total_received) / NULLIF(SUM(total_ordered), 0), 3
|
|
) as fulfillment_rate,
|
|
CAST(SUM(total_cost) AS DECIMAL(15,3)) as total_value,
|
|
CAST(AVG(total_cost) AS DECIMAL(15,3)) as avg_cost,
|
|
COUNT(DISTINCT CASE
|
|
WHEN receiving_status = ${RECEIVING_STATUS.CREATED} THEN po_id
|
|
END) as pending_count,
|
|
COUNT(DISTINCT CASE
|
|
WHEN receiving_status = ${RECEIVING_STATUS.PARTIAL_RECEIVED} THEN po_id
|
|
END) as partial_count,
|
|
COUNT(DISTINCT CASE
|
|
WHEN receiving_status >= ${RECEIVING_STATUS.FULL_RECEIVED} THEN po_id
|
|
END) as completed_count,
|
|
COUNT(DISTINCT CASE
|
|
WHEN receiving_status = ${RECEIVING_STATUS.CANCELED} THEN po_id
|
|
END) as canceled_count
|
|
FROM po_totals
|
|
`);
|
|
|
|
// Parse numeric values
|
|
const parsedStatus = {
|
|
order_count: Number(status[0].order_count) || 0,
|
|
total_ordered: Number(status[0].total_ordered) || 0,
|
|
total_received: Number(status[0].total_received) || 0,
|
|
fulfillment_rate: Number(status[0].fulfillment_rate) || 0,
|
|
total_value: Number(status[0].total_value) || 0,
|
|
avg_cost: Number(status[0].avg_cost) || 0,
|
|
status_breakdown: {
|
|
pending: Number(status[0].pending_count) || 0,
|
|
partial: Number(status[0].partial_count) || 0,
|
|
completed: Number(status[0].completed_count) || 0,
|
|
canceled: Number(status[0].canceled_count) || 0
|
|
}
|
|
};
|
|
|
|
res.json(parsedStatus);
|
|
} catch (error) {
|
|
console.error('Error fetching receiving status:', error);
|
|
res.status(500).json({ error: 'Failed to fetch receiving status' });
|
|
}
|
|
});
|
|
|
|
// Get order vs received quantities by product
|
|
router.get('/order-vs-received', async (req, res) => {
|
|
try {
|
|
const pool = req.app.locals.pool;
|
|
|
|
const [quantities] = await pool.query(`
|
|
SELECT
|
|
p.product_id,
|
|
p.title as product,
|
|
p.SKU as sku,
|
|
SUM(po.ordered) as ordered_quantity,
|
|
SUM(po.received) as received_quantity,
|
|
ROUND(
|
|
SUM(po.received) / NULLIF(SUM(po.ordered), 0) * 100, 1
|
|
) as fulfillment_rate,
|
|
COUNT(DISTINCT po.po_id) as order_count
|
|
FROM products p
|
|
JOIN purchase_orders po ON p.product_id = po.product_id
|
|
WHERE po.date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)
|
|
GROUP BY p.product_id, p.title, p.SKU
|
|
HAVING order_count > 0
|
|
ORDER BY ordered_quantity DESC
|
|
LIMIT 20
|
|
`);
|
|
|
|
// Parse numeric values and add id for React keys
|
|
const parsedQuantities = quantities.map(q => ({
|
|
id: q.product_id,
|
|
...q,
|
|
ordered_quantity: Number(q.ordered_quantity),
|
|
received_quantity: Number(q.received_quantity),
|
|
fulfillment_rate: Number(q.fulfillment_rate),
|
|
order_count: Number(q.order_count)
|
|
}));
|
|
|
|
res.json(parsedQuantities);
|
|
} catch (error) {
|
|
console.error('Error fetching order vs received quantities:', error);
|
|
res.status(500).json({ error: 'Failed to fetch order vs received quantities' });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|