Opus corrections/fixes/additions

This commit is contained in:
2025-06-19 15:49:31 -04:00
parent 8606a90e34
commit 2e3e81a02b
17 changed files with 741 additions and 613 deletions

View File

@@ -116,6 +116,7 @@ CREATE TABLE public.product_metrics (
-- Lifetime Metrics (Recalculated Hourly/Daily from daily_product_snapshots) -- Lifetime Metrics (Recalculated Hourly/Daily from daily_product_snapshots)
lifetime_sales INT, lifetime_sales INT,
lifetime_revenue NUMERIC(16, 4), lifetime_revenue NUMERIC(16, 4),
lifetime_revenue_quality VARCHAR(10), -- 'exact', 'partial', 'estimated'
-- First Period Metrics (Calculated Once/Periodically from daily_product_snapshots) -- First Period Metrics (Calculated Once/Periodically from daily_product_snapshots)
first_7_days_sales INT, first_7_days_revenue NUMERIC(14, 4), first_7_days_sales INT, first_7_days_revenue NUMERIC(14, 4),
@@ -176,6 +177,29 @@ CREATE TABLE public.product_metrics (
-- Product Status (Calculated from metrics) -- Product Status (Calculated from metrics)
status VARCHAR, -- Stores status values like: Critical, Reorder Soon, Healthy, Overstock, At Risk, New status VARCHAR, -- Stores status values like: Critical, Reorder Soon, Healthy, Overstock, At Risk, New
-- Growth Metrics (P3)
sales_growth_30d_vs_prev NUMERIC(10, 2), -- % growth current 30d vs prev 30d
revenue_growth_30d_vs_prev NUMERIC(10, 2), -- % growth current 30d vs prev 30d
sales_growth_yoy NUMERIC(10, 2), -- Year-over-year sales growth %
revenue_growth_yoy NUMERIC(10, 2), -- Year-over-year revenue growth %
-- Demand Variability Metrics (P3)
sales_variance_30d NUMERIC(10, 2), -- Variance of daily sales
sales_std_dev_30d NUMERIC(10, 2), -- Standard deviation of daily sales
sales_cv_30d NUMERIC(10, 2), -- Coefficient of variation
demand_pattern VARCHAR(20), -- 'stable', 'variable', 'sporadic', 'lumpy'
-- Service Level & Fill Rate (P5)
fill_rate_30d NUMERIC(8, 2), -- % of demand fulfilled from stock
stockout_incidents_30d INT, -- Days with stockouts
service_level_30d NUMERIC(8, 2), -- % of days without stockouts
lost_sales_incidents_30d INT, -- Days with potential lost sales
-- Seasonality (P5)
seasonality_index NUMERIC(10, 2), -- Current vs average (100 = average)
seasonal_pattern VARCHAR(20), -- 'none', 'weekly', 'monthly', 'quarterly', 'yearly'
peak_season VARCHAR(20), -- e.g., 'Q4', 'summer', 'holiday'
CONSTRAINT fk_product_metrics_pid FOREIGN KEY (pid) REFERENCES public.products(pid) ON DELETE CASCADE ON UPDATE CASCADE CONSTRAINT fk_product_metrics_pid FOREIGN KEY (pid) REFERENCES public.products(pid) ON DELETE CASCADE ON UPDATE CASCADE
); );
@@ -242,7 +266,8 @@ CREATE TABLE public.category_metrics (
-- Calculated KPIs (Based on 30d aggregates) - Apply to rolled-up metrics -- Calculated KPIs (Based on 30d aggregates) - Apply to rolled-up metrics
avg_margin_30d NUMERIC(7, 3), -- (profit / revenue) * 100 avg_margin_30d NUMERIC(7, 3), -- (profit / revenue) * 100
stock_turn_30d NUMERIC(10, 3), -- sales_units / avg_stock_units (Needs avg stock calc) stock_turn_30d NUMERIC(10, 3), -- sales_units / avg_stock_units (Needs avg stock calc)
-- growth_rate_30d NUMERIC(7, 3), -- (current 30d rev - prev 30d rev) / prev 30d rev sales_growth_30d_vs_prev NUMERIC(10, 2), -- % growth in sales units
revenue_growth_30d_vs_prev NUMERIC(10, 2), -- % growth in revenue
CONSTRAINT fk_category_metrics_cat_id FOREIGN KEY (category_id) REFERENCES public.categories(cat_id) ON DELETE CASCADE ON UPDATE CASCADE CONSTRAINT fk_category_metrics_cat_id FOREIGN KEY (category_id) REFERENCES public.categories(cat_id) ON DELETE CASCADE ON UPDATE CASCADE
); );
@@ -280,7 +305,9 @@ CREATE TABLE public.vendor_metrics (
lifetime_sales INT NOT NULL DEFAULT 0, lifetime_revenue NUMERIC(18, 4) NOT NULL DEFAULT 0.00, lifetime_sales INT NOT NULL DEFAULT 0, lifetime_revenue NUMERIC(18, 4) NOT NULL DEFAULT 0.00,
-- Calculated KPIs (Based on 30d aggregates) -- Calculated KPIs (Based on 30d aggregates)
avg_margin_30d NUMERIC(14, 4) -- (profit / revenue) * 100 avg_margin_30d NUMERIC(14, 4), -- (profit / revenue) * 100
sales_growth_30d_vs_prev NUMERIC(10, 2), -- % growth in sales units
revenue_growth_30d_vs_prev NUMERIC(10, 2), -- % growth in revenue
-- Add more KPIs if needed (e.g., avg product value, sell-through rate for vendor) -- Add more KPIs if needed (e.g., avg product value, sell-through rate for vendor)
); );
CREATE INDEX idx_vendor_metrics_active_count ON public.vendor_metrics(active_product_count); CREATE INDEX idx_vendor_metrics_active_count ON public.vendor_metrics(active_product_count);
@@ -309,7 +336,9 @@ CREATE TABLE public.brand_metrics (
lifetime_sales INT NOT NULL DEFAULT 0, lifetime_revenue NUMERIC(18, 4) NOT NULL DEFAULT 0.00, lifetime_sales INT NOT NULL DEFAULT 0, lifetime_revenue NUMERIC(18, 4) NOT NULL DEFAULT 0.00,
-- Calculated KPIs (Based on 30d aggregates) -- Calculated KPIs (Based on 30d aggregates)
avg_margin_30d NUMERIC(7, 3) -- (profit / revenue) * 100 avg_margin_30d NUMERIC(7, 3), -- (profit / revenue) * 100
sales_growth_30d_vs_prev NUMERIC(10, 2), -- % growth in sales units
revenue_growth_30d_vs_prev NUMERIC(10, 2), -- % growth in revenue
-- Add more KPIs if needed (e.g., avg product value, sell-through rate for brand) -- Add more KPIs if needed (e.g., avg product value, sell-through rate for brand)
); );
CREATE INDEX idx_brand_metrics_active_count ON public.brand_metrics(active_product_count); CREATE INDEX idx_brand_metrics_active_count ON public.brand_metrics(active_product_count);

View File

@@ -437,7 +437,6 @@ async function executeSqlStep(config, progress) {
try { try {
// Try executing exactly as individual scripts do // Try executing exactly as individual scripts do
console.log('Executing SQL with simple query method...');
const result = await connection.query(sqlQuery); const result = await connection.query(sqlQuery);
// Try to extract row count from result // Try to extract row count from result

View File

@@ -42,6 +42,20 @@ BEGIN
JOIN public.products p ON pm.pid = p.pid JOIN public.products p ON pm.pid = p.pid
GROUP BY brand_group GROUP BY brand_group
), ),
PreviousPeriodBrandMetrics AS (
-- Get previous period metrics for growth calculation
SELECT
COALESCE(p.brand, 'Unbranded') AS brand_group,
SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days'
AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days'
THEN dps.units_sold ELSE 0 END) AS sales_prev_30d,
SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days'
AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days'
THEN dps.net_revenue ELSE 0 END) AS revenue_prev_30d
FROM public.daily_product_snapshots dps
JOIN public.products p ON dps.pid = p.pid
GROUP BY brand_group
),
AllBrands AS ( AllBrands AS (
-- Ensure all brands from products table are included, mapping NULL/empty to 'Unbranded' -- Ensure all brands from products table are included, mapping NULL/empty to 'Unbranded'
SELECT DISTINCT COALESCE(brand, 'Unbranded') as brand_group SELECT DISTINCT COALESCE(brand, 'Unbranded') as brand_group
@@ -53,7 +67,8 @@ BEGIN
current_stock_units, current_stock_cost, current_stock_retail, current_stock_units, current_stock_cost, current_stock_retail,
sales_7d, revenue_7d, sales_30d, revenue_30d, profit_30d, cogs_30d, sales_7d, revenue_7d, sales_30d, revenue_30d, profit_30d, cogs_30d,
sales_365d, revenue_365d, lifetime_sales, lifetime_revenue, sales_365d, revenue_365d, lifetime_sales, lifetime_revenue,
avg_margin_30d avg_margin_30d,
sales_growth_30d_vs_prev, revenue_growth_30d_vs_prev
) )
SELECT SELECT
b.brand_group, b.brand_group,
@@ -78,9 +93,13 @@ BEGIN
-- This is mathematically equivalent to profit/revenue but more explicit -- This is mathematically equivalent to profit/revenue but more explicit
((COALESCE(ba.revenue_30d, 0) - COALESCE(ba.cogs_30d, 0)) / COALESCE(ba.revenue_30d, 1)) * 100.0 ((COALESCE(ba.revenue_30d, 0) - COALESCE(ba.cogs_30d, 0)) / COALESCE(ba.revenue_30d, 1)) * 100.0
ELSE NULL -- No margin for low/no revenue brands ELSE NULL -- No margin for low/no revenue brands
END END,
-- Growth metrics
std_numeric(safe_divide((ba.sales_30d - ppbm.sales_prev_30d) * 100.0, ppbm.sales_prev_30d), 2),
std_numeric(safe_divide((ba.revenue_30d - ppbm.revenue_prev_30d) * 100.0, ppbm.revenue_prev_30d), 2)
FROM AllBrands b FROM AllBrands b
LEFT JOIN BrandAggregates ba ON b.brand_group = ba.brand_group LEFT JOIN BrandAggregates ba ON b.brand_group = ba.brand_group
LEFT JOIN PreviousPeriodBrandMetrics ppbm ON b.brand_group = ppbm.brand_group
ON CONFLICT (brand_name) DO UPDATE SET ON CONFLICT (brand_name) DO UPDATE SET
last_calculated = EXCLUDED.last_calculated, last_calculated = EXCLUDED.last_calculated,
@@ -95,7 +114,9 @@ BEGIN
profit_30d = EXCLUDED.profit_30d, cogs_30d = EXCLUDED.cogs_30d, profit_30d = EXCLUDED.profit_30d, cogs_30d = EXCLUDED.cogs_30d,
sales_365d = EXCLUDED.sales_365d, revenue_365d = EXCLUDED.revenue_365d, sales_365d = EXCLUDED.sales_365d, revenue_365d = EXCLUDED.revenue_365d,
lifetime_sales = EXCLUDED.lifetime_sales, lifetime_revenue = EXCLUDED.lifetime_revenue, lifetime_sales = EXCLUDED.lifetime_sales, lifetime_revenue = EXCLUDED.lifetime_revenue,
avg_margin_30d = EXCLUDED.avg_margin_30d avg_margin_30d = EXCLUDED.avg_margin_30d,
sales_growth_30d_vs_prev = EXCLUDED.sales_growth_30d_vs_prev,
revenue_growth_30d_vs_prev = EXCLUDED.revenue_growth_30d_vs_prev
WHERE -- Only update if at least one value has changed WHERE -- Only update if at least one value has changed
brand_metrics.product_count IS DISTINCT FROM EXCLUDED.product_count OR brand_metrics.product_count IS DISTINCT FROM EXCLUDED.product_count OR
brand_metrics.active_product_count IS DISTINCT FROM EXCLUDED.active_product_count OR brand_metrics.active_product_count IS DISTINCT FROM EXCLUDED.active_product_count OR

View File

