PO-related fixes
This commit is contained in:
@@ -406,12 +406,7 @@ async function materializeCalculations(prodConnection, localConnection, incremen
|
||||
WHERE oi.prod_pid = p.pid AND o.order_status >= 20) AS total_sold,
|
||||
pls.date_sold as date_last_sold,
|
||||
(SELECT iid FROM product_images WHERE pid = p.pid AND \`order\` = 255 LIMIT 1) AS primary_iid,
|
||||
GROUP_CONCAT(DISTINCT CASE
|
||||
WHEN pc.cat_id IS NOT NULL
|
||||
AND pc.type IN (10, 20, 11, 21, 12, 13)
|
||||
AND pci.cat_id NOT IN (16, 17)
|
||||
THEN pci.cat_id
|
||||
END) as category_ids
|
||||
NULL as category_ids
|
||||
FROM products p
|
||||
LEFT JOIN shop_inventory si ON p.pid = si.pid AND si.store = 0
|
||||
LEFT JOIN current_inventory ci ON p.pid = ci.pid
|
||||
|
||||
@@ -70,6 +70,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
||||
DROP TABLE IF EXISTS temp_receivings;
|
||||
DROP TABLE IF EXISTS temp_receiving_allocations;
|
||||
DROP TABLE IF EXISTS employee_names;
|
||||
DROP TABLE IF EXISTS temp_supplier_names;
|
||||
|
||||
-- Temporary table for purchase orders
|
||||
CREATE TEMP TABLE temp_purchase_orders (
|
||||
@@ -103,6 +104,8 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
||||
receiving_created_date TIMESTAMP WITH TIME ZONE,
|
||||
supplier_id INTEGER,
|
||||
status TEXT,
|
||||
sku TEXT,
|
||||
name TEXT,
|
||||
PRIMARY KEY (receiving_id, pid)
|
||||
);
|
||||
|
||||
@@ -421,9 +424,12 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
||||
rp.cost_each,
|
||||
rp.received_by,
|
||||
rp.received_date,
|
||||
r.date_created as receiving_created_date
|
||||
r.date_created as receiving_created_date,
|
||||
COALESCE(p.itemnumber, 'NO-SKU') AS sku,
|
||||
COALESCE(p.description, 'Unknown Product') AS name
|
||||
FROM receivings_products rp
|
||||
JOIN receivings r ON rp.receiving_id = r.receiving_id
|
||||
LEFT JOIN products p ON rp.pid = p.pid
|
||||
WHERE rp.receiving_id IN (?)
|
||||
`, [receivingIds]);
|
||||
|
||||
@@ -443,7 +449,9 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
||||
received_date: validateDate(product.received_date) || validateDate(product.receiving_created_date),
|
||||
receiving_created_date: validateDate(product.receiving_created_date),
|
||||
supplier_id: receiving.supplier_id,
|
||||
status: receivingStatusMap[receiving.status] || 'created'
|
||||
status: receivingStatusMap[receiving.status] || 'created',
|
||||
sku: product.sku,
|
||||
name: product.name
|
||||
});
|
||||
}
|
||||
|
||||
@@ -452,8 +460,8 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
||||
const batch = completeReceivings.slice(i, i + INSERT_BATCH_SIZE);
|
||||
|
||||
const placeholders = batch.map((_, idx) => {
|
||||
const base = idx * 10;
|
||||
return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, $${base + 8}, $${base + 9}, $${base + 10})`;
|
||||
const base = idx * 12;
|
||||
return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, $${base + 8}, $${base + 9}, $${base + 10}, $${base + 11}, $${base + 12})`;
|
||||
}).join(',');
|
||||
|
||||
const values = batch.flatMap(r => [
|
||||
@@ -466,13 +474,16 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
||||
r.received_date,
|
||||
r.receiving_created_date,
|
||||
r.supplier_id,
|
||||
r.status
|
||||
r.status,
|
||||
r.sku,
|
||||
r.name
|
||||
]);
|
||||
|
||||
await localConnection.query(`
|
||||
INSERT INTO temp_receivings (
|
||||
receiving_id, po_id, pid, qty_each, cost_each, received_by,
|
||||
received_date, receiving_created_date, supplier_id, status
|
||||
received_date, receiving_created_date, supplier_id, status,
|
||||
sku, name
|
||||
)
|
||||
VALUES ${placeholders}
|
||||
ON CONFLICT (receiving_id, pid) DO UPDATE SET
|
||||
@@ -483,7 +494,9 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
||||
received_date = EXCLUDED.received_date,
|
||||
receiving_created_date = EXCLUDED.receiving_created_date,
|
||||
supplier_id = EXCLUDED.supplier_id,
|
||||
status = EXCLUDED.status
|
||||
status = EXCLUDED.status,
|
||||
sku = EXCLUDED.sku,
|
||||
name = EXCLUDED.name
|
||||
`, values);
|
||||
}
|
||||
|
||||
@@ -506,6 +519,55 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
||||
}
|
||||
}
|
||||
|
||||
// Add this section before the FIFO steps to create a supplier names mapping
|
||||
outputProgress({
|
||||
status: "running",
|
||||
operation: "Purchase orders import",
|
||||
message: "Fetching supplier data for vendor mapping"
|
||||
});
|
||||
|
||||
// Fetch supplier data from production and store in a temp table
|
||||
const [suppliers] = await prodConnection.query(`
|
||||
SELECT
|
||||
supplierid,
|
||||
companyname
|
||||
FROM suppliers
|
||||
WHERE companyname IS NOT NULL AND companyname != ''
|
||||
`);
|
||||
|
||||
if (suppliers.length > 0) {
|
||||
// Create temp table for supplier names
|
||||
await localConnection.query(`
|
||||
DROP TABLE IF EXISTS temp_supplier_names;
|
||||
CREATE TEMP TABLE temp_supplier_names (
|
||||
supplier_id INTEGER PRIMARY KEY,
|
||||
company_name TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Insert supplier data in batches
|
||||
for (let i = 0; i < suppliers.length; i += INSERT_BATCH_SIZE) {
|
||||
const batch = suppliers.slice(i, i + INSERT_BATCH_SIZE);
|
||||
|
||||
const placeholders = batch.map((_, idx) => {
|
||||
const base = idx * 2;
|
||||
return `($${base + 1}, $${base + 2})`;
|
||||
}).join(',');
|
||||
|
||||
const values = batch.flatMap(s => [
|
||||
s.supplierid,
|
||||
s.companyname || 'Unnamed Supplier'
|
||||
]);
|
||||
|
||||
await localConnection.query(`
|
||||
INSERT INTO temp_supplier_names (supplier_id, company_name)
|
||||
VALUES ${placeholders}
|
||||
ON CONFLICT (supplier_id) DO UPDATE SET
|
||||
company_name = EXCLUDED.company_name
|
||||
`, values);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Implement FIFO allocation of receivings to purchase orders
|
||||
outputProgress({
|
||||
status: "running",
|
||||
@@ -583,12 +645,20 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
||||
SELECT
|
||||
r.receiving_id::text as po_id,
|
||||
r.pid,
|
||||
COALESCE(p.sku, 'NO-SKU') as sku,
|
||||
COALESCE(p.name, 'Unknown Product') as name,
|
||||
r.sku,
|
||||
r.name,
|
||||
COALESCE(
|
||||
-- First, check if we already have a vendor name from the temp_purchase_orders table
|
||||
(SELECT vendor FROM temp_purchase_orders
|
||||
WHERE supplier_id = r.supplier_id LIMIT 1),
|
||||
'Unknown Vendor'
|
||||
-- Next, check the supplier_names mapping table we created
|
||||
(SELECT company_name FROM temp_supplier_names
|
||||
WHERE supplier_id = r.supplier_id),
|
||||
-- If both fail, use a generic name with the supplier ID
|
||||
CASE
|
||||
WHEN r.supplier_id IS NOT NULL THEN 'Supplier #' || r.supplier_id::text
|
||||
ELSE 'Unknown Supplier'
|
||||
END
|
||||
) as vendor,
|
||||
COALESCE(r.received_date, r.receiving_created_date) as date,
|
||||
'created' as status,
|
||||
@@ -598,9 +668,6 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
||||
COALESCE(r.receiving_created_date, r.received_date) as date_created,
|
||||
NULL as date_ordered
|
||||
FROM temp_receivings r
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT pid, sku, name FROM temp_purchase_orders
|
||||
) p ON r.pid = p.pid
|
||||
WHERE r.po_id IS NULL
|
||||
OR NOT EXISTS (
|
||||
SELECT 1 FROM temp_purchase_orders po
|
||||
@@ -923,6 +990,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
||||
DROP TABLE IF EXISTS temp_receivings;
|
||||
DROP TABLE IF EXISTS temp_receiving_allocations;
|
||||
DROP TABLE IF EXISTS employee_names;
|
||||
DROP TABLE IF EXISTS temp_supplier_names;
|
||||
`);
|
||||
|
||||
// Commit transaction
|
||||
|
||||
@@ -111,25 +111,35 @@ router.get('/purchase/metrics', async (req, res) => {
|
||||
SELECT
|
||||
COALESCE(COUNT(DISTINCT CASE
|
||||
WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')
|
||||
AND po.date >= CURRENT_DATE - INTERVAL '6 months'
|
||||
AND NOT (po.date < CURRENT_DATE - INTERVAL '1 month' AND po.received >= po.ordered * 0.9)
|
||||
THEN po.po_id
|
||||
END), 0)::integer as active_pos,
|
||||
COALESCE(COUNT(DISTINCT CASE
|
||||
WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')
|
||||
AND po.date >= CURRENT_DATE - INTERVAL '6 months'
|
||||
AND NOT (po.date < CURRENT_DATE - INTERVAL '1 month' AND po.received >= po.ordered * 0.9)
|
||||
AND po.expected_date < CURRENT_DATE
|
||||
THEN po.po_id
|
||||
END), 0)::integer as overdue_pos,
|
||||
COALESCE(SUM(CASE
|
||||
WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')
|
||||
AND po.date >= CURRENT_DATE - INTERVAL '6 months'
|
||||
AND NOT (po.date < CURRENT_DATE - INTERVAL '1 month' AND po.received >= po.ordered * 0.9)
|
||||
THEN po.ordered
|
||||
ELSE 0
|
||||
END), 0)::integer as total_units,
|
||||
ROUND(COALESCE(SUM(CASE
|
||||
WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')
|
||||
AND po.date >= CURRENT_DATE - INTERVAL '6 months'
|
||||
AND NOT (po.date < CURRENT_DATE - INTERVAL '1 month' AND po.received >= po.ordered * 0.9)
|
||||
THEN po.ordered * po.cost_price
|
||||
ELSE 0
|
||||
END), 0)::numeric, 3) as total_cost,
|
||||
ROUND(COALESCE(SUM(CASE
|
||||
WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')
|
||||
AND po.date >= CURRENT_DATE - INTERVAL '6 months'
|
||||
AND NOT (po.date < CURRENT_DATE - INTERVAL '1 month' AND po.received >= po.ordered * 0.9)
|
||||
THEN po.ordered * pm.current_price
|
||||
ELSE 0
|
||||
END), 0)::numeric, 3) as total_retail
|
||||
@@ -147,6 +157,8 @@ router.get('/purchase/metrics', async (req, res) => {
|
||||
FROM purchase_orders po
|
||||
JOIN product_metrics pm ON po.pid = pm.pid
|
||||
WHERE po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')
|
||||
AND po.date >= CURRENT_DATE - INTERVAL '6 months'
|
||||
AND NOT (po.date < CURRENT_DATE - INTERVAL '1 month' AND po.received >= po.ordered * 0.9)
|
||||
GROUP BY po.vendor
|
||||
HAVING ROUND(COALESCE(SUM(po.ordered * po.cost_price), 0)::numeric, 3) > 0
|
||||
ORDER BY cost DESC
|
||||
|
||||
@@ -2,6 +2,7 @@ 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,
|
||||
@@ -13,6 +14,18 @@ const STATUS = {
|
||||
DONE: 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
|
||||
};
|
||||
|
||||
const RECEIVING_STATUS = {
|
||||
CANCELED: 0,
|
||||
CREATED: 1,
|
||||
@@ -21,6 +34,26 @@ const RECEIVING_STATUS = {
|
||||
PAID: 50
|
||||
};
|
||||
|
||||
// Receiving status mapping from database string values to frontend numeric codes
|
||||
const RECEIVING_STATUS_MAPPING = {
|
||||
'canceled': RECEIVING_STATUS.CANCELED,
|
||||
'created': RECEIVING_STATUS.CREATED,
|
||||
'partial_received': RECEIVING_STATUS.PARTIAL_RECEIVED,
|
||||
'full_received': RECEIVING_STATUS.FULL_RECEIVED,
|
||||
'paid': RECEIVING_STATUS.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 {
|
||||
@@ -38,9 +71,7 @@ router.get('/', async (req, res) => {
|
||||
}
|
||||
|
||||
if (status && status !== 'all') {
|
||||
whereClause += ` AND po.status = $${paramCounter}`;
|
||||
params.push(Number(status));
|
||||
paramCounter++;
|
||||
whereClause += ` AND ${getStatusWhereClause(status)}`;
|
||||
}
|
||||
|
||||
if (vendor && vendor !== 'all') {
|
||||
@@ -139,17 +170,17 @@ router.get('/', async (req, res) => {
|
||||
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
|
||||
pt.po_id as id,
|
||||
pt.vendor as vendor_name,
|
||||
to_char(pt.date, 'YYYY-MM-DD') as order_date,
|
||||
pt.status,
|
||||
pt.receiving_status,
|
||||
pt.total_items,
|
||||
pt.total_quantity,
|
||||
pt.total_cost,
|
||||
pt.total_received,
|
||||
pt.fulfillment_rate
|
||||
FROM po_totals pt
|
||||
ORDER BY ${orderByClause}
|
||||
LIMIT $${paramCounter} OFFSET $${paramCounter + 1}
|
||||
`, [...params, Number(limit), offset]);
|
||||
@@ -170,13 +201,36 @@ router.get('/', async (req, res) => {
|
||||
ORDER BY status
|
||||
`);
|
||||
|
||||
// Parse numeric values
|
||||
// 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 => ({
|
||||
id: order.id,
|
||||
vendor_name: order.vendor_name,
|
||||
vendor_name: vendorMappings[order.id] || order.vendor_name,
|
||||
order_date: order.order_date,
|
||||
status: Number(order.status),
|
||||
receiving_status: Number(order.receiving_status),
|
||||
status: STATUS_MAPPING[order.status] || 0, // Map string status to numeric code
|
||||
receiving_status: RECEIVING_STATUS_MAPPING[order.receiving_status] || 0, // Map string receiving status to numeric code
|
||||
total_items: Number(order.total_items) || 0,
|
||||
total_quantity: Number(order.total_quantity) || 0,
|
||||
total_cost: Number(order.total_cost) || 0,
|
||||
@@ -205,7 +259,7 @@ router.get('/', async (req, res) => {
|
||||
},
|
||||
filters: {
|
||||
vendors: vendors.map(v => v.vendor),
|
||||
statuses: statuses.map(s => Number(s.status))
|
||||
statuses: statuses.map(s => STATUS_MAPPING[s.status] || 0) // Map string statuses to numeric codes for the frontend
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -228,14 +282,15 @@ router.get('/vendor-metrics', async (req, res) => {
|
||||
received,
|
||||
cost_price,
|
||||
CASE
|
||||
WHEN status >= ${STATUS.RECEIVING_STARTED} AND receiving_status >= ${RECEIVING_STATUS.PARTIAL_RECEIVED}
|
||||
WHEN status IN ('receiving_started', 'done')
|
||||
AND receiving_status IN ('partial_received', 'full_received', 'paid')
|
||||
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
|
||||
AND status != 'canceled' -- Exclude canceled orders
|
||||
)
|
||||
SELECT
|
||||
vendor as vendor_name,
|
||||
@@ -296,7 +351,7 @@ router.get('/cost-analysis', async (req, res) => {
|
||||
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
|
||||
WHERE po.status != 'canceled' -- Exclude canceled orders
|
||||
)
|
||||
SELECT
|
||||
category,
|
||||
@@ -311,7 +366,7 @@ router.get('/cost-analysis', async (req, res) => {
|
||||
ORDER BY total_spend DESC
|
||||
`);
|
||||
|
||||
// Parse numeric values
|
||||
// Parse numeric values and include ALL data for each category
|
||||
const parsedAnalysis = {
|
||||
unique_products: 0,
|
||||
avg_cost: 0,
|
||||
@@ -320,6 +375,11 @@ router.get('/cost-analysis', async (req, res) => {
|
||||
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
|
||||
}))
|
||||
};
|
||||
@@ -366,7 +426,7 @@ router.get('/receiving-status', async (req, res) => {
|
||||
SUM(received) as total_received,
|
||||
ROUND(SUM(ordered * cost_price)::numeric, 3) as total_cost
|
||||
FROM purchase_orders
|
||||
WHERE status != ${STATUS.CANCELED}
|
||||
WHERE status != 'canceled'
|
||||
GROUP BY po_id, status, receiving_status
|
||||
)
|
||||
SELECT
|
||||
@@ -379,16 +439,16 @@ router.get('/receiving-status', async (req, res) => {
|
||||
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
|
||||
WHEN receiving_status = 'created' THEN po_id
|
||||
END) as pending_count,
|
||||
COUNT(DISTINCT CASE
|
||||
WHEN receiving_status = ${RECEIVING_STATUS.PARTIAL_RECEIVED} THEN po_id
|
||||
WHEN receiving_status = 'partial_received' THEN po_id
|
||||
END) as partial_count,
|
||||
COUNT(DISTINCT CASE
|
||||
WHEN receiving_status >= ${RECEIVING_STATUS.FULL_RECEIVED} THEN po_id
|
||||
WHEN receiving_status IN ('full_received', 'paid') THEN po_id
|
||||
END) as completed_count,
|
||||
COUNT(DISTINCT CASE
|
||||
WHEN receiving_status = ${RECEIVING_STATUS.CANCELED} THEN po_id
|
||||
WHEN receiving_status = 'canceled' THEN po_id
|
||||
END) as canceled_count
|
||||
FROM po_totals
|
||||
`);
|
||||
@@ -423,7 +483,7 @@ router.get('/order-vs-received', async (req, res) => {
|
||||
|
||||
const { rows: quantities } = await pool.query(`
|
||||
SELECT
|
||||
p.product_id,
|
||||
p.pid as product_id,
|
||||
p.title as product,
|
||||
p.SKU as sku,
|
||||
SUM(po.ordered) as ordered_quantity,
|
||||
@@ -433,9 +493,9 @@ router.get('/order-vs-received', async (req, res) => {
|
||||
) 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
|
||||
JOIN purchase_orders po ON p.pid = po.pid
|
||||
WHERE po.date >= (CURRENT_DATE - INTERVAL '90 days')
|
||||
GROUP BY p.product_id, p.title, p.SKU
|
||||
GROUP BY p.pid, p.title, p.SKU
|
||||
HAVING COUNT(DISTINCT po.po_id) > 0
|
||||
ORDER BY SUM(po.ordered) DESC
|
||||
LIMIT 20
|
||||
@@ -445,10 +505,10 @@ router.get('/order-vs-received', async (req, res) => {
|
||||
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)
|
||||
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);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../components/ui/table';
|
||||
import { Loader2, ArrowUpDown } from 'lucide-react';
|
||||
import { Loader2, ArrowUpDown, Info, BarChart3 } from 'lucide-react';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Input } from '../components/ui/input';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
@@ -15,10 +15,26 @@ import {
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '../components/ui/pagination';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '../components/ui/tooltip';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../components/ui/dialog";
|
||||
import { motion } from 'motion/react';
|
||||
import {
|
||||
PurchaseOrderStatus,
|
||||
@@ -29,7 +45,7 @@ import {
|
||||
} from '../types/status-codes';
|
||||
|
||||
interface PurchaseOrder {
|
||||
id: number;
|
||||
id: number | string;
|
||||
vendor_name: string;
|
||||
order_date: string;
|
||||
status: number;
|
||||
@@ -59,6 +75,9 @@ interface CostAnalysis {
|
||||
total_spend_by_category: {
|
||||
category: string;
|
||||
total_spend: number;
|
||||
unique_products?: number;
|
||||
avg_cost?: number;
|
||||
cost_variance?: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -89,7 +108,7 @@ interface PurchaseOrdersResponse {
|
||||
};
|
||||
filters: {
|
||||
vendors: string[];
|
||||
statuses: string[];
|
||||
statuses: number[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -109,7 +128,7 @@ export default function PurchaseOrders() {
|
||||
});
|
||||
const [filterOptions, setFilterOptions] = useState<{
|
||||
vendors: string[];
|
||||
statuses: string[];
|
||||
statuses: number[];
|
||||
}>({
|
||||
vendors: [],
|
||||
statuses: []
|
||||
@@ -120,6 +139,7 @@ export default function PurchaseOrders() {
|
||||
page: 1,
|
||||
limit: 100,
|
||||
});
|
||||
const [costAnalysisOpen, setCostAnalysisOpen] = useState(false);
|
||||
|
||||
const STATUS_FILTER_OPTIONS = [
|
||||
{ value: 'all', label: 'All Statuses' },
|
||||
@@ -133,14 +153,15 @@ export default function PurchaseOrders() {
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const searchParams = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
limit: '100',
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
...filters.search && { search: filters.search },
|
||||
...filters.status && { status: filters.status },
|
||||
...filters.vendor && { vendor: filters.vendor },
|
||||
...filters.status !== 'all' && { status: filters.status },
|
||||
...filters.vendor !== 'all' && { vendor: filters.vendor },
|
||||
});
|
||||
|
||||
const [
|
||||
@@ -205,7 +226,23 @@ export default function PurchaseOrders() {
|
||||
console.error('Failed to fetch cost analysis:', await costAnalysisRes.text());
|
||||
}
|
||||
|
||||
setPurchaseOrders(purchaseOrdersData.orders);
|
||||
// Process orders data
|
||||
const processedOrders = purchaseOrdersData.orders.map(order => {
|
||||
let processedOrder = {
|
||||
...order,
|
||||
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
|
||||
};
|
||||
|
||||
return processedOrder;
|
||||
});
|
||||
|
||||
setPurchaseOrders(processedOrders);
|
||||
setPagination(purchaseOrdersData.pagination);
|
||||
setFilterOptions(purchaseOrdersData.filters);
|
||||
setSummary(purchaseOrdersData.summary);
|
||||
@@ -280,6 +317,10 @@ export default function PurchaseOrders() {
|
||||
});
|
||||
};
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return `$${formatNumber(value)}`;
|
||||
};
|
||||
|
||||
const formatPercent = (value: number) => {
|
||||
return (value * 100).toLocaleString('en-US', {
|
||||
minimumFractionDigits: 1,
|
||||
@@ -287,6 +328,141 @@ export default function PurchaseOrders() {
|
||||
}) + '%';
|
||||
};
|
||||
|
||||
// Generate pagination items
|
||||
const getPaginationItems = () => {
|
||||
const items = [];
|
||||
const maxPagesToShow = 5;
|
||||
const totalPages = pagination.pages;
|
||||
|
||||
// Always show first page
|
||||
if (totalPages > 0) {
|
||||
items.push(
|
||||
<PaginationItem key="first">
|
||||
<PaginationLink
|
||||
isActive={page === 1}
|
||||
onClick={() => page !== 1 && setPage(1)}
|
||||
>
|
||||
1
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
}
|
||||
|
||||
// Add ellipsis if needed
|
||||
if (page > 3) {
|
||||
items.push(
|
||||
<PaginationItem key="ellipsis-1">
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
);
|
||||
}
|
||||
|
||||
// Add pages around current page
|
||||
const startPage = Math.max(2, page - 1);
|
||||
const endPage = Math.min(totalPages - 1, page + 1);
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
if (i <= 1 || i >= totalPages) continue; // Skip first and last page as they're handled separately
|
||||
items.push(
|
||||
<PaginationItem key={i}>
|
||||
<PaginationLink
|
||||
isActive={page === i}
|
||||
onClick={() => page !== i && setPage(i)}
|
||||
>
|
||||
{i}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
}
|
||||
|
||||
// Add ellipsis if needed
|
||||
if (page < totalPages - 2) {
|
||||
items.push(
|
||||
<PaginationItem key="ellipsis-2">
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
);
|
||||
}
|
||||
|
||||
// Always show last page if there are multiple pages
|
||||
if (totalPages > 1) {
|
||||
items.push(
|
||||
<PaginationItem key="last">
|
||||
<PaginationLink
|
||||
isActive={page === totalPages}
|
||||
onClick={() => page !== totalPages && setPage(totalPages)}
|
||||
>
|
||||
{totalPages}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
// Cost Analysis table component
|
||||
const CostAnalysisTable = () => {
|
||||
if (!costAnalysis) return null;
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Products</TableHead>
|
||||
<TableHead>Avg. Cost</TableHead>
|
||||
<TableHead>Price Variance</TableHead>
|
||||
<TableHead>Total Spend</TableHead>
|
||||
<TableHead>% of Total</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{costAnalysis?.total_spend_by_category?.length ?
|
||||
costAnalysis.total_spend_by_category.map((category) => {
|
||||
// Calculate percentage of total spend
|
||||
const totalSpendPercentage =
|
||||
costAnalysis.total_spend_by_category.reduce((sum, cat) => sum + cat.total_spend, 0) > 0
|
||||
? (category.total_spend /
|
||||
costAnalysis.total_spend_by_category.reduce((sum, cat) => sum + cat.total_spend, 0))
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<TableRow key={category.category}>
|
||||
<TableCell className="font-medium">
|
||||
{category.category || 'Uncategorized'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{category.unique_products?.toLocaleString() || "N/A"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{category.avg_cost !== undefined ? formatCurrency(category.avg_cost) : "N/A"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{category.cost_variance !== undefined ?
|
||||
parseFloat(category.cost_variance.toFixed(2)).toLocaleString() : "N/A"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatCurrency(category.total_spend)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatPercent(totalSpendPercentage)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
No cost analysis data available
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
@@ -315,7 +491,7 @@ export default function PurchaseOrders() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
${formatNumber(summary?.total_value || 0)}
|
||||
{formatCurrency(summary?.total_value || 0)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -331,12 +507,44 @@ export default function PurchaseOrders() {
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Avg Cost per PO</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">Spending Analysis</CardTitle>
|
||||
<Dialog open={costAnalysisOpen} onOpenChange={setCostAnalysisOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[90%] w-fit">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
<span>Purchase Order Spending Analysis by Category</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This analysis shows spending distribution across product categories
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="overflow-auto max-h-[70vh]">
|
||||
<CostAnalysisTable />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
${formatNumber(summary?.avg_cost || 0)}
|
||||
{formatCurrency(summary?.avg_cost || 0)}
|
||||
<div className="text-sm font-normal text-muted-foreground">
|
||||
Avg. Cost per PO
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full mt-2 text-sm"
|
||||
onClick={() => setCostAnalysisOpen(true)}
|
||||
>
|
||||
<BarChart3 className="h-4 w-4 mr-2" />
|
||||
View Category Analysis
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -435,9 +643,11 @@ export default function PurchaseOrders() {
|
||||
<TableCell>{getStatusBadge(po.status, po.receiving_status)}</TableCell>
|
||||
<TableCell>{po.total_items.toLocaleString()}</TableCell>
|
||||
<TableCell>{po.total_quantity.toLocaleString()}</TableCell>
|
||||
<TableCell>${formatNumber(po.total_cost)}</TableCell>
|
||||
<TableCell>{formatCurrency(po.total_cost)}</TableCell>
|
||||
<TableCell>{po.total_received.toLocaleString()}</TableCell>
|
||||
<TableCell>{formatPercent(po.fulfillment_rate)}</TableCell>
|
||||
<TableCell>
|
||||
{po.fulfillment_rate === null ? 'N/A' : formatPercent(po.fulfillment_rate)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!purchaseOrders.length && (
|
||||
@@ -454,62 +664,38 @@ export default function PurchaseOrders() {
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination.pages > 1 && (
|
||||
<div className="flex justify-center">
|
||||
<div className="flex justify-center mb-6">
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<Button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="h-9 px-4"
|
||||
>
|
||||
<PaginationPrevious className="h-4 w-4" />
|
||||
</Button>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (page > 1) setPage(page - 1);
|
||||
}}
|
||||
aria-disabled={page === 1}
|
||||
className={page === 1 ? "pointer-events-none opacity-50" : ""}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{getPaginationItems()}
|
||||
|
||||
<PaginationItem>
|
||||
<Button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page === pagination.pages}
|
||||
className="h-9 px-4"
|
||||
>
|
||||
<PaginationNext className="h-4 w-4" />
|
||||
</Button>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (page < pagination.pages) setPage(page + 1);
|
||||
}}
|
||||
aria-disabled={page === pagination.pages}
|
||||
className={page === pagination.pages ? "pointer-events-none opacity-50" : ""}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cost Analysis */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Cost Analysis by Category</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Total Spend</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{costAnalysis?.total_spend_by_category?.map((category) => (
|
||||
<TableRow key={category.category}>
|
||||
<TableCell>{category.category}</TableCell>
|
||||
<TableCell>${formatNumber(category.total_spend)}</TableCell>
|
||||
</TableRow>
|
||||
)) || (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="text-center text-muted-foreground">
|
||||
No cost analysis data available
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user