1280 lines
43 KiB
JavaScript
1280 lines
43 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
|
|
// Status code constants
|
|
// Frontend uses these numeric codes but database uses strings
|
|
const STATUS = {
|
|
CANCELED: 0,
|
|
CREATED: 1,
|
|
ELECTRONICALLY_READY_SEND: 10,
|
|
ORDERED: 11,
|
|
PREORDERED: 12,
|
|
ELECTRONICALLY_SENT: 13,
|
|
RECEIVING_STARTED: 15,
|
|
DONE: 50,
|
|
// Receiving status codes
|
|
RECEIVING_CREATED: 1,
|
|
RECEIVING_PARTIAL: 30,
|
|
RECEIVING_FULL: 40,
|
|
RECEIVING_PAID: 50
|
|
};
|
|
|
|
// Status mapping from database string values to frontend numeric codes
|
|
const STATUS_MAPPING = {
|
|
'canceled': STATUS.CANCELED,
|
|
'created': STATUS.CREATED,
|
|
'electronically_ready_send': STATUS.ELECTRONICALLY_READY_SEND,
|
|
'ordered': STATUS.ORDERED,
|
|
'preordered': STATUS.PREORDERED,
|
|
'electronically_sent': STATUS.ELECTRONICALLY_SENT,
|
|
'receiving_started': STATUS.RECEIVING_STARTED,
|
|
'done': STATUS.DONE,
|
|
// Receiving status mappings
|
|
'partial_received': STATUS.RECEIVING_PARTIAL,
|
|
'full_received': STATUS.RECEIVING_FULL,
|
|
'paid': STATUS.RECEIVING_PAID
|
|
};
|
|
|
|
// Helper for SQL status value comparison with string values in DB
|
|
function getStatusWhereClause(statusNum) {
|
|
const dbStatuses = Object.keys(STATUS_MAPPING).filter(key =>
|
|
STATUS_MAPPING[key] === parseInt(statusNum));
|
|
|
|
if (dbStatuses.length > 0) {
|
|
return `po.status = '${dbStatuses[0]}'`;
|
|
}
|
|
return `1=0`; // No match found, return false condition
|
|
}
|
|
|
|
// Get all purchase orders with summary metrics
|
|
router.get('/', async (req, res) => {
|
|
try {
|
|
const pool = req.app.locals.pool;
|
|
|
|
// Parse query parameters with defaults
|
|
const {
|
|
search = '',
|
|
status = 'all',
|
|
vendor = 'all',
|
|
recordType = 'all',
|
|
startDate = null,
|
|
endDate = null,
|
|
page = 1,
|
|
limit = 100,
|
|
sortColumn = 'id',
|
|
sortDirection = 'desc'
|
|
} = req.query;
|
|
|
|
console.log("Received query parameters:", {
|
|
search, status, vendor, recordType, page, limit, sortColumn, sortDirection
|
|
});
|
|
|
|
// Base where clause for purchase orders
|
|
let poWhereClause = '1=1';
|
|
// Base where clause for receivings (used in the receiving_data CTE)
|
|
let receivingWhereClause = '1=1';
|
|
const params = [];
|
|
let paramCounter = 1;
|
|
|
|
if (search && search.trim() !== '') {
|
|
// Simplified search for purchase orders - improved performance
|
|
const searchTerm = `%${search.trim()}%`;
|
|
poWhereClause += ` AND (
|
|
po.po_id::text ILIKE $${paramCounter}
|
|
OR po.vendor ILIKE $${paramCounter}
|
|
OR po.notes ILIKE $${paramCounter}
|
|
)`;
|
|
params.push(searchTerm);
|
|
paramCounter++;
|
|
|
|
// Add search for receivings
|
|
receivingWhereClause += ` AND (
|
|
r.receiving_id::text ILIKE $${paramCounter}
|
|
OR r.vendor ILIKE $${paramCounter}
|
|
)`;
|
|
params.push(searchTerm);
|
|
paramCounter++;
|
|
}
|
|
|
|
if (status && status !== 'all') {
|
|
poWhereClause += ` AND ${getStatusWhereClause(status)}`;
|
|
|
|
// Handle status for receivings
|
|
const dbStatuses = Object.keys(STATUS_MAPPING).filter(key =>
|
|
STATUS_MAPPING[key] === parseInt(status));
|
|
|
|
if (dbStatuses.length > 0) {
|
|
receivingWhereClause += ` AND r.status = '${dbStatuses[0]}'`;
|
|
}
|
|
}
|
|
|
|
if (vendor && vendor !== 'all') {
|
|
poWhereClause += ` AND po.vendor = $${paramCounter}`;
|
|
params.push(vendor);
|
|
paramCounter++;
|
|
|
|
// Add vendor filter for receivings
|
|
receivingWhereClause += ` AND r.vendor = $${paramCounter}`;
|
|
params.push(vendor);
|
|
paramCounter++;
|
|
}
|
|
|
|
if (startDate) {
|
|
poWhereClause += ` AND po.date >= $${paramCounter}::date`;
|
|
params.push(startDate);
|
|
paramCounter++;
|
|
|
|
// Add date filter for receivings
|
|
receivingWhereClause += ` AND r.received_date >= $${paramCounter}::date`;
|
|
params.push(startDate);
|
|
paramCounter++;
|
|
}
|
|
|
|
if (endDate) {
|
|
poWhereClause += ` AND po.date <= $${paramCounter}::date`;
|
|
params.push(endDate);
|
|
paramCounter++;
|
|
|
|
// Add date filter for receivings
|
|
receivingWhereClause += ` AND r.received_date <= $${paramCounter}::date`;
|
|
params.push(endDate);
|
|
paramCounter++;
|
|
}
|
|
|
|
// Get filtered summary metrics
|
|
const summaryQuery = `
|
|
WITH po_totals AS (
|
|
SELECT
|
|
po_id,
|
|
SUM(ordered) as total_ordered,
|
|
ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_cost
|
|
FROM purchase_orders po
|
|
WHERE ${poWhereClause}
|
|
GROUP BY po_id
|
|
),
|
|
receiving_totals AS (
|
|
SELECT
|
|
r.receiving_id as po_id,
|
|
SUM(r.qty_each) as total_received
|
|
FROM receivings r
|
|
WHERE (${receivingWhereClause})
|
|
AND r.receiving_id IN (SELECT po_id FROM po_totals)
|
|
GROUP BY r.receiving_id
|
|
)
|
|
SELECT
|
|
COUNT(DISTINCT po.po_id) as order_count,
|
|
SUM(po.total_ordered) as total_ordered,
|
|
COALESCE(SUM(r.total_received), 0) as total_received,
|
|
CASE
|
|
WHEN SUM(po.total_ordered) > 0
|
|
THEN ROUND((COALESCE(SUM(r.total_received), 0)::numeric / SUM(po.total_ordered)), 3)
|
|
ELSE 0
|
|
END as fulfillment_rate,
|
|
ROUND(SUM(po.total_cost)::numeric, 3) as total_value,
|
|
CASE
|
|
WHEN COUNT(DISTINCT po.po_id) > 0
|
|
THEN ROUND(AVG(po.total_cost)::numeric, 3)
|
|
ELSE 0
|
|
END as avg_cost
|
|
FROM po_totals po
|
|
LEFT JOIN receiving_totals r ON po.po_id = r.po_id
|
|
`;
|
|
|
|
const { rows: [summary] } = await pool.query(summaryQuery, params);
|
|
|
|
// Prepare query based on record type filter to get correct counts
|
|
let countQuery = '';
|
|
|
|
if (recordType === 'po_only') {
|
|
countQuery = `
|
|
WITH po_data AS (
|
|
SELECT po_id
|
|
FROM purchase_orders po
|
|
WHERE ${poWhereClause}
|
|
GROUP BY po_id
|
|
),
|
|
receiving_data AS (
|
|
SELECT receiving_id
|
|
FROM receivings r
|
|
WHERE ${receivingWhereClause}
|
|
GROUP BY receiving_id
|
|
),
|
|
filtered_data AS (
|
|
SELECT DISTINCT po_id as id
|
|
FROM po_data
|
|
WHERE po_id NOT IN (SELECT receiving_id FROM receiving_data WHERE receiving_id IS NOT NULL)
|
|
)
|
|
SELECT COUNT(*) as total FROM filtered_data
|
|
`;
|
|
} else if (recordType === 'po_with_receiving') {
|
|
countQuery = `
|
|
WITH po_data AS (
|
|
SELECT po_id
|
|
FROM purchase_orders po
|
|
WHERE ${poWhereClause}
|
|
GROUP BY po_id
|
|
),
|
|
receiving_data AS (
|
|
SELECT receiving_id
|
|
FROM receivings r
|
|
WHERE ${receivingWhereClause}
|
|
GROUP BY receiving_id
|
|
),
|
|
filtered_data AS (
|
|
SELECT DISTINCT po_id as id
|
|
FROM po_data
|
|
WHERE po_id IN (SELECT receiving_id FROM receiving_data WHERE receiving_id IS NOT NULL)
|
|
)
|
|
SELECT COUNT(*) as total FROM filtered_data
|
|
`;
|
|
} else if (recordType === 'receiving_only') {
|
|
countQuery = `
|
|
WITH po_data AS (
|
|
SELECT po_id
|
|
FROM purchase_orders po
|
|
WHERE ${poWhereClause}
|
|
GROUP BY po_id
|
|
),
|
|
receiving_data AS (
|
|
SELECT receiving_id
|
|
FROM receivings r
|
|
WHERE ${receivingWhereClause}
|
|
GROUP BY receiving_id
|
|
),
|
|
filtered_data AS (
|
|
SELECT DISTINCT receiving_id as id
|
|
FROM receiving_data
|
|
WHERE receiving_id NOT IN (SELECT po_id FROM po_data WHERE po_id IS NOT NULL)
|
|
)
|
|
SELECT COUNT(*) as total FROM filtered_data
|
|
`;
|
|
} else {
|
|
// 'all' - count both purchase orders and receiving-only records
|
|
countQuery = `
|
|
WITH po_data AS (
|
|
SELECT po_id
|
|
FROM purchase_orders po
|
|
WHERE ${poWhereClause}
|
|
GROUP BY po_id
|
|
),
|
|
receiving_data AS (
|
|
SELECT receiving_id
|
|
FROM receivings r
|
|
WHERE ${receivingWhereClause}
|
|
GROUP BY receiving_id
|
|
),
|
|
filtered_data AS (
|
|
SELECT DISTINCT po_id as id FROM po_data
|
|
UNION
|
|
SELECT DISTINCT receiving_id as id FROM receiving_data
|
|
WHERE receiving_id NOT IN (SELECT po_id FROM po_data WHERE po_id IS NOT NULL)
|
|
)
|
|
SELECT COUNT(*) as total FROM filtered_data
|
|
`;
|
|
}
|
|
|
|
const { rows: [countResult] } = await pool.query(countQuery, params);
|
|
|
|
// Parse parameters safely
|
|
const parsedPage = parseInt(page) || 1;
|
|
const parsedLimit = parseInt(limit) || 100;
|
|
const total = parseInt(countResult?.total) || 0;
|
|
const offset = (parsedPage - 1) * parsedLimit;
|
|
const pages = Math.ceil(total / parsedLimit);
|
|
|
|
// Validated sort parameters
|
|
const validSortColumns = ['id', 'vendor_name', 'order_date', 'receiving_date',
|
|
'status', 'total_cost', 'total_items', 'total_quantity', 'total_received', 'fulfillment_rate'];
|
|
|
|
const finalSortColumn = validSortColumns.includes(sortColumn) ? sortColumn : 'id';
|
|
const finalSortDirection = sortDirection === 'asc' ? 'asc' : 'desc';
|
|
|
|
// Build the order by clause with improved null handling
|
|
let orderByClause = '';
|
|
|
|
// Special sorting that ensures receiving_only records are included with any date sorting
|
|
if (finalSortColumn === 'order_date' || finalSortColumn === 'date') {
|
|
orderByClause = `
|
|
CASE
|
|
WHEN order_date IS NULL THEN
|
|
CASE WHEN receiving_date IS NOT NULL THEN
|
|
to_date(receiving_date, 'YYYY-MM-DD')
|
|
ELSE
|
|
'1900-01-01'::date
|
|
END
|
|
ELSE
|
|
to_date(order_date, 'YYYY-MM-DD')
|
|
END ${finalSortDirection === 'desc' ? 'DESC' : 'ASC'}
|
|
`;
|
|
} else if (finalSortColumn === 'receiving_date') {
|
|
orderByClause = `
|
|
CASE WHEN receiving_date IS NULL THEN
|
|
'1900-01-01'::date
|
|
ELSE
|
|
to_date(receiving_date, 'YYYY-MM-DD')
|
|
END ${finalSortDirection === 'desc' ? 'DESC' : 'ASC'}
|
|
`;
|
|
} else if (finalSortColumn === 'vendor_name') {
|
|
orderByClause = `vendor_name ${finalSortDirection === 'desc' ? 'DESC NULLS LAST' : 'ASC NULLS FIRST'}`;
|
|
} else if (finalSortColumn === 'total_cost' || finalSortColumn === 'total_received' ||
|
|
finalSortColumn === 'total_items' || finalSortColumn === 'total_quantity' || finalSortColumn === 'fulfillment_rate') {
|
|
orderByClause = `COALESCE(${finalSortColumn}, 0) ${finalSortDirection === 'desc' ? 'DESC' : 'ASC'}`;
|
|
} else if (finalSortColumn === 'status') {
|
|
// For status sorting, first convert to numeric values for consistent sorting
|
|
orderByClause = `
|
|
CASE
|
|
WHEN status = 'canceled' THEN 0
|
|
WHEN status = 'created' THEN 1
|
|
WHEN status = 'electronically_ready_send' THEN 10
|
|
WHEN status = 'ordered' THEN 11
|
|
WHEN status = 'receiving_started' THEN 15
|
|
WHEN status = 'done' THEN 50
|
|
WHEN status = 'partial_received' THEN 30
|
|
WHEN status = 'full_received' THEN 40
|
|
WHEN status = 'paid' THEN 50
|
|
ELSE 999
|
|
END ${finalSortDirection === 'desc' ? 'DESC' : 'ASC'}
|
|
`;
|
|
} else {
|
|
// Default to ID sorting
|
|
orderByClause = `id::text::bigint ${finalSortDirection === 'desc' ? 'DESC' : 'ASC'}`;
|
|
}
|
|
|
|
// Main query to get purchase orders and receivings
|
|
let orderQuery = `
|
|
WITH po_data AS (
|
|
SELECT
|
|
po_id,
|
|
vendor,
|
|
date,
|
|
status,
|
|
COUNT(DISTINCT pid) as total_items,
|
|
SUM(ordered) as total_quantity,
|
|
ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_cost,
|
|
MAX(notes) as short_note
|
|
FROM purchase_orders po
|
|
WHERE ${poWhereClause}
|
|
GROUP BY po_id, vendor, date, status
|
|
),
|
|
receiving_data AS (
|
|
SELECT
|
|
r.receiving_id,
|
|
MAX(r.received_date) as receiving_date,
|
|
r.vendor as receiving_vendor,
|
|
COUNT(DISTINCT r.pid) as total_items,
|
|
SUM(r.qty_each) as total_received,
|
|
ROUND(SUM(r.qty_each * r.cost_each)::numeric, 3) as total_cost,
|
|
MAX(r.status) as receiving_status
|
|
FROM receivings r
|
|
WHERE ${receivingWhereClause}
|
|
GROUP BY r.receiving_id, r.vendor
|
|
)`;
|
|
|
|
// Add appropriate record type filtering based on the filter value
|
|
if (recordType === 'po_only') {
|
|
orderQuery += `,
|
|
all_data AS (
|
|
SELECT DISTINCT po_id as id
|
|
FROM po_data
|
|
WHERE po_id NOT IN (SELECT receiving_id FROM receiving_data WHERE receiving_id IS NOT NULL)
|
|
)`;
|
|
} else if (recordType === 'po_with_receiving') {
|
|
orderQuery += `,
|
|
all_data AS (
|
|
SELECT DISTINCT po_id as id
|
|
FROM po_data
|
|
WHERE po_id IN (SELECT receiving_id FROM receiving_data WHERE receiving_id IS NOT NULL)
|
|
)`;
|
|
} else if (recordType === 'receiving_only') {
|
|
orderQuery += `,
|
|
all_data AS (
|
|
SELECT DISTINCT receiving_id as id
|
|
FROM receiving_data
|
|
WHERE receiving_id NOT IN (SELECT po_id FROM po_data WHERE po_id IS NOT NULL)
|
|
)`;
|
|
} else {
|
|
// 'all' - include all records
|
|
orderQuery += `,
|
|
all_data AS (
|
|
SELECT DISTINCT po_id as id FROM po_data
|
|
UNION
|
|
SELECT DISTINCT receiving_id as id FROM receiving_data
|
|
WHERE receiving_id NOT IN (SELECT po_id FROM po_data WHERE po_id IS NOT NULL)
|
|
)`;
|
|
}
|
|
|
|
// Complete the query with combined data and ordering
|
|
orderQuery += `
|
|
,combined_data AS (
|
|
SELECT
|
|
a.id,
|
|
COALESCE(po.vendor, r.receiving_vendor) as vendor_name,
|
|
to_char(po.date, 'YYYY-MM-DD') as order_date,
|
|
to_char(r.receiving_date, 'YYYY-MM-DD') as receiving_date,
|
|
CASE
|
|
WHEN po.po_id IS NULL THEN r.receiving_status
|
|
ELSE po.status
|
|
END as status,
|
|
COALESCE(po.total_items, r.total_items, 0) as total_items,
|
|
COALESCE(po.total_quantity, 0) as total_quantity,
|
|
COALESCE(po.total_cost, r.total_cost, 0) as total_cost,
|
|
COALESCE(r.total_received, 0) as total_received,
|
|
CASE
|
|
WHEN po.po_id IS NULL THEN 1
|
|
WHEN r.receiving_id IS NULL THEN 0
|
|
WHEN po.total_quantity = 0 THEN 0
|
|
ELSE ROUND((r.total_received::numeric / po.total_quantity), 3)
|
|
END as fulfillment_rate,
|
|
po.short_note,
|
|
CASE
|
|
WHEN po.po_id IS NULL THEN 'receiving_only'
|
|
WHEN r.receiving_id IS NULL THEN 'po_only'
|
|
ELSE 'po_with_receiving'
|
|
END as record_type
|
|
FROM all_data a
|
|
LEFT JOIN po_data po ON a.id = po.po_id
|
|
LEFT JOIN receiving_data r ON a.id = r.receiving_id
|
|
)
|
|
SELECT * FROM combined_data
|
|
ORDER BY ${orderByClause}, id::text::bigint DESC
|
|
LIMIT $${paramCounter} OFFSET $${paramCounter + 1}
|
|
`;
|
|
|
|
const { rows: orders } = await pool.query(orderQuery, [...params, parsedLimit, 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 != ''
|
|
UNION
|
|
SELECT DISTINCT vendor
|
|
FROM receivings
|
|
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
|
|
UNION
|
|
SELECT DISTINCT status
|
|
FROM receivings
|
|
WHERE status IS NOT NULL
|
|
ORDER BY status
|
|
`);
|
|
|
|
// Get product vendors for orders with Unknown Vendor
|
|
const poIds = orders.filter(o => o.vendor_name === 'Unknown Vendor').map(o => o.id);
|
|
let vendorMappings = {};
|
|
|
|
if (poIds.length > 0) {
|
|
const { rows: productVendors } = await pool.query(`
|
|
SELECT DISTINCT po.po_id, p.vendor
|
|
FROM purchase_orders po
|
|
JOIN products p ON po.pid = p.pid
|
|
WHERE po.po_id = ANY($1)
|
|
AND p.vendor IS NOT NULL AND p.vendor != ''
|
|
GROUP BY po.po_id, p.vendor
|
|
`, [poIds]);
|
|
|
|
// Create mapping of PO ID to actual vendor from products table
|
|
vendorMappings = productVendors.reduce((acc, pv) => {
|
|
if (!acc[pv.po_id]) {
|
|
acc[pv.po_id] = pv.vendor;
|
|
}
|
|
return acc;
|
|
}, {});
|
|
}
|
|
|
|
// Parse numeric values and map status strings to numeric codes
|
|
const parsedOrders = orders.map(order => {
|
|
// Special handling for status mapping
|
|
let statusCode;
|
|
if (order.record_type === 'receiving_only') {
|
|
// For receiving-only records, use receiving status codes
|
|
statusCode = STATUS_MAPPING[order.status] || 0;
|
|
} else {
|
|
// For PO records, use PO status codes
|
|
statusCode = STATUS_MAPPING[order.status] || 0;
|
|
}
|
|
|
|
return {
|
|
id: order.id,
|
|
vendor_name: vendorMappings[order.id] || order.vendor_name,
|
|
order_date: order.order_date,
|
|
receiving_date: order.receiving_date,
|
|
status: statusCode,
|
|
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,
|
|
short_note: order.short_note,
|
|
record_type: order.record_type
|
|
};
|
|
});
|
|
|
|
// Parse summary metrics with fallbacks
|
|
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
|
|
};
|
|
|
|
console.log(`Returning ${parsedOrders.length} orders, total=${total}, pages=${pages}, page=${parsedPage}`);
|
|
|
|
res.json({
|
|
orders: parsedOrders,
|
|
summary: parsedSummary,
|
|
pagination: {
|
|
total,
|
|
pages,
|
|
page: parsedPage,
|
|
limit: parsedLimit
|
|
},
|
|
filters: {
|
|
vendors: vendors.map(v => v.vendor),
|
|
statuses: statuses.map(s => STATUS_MAPPING[s.status] || 0) // Map string statuses to numeric codes for the frontend
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Error fetching purchase orders:', error);
|
|
res.status(500).json({ error: 'Failed to fetch purchase orders', details: error.message });
|
|
}
|
|
});
|
|
|
|
// 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 po_data AS (
|
|
SELECT
|
|
vendor,
|
|
po_id,
|
|
SUM(ordered) as total_ordered,
|
|
AVG(po_cost_price) as avg_cost_price,
|
|
MAX(date) as po_date
|
|
FROM purchase_orders
|
|
WHERE vendor IS NOT NULL AND vendor != ''
|
|
AND status != 'canceled' -- Exclude canceled orders
|
|
GROUP BY vendor, po_id
|
|
),
|
|
receiving_data AS (
|
|
SELECT
|
|
r.receiving_id as po_id,
|
|
SUM(r.qty_each) as total_received,
|
|
MIN(r.received_date) as first_received_date
|
|
FROM receivings r
|
|
JOIN purchase_orders po ON r.receiving_id = po.po_id
|
|
WHERE po.vendor IS NOT NULL AND po.vendor != ''
|
|
AND po.status != 'canceled'
|
|
GROUP BY r.receiving_id
|
|
),
|
|
delivery_metrics AS (
|
|
SELECT
|
|
po.vendor,
|
|
po.po_id,
|
|
po.total_ordered as ordered,
|
|
COALESCE(r.total_received, 0) as received,
|
|
po.avg_cost_price as po_cost_price,
|
|
CASE
|
|
WHEN r.first_received_date IS NOT NULL AND po.po_date IS NOT NULL
|
|
THEN EXTRACT(DAY FROM (r.first_received_date - po.po_date))
|
|
ELSE NULL
|
|
END as delivery_days
|
|
FROM po_data po
|
|
LEFT JOIN receiving_data r ON po.po_id = r.po_id
|
|
)
|
|
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 * po_cost_price)::numeric / NULLIF(SUM(ordered), 0)), 2
|
|
) as avg_unit_cost,
|
|
ROUND(SUM(ordered * po_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.po_cost_price as cost_price,
|
|
po.ordered,
|
|
po.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 != '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 and include ALL data for each category
|
|
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,
|
|
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
|
|
}))
|
|
};
|
|
|
|
// 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' });
|
|
}
|
|
});
|
|
|
|
// New endpoint for yearly category spending analysis based on receivings
|
|
router.get('/category-analysis', async (req, res) => {
|
|
try {
|
|
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 { rows: analysis } = await pool.query(`
|
|
WITH receiving_costs AS (
|
|
SELECT
|
|
c.name as category,
|
|
r.pid,
|
|
r.cost_each as received_cost,
|
|
r.qty_each as received_qty,
|
|
r.received_date
|
|
FROM receivings r
|
|
JOIN product_categories pc ON r.pid = pc.pid
|
|
JOIN categories c ON pc.cat_id = c.cat_id
|
|
WHERE r.received_date >= $1::date
|
|
AND r.qty_each > 0 -- Only consider actual received quantities
|
|
)
|
|
SELECT
|
|
category,
|
|
COUNT(DISTINCT pid) as unique_products,
|
|
ROUND(AVG(received_cost)::numeric, 3) as avg_cost,
|
|
ROUND(MIN(received_cost)::numeric, 3) as min_cost,
|
|
ROUND(MAX(received_cost)::numeric, 3) as max_cost,
|
|
ROUND(STDDEV(received_cost)::numeric, 3) as cost_variance,
|
|
ROUND(SUM(received_qty * received_cost)::numeric, 3) as total_spend
|
|
FROM receiving_costs
|
|
GROUP BY category
|
|
ORDER BY total_spend DESC
|
|
`, [since]);
|
|
|
|
// Parse numeric values
|
|
const parsedAnalysis = 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 category analysis:', error);
|
|
res.status(500).json({ error: 'Failed to fetch category analysis' });
|
|
}
|
|
});
|
|
|
|
// New endpoint for yearly vendor spending analysis based on receivings
|
|
router.get('/vendor-analysis', async (req, res) => {
|
|
try {
|
|
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 { rows: metrics } = await pool.query(`
|
|
WITH receiving_data AS (
|
|
SELECT
|
|
r.vendor,
|
|
r.receiving_id,
|
|
r.pid,
|
|
r.qty_each,
|
|
r.cost_each,
|
|
r.received_date
|
|
FROM receivings r
|
|
WHERE r.received_date >= $1::date
|
|
AND r.qty_each > 0 -- Only consider actual received quantities
|
|
),
|
|
receiving_totals AS (
|
|
SELECT
|
|
vendor,
|
|
receiving_id,
|
|
COUNT(DISTINCT pid) as unique_products,
|
|
SUM(qty_each) as total_received,
|
|
SUM(qty_each * cost_each) as total_spend
|
|
FROM receiving_data
|
|
GROUP BY vendor, receiving_id
|
|
)
|
|
SELECT
|
|
vendor,
|
|
COUNT(DISTINCT receiving_id) as orders,
|
|
ROUND(SUM(total_spend)::numeric, 3) as total_spend,
|
|
SUM(total_received) as total_received,
|
|
SUM(unique_products) as total_items
|
|
FROM receiving_totals
|
|
WHERE vendor IS NOT NULL AND vendor != ''
|
|
GROUP BY vendor
|
|
HAVING COUNT(DISTINCT receiving_id) > 0
|
|
ORDER BY total_spend DESC
|
|
`, [since]);
|
|
|
|
// Parse numeric values
|
|
const parsedMetrics = metrics.map(vendor => ({
|
|
vendor: vendor.vendor,
|
|
orders: Number(vendor.orders) || 0,
|
|
total_spend: Number(vendor.total_spend) || 0,
|
|
total_received: Number(vendor.total_received) || 0,
|
|
total_items: Number(vendor.total_items) || 0
|
|
}));
|
|
|
|
res.json(parsedMetrics);
|
|
} catch (error) {
|
|
console.error('Error fetching vendor analysis:', error);
|
|
res.status(500).json({ error: 'Failed to fetch vendor analysis' });
|
|
}
|
|
});
|
|
|
|
// Get order 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,
|
|
SUM(ordered) as total_ordered,
|
|
ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_cost
|
|
FROM purchase_orders
|
|
WHERE status != 'canceled'
|
|
GROUP BY po_id, status
|
|
),
|
|
receiving_totals AS (
|
|
SELECT
|
|
po.po_id,
|
|
SUM(r.qty_each) as total_received
|
|
FROM receivings r
|
|
JOIN purchase_orders po ON r.pid = po.pid AND r.sku = po.sku
|
|
WHERE po.po_id IN (SELECT po_id FROM po_totals)
|
|
GROUP BY po.po_id
|
|
),
|
|
combined_data AS (
|
|
SELECT
|
|
po.po_id,
|
|
po.status,
|
|
po.total_ordered,
|
|
po.total_cost,
|
|
COALESCE(r.total_received, 0) as total_received
|
|
FROM po_totals po
|
|
LEFT JOIN receiving_totals r ON po.po_id = r.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,
|
|
ROUND(SUM(total_cost)::numeric, 3) as total_value,
|
|
ROUND(AVG(total_cost)::numeric, 3) as avg_cost,
|
|
COUNT(DISTINCT CASE
|
|
WHEN status = 'created' THEN po_id
|
|
END) as pending_count,
|
|
COUNT(DISTINCT CASE
|
|
WHEN status = 'receiving_started' THEN po_id
|
|
END) as partial_count,
|
|
COUNT(DISTINCT CASE
|
|
WHEN status = 'done' THEN po_id
|
|
END) as completed_count,
|
|
COUNT(DISTINCT CASE
|
|
WHEN status = 'canceled' THEN po_id
|
|
END) as canceled_count
|
|
FROM combined_data
|
|
`);
|
|
|
|
// 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(`
|
|
WITH order_data AS (
|
|
SELECT
|
|
p.pid,
|
|
p.title,
|
|
p.SKU,
|
|
SUM(po.ordered) as ordered_quantity,
|
|
COUNT(DISTINCT po.po_id) as order_count
|
|
FROM products p
|
|
JOIN purchase_orders po ON p.pid = po.pid
|
|
WHERE po.date >= (CURRENT_DATE - INTERVAL '90 days')
|
|
GROUP BY p.pid, p.title, p.SKU
|
|
),
|
|
receiving_data AS (
|
|
SELECT
|
|
r.pid,
|
|
SUM(r.qty_each) as received_quantity
|
|
FROM receivings r
|
|
JOIN purchase_orders po ON r.receiving_id = po.po_id
|
|
WHERE r.received_date >= (CURRENT_DATE - INTERVAL '90 days')
|
|
GROUP BY r.pid
|
|
)
|
|
SELECT
|
|
o.pid as product_id,
|
|
o.title as product,
|
|
o.SKU as sku,
|
|
o.ordered_quantity,
|
|
COALESCE(r.received_quantity, 0) as received_quantity,
|
|
ROUND(
|
|
COALESCE(r.received_quantity, 0) / NULLIF(o.ordered_quantity, 0) * 100, 1
|
|
) as fulfillment_rate,
|
|
o.order_count
|
|
FROM order_data o
|
|
LEFT JOIN receiving_data r ON o.pid = r.pid
|
|
WHERE o.order_count > 0
|
|
ORDER BY o.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) || 0,
|
|
received_quantity: Number(q.received_quantity) || 0,
|
|
fulfillment_rate: Number(q.fulfillment_rate) || 0,
|
|
order_count: Number(q.order_count) || 0
|
|
}));
|
|
|
|
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' });
|
|
}
|
|
});
|
|
|
|
// Get purchase order items
|
|
router.get('/:id/items', async (req, res) => {
|
|
try {
|
|
const pool = req.app.locals.pool;
|
|
const { id } = req.params;
|
|
|
|
if (!id) {
|
|
return res.status(400).json({ error: 'Purchase order ID is required' });
|
|
}
|
|
|
|
// Query to get purchase order items with product details
|
|
const { rows: items } = await pool.query(`
|
|
WITH po_items AS (
|
|
SELECT
|
|
po.po_id,
|
|
po.pid,
|
|
po.sku,
|
|
COALESCE(po.name, p.title) as product_name,
|
|
po.po_cost_price,
|
|
po.ordered,
|
|
po.status
|
|
FROM purchase_orders po
|
|
LEFT JOIN products p ON po.pid = p.pid
|
|
WHERE po.po_id = $1
|
|
),
|
|
receiving_items AS (
|
|
SELECT
|
|
r.receiving_id,
|
|
r.pid,
|
|
r.sku,
|
|
SUM(r.qty_each) as received
|
|
FROM receivings r
|
|
WHERE r.receiving_id = $1
|
|
GROUP BY r.receiving_id, r.pid, r.sku
|
|
)
|
|
SELECT
|
|
pi.po_id as id,
|
|
pi.pid,
|
|
pi.sku,
|
|
pi.product_name,
|
|
p.barcode,
|
|
pi.po_cost_price,
|
|
pi.ordered,
|
|
COALESCE(ri.received, 0) as received,
|
|
ROUND(pi.ordered * pi.po_cost_price, 2) as total_cost,
|
|
CASE
|
|
WHEN ri.received IS NULL THEN 'Not Received'
|
|
WHEN ri.received = 0 THEN 'Not Received'
|
|
WHEN ri.received < pi.ordered THEN 'Partially Received'
|
|
WHEN ri.received >= pi.ordered THEN 'Fully Received'
|
|
END as receiving_status
|
|
FROM po_items pi
|
|
LEFT JOIN receiving_items ri ON pi.pid = ri.pid AND pi.sku = ri.sku
|
|
LEFT JOIN products p ON pi.pid = p.pid
|
|
ORDER BY pi.product_name
|
|
`, [id]);
|
|
|
|
// Parse numeric values
|
|
const parsedItems = items.map(item => ({
|
|
id: `${item.id}_${item.pid}`,
|
|
pid: item.pid,
|
|
product_name: item.product_name,
|
|
sku: item.sku,
|
|
upc: item.barcode || 'N/A',
|
|
ordered: Number(item.ordered) || 0,
|
|
received: Number(item.received) || 0,
|
|
po_cost_price: Number(item.po_cost_price) || 0,
|
|
total_cost: Number(item.total_cost) || 0,
|
|
receiving_status: item.receiving_status
|
|
}));
|
|
|
|
res.json(parsedItems);
|
|
} catch (error) {
|
|
console.error('Error fetching purchase order items:', error);
|
|
res.status(500).json({ error: 'Failed to fetch purchase order items', details: error.message });
|
|
}
|
|
});
|
|
|
|
// Get receiving items
|
|
router.get('/receiving/:id/items', async (req, res) => {
|
|
try {
|
|
const pool = req.app.locals.pool;
|
|
const { id } = req.params;
|
|
|
|
if (!id) {
|
|
return res.status(400).json({ error: 'Receiving ID is required' });
|
|
}
|
|
|
|
// Query to get receiving items with related PO information if available
|
|
const { rows: items } = await pool.query(`
|
|
WITH receiving_items AS (
|
|
SELECT
|
|
r.receiving_id,
|
|
r.pid,
|
|
r.sku,
|
|
COALESCE(r.name, p.title) as product_name,
|
|
r.cost_each,
|
|
r.qty_each,
|
|
r.status
|
|
FROM receivings r
|
|
LEFT JOIN products p ON r.pid = p.pid
|
|
WHERE r.receiving_id = $1
|
|
),
|
|
po_items AS (
|
|
SELECT
|
|
po.po_id,
|
|
po.pid,
|
|
po.sku,
|
|
po.ordered,
|
|
po.po_cost_price
|
|
FROM purchase_orders po
|
|
WHERE po.po_id = $1
|
|
)
|
|
SELECT
|
|
ri.receiving_id as id,
|
|
ri.pid,
|
|
ri.sku,
|
|
ri.product_name,
|
|
p.barcode,
|
|
COALESCE(po.ordered, 0) as ordered,
|
|
ri.qty_each as received,
|
|
COALESCE(po.po_cost_price, ri.cost_each) as po_cost_price,
|
|
ri.cost_each,
|
|
ROUND(ri.qty_each * ri.cost_each, 2) as total_cost,
|
|
CASE
|
|
WHEN po.ordered IS NULL THEN 'Receiving Only'
|
|
WHEN ri.qty_each < po.ordered THEN 'Partially Received'
|
|
WHEN ri.qty_each >= po.ordered THEN 'Fully Received'
|
|
END as receiving_status
|
|
FROM receiving_items ri
|
|
LEFT JOIN po_items po ON ri.pid = po.pid AND ri.sku = po.sku
|
|
LEFT JOIN products p ON ri.pid = p.pid
|
|
ORDER BY ri.product_name
|
|
`, [id]);
|
|
|
|
// Parse numeric values
|
|
const parsedItems = items.map(item => ({
|
|
id: `${item.id}_${item.pid}`,
|
|
pid: item.pid,
|
|
product_name: item.product_name,
|
|
sku: item.sku,
|
|
upc: item.barcode || 'N/A',
|
|
ordered: Number(item.ordered) || 0,
|
|
received: Number(item.received) || 0,
|
|
po_cost_price: Number(item.po_cost_price) || 0,
|
|
cost_each: Number(item.cost_each) || 0,
|
|
qty_each: Number(item.received) || 0,
|
|
total_cost: Number(item.total_cost) || 0,
|
|
receiving_status: item.receiving_status
|
|
}));
|
|
|
|
res.json(parsedItems);
|
|
} catch (error) {
|
|
console.error('Error fetching receiving items:', error);
|
|
res.status(500).json({ error: 'Failed to fetch receiving items', details: error.message });
|
|
}
|
|
});
|
|
|
|
// New endpoint for delivery metrics
|
|
router.get('/delivery-metrics', async (req, res) => {
|
|
try {
|
|
const pool = req.app.locals.pool;
|
|
|
|
const { rows: deliveryData } = await pool.query(`
|
|
WITH po_dates AS (
|
|
SELECT
|
|
po_id,
|
|
date as order_date
|
|
FROM purchase_orders
|
|
WHERE status != 'canceled'
|
|
GROUP BY po_id, date
|
|
),
|
|
receiving_dates AS (
|
|
SELECT
|
|
receiving_id as po_id,
|
|
MIN(received_date) as first_received_date
|
|
FROM receivings
|
|
GROUP BY receiving_id
|
|
),
|
|
delivery_times AS (
|
|
SELECT
|
|
po.po_id,
|
|
po.order_date,
|
|
r.first_received_date,
|
|
CASE
|
|
WHEN r.first_received_date IS NOT NULL AND po.order_date IS NOT NULL
|
|
THEN (r.first_received_date::date - po.order_date::date)
|
|
ELSE NULL
|
|
END as delivery_days
|
|
FROM po_dates po
|
|
JOIN receiving_dates r ON po.po_id = r.po_id
|
|
WHERE
|
|
r.first_received_date IS NOT NULL
|
|
AND po.order_date IS NOT NULL
|
|
AND r.first_received_date::date >= po.order_date::date
|
|
)
|
|
SELECT
|
|
ROUND(AVG(delivery_days)::numeric, 1) as avg_delivery_days,
|
|
ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY delivery_days)::numeric, 1) as median_delivery_days,
|
|
MIN(delivery_days) as min_delivery_days,
|
|
MAX(delivery_days) as max_delivery_days,
|
|
COUNT(*) as total_orders_with_delivery
|
|
FROM delivery_times
|
|
WHERE delivery_days >= 0 AND delivery_days <= 365 -- Filter out unreasonable values
|
|
`);
|
|
|
|
res.json({
|
|
avg_delivery_days: Number(deliveryData[0]?.avg_delivery_days) || 0,
|
|
median_delivery_days: Number(deliveryData[0]?.median_delivery_days) || 0,
|
|
min_delivery_days: Number(deliveryData[0]?.min_delivery_days) || 0,
|
|
max_delivery_days: Number(deliveryData[0]?.max_delivery_days) || 0,
|
|
total_orders_with_delivery: Number(deliveryData[0]?.total_orders_with_delivery) || 0
|
|
});
|
|
} catch (error) {
|
|
console.error('Error fetching delivery metrics:', error);
|
|
res.status(500).json({ error: 'Failed to fetch delivery metrics' });
|
|
}
|
|
});
|
|
|
|
// PO Pipeline — expected arrivals timeline + overdue summary
|
|
router.get('/pipeline', async (req, res) => {
|
|
try {
|
|
const pool = req.app.locals.pool;
|
|
|
|
// Stale PO filter (reused across queries)
|
|
const staleFilter = `
|
|
WITH stale AS (
|
|
SELECT po_id, pid
|
|
FROM purchase_orders po
|
|
WHERE po.status IN ('created', 'ordered', 'preordered', 'electronically_sent',
|
|
'electronically_ready_send', 'receiving_started')
|
|
AND po.expected_date IS NOT NULL
|
|
AND po.expected_date < CURRENT_DATE - INTERVAL '90 days'
|
|
AND EXISTS (
|
|
SELECT 1 FROM purchase_orders newer
|
|
WHERE newer.pid = po.pid
|
|
AND newer.status NOT IN ('canceled', 'done')
|
|
AND COALESCE(newer.date_ordered, newer.date_created)
|
|
> COALESCE(po.date_ordered, po.date_created)
|
|
)
|
|
)`;
|
|
|
|
// Expected arrivals by week (excludes stale POs)
|
|
const { rows: arrivals } = await pool.query(`
|
|
${staleFilter}
|
|
SELECT
|
|
DATE_TRUNC('week', po.expected_date)::date AS week,
|
|
COUNT(DISTINCT po.po_id) AS po_count,
|
|
ROUND(SUM(po.po_cost_price * po.ordered)::numeric, 0) AS expected_value,
|
|
COUNT(DISTINCT po.vendor) AS vendor_count
|
|
FROM purchase_orders po
|
|
WHERE po.status IN ('ordered', 'electronically_sent')
|
|
AND po.expected_date IS NOT NULL
|
|
AND NOT EXISTS (SELECT 1 FROM stale s WHERE s.po_id = po.po_id AND s.pid = po.pid)
|
|
GROUP BY 1
|
|
ORDER BY 1
|
|
`);
|
|
|
|
// Overdue POs (excludes stale)
|
|
const { rows: [overdue] } = await pool.query(`
|
|
${staleFilter}
|
|
SELECT
|
|
COUNT(DISTINCT po.po_id) AS po_count,
|
|
ROUND(COALESCE(SUM(po.po_cost_price * po.ordered), 0)::numeric, 0) AS total_value
|
|
FROM purchase_orders po
|
|
WHERE po.status IN ('ordered', 'electronically_sent')
|
|
AND po.expected_date IS NOT NULL
|
|
AND po.expected_date < CURRENT_DATE
|
|
AND NOT EXISTS (SELECT 1 FROM stale s WHERE s.po_id = po.po_id AND s.pid = po.pid)
|
|
`);
|
|
|
|
// Summary: on-order value from product_metrics (FIFO-accurate), PO counts from purchase_orders with staleness filter
|
|
const { rows: [summary] } = await pool.query(`
|
|
${staleFilter}
|
|
SELECT
|
|
COUNT(DISTINCT po.po_id) AS total_open_pos,
|
|
COUNT(DISTINCT po.vendor) AS vendor_count
|
|
FROM purchase_orders po
|
|
WHERE po.status IN ('ordered', 'electronically_sent')
|
|
AND NOT EXISTS (SELECT 1 FROM stale s WHERE s.po_id = po.po_id AND s.pid = po.pid)
|
|
`);
|
|
|
|
const { rows: [onOrderTotal] } = await pool.query(`
|
|
SELECT ROUND(COALESCE(SUM(on_order_cost), 0)::numeric, 0) AS total_on_order_value
|
|
FROM product_metrics
|
|
WHERE is_visible = true
|
|
`);
|
|
|
|
res.json({
|
|
arrivals: arrivals.map(r => ({
|
|
week: r.week,
|
|
poCount: Number(r.po_count) || 0,
|
|
expectedValue: Number(r.expected_value) || 0,
|
|
vendorCount: Number(r.vendor_count) || 0,
|
|
})),
|
|
overdue: {
|
|
count: Number(overdue.po_count) || 0,
|
|
value: Number(overdue.total_value) || 0,
|
|
},
|
|
summary: {
|
|
totalOpenPOs: Number(summary.total_open_pos) || 0,
|
|
totalOnOrderValue: Number(onOrderTotal.total_on_order_value) || 0,
|
|
vendorCount: Number(summary.vendor_count) || 0,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
console.error('Error fetching PO pipeline:', error);
|
|
res.status(500).json({ error: 'Failed to fetch PO pipeline' });
|
|
}
|
|
});
|
|
|
|
module.exports = router; |