@@ -1,5 +1,5 @@
-- Description: Calculates and updates aggregated metrics per category. -- Description: Calculates and updates aggregated metrics per category with hierarchy rollups.
-- Dependencies: product_metrics, products, categories, product_categories, calculate_status table. -- Dependencies: product_metrics, products, categories, product_categories, category_hierarchy, calculate_status table.
-- Frequency: Daily (after product_metrics update). -- Frequency: Daily (after product_metrics update).
DO $$ DO $$
@@ -10,54 +10,20 @@ DECLARE
BEGIN BEGIN
RAISE NOTICE 'Running % calculation...', _module_name; RAISE NOTICE 'Running % calculation...', _module_name;
WITH -- Refresh the category hierarchy materialized view first
-- Identify the hierarchy depth for each category REFRESH MATERIALIZED VIEW CONCURRENTLY category_hierarchy;
CategoryDepth AS (
WITH RECURSIVE CategoryTree AS (
-- Base case: Start with categories without parents (root categories)
SELECT cat_id, name, parent_id, 0 AS depth
FROM public.categories
WHERE parent_id IS NULL
UNION ALL -- First calculate direct metrics (products directly in each category)
WITH DirectCategoryMetrics AS (
-- Recursive step: Add child categories with incremented depth
SELECT c.cat_id, c.name, c.parent_id, ct.depth + 1
FROM public.categories c
JOIN CategoryTree ct ON c.parent_id = ct.cat_id
)
SELECT cat_id, depth
FROM CategoryTree
),
-- For each product, find the most specific (deepest) category it belongs to
ProductDeepestCategory AS (
SELECT SELECT
pc.pid, pc.cat_id,
pc.cat_id
FROM public.product_categories pc
JOIN CategoryDepth cd ON pc.cat_id = cd.cat_id
-- This is the key part: for each product, select only the category with maximum depth
WHERE (pc.pid, cd.depth) IN (
SELECT pc2.pid, MAX(cd2.depth)
FROM public.product_categories pc2
JOIN CategoryDepth cd2 ON pc2.cat_id = cd2.cat_id
GROUP BY pc2.pid
)
),
-- Calculate metrics only at the most specific category level for each product
-- These are the direct metrics (only products directly in this category)
DirectCategoryMetrics AS (
SELECT
pdc.cat_id,
-- Counts
COUNT(DISTINCT pm.pid) AS product_count, COUNT(DISTINCT pm.pid) AS product_count,
COUNT(DISTINCT CASE WHEN pm.is_visible THEN pm.pid END) AS active_product_count, COUNT(DISTINCT CASE WHEN pm.is_visible THEN pm.pid END) AS active_product_count,
COUNT(DISTINCT CASE WHEN pm.is_replenishable THEN pm.pid END) AS replenishable_product_count, COUNT(DISTINCT CASE WHEN pm.is_replenishable THEN pm.pid END) AS replenishable_product_count,
-- Current Stock
SUM(pm.current_stock) AS current_stock_units, SUM(pm.current_stock) AS current_stock_units,
SUM(pm.current_stock_cost) AS current_stock_cost, SUM(pm.current_stock_cost) AS current_stock_cost,
SUM(pm.current_stock_retail) AS current_stock_retail, SUM(pm.current_stock_retail) AS current_stock_retail,
-- Rolling Periods - Only include products with actual sales in each period -- Sales metrics with proper filtering
SUM(CASE WHEN pm.sales_7d > 0 THEN pm.sales_7d ELSE 0 END) AS sales_7d, SUM(CASE WHEN pm.sales_7d > 0 THEN pm.sales_7d ELSE 0 END) AS sales_7d,
SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d, SUM(CASE WHEN pm.revenue_7d > 0 THEN pm.revenue_7d ELSE 0 END) AS revenue_7d,
SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d, SUM(CASE WHEN pm.sales_30d > 0 THEN pm.sales_30d ELSE 0 END) AS sales_30d,
@@ -67,179 +33,141 @@ BEGIN
SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d, SUM(CASE WHEN pm.sales_365d > 0 THEN pm.sales_365d ELSE 0 END) AS sales_365d,
SUM(CASE WHEN pm.revenue_365d > 0 THEN pm.revenue_365d ELSE 0 END) AS revenue_365d, SUM(CASE WHEN pm.revenue_365d > 0 THEN pm.revenue_365d ELSE 0 END) AS revenue_365d,
SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales, SUM(CASE WHEN pm.lifetime_sales > 0 THEN pm.lifetime_sales ELSE 0 END) AS lifetime_sales,
SUM(CASE WHEN pm.lifetime_revenue > 0 THEN pm.lifetime_revenue ELSE 0 END) AS lifetime_revenue, SUM(CASE WHEN pm.lifetime_revenue > 0 THEN pm.lifetime_revenue ELSE 0 END) AS lifetime_revenue
-- Data for KPIs - Only average stock for products with stock FROM public.product_categories pc
SUM(CASE WHEN pm.avg_stock_units_30d > 0 THEN pm.avg_stock_units_30d ELSE 0 END) AS total_avg_stock_units_30d JOIN public.product_metrics pm ON pc.pid = pm.pid
FROM public.product_metrics pm GROUP BY pc.cat_id
JOIN ProductDeepestCategory pdc ON pm.pid = pdc.pid
GROUP BY pdc.cat_id
), ),
-- Build a category lookup table for parent relationships -- Calculate rolled-up metrics (including all descendant categories)
CategoryHierarchyPaths AS ( RolledUpMetrics AS (
WITH RECURSIVE ParentPaths AS (
-- Base case: All categories with their immediate parents
SELECT
cat_id,
cat_id as leaf_id, -- Every category is its own leaf initially
ARRAY[cat_id] as path
FROM public.categories
UNION ALL
-- Recursive step: Walk up the parent chain
SELECT
c.parent_id as cat_id,
pp.leaf_id, -- Keep the original leaf_id
c.parent_id || pp.path as path
FROM ParentPaths pp
JOIN public.categories c ON pp.cat_id = c.cat_id
WHERE c.parent_id IS NOT NULL -- Stop at root categories
)
-- Select distinct paths to avoid duplication
SELECT DISTINCT cat_id, leaf_id
FROM ParentPaths
),
-- Aggregate metrics from leaf categories to their ancestors without duplication
-- These are the rolled-up metrics (including all child categories)
RollupMetrics AS (
SELECT SELECT
chp.cat_id, ch.cat_id,
-- For each parent category, count distinct products to avoid duplication -- Sum metrics from this category and all its descendants
COUNT(DISTINCT dcm.cat_id) AS child_categories_count, SUM(dcm.product_count) AS product_count,
SUM(dcm.product_count) AS rollup_product_count, SUM(dcm.active_product_count) AS active_product_count,
SUM(dcm.active_product_count) AS rollup_active_product_count, SUM(dcm.replenishable_product_count) AS replenishable_product_count,
SUM(dcm.replenishable_product_count) AS rollup_replenishable_product_count, SUM(dcm.current_stock_units) AS current_stock_units,
SUM(dcm.current_stock_units) AS rollup_current_stock_units, SUM(dcm.current_stock_cost) AS current_stock_cost,
SUM(dcm.current_stock_cost) AS rollup_current_stock_cost, SUM(dcm.current_stock_retail) AS current_stock_retail,
SUM(dcm.current_stock_retail) AS rollup_current_stock_retail, SUM(dcm.sales_7d) AS sales_7d,
SUM(dcm.sales_7d) AS rollup_sales_7d, SUM(dcm.revenue_7d) AS revenue_7d,
SUM(dcm.revenue_7d) AS rollup_revenue_7d, SUM(dcm.sales_30d) AS sales_30d,
SUM(dcm.sales_30d) AS rollup_sales_30d, SUM(dcm.revenue_30d) AS revenue_30d,
SUM(dcm.revenue_30d) AS rollup_revenue_30d, SUM(dcm.cogs_30d) AS cogs_30d,
SUM(dcm.cogs_30d) AS rollup_cogs_30d, SUM(dcm.profit_30d) AS profit_30d,
SUM(dcm.profit_30d) AS rollup_profit_30d, SUM(dcm.sales_365d) AS sales_365d,
SUM(dcm.sales_365d) AS rollup_sales_365d, SUM(dcm.revenue_365d) AS revenue_365d,
SUM(dcm.revenue_365d) AS rollup_revenue_365d, SUM(dcm.lifetime_sales) AS lifetime_sales,
SUM(dcm.lifetime_sales) AS rollup_lifetime_sales, SUM(dcm.lifetime_revenue) AS lifetime_revenue
SUM(dcm.lifetime_revenue) AS rollup_lifetime_revenue, FROM category_hierarchy ch
SUM(dcm.total_avg_stock_units_30d) AS rollup_total_avg_stock_units_30d LEFT JOIN DirectCategoryMetrics dcm ON
FROM CategoryHierarchyPaths chp dcm.cat_id = ch.cat_id OR
JOIN DirectCategoryMetrics dcm ON chp.leaf_id = dcm.cat_id dcm.cat_id = ANY(SELECT cat_id FROM category_hierarchy WHERE ch.cat_id = ANY(ancestor_ids))
GROUP BY chp.cat_id GROUP BY ch.cat_id
), ),
-- Combine direct and rollup metrics PreviousPeriodCategoryMetrics AS (
CombinedMetrics AS ( -- Get previous period metrics for growth calculation
SELECT
pc.cat_id,
SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days'
AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days'
THEN dps.units_sold ELSE 0 END) AS sales_prev_30d,
SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days'
AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days'
THEN dps.net_revenue ELSE 0 END) AS revenue_prev_30d
FROM public.daily_product_snapshots dps
JOIN public.product_categories pc ON dps.pid = pc.pid
GROUP BY pc.cat_id
),
RolledUpPreviousPeriod AS (
-- Calculate rolled-up previous period metrics
SELECT
ch.cat_id,
SUM(ppcm.sales_prev_30d) AS sales_prev_30d,
SUM(ppcm.revenue_prev_30d) AS revenue_prev_30d
FROM category_hierarchy ch
LEFT JOIN PreviousPeriodCategoryMetrics ppcm ON
ppcm.cat_id = ch.cat_id OR
ppcm.cat_id = ANY(SELECT cat_id FROM category_hierarchy WHERE ch.cat_id = ANY(ancestor_ids))
GROUP BY ch.cat_id
),
AllCategories AS (
-- Ensure all categories are included
SELECT SELECT
c.cat_id, c.cat_id,
c.name, c.name,
c.type, c.type,
c.parent_id, c.parent_id
-- Direct metrics (just this category)
COALESCE(dcm.product_count, 0) AS direct_product_count,
COALESCE(dcm.active_product_count, 0) AS direct_active_product_count,
COALESCE(dcm.replenishable_product_count, 0) AS direct_replenishable_product_count,
COALESCE(dcm.current_stock_units, 0) AS direct_current_stock_units,
COALESCE(dcm.current_stock_cost, 0) AS direct_current_stock_cost,
COALESCE(dcm.current_stock_retail, 0) AS direct_current_stock_retail,
COALESCE(dcm.sales_7d, 0) AS direct_sales_7d,
COALESCE(dcm.revenue_7d, 0) AS direct_revenue_7d,
COALESCE(dcm.sales_30d, 0) AS direct_sales_30d,
COALESCE(dcm.revenue_30d, 0) AS direct_revenue_30d,
COALESCE(dcm.cogs_30d, 0) AS direct_cogs_30d,
COALESCE(dcm.profit_30d, 0) AS direct_profit_30d,
COALESCE(dcm.sales_365d, 0) AS direct_sales_365d,
COALESCE(dcm.revenue_365d, 0) AS direct_revenue_365d,
COALESCE(dcm.lifetime_sales, 0) AS direct_lifetime_sales,
COALESCE(dcm.lifetime_revenue, 0) AS direct_lifetime_revenue,
COALESCE(dcm.total_avg_stock_units_30d, 0) AS direct_avg_stock_units_30d,
-- Rolled up metrics (this category + all children)
COALESCE(rm.rollup_product_count, 0) AS product_count,
COALESCE(rm.rollup_active_product_count, 0) AS active_product_count,
COALESCE(rm.rollup_replenishable_product_count, 0) AS replenishable_product_count,
COALESCE(rm.rollup_current_stock_units, 0) AS current_stock_units,
COALESCE(rm.rollup_current_stock_cost, 0) AS current_stock_cost,
COALESCE(rm.rollup_current_stock_retail, 0) AS current_stock_retail,
COALESCE(rm.rollup_sales_7d, 0) AS sales_7d,
COALESCE(rm.rollup_revenue_7d, 0) AS revenue_7d,
COALESCE(rm.rollup_sales_30d, 0) AS sales_30d,
COALESCE(rm.rollup_revenue_30d, 0) AS revenue_30d,
COALESCE(rm.rollup_cogs_30d, 0) AS cogs_30d,
COALESCE(rm.rollup_profit_30d, 0) AS profit_30d,
COALESCE(rm.rollup_sales_365d, 0) AS sales_365d,
COALESCE(rm.rollup_revenue_365d, 0) AS revenue_365d,
COALESCE(rm.rollup_lifetime_sales, 0) AS lifetime_sales,
COALESCE(rm.rollup_lifetime_revenue, 0) AS lifetime_revenue,
COALESCE(rm.rollup_total_avg_stock_units_30d, 0) AS total_avg_stock_units_30d
FROM public.categories c FROM public.categories c
LEFT JOIN DirectCategoryMetrics dcm ON c.cat_id = dcm.cat_id WHERE c.status = 'active'
LEFT JOIN RollupMetrics rm ON c.cat_id = rm.cat_id
) )
INSERT INTO public.category_metrics ( INSERT INTO public.category_metrics (
category_id, category_name, category_type, parent_id, last_calculated, category_id, category_name, category_type, parent_id, last_calculated,
-- Store all direct and rolled up metrics -- Rolled-up metrics
product_count, active_product_count, replenishable_product_count, product_count, active_product_count, replenishable_product_count,
current_stock_units, current_stock_cost, current_stock_retail, current_stock_units, current_stock_cost, current_stock_retail,
sales_7d, revenue_7d, sales_30d, revenue_30d, profit_30d, cogs_30d, sales_7d, revenue_7d, sales_30d, revenue_30d, profit_30d, cogs_30d,
sales_365d, revenue_365d, lifetime_sales, lifetime_revenue, sales_365d, revenue_365d, lifetime_sales, lifetime_revenue,
-- Also store direct metrics with direct_ prefix -- Direct metrics
direct_product_count, direct_active_product_count, direct_replenishable_product_count, direct_product_count, direct_active_product_count, direct_replenishable_product_count,
direct_current_stock_units, direct_stock_cost, direct_stock_retail, direct_current_stock_units, direct_stock_cost, direct_stock_retail,
direct_sales_7d, direct_revenue_7d, direct_sales_30d, direct_revenue_30d, direct_sales_7d, direct_revenue_7d, direct_sales_30d, direct_revenue_30d,
direct_profit_30d, direct_cogs_30d, direct_sales_365d, direct_revenue_365d, direct_profit_30d, direct_cogs_30d, direct_sales_365d, direct_revenue_365d,
direct_lifetime_sales, direct_lifetime_revenue, direct_lifetime_sales, direct_lifetime_revenue,
-- KPIs -- KPIs
avg_margin_30d, stock_turn_30d avg_margin_30d,
sales_growth_30d_vs_prev, revenue_growth_30d_vs_prev
) )
SELECT SELECT
cm.cat_id, ac.cat_id,
cm.name, ac.name,
cm.type, ac.type,
cm.parent_id, ac.parent_id,
_start_time, _start_time,
-- Rolled-up metrics (total including children) -- Rolled-up metrics (includes descendants)
cm.product_count, COALESCE(rum.product_count, 0),
cm.active_product_count, COALESCE(rum.active_product_count, 0),
cm.replenishable_product_count, COALESCE(rum.replenishable_product_count, 0),
cm.current_stock_units, COALESCE(rum.current_stock_units, 0),
cm.current_stock_cost, COALESCE(rum.current_stock_cost, 0.00),
cm.current_stock_retail, COALESCE(rum.current_stock_retail, 0.00),
cm.sales_7d, cm.revenue_7d, COALESCE(rum.sales_7d, 0), COALESCE(rum.revenue_7d, 0.00),
cm.sales_30d, cm.revenue_30d, cm.profit_30d, cm.cogs_30d, COALESCE(rum.sales_30d, 0), COALESCE(rum.revenue_30d, 0.00),
cm.sales_365d, cm.revenue_365d, COALESCE(rum.profit_30d, 0.00), COALESCE(rum.cogs_30d, 0.00),
cm.lifetime_sales, cm.lifetime_revenue, COALESCE(rum.sales_365d, 0), COALESCE(rum.revenue_365d, 0.00),
-- Direct metrics (just this category) COALESCE(rum.lifetime_sales, 0), COALESCE(rum.lifetime_revenue, 0.00),
cm.direct_product_count, -- Direct metrics (only this category)
cm.direct_active_product_count, COALESCE(dcm.product_count, 0),
cm.direct_replenishable_product_count, COALESCE(dcm.active_product_count, 0),
cm.direct_current_stock_units, COALESCE(dcm.replenishable_product_count, 0),
cm.direct_current_stock_cost, COALESCE(dcm.current_stock_units, 0),
cm.direct_current_stock_retail, COALESCE(dcm.current_stock_cost, 0.00),
cm.direct_sales_7d, cm.direct_revenue_7d, COALESCE(dcm.current_stock_retail, 0.00),
cm.direct_sales_30d, cm.direct_revenue_30d, cm.direct_profit_30d, cm.direct_cogs_30d, COALESCE(dcm.sales_7d, 0), COALESCE(dcm.revenue_7d, 0.00),
cm.direct_sales_365d, cm.direct_revenue_365d, COALESCE(dcm.sales_30d, 0), COALESCE(dcm.revenue_30d, 0.00),
cm.direct_lifetime_sales, cm.direct_lifetime_revenue, COALESCE(dcm.profit_30d, 0.00), COALESCE(dcm.cogs_30d, 0.00),
COALESCE(dcm.sales_365d, 0), COALESCE(dcm.revenue_365d, 0.00),
COALESCE(dcm.lifetime_sales, 0), COALESCE(dcm.lifetime_revenue, 0.00),
-- KPIs - Calculate margin only for categories with significant revenue -- KPIs - Calculate margin only for categories with significant revenue
CASE CASE
WHEN cm.revenue_30d >= _min_revenue THEN WHEN COALESCE(rum.revenue_30d, 0) >= _min_revenue THEN
((cm.revenue_30d - cm.cogs_30d) / cm.revenue_30d) * 100.0 ((COALESCE(rum.revenue_30d, 0) - COALESCE(rum.cogs_30d, 0)) / COALESCE(rum.revenue_30d, 1)) * 100.0
ELSE NULL -- No margin for low/no revenue categories ELSE NULL
END, END,
-- Stock Turn calculation -- Growth metrics for rolled-up values
CASE std_numeric(safe_divide((rum.sales_30d - rupp.sales_prev_30d) * 100.0, rupp.sales_prev_30d), 2),
WHEN cm.total_avg_stock_units_30d > 0 THEN std_numeric(safe_divide((rum.revenue_30d - rupp.revenue_prev_30d) * 100.0, rupp.revenue_prev_30d), 2)
cm.sales_30d / cm.total_avg_stock_units_30d FROM AllCategories ac
ELSE NULL -- No stock turn if no average stock LEFT JOIN DirectCategoryMetrics dcm ON ac.cat_id = dcm.cat_id
END LEFT JOIN RolledUpMetrics rum ON ac.cat_id = rum.cat_id
FROM CombinedMetrics cm LEFT JOIN RolledUpPreviousPeriod rupp ON ac.cat_id = rupp.cat_id
ON CONFLICT (category_id) DO UPDATE SET ON CONFLICT (category_id) DO UPDATE SET
last_calculated = EXCLUDED.last_calculated,
category_name = EXCLUDED.category_name, category_name = EXCLUDED.category_name,
category_type = EXCLUDED.category_type, category_type = EXCLUDED.category_type,
parent_id = EXCLUDED.parent_id, parent_id = EXCLUDED.parent_id,
last_calculated = EXCLUDED.last_calculated, -- Rolled-up metrics
-- ROLLED-UP METRICS (includes this category + all descendants)
product_count = EXCLUDED.product_count, product_count = EXCLUDED.product_count,
active_product_count = EXCLUDED.active_product_count, active_product_count = EXCLUDED.active_product_count,
replenishable_product_count = EXCLUDED.replenishable_product_count, replenishable_product_count = EXCLUDED.replenishable_product_count,
@@ -251,8 +179,7 @@ BEGIN
profit_30d = EXCLUDED.profit_30d, cogs_30d = EXCLUDED.cogs_30d, profit_30d = EXCLUDED.profit_30d, cogs_30d = EXCLUDED.cogs_30d,
sales_365d = EXCLUDED.sales_365d, revenue_365d = EXCLUDED.revenue_365d, sales_365d = EXCLUDED.sales_365d, revenue_365d = EXCLUDED.revenue_365d,
lifetime_sales = EXCLUDED.lifetime_sales, lifetime_revenue = EXCLUDED.lifetime_revenue, lifetime_sales = EXCLUDED.lifetime_sales, lifetime_revenue = EXCLUDED.lifetime_revenue,
-- Direct metrics
-- DIRECT METRICS (only products directly in this category)
direct_product_count = EXCLUDED.direct_product_count, direct_product_count = EXCLUDED.direct_product_count,
direct_active_product_count = EXCLUDED.direct_active_product_count, direct_active_product_count = EXCLUDED.direct_active_product_count,
direct_replenishable_product_count = EXCLUDED.direct_replenishable_product_count, direct_replenishable_product_count = EXCLUDED.direct_replenishable_product_count,
@@ -264,10 +191,9 @@ BEGIN
direct_profit_30d = EXCLUDED.direct_profit_30d, direct_cogs_30d = EXCLUDED.direct_cogs_30d, direct_profit_30d = EXCLUDED.direct_profit_30d, direct_cogs_30d = EXCLUDED.direct_cogs_30d,
direct_sales_365d = EXCLUDED.direct_sales_365d, direct_revenue_365d = EXCLUDED.direct_revenue_365d, direct_sales_365d = EXCLUDED.direct_sales_365d, direct_revenue_365d = EXCLUDED.direct_revenue_365d,
direct_lifetime_sales = EXCLUDED.direct_lifetime_sales, direct_lifetime_revenue = EXCLUDED.direct_lifetime_revenue, direct_lifetime_sales = EXCLUDED.direct_lifetime_sales, direct_lifetime_revenue = EXCLUDED.direct_lifetime_revenue,
-- Calculated KPIs
avg_margin_30d = EXCLUDED.avg_margin_30d, avg_margin_30d = EXCLUDED.avg_margin_30d,
stock_turn_30d = EXCLUDED.stock_turn_30d sales_growth_30d_vs_prev = EXCLUDED.sales_growth_30d_vs_prev,
revenue_growth_30d_vs_prev = EXCLUDED.revenue_growth_30d_vs_prev
WHERE -- Only update if at least one value has changed WHERE -- Only update if at least one value has changed
category_metrics.product_count IS DISTINCT FROM EXCLUDED.product_count OR category_metrics.product_count IS DISTINCT FROM EXCLUDED.product_count OR
category_metrics.active_product_count IS DISTINCT FROM EXCLUDED.active_product_count OR category_metrics.active_product_count IS DISTINCT FROM EXCLUDED.active_product_count OR
@@ -291,19 +217,23 @@ WITH update_stats AS (
SELECT SELECT
COUNT(*) as total_categories, COUNT(*) as total_categories,
COUNT(*) FILTER (WHERE last_calculated >= NOW() - INTERVAL '5 minutes') as rows_processed, COUNT(*) FILTER (WHERE last_calculated >= NOW() - INTERVAL '5 minutes') as rows_processed,
COUNT(*) FILTER (WHERE category_type = 11) as main_categories, -- 11 = category COUNT(*) FILTER (WHERE category_type = 10) as sections,
COUNT(*) FILTER (WHERE category_type = 12) as subcategories, -- 12 = subcategory COUNT(*) FILTER (WHERE category_type = 11) as categories,
SUM(product_count) as total_products, COUNT(*) FILTER (WHERE category_type = 12) as subcategories,
SUM(active_product_count) as total_active_products, SUM(product_count) as total_products_rolled,
SUM(current_stock_units) as total_stock_units SUM(direct_product_count) as total_products_direct,
SUM(sales_30d) as total_sales_30d,
SUM(revenue_30d) as total_revenue_30d
FROM public.category_metrics FROM public.category_metrics
) )
SELECT SELECT
rows_processed, rows_processed,
total_categories, total_categories,
main_categories, sections,
categories,
subcategories, subcategories,
total_products::int, total_products_rolled::int,
total_active_products::int, total_products_direct::int,
total_stock_units::int total_sales_30d::int,
ROUND(total_revenue_30d, 2) as total_revenue_30d
FROM update_stats; FROM update_stats;

