diff --git a/inventory-server/db/metrics-schema.sql b/inventory-server/db/metrics-schema.sql index 46d57ec..e7d7bc3 100644 --- a/inventory-server/db/metrics-schema.sql +++ b/inventory-server/db/metrics-schema.sql @@ -39,6 +39,8 @@ CREATE TABLE IF NOT EXISTS product_metrics ( weeks_of_inventory INT, reorder_point INT, safety_stock INT, + reorder_qty INT DEFAULT 0, + overstocked_amt INT DEFAULT 0, -- Financial metrics avg_margin_percent DECIMAL(10,3), total_revenue DECIMAL(10,3), diff --git a/inventory-server/scripts/calculate-metrics.js b/inventory-server/scripts/calculate-metrics.js index 043c2f4..5d72953 100644 --- a/inventory-server/scripts/calculate-metrics.js +++ b/inventory-server/scripts/calculate-metrics.js @@ -760,11 +760,45 @@ async function calculateMetrics() { throw err; }); - // Get current stock + // Get current stock and stock age const [stockInfo] = await connection.query(` - SELECT stock_quantity, cost_price - FROM products - WHERE product_id = ? + SELECT + p.stock_quantity, + p.cost_price, + p.created_at, + p.replenishable, + p.moq, + DATEDIFF(CURDATE(), MIN(po.received_date)) as days_since_first_stock, + DATEDIFF(CURDATE(), COALESCE( + (SELECT MAX(o2.date) + FROM orders o2 + WHERE o2.product_id = p.product_id + AND o2.canceled = false), + CURDATE() -- If no sales, use current date + )) as days_since_last_sale, + (SELECT SUM(quantity) + FROM orders o3 + WHERE o3.product_id = p.product_id + AND o3.canceled = false) as total_quantity_sold, + CASE + WHEN EXISTS ( + SELECT 1 FROM orders o + WHERE o.product_id = p.product_id + AND o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) + AND o.canceled = false + AND (SELECT SUM(quantity) FROM orders o2 + WHERE o2.product_id = p.product_id + AND o2.date >= o.date + AND o2.canceled = false) = 0 + ) THEN true + ELSE false + END as had_recent_stockout + FROM products p + LEFT JOIN purchase_orders po ON p.product_id = po.product_id + AND po.status = 'closed' + AND po.received > 0 + WHERE p.product_id = ? + GROUP BY p.product_id `, [product.product_id]).catch(err => { logError(err, `Failed to get stock info for product ${product.product_id}`); throw err; @@ -787,17 +821,118 @@ async function calculateMetrics() { // Calculate current inventory value const inventory_value = (stock.stock_quantity || 0) * (stock.cost_price || 0); - // Calculate stock status using configurable thresholds with proper handling of zero sales - const stock_status = daily_sales_avg === 0 ? 'New' : - stock.stock_quantity <= Math.max(1, Math.ceil(daily_sales_avg * config.critical_days)) ? 'Critical' : - stock.stock_quantity <= Math.max(1, Math.ceil(daily_sales_avg * config.reorder_days)) ? 'Reorder' : - stock.stock_quantity > Math.max(1, daily_sales_avg * config.overstock_days) ? 'Overstocked' : 'Healthy'; + // Calculate stock status with improved handling + const stock_status = (() => { + const days_since_first_stock = stockInfo[0]?.days_since_first_stock || 0; + const days_since_last_sale = stockInfo[0]?.days_since_last_sale || 9999; + const total_quantity_sold = stockInfo[0]?.total_quantity_sold || 0; + const had_recent_stockout = stockInfo[0]?.had_recent_stockout || false; + const dq = stock.stock_quantity || 0; + const ds = daily_sales_avg || 0; + const ws = weekly_sales_avg || 0; + const ms = monthly_sales_avg || 0; + + // If no stock, return immediately + if (dq === 0) { + return had_recent_stockout ? 'Critical' : 'Out of Stock'; + } + + // 1. Check if truly "New" (≤30 days and no sales) + if (days_since_first_stock <= 30 && total_quantity_sold === 0) { + return 'New'; + } + + // 2. Handle zero or very low sales velocity cases + if (ds === 0 || (ds < 0.1 && ws < 0.5)) { // Less than 1 sale per 10 days and less than 0.5 per week + if (days_since_first_stock > config.overstock_days) { + return 'Overstocked'; + } + if (days_since_first_stock > 30) { + return 'At Risk'; + } + } + + // 3. Calculate days of supply and check velocity trends + const days_of_supply = ds > 0 ? dq / ds : 999; + const velocity_trend = ds > 0 ? (ds / (ms || ds) - 1) * 100 : 0; // Percent change from monthly to daily avg + + // Critical stock level + if (days_of_supply <= config.critical_days) { + return 'Critical'; + } + + // Reorder cases + if (days_of_supply <= config.reorder_days || + (had_recent_stockout && days_of_supply <= config.reorder_days * 1.5)) { + return 'Reorder'; + } + + // At Risk cases (multiple scenarios) + if ( + // Approaching overstock threshold + (days_of_supply >= config.overstock_days * 0.8) || + // Significant sales decline + (velocity_trend <= -50 && days_of_supply > config.reorder_days * 2) || + // No recent sales + (days_since_last_sale > 45 && dq > 0) || + // Very low velocity with significant stock + (ds > 0 && ds < 0.2 && dq > ds * config.overstock_days * 0.5) + ) { + return 'At Risk'; + } + + // Overstock cases + if (days_of_supply >= config.overstock_days) { + return 'Overstocked'; + } + + // If none of the above conditions are met + return 'Healthy'; + })(); // Calculate safety stock using configured values with proper defaults const safety_stock = daily_sales_avg > 0 ? Math.max(1, Math.ceil(daily_sales_avg * (config.safety_stock_days || 14) * ((config.service_level || 95.0) / 100))) : null; + // Calculate reorder quantity and overstocked amount + let reorder_qty = 0; + let overstocked_amt = 0; + + // Only calculate reorder quantity for replenishable products + if (stock.replenishable && (stock_status === 'Critical' || stock_status === 'Reorder')) { + const ds = daily_sales_avg || 0; + const lt = purchases.avg_lead_time_days || 14; // Default to 14 days if no lead time data + const sc = config.safety_stock_days || 14; + const ss = safety_stock || 0; + const dq = stock.stock_quantity || 0; + const moq = stock.moq || 1; + + // Calculate desired stock level based on daily sales, lead time, coverage days, and safety stock + const desired_stock = (ds * (lt + sc)) + ss; + + // Calculate raw reorder amount + const raw_reorder = Math.max(0, desired_stock - dq); + + // Round up to nearest MOQ + reorder_qty = Math.ceil(raw_reorder / moq) * moq; + } + + // Calculate overstocked amount for overstocked products + if (stock_status === 'Overstocked') { + const ds = daily_sales_avg || 0; + const dq = stock.stock_quantity || 0; + const lt = purchases.avg_lead_time_days || 14; + const sc = config.safety_stock_days || 14; + const ss = safety_stock || 0; + + // Calculate maximum desired stock based on overstock days configuration + const max_desired_stock = (ds * config.overstock_days) + ss; + + // Calculate excess inventory + overstocked_amt = Math.max(0, dq - max_desired_stock); + } + // Add to batch update metricsUpdates.push([ product.product_id, @@ -818,7 +953,9 @@ async function calculateMetrics() { purchases.avg_lead_time_days || null, purchases.last_purchase_date || null, purchases.last_received_date || null, - stock_status + stock_status, + reorder_qty, + overstocked_amt ]); } catch (err) { logError(err, `Failed processing product ${product.product_id}`); @@ -849,7 +986,9 @@ async function calculateMetrics() { avg_lead_time_days, last_purchase_date, last_received_date, - stock_status + stock_status, + reorder_qty, + overstocked_amt ) VALUES ? ON DUPLICATE KEY UPDATE last_calculated_at = NOW(), @@ -870,7 +1009,9 @@ async function calculateMetrics() { avg_lead_time_days = VALUES(avg_lead_time_days), last_purchase_date = VALUES(last_purchase_date), last_received_date = VALUES(last_received_date), - stock_status = VALUES(stock_status) + stock_status = VALUES(stock_status), + reorder_qty = VALUES(reorder_qty), + overstocked_amt = VALUES(overstocked_amt) `, [metricsUpdates]).catch(err => { logError(err, `Failed to batch update metrics for ${metricsUpdates.length} products`); throw err; diff --git a/inventory-server/src/routes/products.js b/inventory-server/src/routes/products.js index 1cee690..13d6b5a 100755 --- a/inventory-server/src/routes/products.js +++ b/inventory-server/src/routes/products.js @@ -275,6 +275,8 @@ router.get('/', async (req, res) => { pm.current_lead_time, pm.target_lead_time, pm.lead_time_status, + pm.reorder_qty, + pm.overstocked_amt, COALESCE(pm.days_of_inventory / NULLIF(pt.target_days, 0), 0) as stock_coverage_ratio FROM products p LEFT JOIN product_metrics pm ON p.product_id = pm.product_id @@ -323,7 +325,9 @@ router.get('/', async (req, res) => { current_lead_time: parseFloat(row.current_lead_time) || 0, target_lead_time: parseFloat(row.target_lead_time) || 0, lead_time_status: row.lead_time_status || null, - stock_coverage_ratio: parseFloat(row.stock_coverage_ratio) || 0 + stock_coverage_ratio: parseFloat(row.stock_coverage_ratio) || 0, + reorder_qty: parseInt(row.reorder_qty) || 0, + overstocked_amt: parseInt(row.overstocked_amt) || 0 })); res.json({ @@ -507,7 +511,9 @@ router.get('/:id', async (req, res) => { avg_lead_time_days: parseInt(rows[0].avg_lead_time_days) || 0, current_lead_time: parseInt(rows[0].current_lead_time) || 0, target_lead_time: parseInt(rows[0].target_lead_time) || 14, - lead_time_status: rows[0].lead_time_status || 'Unknown' + lead_time_status: rows[0].lead_time_status || 'Unknown', + reorder_qty: parseInt(rows[0].reorder_qty) || 0, + overstocked_amt: parseInt(rows[0].overstocked_amt) || 0 }, // Vendor performance (if available) @@ -645,7 +651,9 @@ router.get('/:id/metrics', async (req, res) => { COALESCE(pm.avg_lead_time_days, 0) as avg_lead_time_days, COALESCE(pm.current_lead_time, 0) as current_lead_time, COALESCE(pm.target_lead_time, 14) as target_lead_time, - COALESCE(pm.lead_time_status, 'Unknown') as lead_time_status + COALESCE(pm.lead_time_status, 'Unknown') as lead_time_status, + COALESCE(pm.reorder_qty, 0) as reorder_qty, + COALESCE(pm.overstocked_amt, 0) as overstocked_amt FROM products p LEFT JOIN product_metrics pm ON p.product_id = pm.product_id LEFT JOIN inventory_status is ON p.product_id = is.product_id @@ -670,7 +678,9 @@ router.get('/:id/metrics', async (req, res) => { avg_lead_time_days: 0, current_lead_time: 0, target_lead_time: 14, - lead_time_status: 'Unknown' + lead_time_status: 'Unknown', + reorder_qty: 0, + overstocked_amt: 0 }); return; } diff --git a/inventory/src/components/products/ProductTable.tsx b/inventory/src/components/products/ProductTable.tsx index fe6cb91..e8cc85d 100644 --- a/inventory/src/components/products/ProductTable.tsx +++ b/inventory/src/components/products/ProductTable.tsx @@ -26,62 +26,9 @@ import { useSortable, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; +import { Product } from "@/types/products"; -interface Product { - product_id: number; - title: string; - SKU: string; - stock_quantity: number; - price: number; - regular_price: number; - cost_price: number; - landing_cost_price: number | null; - barcode: string; - vendor: string; - vendor_reference: string; - brand: string; - categories: string[]; - tags: string[]; - options: Record; - image: string | null; - moq: number; - uom: number; - visible: boolean; - managing_stock: boolean; - replenishable: boolean; - created_at: string; - updated_at: string; - - // Metrics - daily_sales_avg?: number; - weekly_sales_avg?: number; - monthly_sales_avg?: number; - avg_quantity_per_order?: number; - number_of_orders?: number; - first_sale_date?: string; - last_sale_date?: string; - days_of_inventory?: number; - weeks_of_inventory?: number; - reorder_point?: number; - safety_stock?: number; - avg_margin_percent?: number; - total_revenue?: number; - inventory_value?: number; - cost_of_goods_sold?: number; - gross_profit?: number; - gmroi?: number; - avg_lead_time_days?: number; - last_purchase_date?: string; - last_received_date?: string; - abc_class?: string; - stock_status?: string; - turnover_rate?: number; - current_lead_time?: number; - target_lead_time?: number; - lead_time_status?: string; -} - -type ColumnKey = keyof Product | 'image'; +export type ColumnKey = keyof Product | 'image'; interface ColumnDef { key: ColumnKey; @@ -296,6 +243,12 @@ export function ProductTable({ ) : ( Hidden ); + case 'replenishable': + return value ? ( + Replenishable + ) : ( + Non-Replenishable + ); default: if (columnDef?.format && value !== undefined && value !== null) { // For numeric formats (those using toFixed), ensure the value is a number diff --git a/inventory/src/components/products/ProductViews.tsx b/inventory/src/components/products/ProductViews.tsx index 57490f4..176efb4 100644 --- a/inventory/src/components/products/ProductViews.tsx +++ b/inventory/src/components/products/ProductViews.tsx @@ -23,14 +23,14 @@ export const PRODUCT_VIEWS: ProductView[] = [ label: "Critical Stock", icon: AlertTriangle, iconClassName: "text-destructive", - columns: ["image", "title", "SKU", "stock_quantity", "daily_sales_avg", "last_purchase_date", "lead_time_status"] + columns: ["image", "title", "SKU", "stock_quantity", "daily_sales_avg", "reorder_qty", "replenishable", "last_purchase_date", "lead_time_status"] }, { id: "Reorder", label: "Reorder Soon", icon: AlertCircle, iconClassName: "text-warning", - columns: ["image", "title", "SKU", "stock_quantity", "daily_sales_avg", "last_purchase_date", "lead_time_status"] + columns: ["image", "title", "SKU", "stock_quantity", "daily_sales_avg", "reorder_qty", "replenishable", "last_purchase_date", "lead_time_status"] }, { id: "Healthy", @@ -44,7 +44,7 @@ export const PRODUCT_VIEWS: ProductView[] = [ label: "Overstock", icon: PackageSearch, iconClassName: "text-muted-foreground", - columns: ["image", "title", "stock_quantity", "daily_sales_avg", "last_sale_date", "abc_class"] + columns: ["image", "title", "stock_quantity", "daily_sales_avg", "overstocked_amt", "replenishable", "last_sale_date", "abc_class"] }, { id: "New", diff --git a/inventory/src/pages/Products.tsx b/inventory/src/pages/Products.tsx index 62c4456..37fe8e8 100644 --- a/inventory/src/pages/Products.tsx +++ b/inventory/src/pages/Products.tsx @@ -6,6 +6,8 @@ import { ProductTableSkeleton } from '@/components/products/ProductTableSkeleton import { ProductDetail } from '@/components/products/ProductDetail'; import { ProductViews } from '@/components/products/ProductViews'; import { Button } from '@/components/ui/button'; +import { Product } from '@/types/products'; +import type { ColumnKey } from '@/components/products/ProductTable'; import { DropdownMenu, DropdownMenuCheckboxItem, @@ -26,65 +28,9 @@ import { PaginationPrevious, } from "@/components/ui/pagination" -// Enhanced Product interface with all possible fields -interface Product { - // Basic product info - product_id: number; - title: string; - SKU: string; - stock_quantity: number; - price: number; - regular_price: number; - cost_price: number; - landing_cost_price: number | null; - barcode: string; - vendor: string; - vendor_reference: string; - brand: string; - categories: string[]; - tags: string[]; - options: Record; - image: string | null; - moq: number; - uom: number; - visible: boolean; - managing_stock: boolean; - replenishable: boolean; - created_at: string; - updated_at: string; - - // Metrics - daily_sales_avg?: number; - weekly_sales_avg?: number; - monthly_sales_avg?: number; - avg_quantity_per_order?: number; - number_of_orders?: number; - first_sale_date?: string; - last_sale_date?: string; - days_of_inventory?: number; - weeks_of_inventory?: number; - reorder_point?: number; - safety_stock?: number; - avg_margin_percent?: number; - total_revenue?: number; - inventory_value?: number; - cost_of_goods_sold?: number; - gross_profit?: number; - gmroi?: number; - avg_lead_time_days?: number; - last_purchase_date?: string; - last_received_date?: string; - abc_class?: string; - stock_status?: string; - turnover_rate?: number; - current_lead_time?: number; - target_lead_time?: number; - lead_time_status?: string; -} - // Column definition type interface ColumnDef { - key: keyof Product | 'image'; + key: ColumnKey; label: string; group: string; noLabel?: boolean; @@ -105,6 +51,10 @@ const AVAILABLE_COLUMNS: ColumnDef[] = [ { key: 'stock_status', label: 'Stock Status', group: 'Stock' }, { key: 'days_of_inventory', label: 'Days of Stock', group: 'Stock', format: (v) => v?.toFixed(1) ?? '-' }, { key: 'abc_class', label: 'ABC Class', group: 'Stock' }, + { key: 'replenishable', label: 'Replenishable', group: 'Stock' }, + { key: 'moq', label: 'MOQ', group: 'Stock', format: (v) => v?.toString() ?? '-' }, + { key: 'reorder_qty', label: 'Reorder Qty', group: 'Stock', format: (v) => v?.toString() ?? '-' }, + { key: 'overstocked_amt', label: 'Overstock Amt', group: 'Stock', format: (v) => v?.toString() ?? '-' }, { key: 'price', label: 'Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' }, { key: 'regular_price', label: 'Default Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' }, { key: 'cost_price', label: 'Cost', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' }, @@ -124,7 +74,7 @@ const AVAILABLE_COLUMNS: ColumnDef[] = [ ]; // Default visible columns -const DEFAULT_VISIBLE_COLUMNS: (keyof Product | 'image')[] = [ +const DEFAULT_VISIBLE_COLUMNS: ColumnKey[] = [ 'image', 'title', 'SKU', @@ -132,6 +82,8 @@ const DEFAULT_VISIBLE_COLUMNS: (keyof Product | 'image')[] = [ 'vendor', 'stock_quantity', 'stock_status', + 'replenishable', + 'reorder_qty', 'price', 'regular_price', 'daily_sales_avg', @@ -141,11 +93,11 @@ const DEFAULT_VISIBLE_COLUMNS: (keyof Product | 'image')[] = [ export function Products() { const [filters, setFilters] = useState>({}); - const [sortColumn, setSortColumn] = useState('title'); + const [sortColumn, setSortColumn] = useState('title'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); const [currentPage, setCurrentPage] = useState(1); - const [visibleColumns, setVisibleColumns] = useState>(new Set(DEFAULT_VISIBLE_COLUMNS)); - const [columnOrder, setColumnOrder] = useState<(keyof Product | 'image')[]>([ + const [visibleColumns, setVisibleColumns] = useState>(new Set(DEFAULT_VISIBLE_COLUMNS)); + const [columnOrder, setColumnOrder] = useState([ ...DEFAULT_VISIBLE_COLUMNS, ...AVAILABLE_COLUMNS.map(col => col.key).filter(key => !DEFAULT_VISIBLE_COLUMNS.includes(key)) ]); @@ -162,11 +114,6 @@ export function Products() { return acc; }, {} as Record); - // Handle column reordering from drag and drop - const handleColumnOrderChange = (newOrder: (keyof Product | 'image')[]) => { - setColumnOrder(newOrder); - }; - // Function to fetch products data const fetchProducts = async () => { const params = new URLSearchParams(); diff --git a/inventory/src/types/products.ts b/inventory/src/types/products.ts index 73e4a33..a5195e0 100644 --- a/inventory/src/types/products.ts +++ b/inventory/src/types/products.ts @@ -32,11 +32,24 @@ export interface Product { first_sale_date?: string; last_sale_date?: string; last_purchase_date?: string; - days_of_stock?: number; - stock_status?: string; - abc_class?: string; - profit_margin?: number; + days_of_inventory?: number; + weeks_of_inventory?: number; reorder_point?: number; - max_stock?: number; + safety_stock?: number; + avg_margin_percent?: number; + total_revenue?: number; + inventory_value?: number; + cost_of_goods_sold?: number; + gross_profit?: number; + gmroi?: number; + avg_lead_time_days?: number; + last_received_date?: string; + abc_class?: string; + stock_status?: string; + turnover_rate?: number; + current_lead_time?: number; + target_lead_time?: number; lead_time_status?: string; + reorder_qty?: number; + overstocked_amt?: number; }