Files
inventory/inventory-server/src/routes/purchase-orders.js

461 lines
15 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 = [];
let paramCounter = 1;
if (search) {
whereClause += ` AND (po.po_id ILIKE $${paramCounter} OR po.vendor ILIKE $${paramCounter})`;
params.push(`%${search}%`);
paramCounter++;
}
if (status && status !== 'all') {
whereClause += ` AND po.status = $${paramCounter}`;
params.push(Number(status));
paramCounter++;
}
if (vendor && vendor !== 'all') {
whereClause += ` AND po.vendor = $${paramCounter}`;
params.push(vendor);
paramCounter++;
}
if (startDate) {
whereClause += ` AND po.date >= $${paramCounter}`;
params.push(startDate);
paramCounter++;
}
if (endDate) {
whereClause += ` AND po.date <= $${paramCounter}`;
params.push(endDate);
paramCounter++;
}
// Get filtered summary metrics
const { rows: [summary] } = await pool.query(`
WITH po_totals AS (
SELECT
po_id,
SUM(ordered) as total_ordered,
SUM(received) as total_received,
ROUND(SUM(ordered * cost_price)::numeric, 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)::numeric / NULLIF(SUM(total_ordered), 0)), 3
) as fulfillment_rate,
ROUND(SUM(total_cost)::numeric, 3) as total_value,
ROUND(AVG(total_cost)::numeric, 3) as avg_cost
FROM po_totals
`, params);
// Get total count for pagination
const { rows: [countResult] } = await pool.query(`
SELECT COUNT(DISTINCT po_id) as total
FROM purchase_orders po
WHERE ${whereClause}
`, params);
const total = countResult.total;
const offset = (page - 1) * limit;
const pages = Math.ceil(total / limit);
// Get recent purchase orders
let orderByClause;
if (sortColumn === 'order_date') {
orderByClause = `date ${sortDirection === 'desc' ? 'DESC' : 'ASC'}`;
} else if (sortColumn === 'vendor_name') {
orderByClause = `vendor ${sortDirection === 'desc' ? 'DESC' : 'ASC'}`;
} else if (sortColumn === 'total_cost') {
orderByClause = `total_cost ${sortDirection === 'desc' ? 'DESC' : 'ASC'}`;
} else if (sortColumn === 'total_received') {
orderByClause = `total_received ${sortDirection === 'desc' ? 'DESC' : 'ASC'}`;
} else if (sortColumn === 'total_items') {
orderByClause = `total_items ${sortDirection === 'desc' ? 'DESC' : 'ASC'}`;
} else if (sortColumn === 'total_quantity') {
orderByClause = `total_quantity ${sortDirection === 'desc' ? 'DESC' : 'ASC'}`;
} else if (sortColumn === 'fulfillment_rate') {
orderByClause = `fulfillment_rate ${sortDirection === 'desc' ? 'DESC' : 'ASC'}`;
} else if (sortColumn === 'status') {
orderByClause = `status ${sortDirection === 'desc' ? 'DESC' : 'ASC'}`;
} else {
orderByClause = `date ${sortDirection === 'desc' ? 'DESC' : 'ASC'}`;
}
const { rows: 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,
ROUND(SUM(ordered * cost_price)::numeric, 3) as total_cost,
SUM(received) as total_received,
ROUND(
(SUM(received)::numeric / 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,
to_char(date, 'YYYY-MM-DD') as order_date,
status,
receiving_status,
total_items,
total_quantity,
total_cost,
total_received,
fulfillment_rate
FROM po_totals
ORDER BY ${orderByClause}
LIMIT $${paramCounter} OFFSET $${paramCounter + 1}
`, [...params, Number(limit), offset]);
// Get unique vendors for filter options
const { rows: 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 { rows: 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.order_count) || 0,
total_ordered: Number(summary.total_ordered) || 0,
total_received: Number(summary.total_received) || 0,
fulfillment_rate: Number(summary.fulfillment_rate) || 0,
total_value: Number(summary.total_value) || 0,
avg_cost: Number(summary.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 { rows: 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 (received_date - date)::integer
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)::numeric / NULLIF(SUM(ordered), 0)), 3
) as fulfillment_rate,
ROUND(
(SUM(ordered * cost_price)::numeric / NULLIF(SUM(ordered), 0)), 2
) as avg_unit_cost,
ROUND(SUM(ordered * cost_price)::numeric, 3) as total_spend,
ROUND(
AVG(NULLIF(delivery_days, 0))::numeric, 1
) as avg_delivery_days
FROM delivery_metrics
GROUP BY vendor
HAVING COUNT(DISTINCT po_id) > 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: Number(vendor.avg_delivery_days) || 0
}));
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 { rows: 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,
ROUND(AVG(cost_price)::numeric, 3) as avg_cost,
ROUND(MIN(cost_price)::numeric, 3) as min_cost,
ROUND(MAX(cost_price)::numeric, 3) as max_cost,
ROUND(STDDEV(cost_price)::numeric, 3) as cost_variance,
ROUND(SUM(ordered * cost_price)::numeric, 3) as total_spend
FROM category_costs
GROUP BY category
ORDER BY total_spend DESC
`);
// Parse numeric values
const parsedAnalysis = {
unique_products: 0,
avg_cost: 0,
min_cost: 0,
max_cost: 0,
cost_variance: 0,
total_spend_by_category: analysis.map(cat => ({
category: cat.category,
total_spend: Number(cat.total_spend) || 0
}))
};
// Calculate aggregated stats if data exists
if (analysis.length > 0) {
parsedAnalysis.unique_products = analysis.reduce((sum, cat) => sum + Number(cat.unique_products || 0), 0);
// Calculate weighted average cost
const totalProducts = parsedAnalysis.unique_products;
if (totalProducts > 0) {
parsedAnalysis.avg_cost = analysis.reduce((sum, cat) =>
sum + (Number(cat.avg_cost || 0) * Number(cat.unique_products || 0)), 0) / totalProducts;
}
// Find min and max across all categories
parsedAnalysis.min_cost = Math.min(...analysis.map(cat => Number(cat.min_cost || 0)));
parsedAnalysis.max_cost = Math.max(...analysis.map(cat => Number(cat.max_cost || 0)));
// Average variance
parsedAnalysis.cost_variance = analysis.reduce((sum, cat) =>
sum + Number(cat.cost_variance || 0), 0) / analysis.length;
}
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 { rows: status } = await pool.query(`
WITH po_totals AS (
SELECT
po_id,
status,
receiving_status,
SUM(ordered) as total_ordered,
SUM(received) as total_received,
ROUND(SUM(ordered * cost_price)::numeric, 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,
ROUND(SUM(total_cost)::numeric, 3) as total_value,
ROUND(AVG(total_cost)::numeric, 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 { rows: 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 >= (CURRENT_DATE - INTERVAL '90 days')
GROUP BY p.product_id, p.title, p.SKU
HAVING COUNT(DISTINCT po.po_id) > 0
ORDER BY SUM(po.ordered) 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;