View File

@@ -44,6 +44,21 @@ BEGIN
WHERE p.vendor IS NOT NULL AND p.vendor <> '' WHERE p.vendor IS NOT NULL AND p.vendor <> ''
GROUP BY p.vendor GROUP BY p.vendor
), ),
PreviousPeriodVendorMetrics AS (
-- Get previous period metrics for growth calculation
SELECT
p.vendor,
SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days'
AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days'
THEN dps.units_sold ELSE 0 END) AS sales_prev_30d,
SUM(CASE WHEN dps.snapshot_date >= CURRENT_DATE - INTERVAL '59 days'
AND dps.snapshot_date < CURRENT_DATE - INTERVAL '29 days'
THEN dps.net_revenue ELSE 0 END) AS revenue_prev_30d
FROM public.daily_product_snapshots dps
JOIN public.products p ON dps.pid = p.pid
WHERE p.vendor IS NOT NULL AND p.vendor <> ''
GROUP BY p.vendor
),
VendorPOAggregates AS ( VendorPOAggregates AS (
-- Aggregate PO related stats including lead time calculated from POs to receivings -- Aggregate PO related stats including lead time calculated from POs to receivings
SELECT SELECT
@@ -78,7 +93,8 @@ BEGIN
po_count_365d, avg_lead_time_days, po_count_365d, avg_lead_time_days,
sales_7d, revenue_7d, sales_30d, revenue_30d, profit_30d, cogs_30d, sales_7d, revenue_7d, sales_30d, revenue_30d, profit_30d, cogs_30d,
sales_365d, revenue_365d, lifetime_sales, lifetime_revenue, sales_365d, revenue_365d, lifetime_sales, lifetime_revenue,
avg_margin_30d avg_margin_30d,
sales_growth_30d_vs_prev, revenue_growth_30d_vs_prev
) )
SELECT SELECT
v.vendor, v.vendor,
@@ -102,10 +118,14 @@ BEGIN
COALESCE(vpa.sales_365d, 0), COALESCE(vpa.revenue_365d, 0.00), COALESCE(vpa.sales_365d, 0), COALESCE(vpa.revenue_365d, 0.00),
COALESCE(vpa.lifetime_sales, 0), COALESCE(vpa.lifetime_revenue, 0.00), COALESCE(vpa.lifetime_sales, 0), COALESCE(vpa.lifetime_revenue, 0.00),
-- KPIs -- KPIs
(vpa.profit_30d / NULLIF(vpa.revenue_30d, 0)) * 100.0 (vpa.profit_30d / NULLIF(vpa.revenue_30d, 0)) * 100.0,
-- Growth metrics
std_numeric(safe_divide((vpa.sales_30d - ppvm.sales_prev_30d) * 100.0, ppvm.sales_prev_30d), 2),
std_numeric(safe_divide((vpa.revenue_30d - ppvm.revenue_prev_30d) * 100.0, ppvm.revenue_prev_30d), 2)
FROM AllVendors v FROM AllVendors v
LEFT JOIN VendorProductAggregates vpa ON v.vendor = vpa.vendor LEFT JOIN VendorProductAggregates vpa ON v.vendor = vpa.vendor
LEFT JOIN VendorPOAggregates vpoa ON v.vendor = vpoa.vendor LEFT JOIN VendorPOAggregates vpoa ON v.vendor = vpoa.vendor
LEFT JOIN PreviousPeriodVendorMetrics ppvm ON v.vendor = ppvm.vendor
ON CONFLICT (vendor_name) DO UPDATE SET ON CONFLICT (vendor_name) DO UPDATE SET
last_calculated = EXCLUDED.last_calculated, last_calculated = EXCLUDED.last_calculated,
@@ -124,7 +144,9 @@ BEGIN
profit_30d = EXCLUDED.profit_30d, cogs_30d = EXCLUDED.cogs_30d, profit_30d = EXCLUDED.profit_30d, cogs_30d = EXCLUDED.cogs_30d,
sales_365d = EXCLUDED.sales_365d, revenue_365d = EXCLUDED.revenue_365d, sales_365d = EXCLUDED.sales_365d, revenue_365d = EXCLUDED.revenue_365d,
lifetime_sales = EXCLUDED.lifetime_sales, lifetime_revenue = EXCLUDED.lifetime_revenue, lifetime_sales = EXCLUDED.lifetime_sales, lifetime_revenue = EXCLUDED.lifetime_revenue,
avg_margin_30d = EXCLUDED.avg_margin_30d avg_margin_30d = EXCLUDED.avg_margin_30d,
sales_growth_30d_vs_prev = EXCLUDED.sales_growth_30d_vs_prev,
revenue_growth_30d_vs_prev = EXCLUDED.revenue_growth_30d_vs_prev
WHERE -- Only update if at least one value has changed WHERE -- Only update if at least one value has changed
vendor_metrics.product_count IS DISTINCT FROM EXCLUDED.product_count OR vendor_metrics.product_count IS DISTINCT FROM EXCLUDED.product_count OR
vendor_metrics.active_product_count IS DISTINCT FROM EXCLUDED.active_product_count OR vendor_metrics.active_product_count IS DISTINCT FROM EXCLUDED.active_product_count OR

View File

@@ -86,7 +86,14 @@ BEGIN
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.quantity ELSE 0 END), 0) AS units_sold, COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.quantity ELSE 0 END), 0) AS units_sold,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.price * o.quantity ELSE 0 END), 0.00) AS gross_revenue_unadjusted, -- Before discount COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.price * o.quantity ELSE 0 END), 0.00) AS gross_revenue_unadjusted, -- Before discount
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.discount ELSE 0 END), 0.00) AS discounts, COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.discount ELSE 0 END), 0.00) AS discounts,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN COALESCE(o.costeach, p.landing_cost_price, p.cost_price) * o.quantity ELSE 0 END), 0.00) AS cogs, COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN
COALESCE(
o.costeach, -- First use order-specific cost if available
get_weighted_avg_cost(p.pid, o.date::date), -- Then use weighted average cost
p.landing_cost_price, -- Fallback to landing cost
p.cost_price -- Final fallback to current cost
) * o.quantity
ELSE 0 END), 0.00) AS cogs,
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN p.regular_price * o.quantity ELSE 0 END), 0.00) AS gross_regular_revenue, -- Use current regular price for simplicity here COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN p.regular_price * o.quantity ELSE 0 END), 0.00) AS gross_regular_revenue, -- Use current regular price for simplicity here
-- Aggregate Returns (Quantity < 0 or Status = Returned) -- Aggregate Returns (Quantity < 0 or Status = Returned)

View File

@@ -171,6 +171,85 @@ BEGIN
FROM public.products p FROM public.products p
LEFT JOIN public.settings_product sp ON p.pid = sp.pid LEFT JOIN public.settings_product sp ON p.pid = sp.pid
LEFT JOIN public.settings_vendor sv ON p.vendor = sv.vendor LEFT JOIN public.settings_vendor sv ON p.vendor = sv.vendor
),
LifetimeRevenue AS (
-- Calculate actual revenue from orders table
SELECT
o.pid,
SUM(o.price * o.quantity - COALESCE(o.discount, 0)) AS lifetime_revenue_from_orders,
SUM(o.quantity) AS lifetime_units_from_orders
FROM public.orders o
WHERE o.status NOT IN ('canceled', 'returned')
AND o.quantity > 0
GROUP BY o.pid
),
PreviousPeriodMetrics AS (
-- Calculate metrics for previous 30-day period for growth comparison
SELECT
pid,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '59 days'
AND snapshot_date < _current_date - INTERVAL '29 days'
THEN units_sold ELSE 0 END) AS sales_prev_30d,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '59 days'
AND snapshot_date < _current_date - INTERVAL '29 days'
THEN net_revenue ELSE 0 END) AS revenue_prev_30d,
-- Year-over-year comparison
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '395 days'
AND snapshot_date < _current_date - INTERVAL '365 days'
THEN units_sold ELSE 0 END) AS sales_30d_last_year,
SUM(CASE WHEN snapshot_date >= _current_date - INTERVAL '395 days'
AND snapshot_date < _current_date - INTERVAL '365 days'
THEN net_revenue ELSE 0 END) AS revenue_30d_last_year
FROM public.daily_product_snapshots
GROUP BY pid
),
DemandVariability AS (
-- Calculate variance and standard deviation of daily sales
SELECT
pid,
COUNT(*) AS days_with_data,
AVG(units_sold) AS avg_daily_sales,
VARIANCE(units_sold) AS sales_variance,
STDDEV(units_sold) AS sales_std_dev,
-- Coefficient of variation
CASE
WHEN AVG(units_sold) > 0 THEN STDDEV(units_sold) / AVG(units_sold)
ELSE NULL
END AS sales_cv
FROM public.daily_product_snapshots
WHERE snapshot_date >= _current_date - INTERVAL '29 days'
AND snapshot_date <= _current_date
GROUP BY pid
),
ServiceLevels AS (
-- Calculate service level and fill rate metrics
SELECT
pid,
COUNT(*) FILTER (WHERE stockout_flag = true) AS stockout_incidents_30d,
COUNT(*) FILTER (WHERE stockout_flag = true AND units_sold > 0) AS lost_sales_incidents_30d,
-- Service level: percentage of days without stockouts
(1.0 - (COUNT(*) FILTER (WHERE stockout_flag = true)::NUMERIC / NULLIF(COUNT(*), 0))) * 100 AS service_level_30d,
-- Fill rate: units sold / (units sold + potential lost sales)
CASE
WHEN SUM(units_sold) > 0 THEN
(SUM(units_sold)::NUMERIC /
(SUM(units_sold) + SUM(CASE WHEN stockout_flag THEN units_sold * 0.2 ELSE 0 END))) * 100
ELSE NULL
END AS fill_rate_30d
FROM public.daily_product_snapshots
WHERE snapshot_date >= _current_date - INTERVAL '29 days'
AND snapshot_date <= _current_date
GROUP BY pid
),
SeasonalityAnalysis AS (
-- Simple seasonality detection
SELECT
p.pid,
sp.seasonal_pattern,
sp.seasonality_index,
sp.peak_season
FROM products p
CROSS JOIN LATERAL detect_seasonal_pattern(p.pid) sp
) )
-- Final UPSERT into product_metrics -- Final UPSERT into product_metrics
INSERT INTO public.product_metrics ( INSERT INTO public.product_metrics (
@@ -187,7 +266,7 @@ BEGIN
stockout_days_30d, sales_365d, revenue_365d, stockout_days_30d, sales_365d, revenue_365d,
avg_stock_units_30d, avg_stock_cost_30d, avg_stock_retail_30d, avg_stock_gross_30d, avg_stock_units_30d, avg_stock_cost_30d, avg_stock_retail_30d, avg_stock_gross_30d,
received_qty_30d, received_cost_30d, received_qty_30d, received_cost_30d,
lifetime_sales, lifetime_revenue, lifetime_sales, lifetime_revenue, lifetime_revenue_quality,
first_7_days_sales, first_7_days_revenue, first_30_days_sales, first_30_days_revenue, first_7_days_sales, first_7_days_revenue, first_30_days_sales, first_30_days_revenue,
first_60_days_sales, first_60_days_revenue, first_90_days_sales, first_90_days_revenue, first_60_days_sales, first_60_days_revenue, first_90_days_sales, first_90_days_revenue,
asp_30d, acp_30d, avg_ros_30d, avg_sales_per_day_30d, avg_sales_per_month_30d, asp_30d, acp_30d, avg_ros_30d, avg_sales_per_day_30d, avg_sales_per_month_30d,
@@ -203,7 +282,13 @@ BEGIN
stock_cover_in_days, po_cover_in_days, sells_out_in_days, replenish_date, stock_cover_in_days, po_cover_in_days, sells_out_in_days, replenish_date,
overstocked_units, overstocked_cost, overstocked_retail, is_old_stock, overstocked_units, overstocked_cost, overstocked_retail, is_old_stock,
yesterday_sales, yesterday_sales,
status -- Add status field for calculated status status, -- Add status field for calculated status
-- New fields
sales_growth_30d_vs_prev, revenue_growth_30d_vs_prev,
sales_growth_yoy, revenue_growth_yoy,
sales_variance_30d, sales_std_dev_30d, sales_cv_30d, demand_pattern,
fill_rate_30d, stockout_incidents_30d, service_level_30d, lost_sales_incidents_30d,
seasonality_index, seasonal_pattern, peak_season
) )
SELECT SELECT
ci.pid, _start_time, ci.sku, ci.title, ci.brand, ci.vendor, ci.image_url, ci.is_visible, ci.is_replenishable, ci.pid, _start_time, ci.sku, ci.title, ci.brand, ci.vendor, ci.image_url, ci.is_visible, ci.is_replenishable,
@@ -228,26 +313,32 @@ BEGIN
-- Use total_sold from products table as the source of truth for lifetime sales -- Use total_sold from products table as the source of truth for lifetime sales
-- This includes all historical data from the production database -- This includes all historical data from the production database
ci.historical_total_sold AS lifetime_sales, ci.historical_total_sold AS lifetime_sales,
COALESCE( -- Calculate lifetime revenue using actual historical prices where available
-- Option 1: Use 30-day average price if available CASE
CASE WHEN sa.sales_30d > 0 THEN WHEN lr.lifetime_revenue_from_orders IS NOT NULL THEN
ci.historical_total_sold * (sa.revenue_30d / NULLIF(sa.sales_30d, 0)) -- We have some order history - use it plus estimate for remaining
ELSE NULL END, lr.lifetime_revenue_from_orders +
-- Option 2: Try 365-day average price if available (GREATEST(0, ci.historical_total_sold - COALESCE(lr.lifetime_units_from_orders, 0)) *
CASE WHEN sa.sales_365d > 0 THEN COALESCE(
ci.historical_total_sold * (sa.revenue_365d / NULLIF(sa.sales_365d, 0)) -- Use oldest known price from snapshots as proxy
ELSE NULL END, (SELECT revenue_7d / NULLIF(sales_7d, 0)
-- Option 3: Use current price as a reasonable estimate FROM daily_product_snapshots
ci.historical_total_sold * ci.current_price, WHERE pid = ci.pid AND sales_7d > 0
-- Option 4: Use regular price if current price might be zero ORDER BY snapshot_date ASC
ci.historical_total_sold * ci.current_regular_price, LIMIT 1),
-- Final fallback: Use accumulated revenue (this is less accurate for old products) ci.current_price
sa.total_net_revenue ))
) AS lifetime_revenue, ELSE
-- No order history - estimate using current price
ci.historical_total_sold * ci.current_price
END AS lifetime_revenue,
CASE
WHEN lr.lifetime_units_from_orders >= ci.historical_total_sold * 0.9 THEN 'exact'
WHEN lr.lifetime_units_from_orders >= ci.historical_total_sold * 0.5 THEN 'partial'
ELSE 'estimated'
END AS lifetime_revenue_quality,
fpm.first_7_days_sales, fpm.first_7_days_revenue, fpm.first_30_days_sales, fpm.first_30_days_revenue, fpm.first_7_days_sales, fpm.first_7_days_revenue, fpm.first_30_days_sales, fpm.first_30_days_revenue,
fpm.first_60_days_sales, fpm.first_60_days_revenue, fpm.first_90_days_sales, fpm.first_90_days_revenue, fpm.first_60_days_sales, fpm.first_60_days_revenue, fpm.first_90_days_sales, fpm.first_90_days_revenue,
-- Calculated KPIs
sa.revenue_30d / NULLIF(sa.sales_30d, 0) AS asp_30d, sa.revenue_30d / NULLIF(sa.sales_30d, 0) AS asp_30d,
sa.cogs_30d / NULLIF(sa.sales_30d, 0) AS acp_30d, sa.cogs_30d / NULLIF(sa.sales_30d, 0) AS acp_30d,
sa.profit_30d / NULLIF(sa.sales_30d, 0) AS avg_ros_30d, sa.profit_30d / NULLIF(sa.sales_30d, 0) AS avg_ros_30d,
@@ -262,317 +353,59 @@ BEGIN
(sa.stockout_days_30d / 30.0) * 100 AS stockout_rate_30d, (sa.stockout_days_30d / 30.0) * 100 AS stockout_rate_30d,
sa.gross_regular_revenue_30d - sa.gross_revenue_30d AS markdown_30d, sa.gross_regular_revenue_30d - sa.gross_revenue_30d AS markdown_30d,
((sa.gross_regular_revenue_30d - sa.gross_revenue_30d) / NULLIF(sa.gross_regular_revenue_30d, 0)) * 100 AS markdown_rate_30d, ((sa.gross_regular_revenue_30d - sa.gross_revenue_30d) / NULLIF(sa.gross_regular_revenue_30d, 0)) * 100 AS markdown_rate_30d,
(sa.sales_30d / NULLIF(ci.current_stock + sa.sales_30d, 0)) * 100 AS sell_through_30d, -- Fix sell-through rate: Industry standard is Units Sold / (Beginning Inventory + Units Received)
-- Approximating beginning inventory as current stock + units sold - units received
(sa.sales_30d / NULLIF(
ci.current_stock + sa.sales_30d + sa.returns_units_30d - sa.received_qty_30d,
0
)) * 100 AS sell_through_30d,
-- Forecasting intermediate values -- Forecasting intermediate values
-- CRITICAL FIX: Use safer velocity calculation to prevent extreme values -- Use the calculate_sales_velocity function instead of repetitive calculation
-- Original problematic calculation: (sa.sales_30d / NULLIF(30.0 - sa.stockout_days_30d, 0)) calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) AS sales_velocity_daily,
-- Use available days (not stockout days) as denominator with a minimum safety value
(sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d, -- Standard calculation
CASE
WHEN sa.sales_30d > 0 THEN 14.0 -- If we have sales, ensure at least 14 days denominator
ELSE 30.0 -- If no sales, use full period
END
),
0
)
) AS sales_velocity_daily,
s.effective_lead_time AS config_lead_time, s.effective_lead_time AS config_lead_time,
s.effective_days_of_stock AS config_days_of_stock, s.effective_days_of_stock AS config_days_of_stock,
s.effective_safety_stock AS config_safety_stock, s.effective_safety_stock AS config_safety_stock,
(s.effective_lead_time + s.effective_days_of_stock) AS planning_period_days, (s.effective_lead_time + s.effective_days_of_stock) AS planning_period_days,
-- Apply the same fix to all derived calculations calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time AS lead_time_forecast_units,
(sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time AS lead_time_forecast_units,
(sa.sales_30d / calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock AS days_of_stock_forecast_units,
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_days_of_stock AS days_of_stock_forecast_units,
(sa.sales_30d / calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * (s.effective_lead_time + s.effective_days_of_stock) AS planning_period_forecast_units,
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * (s.effective_lead_time + s.effective_days_of_stock) AS planning_period_forecast_units,
(ci.current_stock + COALESCE(ooi.on_order_qty, 0) - ((sa.sales_30d / (ci.current_stock + COALESCE(ooi.on_order_qty, 0) - (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time)) AS lead_time_closing_stock,
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time)) AS lead_time_closing_stock,
((ci.current_stock + COALESCE(ooi.on_order_qty, 0) - ((sa.sales_30d / ((ci.current_stock + COALESCE(ooi.on_order_qty, 0) - (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time))) - (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock) AS days_of_stock_closing_stock,
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time))) - ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_days_of_stock) AS days_of_stock_closing_stock,
(((sa.sales_30d / ((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0) AS replenishment_needed_raw,
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0) AS replenishment_needed_raw,
-- Final Forecasting / Replenishment Metrics (apply CEILING/GREATEST/etc.) -- Final Forecasting / Replenishment Metrics
-- Note: These calculations are nested for clarity, can be simplified in prod CEILING(GREATEST(0, (((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int AS replenishment_units,
CEILING(GREATEST(0, ((((sa.sales_30d / (CEILING(GREATEST(0, (((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int) * ci.current_effective_cost AS replenishment_cost,
NULLIF( (CEILING(GREATEST(0, (((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int) * ci.current_price AS replenishment_retail,
GREATEST( (CEILING(GREATEST(0, (((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int) * (ci.current_price - ci.current_effective_cost) AS replenishment_profit,
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int AS replenishment_units,
(CEILING(GREATEST(0, ((((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int) * ci.current_effective_cost AS replenishment_cost,
(CEILING(GREATEST(0, ((((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int) * ci.current_price AS replenishment_retail,
(CEILING(GREATEST(0, ((((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int) * (ci.current_price - ci.current_effective_cost) AS replenishment_profit,
-- Placeholder for To Order (Apply MOQ/UOM logic here if needed, otherwise equals replenishment) -- To Order (Apply MOQ/UOM logic here if needed, otherwise equals replenishment)
CEILING(GREATEST(0, ((((sa.sales_30d / CEILING(GREATEST(0, (((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int AS to_order_units,
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_days_of_stock)) + s.effective_safety_stock - ci.current_stock - COALESCE(ooi.on_order_qty, 0))))::int AS to_order_units,
GREATEST(0, - (ci.current_stock + COALESCE(ooi.on_order_qty, 0) - ((sa.sales_30d / GREATEST(0, - (ci.current_stock + COALESCE(ooi.on_order_qty, 0) - (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time))) AS forecast_lost_sales_units,
NULLIF( GREATEST(0, - (ci.current_stock + COALESCE(ooi.on_order_qty, 0) - (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time))) * ci.current_price AS forecast_lost_revenue,
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time))) AS forecast_lost_sales_units,
GREATEST(0, - (ci.current_stock + COALESCE(ooi.on_order_qty, 0) - ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time))) * ci.current_price AS forecast_lost_revenue,
ci.current_stock / NULLIF((sa.sales_30d / ci.current_stock / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0) AS stock_cover_in_days,
NULLIF( COALESCE(ooi.on_order_qty, 0) / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0) AS po_cover_in_days,
GREATEST( (ci.current_stock + COALESCE(ooi.on_order_qty, 0)) / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0) AS sells_out_in_days,
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
), 0) AS stock_cover_in_days,
COALESCE(ooi.on_order_qty, 0) / NULLIF((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
), 0) AS po_cover_in_days,
(ci.current_stock + COALESCE(ooi.on_order_qty, 0)) / NULLIF((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
), 0) AS sells_out_in_days,
-- Replenish Date: Date when stock is projected to hit safety stock, minus lead time -- Replenish Date: Date when stock is projected to hit safety stock, minus lead time
CASE CASE
WHEN (sa.sales_30d / WHEN calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) > 0
NULLIF( THEN _current_date + FLOOR(GREATEST(0, ci.current_stock - s.effective_safety_stock) / calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int))::int - s.effective_lead_time
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) > 0
THEN _current_date + FLOOR(GREATEST(0, ci.current_stock - s.effective_safety_stock) / (sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
))::int - s.effective_lead_time
ELSE NULL ELSE NULL
END AS replenish_date, END AS replenish_date,
GREATEST(0, ci.current_stock - s.effective_safety_stock - (((sa.sales_30d / GREATEST(0, ci.current_stock - s.effective_safety_stock - ((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)))::int AS overstocked_units,
NULLIF( (GREATEST(0, ci.current_stock - s.effective_safety_stock - ((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)))) * ci.current_effective_cost AS overstocked_cost,
GREATEST( (GREATEST(0, ci.current_stock - s.effective_safety_stock - ((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock)))) * ci.current_price AS overstocked_retail,
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_days_of_stock)))::int AS overstocked_units,
(GREATEST(0, ci.current_stock - s.effective_safety_stock - (((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_days_of_stock)))) * ci.current_effective_cost AS overstocked_cost,
(GREATEST(0, ci.current_stock - s.effective_safety_stock - (((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_days_of_stock)))) * ci.current_price AS overstocked_retail,
-- Old Stock Flag -- Old Stock Flag
(ci.created_at::date < _current_date - INTERVAL '60 day') AND (ci.created_at::date < _current_date - INTERVAL '60 day') AND
@@ -592,66 +425,18 @@ BEGIN
ELSE ELSE
CASE CASE
-- Check for overstock first -- Check for overstock first
WHEN GREATEST(0, ci.current_stock - s.effective_safety_stock - (((sa.sales_30d / WHEN GREATEST(0, ci.current_stock - s.effective_safety_stock - ((calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_lead_time) + (calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int) * s.effective_days_of_stock))) > 0 THEN 'Overstock'
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_lead_time) + ((sa.sales_30d /
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
) * s.effective_days_of_stock))) > 0 THEN 'Overstock'
-- Check for Critical stock -- Check for Critical stock
WHEN ci.current_stock <= 0 OR WHEN ci.current_stock <= 0 OR
(ci.current_stock / NULLIF((sa.sales_30d / (ci.current_stock / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0)) <= 0 THEN 'Critical'
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
), 0)) <= 0 THEN 'Critical'
WHEN (ci.current_stock / NULLIF((sa.sales_30d / WHEN (ci.current_stock / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0)) < (COALESCE(s.effective_lead_time, 30) * 0.5) THEN 'Critical'
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
), 0)) < (COALESCE(s.effective_lead_time, 30) * 0.5) THEN 'Critical'
-- Check for reorder soon -- Check for reorder soon
WHEN ((ci.current_stock + COALESCE(ooi.on_order_qty, 0)) / NULLIF((sa.sales_30d / WHEN ((ci.current_stock + COALESCE(ooi.on_order_qty, 0)) / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0)) < (COALESCE(s.effective_lead_time, 30) + 7) THEN
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
), 0)) < (COALESCE(s.effective_lead_time, 30) + 7) THEN
CASE CASE
WHEN (ci.current_stock / NULLIF((sa.sales_30d / WHEN (ci.current_stock / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0)) < (COALESCE(s.effective_lead_time, 30) * 0.5) THEN 'Critical'
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
), 0)) < (COALESCE(s.effective_lead_time, 30) * 0.5) THEN 'Critical'
ELSE 'Reorder Soon' ELSE 'Reorder Soon'
END END
@@ -672,15 +457,7 @@ BEGIN
END) > 180 THEN 'At Risk' END) > 180 THEN 'At Risk'
-- Very high stock cover is at risk too -- Very high stock cover is at risk too
WHEN (ci.current_stock / NULLIF((sa.sales_30d / WHEN (ci.current_stock / NULLIF(calculate_sales_velocity(sa.sales_30d::int, sa.stockout_days_30d::int), 0)) > 365 THEN 'At Risk'
NULLIF(
GREATEST(
30.0 - sa.stockout_days_30d,
CASE WHEN sa.sales_30d > 0 THEN 14.0 ELSE 30.0 END
),
0
)
), 0)) > 365 THEN 'At Risk'
-- New products (less than 30 days old) -- New products (less than 30 days old)
WHEN (CASE WHEN (CASE
@@ -693,7 +470,30 @@ BEGIN
-- If none of the above, assume Healthy -- If none of the above, assume Healthy
ELSE 'Healthy' ELSE 'Healthy'
END END
END AS status END AS status,
-- Growth Metrics (P3) - using safe_divide and std_numeric for consistency
std_numeric(safe_divide((sa.sales_30d - ppm.sales_prev_30d) * 100.0, ppm.sales_prev_30d), 2) AS sales_growth_30d_vs_prev,
std_numeric(safe_divide((sa.revenue_30d - ppm.revenue_prev_30d) * 100.0, ppm.revenue_prev_30d), 2) AS revenue_growth_30d_vs_prev,
std_numeric(safe_divide((sa.sales_30d - ppm.sales_30d_last_year) * 100.0, ppm.sales_30d_last_year), 2) AS sales_growth_yoy,
std_numeric(safe_divide((sa.revenue_30d - ppm.revenue_30d_last_year) * 100.0, ppm.revenue_30d_last_year), 2) AS revenue_growth_yoy,
-- Demand Variability (P3)
std_numeric(dv.sales_variance, 2) AS sales_variance_30d,
std_numeric(dv.sales_std_dev, 2) AS sales_std_dev_30d,
std_numeric(dv.sales_cv, 2) AS sales_cv_30d,
classify_demand_pattern(dv.avg_daily_sales, dv.sales_cv) AS demand_pattern,
-- Service Levels (P5)
std_numeric(COALESCE(sl.fill_rate_30d, 100), 2) AS fill_rate_30d,
COALESCE(sl.stockout_incidents_30d, 0)::int AS stockout_incidents_30d,
std_numeric(COALESCE(sl.service_level_30d, 100), 2) AS service_level_30d,
COALESCE(sl.lost_sales_incidents_30d, 0)::int AS lost_sales_incidents_30d,
-- Seasonality (P5)
std_numeric(season.seasonality_index, 2) AS seasonality_index,
COALESCE(season.seasonal_pattern, 'none') AS seasonal_pattern,
season.peak_season
FROM CurrentInfo ci FROM CurrentInfo ci
LEFT JOIN OnOrderInfo ooi ON ci.pid = ooi.pid LEFT JOIN OnOrderInfo ooi ON ci.pid = ooi.pid
@@ -701,6 +501,11 @@ BEGIN
LEFT JOIN SnapshotAggregates sa ON ci.pid = sa.pid LEFT JOIN SnapshotAggregates sa ON ci.pid = sa.pid
LEFT JOIN FirstPeriodMetrics fpm ON ci.pid = fpm.pid LEFT JOIN FirstPeriodMetrics fpm ON ci.pid = fpm.pid
LEFT JOIN Settings s ON ci.pid = s.pid LEFT JOIN Settings s ON ci.pid = s.pid
LEFT JOIN LifetimeRevenue lr ON ci.pid = lr.pid
LEFT JOIN PreviousPeriodMetrics ppm ON ci.pid = ppm.pid
LEFT JOIN DemandVariability dv ON ci.pid = dv.pid
LEFT JOIN ServiceLevels sl ON ci.pid = sl.pid
LEFT JOIN SeasonalityAnalysis season ON ci.pid = season.pid
WHERE s.exclude_forecast IS FALSE OR s.exclude_forecast IS NULL -- Exclude products explicitly marked WHERE s.exclude_forecast IS FALSE OR s.exclude_forecast IS NULL -- Exclude products explicitly marked
ON CONFLICT (pid) DO UPDATE SET ON CONFLICT (pid) DO UPDATE SET
@@ -718,7 +523,7 @@ BEGIN
stockout_days_30d = EXCLUDED.stockout_days_30d, sales_365d = EXCLUDED.sales_365d, revenue_365d = EXCLUDED.revenue_365d, stockout_days_30d = EXCLUDED.stockout_days_30d, sales_365d = EXCLUDED.sales_365d, revenue_365d = EXCLUDED.revenue_365d,
avg_stock_units_30d = EXCLUDED.avg_stock_units_30d, avg_stock_cost_30d = EXCLUDED.avg_stock_cost_30d, avg_stock_retail_30d = EXCLUDED.avg_stock_retail_30d, avg_stock_gross_30d = EXCLUDED.avg_stock_gross_30d, avg_stock_units_30d = EXCLUDED.avg_stock_units_30d, avg_stock_cost_30d = EXCLUDED.avg_stock_cost_30d, avg_stock_retail_30d = EXCLUDED.avg_stock_retail_30d, avg_stock_gross_30d = EXCLUDED.avg_stock_gross_30d,
received_qty_30d = EXCLUDED.received_qty_30d, received_cost_30d = EXCLUDED.received_cost_30d, received_qty_30d = EXCLUDED.received_qty_30d, received_cost_30d = EXCLUDED.received_cost_30d,
lifetime_sales = EXCLUDED.lifetime_sales, lifetime_revenue = EXCLUDED.lifetime_revenue, lifetime_sales = EXCLUDED.lifetime_sales, lifetime_revenue = EXCLUDED.lifetime_revenue, lifetime_revenue_quality = EXCLUDED.lifetime_revenue_quality,
first_7_days_sales = EXCLUDED.first_7_days_sales, first_7_days_revenue = EXCLUDED.first_7_days_revenue, first_30_days_sales = EXCLUDED.first_30_days_sales, first_30_days_revenue = EXCLUDED.first_30_days_revenue, first_7_days_sales = EXCLUDED.first_7_days_sales, first_7_days_revenue = EXCLUDED.first_7_days_revenue, first_30_days_sales = EXCLUDED.first_30_days_sales, first_30_days_revenue = EXCLUDED.first_30_days_revenue,
first_60_days_sales = EXCLUDED.first_60_days_sales, first_60_days_revenue = EXCLUDED.first_60_days_revenue, first_90_days_sales = EXCLUDED.first_90_days_sales, first_90_days_revenue = EXCLUDED.first_90_days_revenue, first_60_days_sales = EXCLUDED.first_60_days_sales, first_60_days_revenue = EXCLUDED.first_60_days_revenue, first_90_days_sales = EXCLUDED.first_90_days_sales, first_90_days_revenue = EXCLUDED.first_90_days_revenue,
asp_30d = EXCLUDED.asp_30d, acp_30d = EXCLUDED.acp_30d, avg_ros_30d = EXCLUDED.avg_ros_30d, avg_sales_per_day_30d = EXCLUDED.avg_sales_per_day_30d, avg_sales_per_month_30d = EXCLUDED.avg_sales_per_month_30d, asp_30d = EXCLUDED.asp_30d, acp_30d = EXCLUDED.acp_30d, avg_ros_30d = EXCLUDED.avg_ros_30d, avg_sales_per_day_30d = EXCLUDED.avg_sales_per_day_30d, avg_sales_per_month_30d = EXCLUDED.avg_sales_per_month_30d,
@@ -734,7 +539,22 @@ BEGIN
stock_cover_in_days = EXCLUDED.stock_cover_in_days, po_cover_in_days = EXCLUDED.po_cover_in_days, sells_out_in_days = EXCLUDED.sells_out_in_days, replenish_date = EXCLUDED.replenish_date, stock_cover_in_days = EXCLUDED.stock_cover_in_days, po_cover_in_days = EXCLUDED.po_cover_in_days, sells_out_in_days = EXCLUDED.sells_out_in_days, replenish_date = EXCLUDED.replenish_date,
overstocked_units = EXCLUDED.overstocked_units, overstocked_cost = EXCLUDED.overstocked_cost, overstocked_retail = EXCLUDED.overstocked_retail, is_old_stock = EXCLUDED.is_old_stock, overstocked_units = EXCLUDED.overstocked_units, overstocked_cost = EXCLUDED.overstocked_cost, overstocked_retail = EXCLUDED.overstocked_retail, is_old_stock = EXCLUDED.is_old_stock,
yesterday_sales = EXCLUDED.yesterday_sales, yesterday_sales = EXCLUDED.yesterday_sales,
status = EXCLUDED.status status = EXCLUDED.status,
sales_growth_30d_vs_prev = EXCLUDED.sales_growth_30d_vs_prev,
revenue_growth_30d_vs_prev = EXCLUDED.revenue_growth_30d_vs_prev,
sales_growth_yoy = EXCLUDED.sales_growth_yoy,
revenue_growth_yoy = EXCLUDED.revenue_growth_yoy,
sales_variance_30d = EXCLUDED.sales_variance_30d,
sales_std_dev_30d = EXCLUDED.sales_std_dev_30d,
sales_cv_30d = EXCLUDED.sales_cv_30d,
demand_pattern = EXCLUDED.demand_pattern,
fill_rate_30d = EXCLUDED.fill_rate_30d,
stockout_incidents_30d = EXCLUDED.stockout_incidents_30d,
service_level_30d = EXCLUDED.service_level_30d,
lost_sales_incidents_30d = EXCLUDED.lost_sales_incidents_30d,
seasonality_index = EXCLUDED.seasonality_index,
seasonal_pattern = EXCLUDED.seasonal_pattern,
peak_season = EXCLUDED.peak_season
WHERE -- Only update if at least one key metric has changed WHERE -- Only update if at least one key metric has changed
product_metrics.current_stock IS DISTINCT FROM EXCLUDED.current_stock OR product_metrics.current_stock IS DISTINCT FROM EXCLUDED.current_stock OR
product_metrics.current_price IS DISTINCT FROM EXCLUDED.current_price OR product_metrics.current_price IS DISTINCT FROM EXCLUDED.current_price OR
@@ -750,7 +570,8 @@ BEGIN
-- Check a few other important fields that might change -- Check a few other important fields that might change
product_metrics.date_last_sold IS DISTINCT FROM EXCLUDED.date_last_sold OR product_metrics.date_last_sold IS DISTINCT FROM EXCLUDED.date_last_sold OR
product_metrics.earliest_expected_date IS DISTINCT FROM EXCLUDED.earliest_expected_date OR product_metrics.earliest_expected_date IS DISTINCT FROM EXCLUDED.earliest_expected_date OR
product_metrics.lifetime_sales IS DISTINCT FROM EXCLUDED.lifetime_sales product_metrics.lifetime_sales IS DISTINCT FROM EXCLUDED.lifetime_sales OR
product_metrics.lifetime_revenue_quality IS DISTINCT FROM EXCLUDED.lifetime_revenue_quality
; ;
-- Update the status table with the timestamp from the START of this run -- Update the status table with the timestamp from the START of this run

View File

@@ -203,12 +203,8 @@ router.get('/vendors', async (req, res) => {
0 0
) as stock_turnover, ) as stock_turnover,
product_count, product_count,
-- Use an estimate of growth based on 7-day vs 30-day revenue -- Use actual growth metrics from the vendor_metrics table
CASE sales_growth_30d_vs_prev as growth
WHEN revenue_30d > 0
THEN ((revenue_7d * 4.0) / revenue_30d - 1) * 100
ELSE 0
END as growth
FROM vendor_metrics FROM vendor_metrics
WHERE revenue_30d > 0 WHERE revenue_30d > 0
ORDER BY revenue_30d DESC ORDER BY revenue_30d DESC

View File

@@ -26,6 +26,9 @@ const COLUMN_MAP = {
lifetimeSales: { dbCol: 'bm.lifetime_sales', type: 'number' }, lifetimeSales: { dbCol: 'bm.lifetime_sales', type: 'number' },
lifetimeRevenue: { dbCol: 'bm.lifetime_revenue', type: 'number' }, lifetimeRevenue: { dbCol: 'bm.lifetime_revenue', type: 'number' },
avgMargin30d: { dbCol: 'bm.avg_margin_30d', type: 'number' }, avgMargin30d: { dbCol: 'bm.avg_margin_30d', type: 'number' },
// Growth metrics
salesGrowth30dVsPrev: { dbCol: 'bm.sales_growth_30d_vs_prev', type: 'number' },
revenueGrowth30dVsPrev: { dbCol: 'bm.revenue_growth_30d_vs_prev', type: 'number' },
// Add aliases if needed // Add aliases if needed
name: { dbCol: 'bm.brand_name', type: 'string' }, name: { dbCol: 'bm.brand_name', type: 'string' },
// Add status for filtering // Add status for filtering

View File

@@ -31,6 +31,9 @@ const COLUMN_MAP = {
lifetimeRevenue: { dbCol: 'cm.lifetime_revenue', type: 'number' }, lifetimeRevenue: { dbCol: 'cm.lifetime_revenue', type: 'number' },
avgMargin30d: { dbCol: 'cm.avg_margin_30d', type: 'number' }, avgMargin30d: { dbCol: 'cm.avg_margin_30d', type: 'number' },
stockTurn30d: { dbCol: 'cm.stock_turn_30d', type: 'number' }, stockTurn30d: { dbCol: 'cm.stock_turn_30d', type: 'number' },
// Growth metrics
salesGrowth30dVsPrev: { dbCol: 'cm.sales_growth_30d_vs_prev', type: 'number' },
revenueGrowth30dVsPrev: { dbCol: 'cm.revenue_growth_30d_vs_prev', type: 'number' },
// Add status from the categories table for filtering // Add status from the categories table for filtering
status: { dbCol: 'c.status', type: 'string' }, status: { dbCol: 'c.status', type: 'string' },
}; };

View File

@@ -143,7 +143,33 @@ const COLUMN_MAP = {
// Yesterday // Yesterday
yesterdaySales: 'pm.yesterday_sales', yesterdaySales: 'pm.yesterday_sales',
// Map status column - directly mapped now instead of calculated on frontend // Map status column - directly mapped now instead of calculated on frontend
status: 'pm.status' status: 'pm.status',
// Growth Metrics (P3)
salesGrowth30dVsPrev: 'pm.sales_growth_30d_vs_prev',
revenueGrowth30dVsPrev: 'pm.revenue_growth_30d_vs_prev',
salesGrowthYoy: 'pm.sales_growth_yoy',
revenueGrowthYoy: 'pm.revenue_growth_yoy',
// Demand Variability Metrics (P3)
salesVariance30d: 'pm.sales_variance_30d',
salesStdDev30d: 'pm.sales_std_dev_30d',
salesCv30d: 'pm.sales_cv_30d',
demandPattern: 'pm.demand_pattern',
// Service Level Metrics (P5)
fillRate30d: 'pm.fill_rate_30d',
stockoutIncidents30d: 'pm.stockout_incidents_30d',
serviceLevel30d: 'pm.service_level_30d',
lostSalesIncidents30d: 'pm.lost_sales_incidents_30d',
// Seasonality Metrics (P5)
seasonalityIndex: 'pm.seasonality_index',
seasonalPattern: 'pm.seasonal_pattern',
peakSeason: 'pm.peak_season',
// Lifetime Revenue Quality
lifetimeRevenueQuality: 'pm.lifetime_revenue_quality'
}; };
// Define column types for use in sorting/filtering // Define column types for use in sorting/filtering
@@ -173,7 +199,15 @@ const COLUMN_TYPES = {
'overstockedCost', 'overstockedRetail', 'yesterdaySales', 'overstockedCost', 'overstockedRetail', 'yesterdaySales',
// New numeric columns // New numeric columns
'moq', 'rating', 'reviews', 'weight', 'length', 'width', 'height', 'moq', 'rating', 'reviews', 'weight', 'length', 'width', 'height',
'baskets', 'notifies', 'preorderCount', 'notionsInvCount' 'baskets', 'notifies', 'preorderCount', 'notionsInvCount',
// Growth metrics
'salesGrowth30dVsPrev', 'revenueGrowth30dVsPrev', 'salesGrowthYoy', 'revenueGrowthYoy',
// Demand variability metrics
'salesVariance30d', 'salesStdDev30d', 'salesCv30d',
// Service level metrics
'fillRate30d', 'stockoutIncidents30d', 'serviceLevel30d', 'lostSalesIncidents30d',
// Seasonality metrics
'seasonalityIndex'
], ],
// Date columns (use date operators and sorting) // Date columns (use date operators and sorting)
date: [ date: [
@@ -185,7 +219,9 @@ const COLUMN_TYPES = {
'sku', 'title', 'brand', 'vendor', 'imageUrl', 'abcClass', 'status', 'sku', 'title', 'brand', 'vendor', 'imageUrl', 'abcClass', 'status',
// New string columns // New string columns
'barcode', 'harmonizedTariffCode', 'vendorReference', 'notionsReference', 'barcode', 'harmonizedTariffCode', 'vendorReference', 'notionsReference',
'line', 'subline', 'artist', 'countryOfOrigin', 'location' 'line', 'subline', 'artist', 'countryOfOrigin', 'location',
// New string columns for patterns
'demandPattern', 'seasonalPattern', 'peakSeason', 'lifetimeRevenueQuality'
], ],
// Boolean columns (use boolean operators and sorting) // Boolean columns (use boolean operators and sorting)
boolean: ['isVisible', 'isReplenishable', 'isOldStock'] boolean: ['isVisible', 'isReplenishable', 'isOldStock']
@@ -208,6 +244,12 @@ const SPECIAL_SORT_COLUMNS = {
// Velocity columns // Velocity columns
salesVelocityDaily: true, salesVelocityDaily: true,
// Growth rate columns
salesGrowth30dVsPrev: 'abs',
revenueGrowth30dVsPrev: 'abs',
salesGrowthYoy: 'abs',
revenueGrowthYoy: 'abs',
// Status column needs special ordering // Status column needs special ordering
status: 'priority' status: 'priority'
}; };

View File

@@ -30,6 +30,9 @@ const COLUMN_MAP = {
lifetimeSales: { dbCol: 'vm.lifetime_sales', type: 'number' }, lifetimeSales: { dbCol: 'vm.lifetime_sales', type: 'number' },
lifetimeRevenue: { dbCol: 'vm.lifetime_revenue', type: 'number' }, lifetimeRevenue: { dbCol: 'vm.lifetime_revenue', type: 'number' },
avgMargin30d: { dbCol: 'vm.avg_margin_30d', type: 'number' }, avgMargin30d: { dbCol: 'vm.avg_margin_30d', type: 'number' },
// Growth metrics
salesGrowth30dVsPrev: { dbCol: 'vm.sales_growth_30d_vs_prev', type: 'number' },
revenueGrowth30dVsPrev: { dbCol: 'vm.revenue_growth_30d_vs_prev', type: 'number' },
// Add aliases if needed for frontend compatibility // Add aliases if needed for frontend compatibility
name: { dbCol: 'vm.vendor_name', type: 'string' }, name: { dbCol: 'vm.vendor_name', type: 'string' },
leadTime: { dbCol: 'vm.avg_lead_time_days', type: 'number' }, leadTime: { dbCol: 'vm.avg_lead_time_days', type: 'number' },

View File

@@ -16,7 +16,8 @@ import { Badge } from "@/components/ui/badge";
type BrandSortableColumns = type BrandSortableColumns =
| 'brandName' | 'productCount' | 'activeProductCount' | 'currentStockUnits' | 'brandName' | 'productCount' | 'activeProductCount' | 'currentStockUnits'
| 'currentStockCost' | 'currentStockRetail' | 'revenue_7d' | 'revenue_30d' | 'currentStockCost' | 'currentStockRetail' | 'revenue_7d' | 'revenue_30d'
| 'profit_30d' | 'sales_30d' | 'avg_margin_30d' | 'stock_turn_30d' | 'status'; // Add more as needed | 'profit_30d' | 'sales_30d' | 'avg_margin_30d' | 'stock_turn_30d'
| 'salesGrowth30dVsPrev' | 'revenueGrowth30dVsPrev' | 'status';
interface BrandMetric { interface BrandMetric {
brand_id: string | number; brand_id: string | number;
@@ -40,6 +41,9 @@ interface BrandMetric {
lifetime_revenue: string | number; lifetime_revenue: string | number;
avg_margin_30d: string | number | null; avg_margin_30d: string | number | null;
stock_turn_30d: string | number | null; stock_turn_30d: string | number | null;
// Growth metrics
sales_growth_30d_vs_prev: string | number | null;
revenue_growth_30d_vs_prev: string | number | null;
status: string; status: string;
brand_status: string; brand_status: string;
description: string; description: string;
@@ -57,6 +61,8 @@ interface BrandMetric {
lifetimeRevenue: string | number; lifetimeRevenue: string | number;
avgMargin_30d: string | number | null; avgMargin_30d: string | number | null;
stockTurn_30d: string | number | null; stockTurn_30d: string | number | null;
salesGrowth30dVsPrev: string | number | null;
revenueGrowth30dVsPrev: string | number | null;
} }
// Define response type to avoid type errors // Define response type to avoid type errors
@@ -140,6 +146,19 @@ const formatPercentage = (value: number | string | null | undefined, digits = 1)
return `${value.toFixed(digits)}%`; return `${value.toFixed(digits)}%`;
}; };
// Growth formatting with color coding
const formatGrowth = (value: number | string | null | undefined, digits = 1) => {
if (value == null) return <span className="text-muted-foreground">N/A</span>;
const numValue = typeof value === "string" ? parseFloat(value) : value;
if (isNaN(numValue)) return <span className="text-muted-foreground">N/A</span>;
const formatted = `${numValue >= 0 ? '+' : ''}${numValue.toFixed(digits)}%`;
const colorClass = numValue >= 0 ? 'text-green-600' : 'text-red-600';
return <span className={colorClass}>{formatted}</span>;
};
const getStatusVariant = (status: string): "default" | "secondary" | "outline" | "destructive" => { const getStatusVariant = (status: string): "default" | "secondary" | "outline" | "destructive" => {
switch (status) { switch (status) {
case 'active': case 'active':
@@ -361,6 +380,8 @@ export function Brands() {
<TableHead onClick={() => handleSort("profit_30d")} className="cursor-pointer text-right">Profit (30d)</TableHead> <TableHead onClick={() => handleSort("profit_30d")} className="cursor-pointer text-right">Profit (30d)</TableHead>
<TableHead onClick={() => handleSort("avg_margin_30d")} className="cursor-pointer text-right">Margin (30d)</TableHead> <TableHead onClick={() => handleSort("avg_margin_30d")} className="cursor-pointer text-right">Margin (30d)</TableHead>
<TableHead onClick={() => handleSort("stock_turn_30d")} className="cursor-pointer text-right">Stock Turn (30d)</TableHead> <TableHead onClick={() => handleSort("stock_turn_30d")} className="cursor-pointer text-right">Stock Turn (30d)</TableHead>
<TableHead onClick={() => handleSort("salesGrowth30dVsPrev")} className="cursor-pointer text-right">Sales Growth</TableHead>
<TableHead onClick={() => handleSort("revenueGrowth30dVsPrev")} className="cursor-pointer text-right">Revenue Growth</TableHead>
<TableHead onClick={() => handleSort("status")} className="cursor-pointer text-right">Status</TableHead> <TableHead onClick={() => handleSort("status")} className="cursor-pointer text-right">Status</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -378,17 +399,19 @@ export function Brands() {
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell> <TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell> <TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell> <TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
</TableRow> </TableRow>
)) ))
) : listError ? ( ) : listError ? (
<TableRow> <TableRow>
<TableCell colSpan={9} className="text-center py-8 text-destructive"> <TableCell colSpan={12} className="text-center py-8 text-destructive">
Error loading brands: {listError.message} Error loading brands: {listError.message}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : brands.length === 0 ? ( ) : brands.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground"> <TableCell colSpan={12} className="text-center py-8 text-muted-foreground">
No brands found matching your criteria. No brands found matching your criteria.
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -404,6 +427,8 @@ export function Brands() {
<TableCell className="text-right">{formatCurrency(brand.profit_30d as number)}</TableCell> <TableCell className="text-right">{formatCurrency(brand.profit_30d as number)}</TableCell>
<TableCell className="text-right">{formatPercentage(brand.avg_margin_30d as number)}</TableCell> <TableCell className="text-right">{formatPercentage(brand.avg_margin_30d as number)}</TableCell>
<TableCell className="text-right">{formatNumber(brand.stock_turn_30d, 2)}</TableCell> <TableCell className="text-right">{formatNumber(brand.stock_turn_30d, 2)}</TableCell>
<TableCell className="text-right">{formatGrowth(brand.sales_growth_30d_vs_prev)}</TableCell>
<TableCell className="text-right">{formatGrowth(brand.revenue_growth_30d_vs_prev)}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Badge variant={getStatusVariant(brand.status)}> <Badge variant={getStatusVariant(brand.status)}>
{brand.status || 'Unknown'} {brand.status || 'Unknown'}

View File

@@ -60,6 +60,8 @@ type CategorySortableColumns =
| "sales30d" | "sales30d"
| "avgMargin30d" | "avgMargin30d"
| "stockTurn30d" | "stockTurn30d"
| "salesGrowth30dVsPrev"
| "revenueGrowth30dVsPrev"
| "status"; | "status";
interface CategoryMetric { interface CategoryMetric {
@@ -88,6 +90,9 @@ interface CategoryMetric {
lifetime_revenue: string | number; lifetime_revenue: string | number;
avg_margin_30d: string | number | null; avg_margin_30d: string | number | null;
stock_turn_30d: string | number | null; stock_turn_30d: string | number | null;
// Growth metrics
sales_growth_30d_vs_prev: string | number | null;
revenue_growth_30d_vs_prev: string | number | null;
// Fields from categories table // Fields from categories table
status: string; status: string;
description: string; description: string;
@@ -108,6 +113,8 @@ interface CategoryMetric {
lifetimeRevenue: string | number; lifetimeRevenue: string | number;
avgMargin_30d: string | number | null; avgMargin_30d: string | number | null;
stockTurn_30d: string | number | null; stockTurn_30d: string | number | null;
salesGrowth30dVsPrev: string | number | null;
revenueGrowth30dVsPrev: string | number | null;
direct_active_product_count: number; direct_active_product_count: number;
direct_current_stock_units: number; direct_current_stock_units: number;
direct_stock_cost: string | number; direct_stock_cost: string | number;
@@ -208,6 +215,19 @@ const formatPercentage = (
return `${value.toFixed(digits)}%`; return `${value.toFixed(digits)}%`;
}; };
// Growth formatting with color coding
const formatGrowth = (value: number | string | null | undefined, digits = 1) => {
if (value == null) return <span className="text-muted-foreground">N/A</span>;
const numValue = typeof value === "string" ? parseFloat(value) : value;
if (isNaN(numValue)) return <span className="text-muted-foreground">N/A</span>;
const formatted = `${numValue >= 0 ? '+' : ''}${numValue.toFixed(digits)}%`;
const colorClass = numValue >= 0 ? 'text-green-600' : 'text-red-600';
return <span className={colorClass}>{formatted}</span>;
};
// Define interfaces for hierarchical structure // Define interfaces for hierarchical structure
interface CategoryWithChildren extends CategoryMetric { interface CategoryWithChildren extends CategoryMetric {
children: CategoryWithChildren[]; children: CategoryWithChildren[];
@@ -221,6 +241,8 @@ interface CategoryWithChildren extends CategoryMetric {
revenue30d: number; revenue30d: number;
profit30d: number; profit30d: number;
avg_margin_30d?: number; avg_margin_30d?: number;
sales_growth_30d_vs_prev?: number;
revenue_growth_30d_vs_prev?: number;
}; };
} }
@@ -683,7 +705,9 @@ export function Categories() {
profit30d: totals.profit30d, profit30d: totals.profit30d,
avg_margin_30d: totals.revenue30d > 0 avg_margin_30d: totals.revenue30d > 0
? (totals.profit30d / totals.revenue30d) * 100 ? (totals.profit30d / totals.revenue30d) * 100
: 0 : 0,
sales_growth_30d_vs_prev: parseFloat(cat.sales_growth_30d_vs_prev?.toString() || "0"),
revenue_growth_30d_vs_prev: parseFloat(cat.revenue_growth_30d_vs_prev?.toString() || "0")
}; };
} else { } else {
// If we don't have pre-calculated values (shouldn't happen with our algorithm) // If we don't have pre-calculated values (shouldn't happen with our algorithm)
@@ -694,7 +718,9 @@ export function Categories() {
currentStockCost: parseFloat(cat.direct_stock_cost?.toString() || "0"), currentStockCost: parseFloat(cat.direct_stock_cost?.toString() || "0"),
revenue30d: parseFloat(cat.direct_revenue_30d?.toString() || "0"), revenue30d: parseFloat(cat.direct_revenue_30d?.toString() || "0"),
profit30d: parseFloat(cat.direct_profit_30d?.toString() || "0"), profit30d: parseFloat(cat.direct_profit_30d?.toString() || "0"),
avg_margin_30d: parseFloat(cat.avg_margin_30d?.toString() || "0") avg_margin_30d: parseFloat(cat.avg_margin_30d?.toString() || "0"),
sales_growth_30d_vs_prev: parseFloat(cat.sales_growth_30d_vs_prev?.toString() || "0"),
revenue_growth_30d_vs_prev: parseFloat(cat.revenue_growth_30d_vs_prev?.toString() || "0")
}; };
} }
@@ -953,6 +979,56 @@ export function Categories() {
formatPercentage(category.avg_margin_30d)} formatPercentage(category.avg_margin_30d)}
</TableCell> </TableCell>
{/* Sales Growth Cell */}
<TableCell className="h-16 py-2 text-right">
{hasChildren && category.aggregatedStats ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="font-medium cursor-help">
{formatGrowth(category.aggregatedStats.sales_growth_30d_vs_prev)}
</span>
</TooltipTrigger>
<TooltipContent>
<p>
Sales Growth (incl. children):{" "}
{formatGrowth(category.aggregatedStats.sales_growth_30d_vs_prev)}
</p>
<p>
Directly from '{category.category_name}':{" "}
{formatGrowth(category.sales_growth_30d_vs_prev)}
</p>
</TooltipContent>
</Tooltip>
) : (
formatGrowth(category.sales_growth_30d_vs_prev)
)}
</TableCell>
{/* Revenue Growth Cell */}
<TableCell className="h-16 py-2 text-right">
{hasChildren && category.aggregatedStats ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="font-medium cursor-help">
{formatGrowth(category.aggregatedStats.revenue_growth_30d_vs_prev)}
</span>
</TooltipTrigger>
<TooltipContent>
<p>
Revenue Growth (incl. children):{" "}
{formatGrowth(category.aggregatedStats.revenue_growth_30d_vs_prev)}
</p>
<p>
Directly from '{category.category_name}':{" "}
{formatGrowth(category.revenue_growth_30d_vs_prev)}
</p>
</TooltipContent>
</Tooltip>
) : (
formatGrowth(category.revenue_growth_30d_vs_prev)
)}
</TableCell>
{/* Stock Turn (30d) Cell - Display direct value */} {/* Stock Turn (30d) Cell - Display direct value */}
<TableCell className="h-16 py-2 text-right"> <TableCell className="h-16 py-2 text-right">
{formatNumber(category.stock_turn_30d, 2)} {formatNumber(category.stock_turn_30d, 2)}
@@ -1009,6 +1085,9 @@ export function Categories() {
<TableCell className="text-right w-[8%]"> <TableCell className="text-right w-[8%]">
<Skeleton className="h-5 w-full ml-auto" /> <Skeleton className="h-5 w-full ml-auto" />
</TableCell> </TableCell>
<TableCell className="text-right w-[8%]">
<Skeleton className="h-5 w-full ml-auto" />
</TableCell>
<TableCell className="text-right w-[6%]"> <TableCell className="text-right w-[6%]">
<Skeleton className="h-5 w-full ml-auto" /> <Skeleton className="h-5 w-full ml-auto" />
</TableCell> </TableCell>
@@ -1027,7 +1106,7 @@ export function Categories() {
return ( return (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={11} colSpan={13}
className="h-16 text-center py-8 text-muted-foreground" className="h-16 text-center py-8 text-muted-foreground"
> >
{categories && categories.length > 0 ? ( {categories && categories.length > 0 ? (
@@ -1321,6 +1400,20 @@ export function Categories() {
Margin (30d) Margin (30d)
<SortIndicator active={sortColumn === "avgMargin30d"} /> <SortIndicator active={sortColumn === "avgMargin30d"} />
</TableHead> </TableHead>
<TableHead
onClick={() => handleSort("salesGrowth30dVsPrev")}
className="cursor-pointer text-right w-[8%]"
>
Sales Growth
<SortIndicator active={sortColumn === "salesGrowth30dVsPrev"} />
</TableHead>
<TableHead
onClick={() => handleSort("revenueGrowth30dVsPrev")}
className="cursor-pointer text-right w-[8%]"
>
Revenue Growth
<SortIndicator active={sortColumn === "revenueGrowth30dVsPrev"} />
</TableHead>
<TableHead <TableHead
onClick={() => handleSort("stockTurn30d")} onClick={() => handleSort("stockTurn30d")}
className="cursor-pointer text-right w-[6%]" className="cursor-pointer text-right w-[6%]"

View File

@@ -182,6 +182,32 @@ const AVAILABLE_COLUMNS: ColumnDef[] = [
{ key: 'first60DaysRevenue', label: 'First 60 Days Revenue', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' }, { key: 'first60DaysRevenue', label: 'First 60 Days Revenue', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'first90DaysSales', label: 'First 90 Days Sales', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' }, { key: 'first90DaysSales', label: 'First 90 Days Sales', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
{ key: 'first90DaysRevenue', label: 'First 90 Days Revenue', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' }, { key: 'first90DaysRevenue', label: 'First 90 Days Revenue', group: 'First Period', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
// Growth Metrics
{ key: 'salesGrowth30dVsPrev', label: 'Sales Growth % (30d vs Prev)', group: 'Growth Analysis', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
{ key: 'revenueGrowth30dVsPrev', label: 'Revenue Growth % (30d vs Prev)', group: 'Growth Analysis', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
{ key: 'salesGrowthYoy', label: 'Sales Growth % YoY', group: 'Growth Analysis', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
{ key: 'revenueGrowthYoy', label: 'Revenue Growth % YoY', group: 'Growth Analysis', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
// Demand Variability Metrics
{ key: 'salesVariance30d', label: 'Sales Variance (30d)', group: 'Demand Variability', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'salesStdDev30d', label: 'Sales Std Dev (30d)', group: 'Demand Variability', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'salesCv30d', label: 'Sales CV % (30d)', group: 'Demand Variability', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
{ key: 'demandPattern', label: 'Demand Pattern', group: 'Demand Variability' },
// Service Level Metrics
{ key: 'fillRate30d', label: 'Fill Rate % (30d)', group: 'Service Level', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
{ key: 'stockoutIncidents30d', label: 'Stockout Incidents (30d)', group: 'Service Level', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
{ key: 'serviceLevel30d', label: 'Service Level % (30d)', group: 'Service Level', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
{ key: 'lostSalesIncidents30d', label: 'Lost Sales Incidents (30d)', group: 'Service Level', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
// Seasonality Metrics
{ key: 'seasonalityIndex', label: 'Seasonality Index', group: 'Seasonality', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'seasonalPattern', label: 'Seasonal Pattern', group: 'Seasonality' },
{ key: 'peakSeason', label: 'Peak Season', group: 'Seasonality' },
// Quality Indicators
{ key: 'lifetimeRevenueQuality', label: 'Lifetime Revenue Quality', group: 'Data Quality' },
]; ];
// Define default columns for each view // Define default columns for each view
@@ -198,7 +224,8 @@ const VIEW_COLUMNS: Record<string, ProductMetricColumnKey[]> = {
'revenue30d', 'revenue30d',
'profit30d', 'profit30d',
'stockCoverInDays', 'stockCoverInDays',
'currentStockCost' 'currentStockCost',
'salesGrowth30dVsPrev'
], ],
critical: [ critical: [
'status', 'status',
@@ -214,7 +241,9 @@ const VIEW_COLUMNS: Record<string, ProductMetricColumnKey[]> = {
'earliestExpectedDate', 'earliestExpectedDate',
'vendor', 'vendor',
'dateLastReceived', 'dateLastReceived',
'avgLeadTimeDays' 'avgLeadTimeDays',
'serviceLevel30d',
'stockoutIncidents30d'
], ],
reorder: [ reorder: [
'status', 'status',
@@ -229,7 +258,8 @@ const VIEW_COLUMNS: Record<string, ProductMetricColumnKey[]> = {
'sales30d', 'sales30d',
'vendor', 'vendor',
'avgLeadTimeDays', 'avgLeadTimeDays',
'dateLastReceived' 'dateLastReceived',
'demandPattern'
], ],
overstocked: [ overstocked: [
'status', 'status',
@@ -244,7 +274,8 @@ const VIEW_COLUMNS: Record<string, ProductMetricColumnKey[]> = {
'stockturn30d', 'stockturn30d',
'currentStockCost', 'currentStockCost',
'overstockedCost', 'overstockedCost',
'dateLastSold' 'dateLastSold',
'salesVariance30d'
], ],
'at-risk': [ 'at-risk': [
'status', 'status',
@@ -259,7 +290,9 @@ const VIEW_COLUMNS: Record<string, ProductMetricColumnKey[]> = {
'sellsOutInDays', 'sellsOutInDays',
'dateLastSold', 'dateLastSold',
'avgLeadTimeDays', 'avgLeadTimeDays',
'profit30d' 'profit30d',
'fillRate30d',
'salesGrowth30dVsPrev'
], ],
new: [ new: [
'status', 'status',
@@ -274,7 +307,9 @@ const VIEW_COLUMNS: Record<string, ProductMetricColumnKey[]> = {
'currentCostPrice', 'currentCostPrice',
'dateFirstReceived', 'dateFirstReceived',
'ageDays', 'ageDays',
'abcClass' 'abcClass',
'first7DaysSales',
'first30DaysSales'
], ],
healthy: [ healthy: [
'status', 'status',
@@ -288,7 +323,9 @@ const VIEW_COLUMNS: Record<string, ProductMetricColumnKey[]> = {
'profit30d', 'profit30d',
'margin30d', 'margin30d',
'gmroi30d', 'gmroi30d',
'stockturn30d' 'stockturn30d',
'salesGrowth30dVsPrev',
'serviceLevel30d'
], ],
}; };

View File

@@ -16,7 +16,8 @@ import { Label } from "@/components/ui/label";
type VendorSortableColumns = type VendorSortableColumns =
| 'vendorName' | 'productCount' | 'activeProductCount' | 'currentStockUnits' | 'vendorName' | 'productCount' | 'activeProductCount' | 'currentStockUnits'
| 'currentStockCost' | 'onOrderUnits' | 'onOrderCost' | 'avgLeadTimeDays' | 'currentStockCost' | 'onOrderUnits' | 'onOrderCost' | 'avgLeadTimeDays'
| 'revenue_30d' | 'profit_30d' | 'avg_margin_30d' | 'po_count_365d' | 'status'; | 'revenue_30d' | 'profit_30d' | 'avg_margin_30d' | 'po_count_365d'
| 'salesGrowth30dVsPrev' | 'revenueGrowth30dVsPrev' | 'status';
interface VendorMetric { interface VendorMetric {
vendor_id: string | number; vendor_id: string | number;
@@ -43,6 +44,9 @@ interface VendorMetric {
lifetime_sales: number; lifetime_sales: number;
lifetime_revenue: string | number; lifetime_revenue: string | number;
avg_margin_30d: string | number | null; avg_margin_30d: string | number | null;
// Growth metrics
sales_growth_30d_vs_prev: string | number | null;
revenue_growth_30d_vs_prev: string | number | null;
// New fields added by vendorsAggregate // New fields added by vendorsAggregate
status: string; status: string;
vendor_status: string; vendor_status: string;
@@ -68,6 +72,8 @@ interface VendorMetric {
lifetimeSales: number; lifetimeSales: number;
lifetimeRevenue: string | number; lifetimeRevenue: string | number;
avgMargin_30d: string | number | null; avgMargin_30d: string | number | null;
salesGrowth30dVsPrev: string | number | null;
revenueGrowth30dVsPrev: string | number | null;
} }
// Define response type to avoid type errors // Define response type to avoid type errors
@@ -162,6 +168,19 @@ const formatDays = (value: number | string | null | undefined, digits = 1): stri
return `${value.toFixed(digits)} days`; return `${value.toFixed(digits)} days`;
}; };
// Growth formatting with color coding
const formatGrowth = (value: number | string | null | undefined, digits = 1) => {
if (value == null) return <span className="text-muted-foreground">N/A</span>;
const numValue = typeof value === "string" ? parseFloat(value) : value;
if (isNaN(numValue)) return <span className="text-muted-foreground">N/A</span>;
const formatted = `${numValue >= 0 ? '+' : ''}${numValue.toFixed(digits)}%`;
const colorClass = numValue >= 0 ? 'text-green-600' : 'text-red-600';
return <span className={colorClass}>{formatted}</span>;
};
const getStatusVariant = (status: string): "default" | "secondary" | "outline" | "destructive" => { const getStatusVariant = (status: string): "default" | "secondary" | "outline" | "destructive" => {
switch (status) { switch (status) {
case 'active': case 'active':
@@ -381,6 +400,8 @@ export function Vendors() {
<TableHead onClick={() => handleSort("profit_30d")} className="cursor-pointer text-right">Profit (30d)</TableHead> <TableHead onClick={() => handleSort("profit_30d")} className="cursor-pointer text-right">Profit (30d)</TableHead>
<TableHead onClick={() => handleSort("avg_margin_30d")} className="cursor-pointer text-right">Margin (30d)</TableHead> <TableHead onClick={() => handleSort("avg_margin_30d")} className="cursor-pointer text-right">Margin (30d)</TableHead>
<TableHead onClick={() => handleSort("po_count_365d")} className="cursor-pointer text-right">POs (365d)</TableHead> <TableHead onClick={() => handleSort("po_count_365d")} className="cursor-pointer text-right">POs (365d)</TableHead>
<TableHead onClick={() => handleSort("salesGrowth30dVsPrev")} className="cursor-pointer text-right">Sales Growth</TableHead>
<TableHead onClick={() => handleSort("revenueGrowth30dVsPrev")} className="cursor-pointer text-right">Revenue Growth</TableHead>
<TableHead onClick={() => handleSort("status")} className="cursor-pointer text-right">Status</TableHead> <TableHead onClick={() => handleSort("status")} className="cursor-pointer text-right">Status</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -399,17 +420,19 @@ export function Vendors() {
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell> <TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell> <TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell> <TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
</TableRow> </TableRow>
)) ))
) : listError ? ( ) : listError ? (
<TableRow> <TableRow>
<TableCell colSpan={11} className="text-center py-8 text-destructive"> <TableCell colSpan={13} className="text-center py-8 text-destructive">
Error loading vendors: {listError.message} Error loading vendors: {listError.message}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : vendors.length === 0 ? ( ) : vendors.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={11} className="text-center py-8 text-muted-foreground"> <TableCell colSpan={13} className="text-center py-8 text-muted-foreground">
No vendors found matching your criteria. No vendors found matching your criteria.
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -426,6 +449,8 @@ export function Vendors() {
<TableCell className="text-right">{formatCurrency(vendor.profit_30d as number)}</TableCell> <TableCell className="text-right">{formatCurrency(vendor.profit_30d as number)}</TableCell>
<TableCell className="text-right">{formatPercentage(vendor.avg_margin_30d as number)}</TableCell> <TableCell className="text-right">{formatPercentage(vendor.avg_margin_30d as number)}</TableCell>
<TableCell className="text-right">{formatNumber(vendor.po_count_365d || vendor.poCount_365d)}</TableCell> <TableCell className="text-right">{formatNumber(vendor.po_count_365d || vendor.poCount_365d)}</TableCell>
<TableCell className="text-right">{formatGrowth(vendor.sales_growth_30d_vs_prev)}</TableCell>
<TableCell className="text-right">{formatGrowth(vendor.revenue_growth_30d_vs_prev)}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Badge variant={getStatusVariant(vendor.status)}> <Badge variant={getStatusVariant(vendor.status)}>
{vendor.status || 'Unknown'} {vendor.status || 'Unknown'}

View File

@@ -213,6 +213,44 @@ export interface ProductMetric {
// Yesterday // Yesterday
yesterdaySales: number | null; yesterdaySales: number | null;
// Growth Metrics (P3)
salesGrowth30dVsPrev: number | null;
revenueGrowth30dVsPrev: number | null;
salesGrowthYoy: number | null;
revenueGrowthYoy: number | null;
// Demand Variability Metrics (P3)
salesVariance30d: number | null;
salesStdDev30d: number | null;
salesCv30d: number | null;
demandPattern: string | null;
// Service Level Metrics (P5)
fillRate30d: number | null;
stockoutIncidents30d: number | null;
serviceLevel30d: number | null;
lostSalesIncidents30d: number | null;
// Seasonality Metrics (P5)
seasonalityIndex: number | null;
seasonalPattern: string | null;
peakSeason: string | null;
// Lifetime Metrics
lifetimeSales: number | null;
lifetimeRevenue: number | null;
lifetimeRevenueQuality: string | null;
// First Period Metrics
first7DaysSales: number | null;
first7DaysRevenue: number | null;
first30DaysSales: number | null;
first30DaysRevenue: number | null;
first60DaysSales: number | null;
first60DaysRevenue: number | null;
first90DaysSales: number | null;
first90DaysRevenue: number | null;
// Calculated status (added by frontend) // Calculated status (added by frontend)
status?: ProductStatus; status?: ProductStatus;
} }
@@ -364,7 +402,24 @@ export type ProductMetricColumnKey =
| 'dateLastReceived' | 'dateLastReceived'
| 'dateFirstReceived' | 'dateFirstReceived'
| 'dateFirstSold' | 'dateFirstSold'
| 'imageUrl'; | 'imageUrl'
// New metrics from P3-P5 implementation
| 'salesGrowth30dVsPrev'
| 'revenueGrowth30dVsPrev'
| 'salesGrowthYoy'
| 'revenueGrowthYoy'
| 'salesVariance30d'
| 'salesStdDev30d'
| 'salesCv30d'
| 'demandPattern'
| 'fillRate30d'
| 'stockoutIncidents30d'
| 'serviceLevel30d'
| 'lostSalesIncidents30d'
| 'seasonalityIndex'
| 'seasonalPattern'
| 'peakSeason'
| 'lifetimeRevenueQuality';
// Mapping frontend keys to backend query param keys // Mapping frontend keys to backend query param keys
export const FRONTEND_TO_BACKEND_KEY_MAP: Record<string, string> = { export const FRONTEND_TO_BACKEND_KEY_MAP: Record<string, string> = {
@@ -427,7 +482,24 @@ export const FRONTEND_TO_BACKEND_KEY_MAP: Record<string, string> = {
overstockedCost: 'overstockedCost', overstockedCost: 'overstockedCost',
isOldStock: 'isOldStock', isOldStock: 'isOldStock',
yesterdaySales: 'yesterdaySales', yesterdaySales: 'yesterdaySales',
status: 'status' // Frontend-only field status: 'status', // Frontend-only field
// New metrics from P3-P5 implementation
salesGrowth30dVsPrev: 'salesGrowth30dVsPrev',
revenueGrowth30dVsPrev: 'revenueGrowth30dVsPrev',
salesGrowthYoy: 'salesGrowthYoy',
revenueGrowthYoy: 'revenueGrowthYoy',
salesVariance30d: 'salesVariance30d',
salesStdDev30d: 'salesStdDev30d',
salesCv30d: 'salesCv30d',
demandPattern: 'demandPattern',
fillRate30d: 'fillRate30d',
stockoutIncidents30d: 'stockoutIncidents30d',
serviceLevel30d: 'serviceLevel30d',
lostSalesIncidents30d: 'lostSalesIncidents30d',
seasonalityIndex: 'seasonalityIndex',
seasonalPattern: 'seasonalPattern',
peakSeason: 'peakSeason',
lifetimeRevenueQuality: 'lifetimeRevenueQuality'
}; };
// Function to get backend key safely // Function to get backend key safely