10 Commits

51 changed files with 3152 additions and 2434 deletions

181
docs/metrics-changes.md Normal file
View File

@@ -0,0 +1,181 @@
# Metrics System Changes
## Schema Changes
### Product Identifiers
- Changed `product_id` to `pid` throughout all metrics tables and queries
- Changed `category_id` to `cat_id` in category-related queries
### Purchase Orders
- Changed status check from `status = 'closed'` to `receiving_status >= 30`
- Added comment `-- Partial or fully received` for clarity
- Now using `received_date` instead of relying on status changes
### New Product Fields
- Added support for `notions_inv_count`
- Added support for `date_last_sold`
- Added support for `total_sold`
- Using `visible` flag for active product counts
### Field Size Updates
- Increased size of financial fields to handle larger numbers:
- Changed category metrics `total_value` from `DECIMAL(10,3)` to `DECIMAL(15,3)`
- Changed brand metrics financial fields from `DECIMAL(10,2)` to `DECIMAL(15,2)`
- Affects `total_stock_cost`, `total_stock_retail`, `total_revenue`
## Metrics File Changes
### Product Metrics (`product-metrics.js`)
- Updated SQL queries to use new field names
- Enhanced stock status calculations
- Added financial metrics:
- `gross_profit`
- `gmroi`
- `avg_margin_percent`
- `inventory_value`
- Improved reorder quantity calculations with:
- Enhanced safety stock calculation
- Lead time consideration
- Service level factors
- Added NaN/NULL value handling:
- Added `sanitizeValue` helper function
- Properly converts JavaScript NaN to SQL NULL
- Ensures all numeric fields have valid values
### Vendor Metrics (`vendor-metrics.js`)
- Updated field references to use `pid`
- Modified purchase order status checks
- Enhanced vendor performance metrics:
- Order fill rate calculation
- On-time delivery rate
- Lead time tracking
### Category Metrics (`category-metrics.js`)
- Updated to use `cat_id` instead of `category_id`
- Enhanced category performance tracking:
- Active vs total products
- Category growth rate
- Turnover rate
- Added time-based metrics for:
- Product counts
- Revenue tracking
- Margin analysis
- Added NULL brand handling:
- Uses 'Unbranded' for NULL brand values
- Maintains data integrity in category sales metrics
### Brand Metrics (`brand-metrics.js`)
- Updated product references to use `pid`
- Enhanced brand performance metrics:
- Stock value calculations
- Revenue tracking
- Growth rate analysis
- Added time-based aggregates for:
- Stock levels
- Sales performance
- Margin analysis
- Increased field sizes to handle large retailers
### Sales Forecasts (`sales-forecasts.js`)
- Updated to use new product identifiers
- Enhanced forecast calculations:
- Day-of-week patterns
- Seasonality factors
- Confidence levels
- Added category-level forecasts with:
- Units and revenue predictions
- Confidence scoring
- Seasonal adjustments
### Time Aggregates (`time-aggregates.js`)
- Updated field references to use `pid`
- Enhanced financial metrics:
- GMROI calculations
- Profit margin tracking
- Added inventory value tracking
- Improved purchase order integration
## Database Impact
### Tables Modified
- `product_metrics`
- `vendor_metrics`
- `vendor_time_metrics`
- `category_metrics`
- `category_time_metrics`
- `brand_metrics`
- `brand_time_metrics`
- `sales_forecasts`
- `category_forecasts`
- `product_time_aggregates`
### New Fields Added
Several tables have new fields for:
- Enhanced financial tracking
- Improved inventory metrics
- Better performance monitoring
- More accurate forecasting
## Frontend Considerations
### Data Access Changes
- All product lookups need to use `pid` instead of `product_id`
- Category references should use `cat_id`
- Purchase order status checks need updating
- Handle 'Unbranded' as a valid brand value
### New Features Available
- Enhanced stock status indicators
- More detailed financial metrics
- Improved forecasting data
- Better category and brand performance tracking
### UI Updates Needed
- Update all API calls to use new field names
- Modify data displays for new metrics
- Add new financial performance indicators
- Update stock status logic
- Enhance forecast displays
## API Route Updates Needed
### Product Routes
- Update ID field references
- Modify stock status calculations
- Add new financial metrics endpoints
### Category Routes
- Update to use `cat_id`
- Add new performance metrics
- Include time-based aggregates
### Vendor Routes
- Update product reference handling
- Add enhanced performance metrics
- Include new time-based data
### Reporting Routes
- Update all ID references
- Add new metrics support
- Include enhanced forecasting data
## Migration Considerations
### Data Migration
- Update existing records to use new IDs
- Backfill new metrics where possible
- Verify data integrity after changes
- Handle NULL to 'Unbranded' brand conversion
### Code Updates
- Update all API endpoints
- Modify database queries
- Update frontend components
- Revise reporting logic
### Testing Requirements
- Verify ID changes throughout system
- Test new metrics calculations
- Validate forecasting accuracy
- Check performance impact
- Verify NULL value handling

View File

@@ -0,0 +1,270 @@
# Schema Update Changes Required
## Core Field Name Changes
### Global Changes
- Update all references from `product_id` to `pid` in all tables and queries
- This includes foreign key references in related tables
- Update TypeScript interfaces and types (e.g., `interface Product { pid: number; ... }`)
- Update API request/response types
- Update all references from `category_id` to `cat_id` in category-related queries
- This affects the `categories` table and all tables with category foreign keys
- Update purchase order status to use numeric codes instead of strings
- Status codes: 0=canceled, 1=created, 10=electronically_ready_send, 11=ordered, 12=preordered, 13=electronically_sent, 15=receiving_started, 50=done
- Receiving status codes: 0=canceled, 1=created, 30=partial_received, 40=full_received, 50=paid
- Handle NULL brand values as 'Unbranded'
- Add COALESCE(brand, 'Unbranded') in all brand-related queries
- Update frontend brand filters to handle 'Unbranded' as a valid value
## Backend Route Changes
### Product Routes
1. Update ID field references in all product routes:
- `/api/products/:id` -> `/api/products/:pid`
- All query parameters using `product_id` should be changed to `pid`
- Update all SQL queries to use `pid` instead of `product_id`
- Update `/api/products/:id/metrics` -> `/api/products/:pid/metrics`
- Update `/api/products/:id/time-series` -> `/api/products/:pid/time-series`
- Update request parameter validation in routes
- Example query change:
```sql
-- Old
SELECT * FROM products WHERE product_id = ?
-- New
SELECT * FROM products WHERE pid = ?
```
2. Update purchase order status checks:
- Change `status = 'closed'` to `receiving_status >= 30` in all relevant queries
- Update any route logic that checks PO status to use the new numeric status codes
- Example status check change:
```sql
-- Old
WHERE po.status = 'closed'
-- New
WHERE po.receiving_status >= 30 -- Partial or fully received
```
### Category Routes
1. Update ID references:
- `/api/categories/:id` -> `/api/categories/:cat_id`
- Update all SQL queries to use `cat_id` instead of `category_id`
- Update join conditions in category-related queries
- Example join change:
```sql
-- Old
JOIN categories c ON p.category_id = c.category_id
-- New
JOIN categories c ON p.cat_id = c.cat_id
```
2. Update category metrics queries:
- Modify field size handling for financial fields (DECIMAL(15,3) instead of DECIMAL(10,3))
- Update category performance calculations to use new field sizes
- Example field size change:
```sql
-- Old
total_value DECIMAL(10,3)
-- New
total_value DECIMAL(15,3)
```
### Vendor Routes
1. Update product references:
- Change all queries to use `pid` instead of `product_id`
- Update purchase order status checks to use new numeric codes
- Example vendor query change:
```sql
-- Old
SELECT v.*, p.product_id FROM vendors v
JOIN products p ON p.vendor = v.name
WHERE p.product_id = ?
-- New
SELECT v.*, p.pid FROM vendors v
JOIN products p ON p.vendor = v.name
WHERE p.pid = ?
```
2. Update vendor metrics queries:
- Add COALESCE for NULL brand handling:
```sql
-- Old
GROUP BY brand
-- New
GROUP BY COALESCE(brand, 'Unbranded')
```
- Update field references in performance metrics calculations
### Dashboard Routes
1. Update all dashboard endpoints:
- `/dashboard/best-sellers`:
```typescript
interface BestSellerProduct {
pid: number; // Changed from product_id
sku: string;
title: string;
units_sold: number;
revenue: number; // Now handles larger decimals
profit: number; // Now handles larger decimals
}
```
- `/dashboard/overstock/products`:
```typescript
interface OverstockedProduct {
pid: number; // Changed from product_id
sku: string;
title: string;
stock_quantity: number;
overstocked_amt: number;
excess_cost: number; // Now DECIMAL(15,3)
excess_retail: number; // Now DECIMAL(15,3)
}
```
### Analytics Routes
1. Update analytics endpoints:
- `/analytics/stats` - Update all ID references and decimal handling
- `/analytics/profit` - Update decimal precision in calculations
- `/analytics/vendors` - Add brand NULL handling
- Example analytics query change:
```sql
-- Old
SELECT product_id, SUM(price * quantity) as revenue
FROM orders
GROUP BY product_id
-- New
SELECT pid, CAST(SUM(price * quantity) AS DECIMAL(15,3)) as revenue
FROM orders
GROUP BY pid
```
## Frontend Component Changes
### Product Components
1. Update API calls:
```typescript
// Old
fetch(`/api/products/${product_id}`)
// New
fetch(`/api/products/${pid}`)
```
- Update route parameters in React Router:
```typescript
// Old
<Route path="/products/:product_id" />
// New
<Route path="/products/:pid" />
```
- Update useParams usage:
```typescript
// Old
const { product_id } = useParams();
// New
const { pid } = useParams();
```
2. Update data display:
```typescript
// Old
<td>{formatCurrency(product.price)}</td>
// New
<td>{formatCurrency(Number(product.price))}</td>
```
### Dashboard Components
1. Update metrics displays:
```typescript
// Old
interface ProductMetrics {
product_id: number;
total_value: number;
}
// New
interface ProductMetrics {
pid: number;
total_value: string; // Handle as string due to DECIMAL(15,3)
}
```
2. Update stock status displays:
```typescript
// Old
const isReceived = po.status === 'closed';
// New
const isReceived = po.receiving_status >= 30;
```
### Product Filters Component
1. Update filter options:
```typescript
// Old
const productFilter = { id: 'product_id', value: id };
// New
const productFilter = { id: 'pid', value: id };
```
2. Update status filters:
```typescript
// Old
const poStatusOptions = [
{ label: 'Closed', value: 'closed' }
];
// New
const poStatusOptions = [
{ label: 'Received', value: '30' } // Using numeric codes
];
```
## Data Type Considerations
### Financial Fields
- Update TypeScript types:
```typescript
// Old
price: number;
// New
price: string; // Handle as string due to DECIMAL(15,3)
```
- Update formatting:
```typescript
// Old
formatCurrency(value)
// New
formatCurrency(Number(value))
```
### Status Codes
- Add status code mapping:
```typescript
const PO_STATUS_MAP = {
0: 'Canceled',
1: 'Created',
10: 'Ready to Send',
11: 'Ordered',
12: 'Preordered',
13: 'Sent',
15: 'Receiving Started',
50: 'Done'
};
```
## Testing Requirements
1. API Route Testing:
```typescript
// Test decimal handling
expect(typeof response.total_value).toBe('string');
expect(response.total_value).toMatch(/^\d+\.\d{3}$/);
// Test status codes
expect(response.receiving_status).toBeGreaterThanOrEqual(30);
// Test brand handling
expect(response.brand || 'Unbranded').toBeDefined();
```
## Notes
- All numeric status code comparisons should use >= for status checks to handle future status codes
- All financial values should be handled as strings in TypeScript/JavaScript to preserve precision
- Brand grouping should always use COALESCE(brand, 'Unbranded') in SQL queries
- All ID parameters in routes should be validated as numbers

View File

@@ -14,7 +14,8 @@ CREATE TABLE IF NOT EXISTS stock_thresholds (
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE, FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE,
UNIQUE KEY unique_category_vendor (category_id, vendor) UNIQUE KEY unique_category_vendor (category_id, vendor),
INDEX idx_st_metrics (category_id, vendor)
); );
-- Lead time threshold configurations -- Lead time threshold configurations
@@ -44,7 +45,8 @@ CREATE TABLE IF NOT EXISTS sales_velocity_config (
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE, FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE,
UNIQUE KEY unique_category_vendor (category_id, vendor) UNIQUE KEY unique_category_vendor (category_id, vendor),
INDEX idx_sv_metrics (category_id, vendor)
); );
-- ABC Classification configurations -- ABC Classification configurations
@@ -68,7 +70,8 @@ CREATE TABLE IF NOT EXISTS safety_stock_config (
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id), PRIMARY KEY (id),
FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE, FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE,
UNIQUE KEY unique_category_vendor (category_id, vendor) UNIQUE KEY unique_category_vendor (category_id, vendor),
INDEX idx_ss_metrics (category_id, vendor)
); );
-- Turnover rate configurations -- Turnover rate configurations

View File

@@ -152,7 +152,7 @@ CREATE TABLE IF NOT EXISTS category_metrics (
product_count INT DEFAULT 0, product_count INT DEFAULT 0,
active_products INT DEFAULT 0, active_products INT DEFAULT 0,
-- Financial metrics -- Financial metrics
total_value DECIMAL(10,3) DEFAULT 0, total_value DECIMAL(15,3) DEFAULT 0,
avg_margin DECIMAL(5,2), avg_margin DECIMAL(5,2),
turnover_rate DECIMAL(12,3), turnover_rate DECIMAL(12,3),
growth_rate DECIMAL(5,2), growth_rate DECIMAL(5,2),
@@ -193,8 +193,8 @@ CREATE TABLE IF NOT EXISTS category_time_metrics (
product_count INT DEFAULT 0, product_count INT DEFAULT 0,
active_products INT DEFAULT 0, active_products INT DEFAULT 0,
-- Financial metrics -- Financial metrics
total_value DECIMAL(10,3) DEFAULT 0, total_value DECIMAL(15,3) DEFAULT 0,
total_revenue DECIMAL(10,3) DEFAULT 0, total_revenue DECIMAL(15,3) DEFAULT 0,
avg_margin DECIMAL(5,2), avg_margin DECIMAL(5,2),
turnover_rate DECIMAL(12,3), turnover_rate DECIMAL(12,3),
PRIMARY KEY (category_id, year, month), PRIMARY KEY (category_id, year, month),
@@ -228,10 +228,10 @@ CREATE TABLE IF NOT EXISTS brand_metrics (
active_products INT DEFAULT 0, active_products INT DEFAULT 0,
-- Stock metrics -- Stock metrics
total_stock_units INT DEFAULT 0, total_stock_units INT DEFAULT 0,
total_stock_cost DECIMAL(10,2) DEFAULT 0, total_stock_cost DECIMAL(15,2) DEFAULT 0,
total_stock_retail DECIMAL(10,2) DEFAULT 0, total_stock_retail DECIMAL(15,2) DEFAULT 0,
-- Sales metrics -- Sales metrics
total_revenue DECIMAL(10,2) DEFAULT 0, total_revenue DECIMAL(15,2) DEFAULT 0,
avg_margin DECIMAL(5,2) DEFAULT 0, avg_margin DECIMAL(5,2) DEFAULT 0,
growth_rate DECIMAL(5,2) DEFAULT 0, growth_rate DECIMAL(5,2) DEFAULT 0,
PRIMARY KEY (brand), PRIMARY KEY (brand),
@@ -250,10 +250,10 @@ CREATE TABLE IF NOT EXISTS brand_time_metrics (
active_products INT DEFAULT 0, active_products INT DEFAULT 0,
-- Stock metrics -- Stock metrics
total_stock_units INT DEFAULT 0, total_stock_units INT DEFAULT 0,
total_stock_cost DECIMAL(10,2) DEFAULT 0, total_stock_cost DECIMAL(15,2) DEFAULT 0,
total_stock_retail DECIMAL(10,2) DEFAULT 0, total_stock_retail DECIMAL(15,2) DEFAULT 0,
-- Sales metrics -- Sales metrics
total_revenue DECIMAL(10,2) DEFAULT 0, total_revenue DECIMAL(15,2) DEFAULT 0,
avg_margin DECIMAL(5,2) DEFAULT 0, avg_margin DECIMAL(5,2) DEFAULT 0,
PRIMARY KEY (brand, year, month), PRIMARY KEY (brand, year, month),
INDEX idx_brand_date (year, month) INDEX idx_brand_date (year, month)

View File

@@ -122,7 +122,8 @@ CREATE TABLE IF NOT EXISTS orders (
KEY pid (pid), KEY pid (pid),
KEY customer (customer), KEY customer (customer),
KEY date (date), KEY date (date),
KEY status (status) KEY status (status),
INDEX idx_orders_metrics (pid, date, canceled)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Create purchase_orders table with its indexes -- Create purchase_orders table with its indexes
@@ -152,6 +153,7 @@ CREATE TABLE purchase_orders (
INDEX idx_status (status), INDEX idx_status (status),
INDEX idx_receiving_status (receiving_status), INDEX idx_receiving_status (receiving_status),
INDEX idx_purchase_orders_metrics (pid, date, status, ordered, received), INDEX idx_purchase_orders_metrics (pid, date, status, ordered, received),
INDEX idx_po_metrics (pid, date, receiving_status, received_date),
INDEX idx_po_product_date (pid, date), INDEX idx_po_product_date (pid, date),
INDEX idx_po_product_status (pid, status), INDEX idx_po_product_status (pid, status),
UNIQUE KEY unique_po_product (po_id, pid) UNIQUE KEY unique_po_product (po_id, pid)

View File

@@ -5,6 +5,16 @@ process.chdir(path.dirname(__filename));
require('dotenv').config({ path: path.resolve(__dirname, '..', '.env') }); require('dotenv').config({ path: path.resolve(__dirname, '..', '.env') });
// Configuration flags for controlling which metrics to calculate
// Set to 1 to skip the corresponding calculation, 0 to run it
const SKIP_PRODUCT_METRICS = 1; // Skip all product metrics
const SKIP_TIME_AGGREGATES = 1; // Skip time aggregates
const SKIP_FINANCIAL_METRICS = 1; // Skip financial metrics
const SKIP_VENDOR_METRICS = 1; // Skip vendor metrics
const SKIP_CATEGORY_METRICS = 1; // Skip category metrics
const SKIP_BRAND_METRICS = 1; // Skip brand metrics
const SKIP_SALES_FORECASTS = 1; // Skip sales forecasts
// Add error handler for uncaught exceptions // Add error handler for uncaught exceptions
process.on('uncaughtException', (error) => { process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error); console.error('Uncaught Exception:', error);
@@ -43,9 +53,6 @@ const calculateCategoryMetrics = require('./metrics/category-metrics');
const calculateBrandMetrics = require('./metrics/brand-metrics'); const calculateBrandMetrics = require('./metrics/brand-metrics');
const calculateSalesForecasts = require('./metrics/sales-forecasts'); const calculateSalesForecasts = require('./metrics/sales-forecasts');
// Set to 1 to skip product metrics and only calculate the remaining metrics
const SKIP_PRODUCT_METRICS = 1;
// Add cancel handler // Add cancel handler
let isCancelled = false; let isCancelled = false;
@@ -137,50 +144,136 @@ async function calculateMetrics() {
} }
// Calculate time-based aggregates // Calculate time-based aggregates
if (!SKIP_TIME_AGGREGATES) {
processedCount = await calculateTimeAggregates(startTime, totalProducts, processedCount); processedCount = await calculateTimeAggregates(startTime, totalProducts, processedCount);
} else {
console.log('Skipping time aggregates calculation');
}
// Calculate financial metrics // Calculate financial metrics
if (!SKIP_FINANCIAL_METRICS) {
processedCount = await calculateFinancialMetrics(startTime, totalProducts, processedCount); processedCount = await calculateFinancialMetrics(startTime, totalProducts, processedCount);
} else {
console.log('Skipping financial metrics calculation');
}
// Calculate vendor metrics // Calculate vendor metrics
if (!SKIP_VENDOR_METRICS) {
processedCount = await calculateVendorMetrics(startTime, totalProducts, processedCount); processedCount = await calculateVendorMetrics(startTime, totalProducts, processedCount);
} else {
console.log('Skipping vendor metrics calculation');
}
// Calculate category metrics // Calculate category metrics
if (!SKIP_CATEGORY_METRICS) {
processedCount = await calculateCategoryMetrics(startTime, totalProducts, processedCount); processedCount = await calculateCategoryMetrics(startTime, totalProducts, processedCount);
} else {
console.log('Skipping category metrics calculation');
}
// Calculate brand metrics // Calculate brand metrics
if (!SKIP_BRAND_METRICS) {
processedCount = await calculateBrandMetrics(startTime, totalProducts, processedCount); processedCount = await calculateBrandMetrics(startTime, totalProducts, processedCount);
} else {
console.log('Skipping brand metrics calculation');
}
// Calculate sales forecasts // Calculate sales forecasts
if (!SKIP_SALES_FORECASTS) {
processedCount = await calculateSalesForecasts(startTime, totalProducts, processedCount); processedCount = await calculateSalesForecasts(startTime, totalProducts, processedCount);
} else {
console.log('Skipping sales forecasts calculation');
}
// Calculate ABC classification // Calculate ABC classification
const [abcConfig] = await connection.query('SELECT a_threshold, b_threshold FROM abc_classification_config WHERE id = 1'); const [abcConfig] = await connection.query('SELECT a_threshold, b_threshold FROM abc_classification_config WHERE id = 1');
const abcThresholds = abcConfig[0] || { a_threshold: 20, b_threshold: 50 }; const abcThresholds = abcConfig[0] || { a_threshold: 20, b_threshold: 50 };
// First, create and populate the rankings table with an index
await connection.query('DROP TEMPORARY TABLE IF EXISTS temp_revenue_ranks');
await connection.query(` await connection.query(`
WITH revenue_rankings AS ( CREATE TEMPORARY TABLE temp_revenue_ranks (
pid BIGINT NOT NULL,
total_revenue DECIMAL(10,3),
rank_num INT,
total_count INT,
PRIMARY KEY (pid),
INDEX (rank_num)
) ENGINE=MEMORY
`);
await connection.query(`
INSERT INTO temp_revenue_ranks
SELECT SELECT
product_id, pid,
total_revenue, total_revenue,
PERCENT_RANK() OVER (ORDER BY COALESCE(total_revenue, 0) DESC) * 100 as revenue_percentile @rank := @rank + 1 as rank_num,
@total_count := @rank as total_count
FROM (
SELECT pid, total_revenue
FROM product_metrics FROM product_metrics
), WHERE total_revenue > 0
classification_update AS ( ORDER BY total_revenue DESC
SELECT ) ranked,
product_id, (SELECT @rank := 0) r
`);
// Get total count for percentage calculation
const [rankingCount] = await connection.query('SELECT MAX(rank_num) as total_count FROM temp_revenue_ranks');
const totalCount = rankingCount[0].total_count || 1;
// Process updates in batches
let abcProcessedCount = 0;
const batchSize = 5000;
while (true) {
// First get a batch of PIDs that need updating
const [pids] = await connection.query(`
SELECT pm.pid
FROM product_metrics pm
LEFT JOIN temp_revenue_ranks tr ON pm.pid = tr.pid
WHERE pm.abc_class IS NULL
OR pm.abc_class !=
CASE CASE
WHEN revenue_percentile <= ? THEN 'A' WHEN tr.rank_num IS NULL THEN 'C'
WHEN revenue_percentile <= ? THEN 'B' WHEN (tr.rank_num / ?) * 100 <= ? THEN 'A'
WHEN (tr.rank_num / ?) * 100 <= ? THEN 'B'
ELSE 'C' ELSE 'C'
END as abc_class END
FROM revenue_rankings LIMIT ?
) `, [totalCount, abcThresholds.a_threshold,
totalCount, abcThresholds.b_threshold,
batchSize]);
if (pids.length === 0) {
break;
}
// Then update just those PIDs
const [result] = await connection.query(`
UPDATE product_metrics pm UPDATE product_metrics pm
JOIN classification_update cu ON pm.product_id = cu.product_id LEFT JOIN temp_revenue_ranks tr ON pm.pid = tr.pid
SET pm.abc_class = cu.abc_class, SET pm.abc_class =
CASE
WHEN tr.rank_num IS NULL THEN 'C'
WHEN (tr.rank_num / ?) * 100 <= ? THEN 'A'
WHEN (tr.rank_num / ?) * 100 <= ? THEN 'B'
ELSE 'C'
END,
pm.last_calculated_at = NOW() pm.last_calculated_at = NOW()
`, [abcThresholds.a_threshold, abcThresholds.b_threshold]); WHERE pm.pid IN (?)
`, [totalCount, abcThresholds.a_threshold,
totalCount, abcThresholds.b_threshold,
pids.map(row => row.pid)]);
abcProcessedCount += result.affectedRows;
// Small delay between batches to allow other transactions
await new Promise(resolve => setTimeout(resolve, 100));
}
// Clean up
await connection.query('DROP TEMPORARY TABLE IF EXISTS temp_revenue_ranks');
// Final success message // Final success message
global.outputProgress({ global.outputProgress({

View File

@@ -15,7 +15,7 @@ async function calculateBrandMetrics(startTime, totalProducts, processedCount) {
percentage: '95' percentage: '95'
}); });
// Calculate brand metrics // Calculate brand metrics with optimized queries
await connection.query(` await connection.query(`
INSERT INTO brand_metrics ( INSERT INTO brand_metrics (
brand, brand,
@@ -28,59 +28,77 @@ async function calculateBrandMetrics(startTime, totalProducts, processedCount) {
avg_margin, avg_margin,
growth_rate growth_rate
) )
WITH brand_data AS ( WITH filtered_products AS (
SELECT
p.*,
CASE WHEN p.stock_quantity <= 5000 THEN p.pid END as valid_pid,
CASE WHEN p.visible = true AND p.stock_quantity <= 5000 THEN p.pid END as active_pid,
CASE
WHEN p.stock_quantity IS NULL OR p.stock_quantity < 0 OR p.stock_quantity > 5000 THEN 0
ELSE p.stock_quantity
END as valid_stock
FROM products p
WHERE p.brand IS NOT NULL
),
sales_periods AS (
SELECT SELECT
p.brand, p.brand,
COUNT(DISTINCT p.product_id) as product_count, SUM(o.quantity * o.price) as period_revenue,
COUNT(DISTINCT CASE WHEN p.visible = true THEN p.product_id END) as active_products,
SUM(p.stock_quantity) as total_stock_units,
SUM(p.stock_quantity * p.cost_price) as total_stock_cost,
SUM(p.stock_quantity * p.price) as total_stock_retail,
SUM(o.price * o.quantity) as total_revenue,
CASE CASE
WHEN SUM(o.price * o.quantity) > 0 THEN WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 3 MONTH) THEN 'current'
WHEN o.date BETWEEN DATE_SUB(CURRENT_DATE, INTERVAL 15 MONTH) AND DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH) THEN 'previous'
END as period_type
FROM filtered_products p
JOIN orders o ON p.pid = o.pid
WHERE o.canceled = false
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 15 MONTH)
GROUP BY p.brand, period_type
),
brand_data AS (
SELECT
p.brand,
COUNT(DISTINCT p.valid_pid) as product_count,
COUNT(DISTINCT p.active_pid) as active_products,
SUM(p.valid_stock) as total_stock_units,
SUM(p.valid_stock * p.cost_price) as total_stock_cost,
SUM(p.valid_stock * p.price) as total_stock_retail,
COALESCE(SUM(o.quantity * o.price), 0) as total_revenue,
CASE
WHEN SUM(o.quantity * o.price) > 0 THEN
(SUM((o.price - p.cost_price) * o.quantity) * 100.0) / SUM(o.price * o.quantity) (SUM((o.price - p.cost_price) * o.quantity) * 100.0) / SUM(o.price * o.quantity)
ELSE 0 ELSE 0
END as avg_margin, END as avg_margin
-- Current period (last 3 months) FROM filtered_products p
SUM(CASE LEFT JOIN orders o ON p.pid = o.pid AND o.canceled = false
WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 3 MONTH)
THEN COALESCE(o.quantity * o.price, 0)
ELSE 0
END) as current_period_sales,
-- Previous year same period
SUM(CASE
WHEN o.date BETWEEN DATE_SUB(CURRENT_DATE, INTERVAL 15 MONTH) AND DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
THEN COALESCE(o.quantity * o.price, 0)
ELSE 0
END) as previous_year_period_sales
FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id AND o.canceled = false
WHERE p.brand IS NOT NULL
GROUP BY p.brand GROUP BY p.brand
) )
SELECT SELECT
brand, bd.brand,
product_count, bd.product_count,
active_products, bd.active_products,
total_stock_units, bd.total_stock_units,
total_stock_cost, bd.total_stock_cost,
total_stock_retail, bd.total_stock_retail,
total_revenue, bd.total_revenue,
avg_margin, bd.avg_margin,
CASE CASE
WHEN previous_year_period_sales = 0 AND current_period_sales > 0 THEN 100.0 WHEN MAX(CASE WHEN sp.period_type = 'previous' THEN sp.period_revenue END) = 0
WHEN previous_year_period_sales = 0 THEN 0.0 AND MAX(CASE WHEN sp.period_type = 'current' THEN sp.period_revenue END) > 0 THEN 100.0
WHEN MAX(CASE WHEN sp.period_type = 'previous' THEN sp.period_revenue END) = 0 THEN 0.0
ELSE LEAST( ELSE LEAST(
GREATEST( GREATEST(
((current_period_sales - previous_year_period_sales) / ((MAX(CASE WHEN sp.period_type = 'current' THEN sp.period_revenue END) -
NULLIF(previous_year_period_sales, 0)) * 100.0, MAX(CASE WHEN sp.period_type = 'previous' THEN sp.period_revenue END)) /
NULLIF(MAX(CASE WHEN sp.period_type = 'previous' THEN sp.period_revenue END), 0)) * 100.0,
-100.0 -100.0
), ),
999.99 999.99
) )
END as growth_rate END as growth_rate
FROM brand_data FROM brand_data bd
LEFT JOIN sales_periods sp ON bd.brand = sp.brand
GROUP BY bd.brand, bd.product_count, bd.active_products, bd.total_stock_units,
bd.total_stock_cost, bd.total_stock_retail, bd.total_revenue, bd.avg_margin
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
product_count = VALUES(product_count), product_count = VALUES(product_count),
active_products = VALUES(active_products), active_products = VALUES(active_products),
@@ -93,7 +111,7 @@ async function calculateBrandMetrics(startTime, totalProducts, processedCount) {
last_calculated_at = CURRENT_TIMESTAMP last_calculated_at = CURRENT_TIMESTAMP
`); `);
// Calculate brand time-based metrics // Calculate brand time-based metrics with optimized query
await connection.query(` await connection.query(`
INSERT INTO brand_time_metrics ( INSERT INTO brand_time_metrics (
brand, brand,
@@ -107,26 +125,41 @@ async function calculateBrandMetrics(startTime, totalProducts, processedCount) {
total_revenue, total_revenue,
avg_margin avg_margin
) )
WITH filtered_products AS (
SELECT
p.*,
CASE WHEN p.stock_quantity <= 5000 THEN p.pid END as valid_pid,
CASE WHEN p.visible = true AND p.stock_quantity <= 5000 THEN p.pid END as active_pid,
CASE
WHEN p.stock_quantity IS NULL OR p.stock_quantity < 0 OR p.stock_quantity > 5000 THEN 0
ELSE p.stock_quantity
END as valid_stock
FROM products p
WHERE p.brand IS NOT NULL
),
monthly_metrics AS (
SELECT SELECT
p.brand, p.brand,
YEAR(o.date) as year, YEAR(o.date) as year,
MONTH(o.date) as month, MONTH(o.date) as month,
COUNT(DISTINCT p.product_id) as product_count, COUNT(DISTINCT p.valid_pid) as product_count,
COUNT(DISTINCT CASE WHEN p.visible = true THEN p.product_id END) as active_products, COUNT(DISTINCT p.active_pid) as active_products,
SUM(p.stock_quantity) as total_stock_units, SUM(p.valid_stock) as total_stock_units,
SUM(p.stock_quantity * p.cost_price) as total_stock_cost, SUM(p.valid_stock * p.cost_price) as total_stock_cost,
SUM(p.stock_quantity * p.price) as total_stock_retail, SUM(p.valid_stock * p.price) as total_stock_retail,
SUM(o.price * o.quantity) as total_revenue, SUM(o.quantity * o.price) as total_revenue,
CASE CASE
WHEN SUM(o.price * o.quantity) > 0 THEN WHEN SUM(o.quantity * o.price) > 0 THEN
(SUM((o.price - p.cost_price) * o.quantity) * 100.0) / SUM(o.price * o.quantity) (SUM((o.price - p.cost_price) * o.quantity) * 100.0) / SUM(o.price * o.quantity)
ELSE 0 ELSE 0
END as avg_margin END as avg_margin
FROM products p FROM filtered_products p
LEFT JOIN orders o ON p.product_id = o.product_id AND o.canceled = false LEFT JOIN orders o ON p.pid = o.pid AND o.canceled = false
WHERE p.brand IS NOT NULL WHERE o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
GROUP BY p.brand, YEAR(o.date), MONTH(o.date) GROUP BY p.brand, YEAR(o.date), MONTH(o.date)
)
SELECT *
FROM monthly_metrics
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
product_count = VALUES(product_count), product_count = VALUES(product_count),
active_products = VALUES(active_products), active_products = VALUES(active_products),

View File

@@ -15,98 +15,104 @@ async function calculateCategoryMetrics(startTime, totalProducts, processedCount
percentage: '85' percentage: '85'
}); });
// Calculate category performance metrics // First, calculate base category metrics
await connection.query(` await connection.query(`
INSERT INTO category_metrics ( INSERT INTO category_metrics (
category_id, category_id,
product_count, product_count,
active_products, active_products,
total_value, total_value,
avg_margin, status,
turnover_rate, last_calculated_at
growth_rate,
status
) )
WITH category_sales AS (
SELECT SELECT
c.id as category_id, c.cat_id,
COUNT(DISTINCT p.product_id) as product_count, COUNT(DISTINCT p.pid) as product_count,
COUNT(DISTINCT CASE WHEN p.visible = true THEN p.product_id END) as active_products, COUNT(DISTINCT CASE WHEN p.visible = true THEN p.pid END) as active_products,
SUM(p.stock_quantity * p.cost_price) as total_value, COALESCE(SUM(p.stock_quantity * p.cost_price), 0) as total_value,
CASE c.status,
WHEN SUM(o.price * o.quantity) > 0 NOW() as last_calculated_at
THEN (SUM((o.price - p.cost_price) * o.quantity) * 100.0) / SUM(o.price * o.quantity)
ELSE 0
END as avg_margin,
CASE
WHEN AVG(GREATEST(p.stock_quantity, 0)) >= 0.01
THEN LEAST(
SUM(CASE
WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 1 YEAR)
THEN COALESCE(o.quantity, 0)
ELSE 0
END) /
GREATEST(
AVG(GREATEST(p.stock_quantity, 0)),
1.0
),
999.99
)
ELSE 0
END as turnover_rate,
-- Current period (last 3 months)
SUM(CASE
WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 3 MONTH)
THEN COALESCE(o.quantity * o.price, 0)
ELSE 0
END) as current_period_sales,
-- Previous year same period
SUM(CASE
WHEN o.date BETWEEN DATE_SUB(CURRENT_DATE, INTERVAL 15 MONTH) AND DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
THEN COALESCE(o.quantity * o.price, 0)
ELSE 0
END) as previous_year_period_sales,
c.status
FROM categories c FROM categories c
LEFT JOIN product_categories pc ON c.id = pc.category_id LEFT JOIN product_categories pc ON c.cat_id = pc.cat_id
LEFT JOIN products p ON pc.product_id = p.product_id LEFT JOIN products p ON pc.pid = p.pid
LEFT JOIN orders o ON p.product_id = o.product_id AND o.canceled = false GROUP BY c.cat_id, c.status
GROUP BY c.id, c.status
)
SELECT
category_id,
product_count,
active_products,
total_value,
COALESCE(avg_margin, 0) as avg_margin,
COALESCE(turnover_rate, 0) as turnover_rate,
-- Enhanced YoY growth rate calculation
CASE
WHEN previous_year_period_sales = 0 AND current_period_sales > 0 THEN 100.0
WHEN previous_year_period_sales = 0 THEN 0.0
ELSE LEAST(
GREATEST(
((current_period_sales - previous_year_period_sales) /
NULLIF(previous_year_period_sales, 0)) * 100.0,
-100.0
),
999.99
)
END as growth_rate,
status
FROM category_sales
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
product_count = VALUES(product_count), product_count = VALUES(product_count),
active_products = VALUES(active_products), active_products = VALUES(active_products),
total_value = VALUES(total_value), total_value = VALUES(total_value),
avg_margin = VALUES(avg_margin),
turnover_rate = VALUES(turnover_rate),
growth_rate = VALUES(growth_rate),
status = VALUES(status), status = VALUES(status),
last_calculated_at = CURRENT_TIMESTAMP last_calculated_at = VALUES(last_calculated_at)
`); `);
// Calculate category time-based metrics // Then update with margin and turnover data
await connection.query(`
WITH category_sales AS (
SELECT
pc.cat_id,
SUM(o.quantity * o.price) as total_sales,
SUM(o.quantity * (o.price - p.cost_price)) as total_margin,
SUM(o.quantity) as units_sold,
AVG(GREATEST(p.stock_quantity, 0)) as avg_stock
FROM product_categories pc
JOIN products p ON pc.pid = p.pid
JOIN orders o ON p.pid = o.pid
WHERE o.canceled = false
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 1 YEAR)
GROUP BY pc.cat_id
)
UPDATE category_metrics cm
JOIN category_sales cs ON cm.category_id = cs.cat_id
SET
cm.avg_margin = COALESCE(cs.total_margin * 100.0 / NULLIF(cs.total_sales, 0), 0),
cm.turnover_rate = LEAST(COALESCE(cs.units_sold / NULLIF(cs.avg_stock, 0), 0), 999.99),
cm.last_calculated_at = NOW()
`);
// Finally update growth rates
await connection.query(`
WITH current_period AS (
SELECT
pc.cat_id,
SUM(o.quantity * o.price) as revenue
FROM product_categories pc
JOIN products p ON pc.pid = p.pid
JOIN orders o ON p.pid = o.pid
WHERE o.canceled = false
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 3 MONTH)
GROUP BY pc.cat_id
),
previous_period AS (
SELECT
pc.cat_id,
SUM(o.quantity * o.price) as revenue
FROM product_categories pc
JOIN products p ON pc.pid = p.pid
JOIN orders o ON p.pid = o.pid
WHERE o.canceled = false
AND o.date BETWEEN DATE_SUB(CURRENT_DATE, INTERVAL 15 MONTH)
AND DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
GROUP BY pc.cat_id
)
UPDATE category_metrics cm
LEFT JOIN current_period cp ON cm.category_id = cp.cat_id
LEFT JOIN previous_period pp ON cm.category_id = pp.cat_id
SET
cm.growth_rate = CASE
WHEN pp.revenue = 0 AND COALESCE(cp.revenue, 0) > 0 THEN 100.0
WHEN pp.revenue = 0 THEN 0.0
ELSE LEAST(
GREATEST(
((COALESCE(cp.revenue, 0) - pp.revenue) / pp.revenue) * 100.0,
-100.0
),
999.99
)
END,
cm.last_calculated_at = NOW()
WHERE cp.cat_id IS NOT NULL OR pp.cat_id IS NOT NULL
`);
// Calculate time-based metrics
await connection.query(` await connection.query(`
INSERT INTO category_time_metrics ( INSERT INTO category_time_metrics (
category_id, category_id,
@@ -120,29 +126,28 @@ async function calculateCategoryMetrics(startTime, totalProducts, processedCount
turnover_rate turnover_rate
) )
SELECT SELECT
c.id as category_id, pc.cat_id,
YEAR(o.date) as year, YEAR(o.date) as year,
MONTH(o.date) as month, MONTH(o.date) as month,
COUNT(DISTINCT p.product_id) as product_count, COUNT(DISTINCT p.pid) as product_count,
COUNT(DISTINCT CASE WHEN p.visible = true THEN p.product_id END) as active_products, COUNT(DISTINCT CASE WHEN p.visible = true THEN p.pid END) as active_products,
SUM(p.stock_quantity * p.cost_price) as total_value, SUM(p.stock_quantity * p.cost_price) as total_value,
SUM(o.price * o.quantity) as total_revenue, SUM(o.quantity * o.price) as total_revenue,
CASE COALESCE(
WHEN SUM(o.price * o.quantity) > 0 SUM(o.quantity * (o.price - p.cost_price)) * 100.0 /
THEN (SUM((o.price - p.cost_price) * o.quantity) * 100.0) / SUM(o.price * o.quantity) NULLIF(SUM(o.quantity * o.price), 0),
ELSE 0 0
END as avg_margin, ) as avg_margin,
CASE COALESCE(
WHEN AVG(p.stock_quantity) > 0 SUM(o.quantity) / NULLIF(AVG(GREATEST(p.stock_quantity, 0)), 0),
THEN SUM(o.quantity) / AVG(p.stock_quantity) 0
ELSE 0 ) as turnover_rate
END as turnover_rate FROM product_categories pc
FROM categories c JOIN products p ON pc.pid = p.pid
LEFT JOIN product_categories pc ON c.id = pc.category_id JOIN orders o ON p.pid = o.pid
LEFT JOIN products p ON pc.product_id = p.product_id WHERE o.canceled = false
LEFT JOIN orders o ON p.product_id = o.product_id AND o.canceled = false AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
WHERE o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH) GROUP BY pc.cat_id, YEAR(o.date), MONTH(o.date)
GROUP BY c.id, YEAR(o.date), MONTH(o.date)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
product_count = VALUES(product_count), product_count = VALUES(product_count),
active_products = VALUES(active_products), active_products = VALUES(active_products),
@@ -152,7 +157,9 @@ async function calculateCategoryMetrics(startTime, totalProducts, processedCount
turnover_rate = VALUES(turnover_rate) turnover_rate = VALUES(turnover_rate)
`); `);
// Calculate category sales metrics // Calculate sales metrics for different time periods
const periods = [30, 90, 180, 365];
for (const days of periods) {
await connection.query(` await connection.query(`
INSERT INTO category_sales_metrics ( INSERT INTO category_sales_metrics (
category_id, category_id,
@@ -165,60 +172,30 @@ async function calculateCategoryMetrics(startTime, totalProducts, processedCount
avg_price, avg_price,
last_calculated_at last_calculated_at
) )
WITH date_ranges AS (
SELECT SELECT
DATE_SUB(CURDATE(), INTERVAL 30 DAY) as period_start, pc.cat_id as category_id,
CURDATE() as period_end COALESCE(p.brand, 'Unbranded') as brand,
UNION ALL DATE_SUB(CURDATE(), INTERVAL ? DAY) as period_start,
SELECT CURDATE() as period_end,
DATE_SUB(CURDATE(), INTERVAL 90 DAY), COALESCE(SUM(o.quantity), 0) / ? as avg_daily_sales,
CURDATE()
UNION ALL
SELECT
DATE_SUB(CURDATE(), INTERVAL 180 DAY),
CURDATE()
UNION ALL
SELECT
DATE_SUB(CURDATE(), INTERVAL 365 DAY),
CURDATE()
),
category_metrics AS (
SELECT
c.id as category_id,
p.brand,
dr.period_start,
dr.period_end,
COUNT(DISTINCT p.product_id) as num_products,
COALESCE(SUM(o.quantity), 0) / DATEDIFF(dr.period_end, dr.period_start) as avg_daily_sales,
COALESCE(SUM(o.quantity), 0) as total_sold, COALESCE(SUM(o.quantity), 0) as total_sold,
COALESCE(AVG(o.price), 0) as avg_price COUNT(DISTINCT p.pid) as num_products,
FROM categories c COALESCE(AVG(o.price), 0) as avg_price,
JOIN product_categories pc ON c.id = pc.category_id
JOIN products p ON pc.product_id = p.product_id
CROSS JOIN date_ranges dr
LEFT JOIN orders o ON p.product_id = o.product_id
AND o.date BETWEEN dr.period_start AND dr.period_end
AND o.canceled = false
GROUP BY c.id, p.brand, dr.period_start, dr.period_end
)
SELECT
category_id,
brand,
period_start,
period_end,
avg_daily_sales,
total_sold,
num_products,
avg_price,
NOW() as last_calculated_at NOW() as last_calculated_at
FROM category_metrics FROM product_categories pc
JOIN products p ON pc.pid = p.pid
LEFT JOIN orders o ON p.pid = o.pid
AND o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
AND o.canceled = false
GROUP BY pc.cat_id, p.brand
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
avg_daily_sales = VALUES(avg_daily_sales), avg_daily_sales = VALUES(avg_daily_sales),
total_sold = VALUES(total_sold), total_sold = VALUES(total_sold),
num_products = VALUES(num_products), num_products = VALUES(num_products),
avg_price = VALUES(avg_price), avg_price = VALUES(avg_price),
last_calculated_at = NOW() last_calculated_at = NOW()
`); `, [days, days, days]);
}
return Math.floor(totalProducts * 0.9); return Math.floor(totalProducts * 0.9);
} finally { } finally {

View File

@@ -15,59 +15,65 @@ async function calculateFinancialMetrics(startTime, totalProducts, processedCoun
percentage: '60' percentage: '60'
}); });
// Calculate financial metrics with optimized query
await connection.query(` await connection.query(`
UPDATE product_metrics pm WITH product_financials AS (
JOIN (
SELECT SELECT
p.product_id, p.pid,
p.cost_price * p.stock_quantity as inventory_value, p.cost_price * p.stock_quantity as inventory_value,
SUM(o.quantity * o.price) as total_revenue, SUM(o.quantity * o.price) as total_revenue,
SUM(o.quantity * p.cost_price) as cost_of_goods_sold, SUM(o.quantity * p.cost_price) as cost_of_goods_sold,
SUM(o.quantity * (o.price - p.cost_price)) as gross_profit, SUM(o.quantity * (o.price - p.cost_price)) as gross_profit,
MIN(o.date) as first_sale_date, MIN(o.date) as first_sale_date,
MAX(o.date) as last_sale_date, MAX(o.date) as last_sale_date,
DATEDIFF(MAX(o.date), MIN(o.date)) + 1 as calculation_period_days DATEDIFF(MAX(o.date), MIN(o.date)) + 1 as calculation_period_days,
COUNT(DISTINCT DATE(o.date)) as active_days
FROM products p FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id LEFT JOIN orders o ON p.pid = o.pid
WHERE o.canceled = false WHERE o.canceled = false
AND DATE(o.date) >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH) AND DATE(o.date) >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH)
GROUP BY p.product_id GROUP BY p.pid
) fin ON pm.product_id = fin.product_id )
UPDATE product_metrics pm
JOIN product_financials pf ON pm.pid = pf.pid
SET SET
pm.inventory_value = COALESCE(fin.inventory_value, 0), pm.inventory_value = COALESCE(pf.inventory_value, 0),
pm.total_revenue = COALESCE(fin.total_revenue, 0), pm.total_revenue = COALESCE(pf.total_revenue, 0),
pm.cost_of_goods_sold = COALESCE(fin.cost_of_goods_sold, 0), pm.cost_of_goods_sold = COALESCE(pf.cost_of_goods_sold, 0),
pm.gross_profit = COALESCE(fin.gross_profit, 0), pm.gross_profit = COALESCE(pf.gross_profit, 0),
pm.gmroi = CASE pm.gmroi = CASE
WHEN COALESCE(fin.inventory_value, 0) > 0 AND fin.calculation_period_days > 0 THEN WHEN COALESCE(pf.inventory_value, 0) > 0 AND pf.active_days > 0 THEN
(COALESCE(fin.gross_profit, 0) * (365.0 / fin.calculation_period_days)) / COALESCE(fin.inventory_value, 0) (COALESCE(pf.gross_profit, 0) * (365.0 / pf.active_days)) / COALESCE(pf.inventory_value, 0)
ELSE 0 ELSE 0
END END
`); `);
// Update time-based aggregates with financial metrics // Update time-based aggregates with optimized query
await connection.query(` await connection.query(`
UPDATE product_time_aggregates pta WITH monthly_financials AS (
JOIN (
SELECT SELECT
p.product_id, p.pid,
YEAR(o.date) as year, YEAR(o.date) as year,
MONTH(o.date) as month, MONTH(o.date) as month,
p.cost_price * p.stock_quantity as inventory_value, p.cost_price * p.stock_quantity as inventory_value,
SUM(o.quantity * (o.price - p.cost_price)) as gross_profit, SUM(o.quantity * (o.price - p.cost_price)) as gross_profit,
COUNT(DISTINCT DATE(o.date)) as days_in_period COUNT(DISTINCT DATE(o.date)) as active_days,
MIN(o.date) as period_start,
MAX(o.date) as period_end
FROM products p FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id LEFT JOIN orders o ON p.pid = o.pid
WHERE o.canceled = false WHERE o.canceled = false
GROUP BY p.product_id, YEAR(o.date), MONTH(o.date) GROUP BY p.pid, YEAR(o.date), MONTH(o.date)
) fin ON pta.product_id = fin.product_id )
AND pta.year = fin.year UPDATE product_time_aggregates pta
AND pta.month = fin.month JOIN monthly_financials mf ON pta.pid = mf.pid
AND pta.year = mf.year
AND pta.month = mf.month
SET SET
pta.inventory_value = COALESCE(fin.inventory_value, 0), pta.inventory_value = COALESCE(mf.inventory_value, 0),
pta.gmroi = CASE pta.gmroi = CASE
WHEN COALESCE(fin.inventory_value, 0) > 0 AND fin.days_in_period > 0 THEN WHEN COALESCE(mf.inventory_value, 0) > 0 AND mf.active_days > 0 THEN
(COALESCE(fin.gross_profit, 0) * (365.0 / fin.days_in_period)) / COALESCE(fin.inventory_value, 0) (COALESCE(mf.gross_profit, 0) * (365.0 / mf.active_days)) / COALESCE(mf.inventory_value, 0)
ELSE 0 ELSE 0
END END
`); `);

View File

@@ -1,498 +1,244 @@
const { outputProgress, logError } = require('./utils/progress'); const { outputProgress, logError } = require('./utils/progress');
const { getConnection } = require('./utils/db'); const { getConnection } = require('./utils/db');
// Helper function to handle NaN and undefined values
function sanitizeValue(value) {
if (value === undefined || value === null || Number.isNaN(value)) {
return null;
}
return value;
}
async function calculateProductMetrics(startTime, totalProducts, processedCount = 0) { async function calculateProductMetrics(startTime, totalProducts, processedCount = 0) {
const connection = await getConnection(); const connection = await getConnection();
try { try {
// Process in batches of 250 // Skip flags are inherited from the parent scope
const batchSize = 250; const SKIP_PRODUCT_BASE_METRICS = 0;
for (let offset = 0; offset < totalProducts; offset += batchSize) { const SKIP_PRODUCT_TIME_AGGREGATES =0;
const [products] = await connection.query('SELECT product_id, vendor FROM products LIMIT ? OFFSET ?', [batchSize, offset])
.catch(err => {
logError(err, `Failed to fetch products batch at offset ${offset}`);
throw err;
});
processedCount += products.length;
// Update progress after each batch // Calculate base product metrics
if (!SKIP_PRODUCT_BASE_METRICS) {
outputProgress({ outputProgress({
status: 'running', status: 'running',
operation: 'Processing products', operation: 'Calculating base product metrics',
current: Math.floor(totalProducts * 0.2),
total: totalProducts,
elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, Math.floor(totalProducts * 0.2), totalProducts),
rate: calculateRate(startTime, Math.floor(totalProducts * 0.2)),
percentage: '20'
});
// Calculate base metrics
await connection.query(`
UPDATE product_metrics pm
JOIN (
SELECT
p.pid,
p.cost_price * p.stock_quantity as inventory_value,
SUM(o.quantity) as total_quantity,
COUNT(DISTINCT o.order_number) as number_of_orders,
SUM(o.quantity * o.price) as total_revenue,
SUM(o.quantity * p.cost_price) as cost_of_goods_sold,
AVG(o.price) as avg_price,
STDDEV(o.price) as price_std,
MIN(o.date) as first_sale_date,
MAX(o.date) as last_sale_date,
COUNT(DISTINCT DATE(o.date)) as active_days
FROM products p
LEFT JOIN orders o ON p.pid = o.pid AND o.canceled = false
GROUP BY p.pid
) stats ON pm.pid = stats.pid
SET
pm.inventory_value = COALESCE(stats.inventory_value, 0),
pm.avg_quantity_per_order = COALESCE(stats.total_quantity / NULLIF(stats.number_of_orders, 0), 0),
pm.number_of_orders = COALESCE(stats.number_of_orders, 0),
pm.total_revenue = COALESCE(stats.total_revenue, 0),
pm.cost_of_goods_sold = COALESCE(stats.cost_of_goods_sold, 0),
pm.gross_profit = COALESCE(stats.total_revenue - stats.cost_of_goods_sold, 0),
pm.avg_margin_percent = CASE
WHEN COALESCE(stats.total_revenue, 0) > 0
THEN ((stats.total_revenue - stats.cost_of_goods_sold) / stats.total_revenue) * 100
ELSE 0
END,
pm.first_sale_date = stats.first_sale_date,
pm.last_sale_date = stats.last_sale_date,
pm.gmroi = CASE
WHEN COALESCE(stats.inventory_value, 0) > 0
THEN (stats.total_revenue - stats.cost_of_goods_sold) / stats.inventory_value
ELSE 0
END,
pm.last_calculated_at = NOW()
`);
processedCount = Math.floor(totalProducts * 0.4);
} else {
console.log('Skipping base product metrics calculation');
processedCount = Math.floor(totalProducts * 0.4);
outputProgress({
status: 'running',
operation: 'Skipping base product metrics calculation',
current: processedCount, current: processedCount,
total: totalProducts, total: totalProducts,
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts), remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount), rate: calculateRate(startTime, processedCount),
percentage: ((processedCount / totalProducts) * 100).toFixed(1) percentage: '40'
});
}
// Calculate product time aggregates
if (!SKIP_PRODUCT_TIME_AGGREGATES) {
outputProgress({
status: 'running',
operation: 'Calculating product time aggregates',
current: Math.floor(totalProducts * 0.4),
total: totalProducts,
elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, Math.floor(totalProducts * 0.4), totalProducts),
rate: calculateRate(startTime, Math.floor(totalProducts * 0.4)),
percentage: '40'
}); });
// Process the batch // Calculate time-based aggregates
const metricsUpdates = []; await connection.query(`
for (const product of products) { INSERT INTO product_time_aggregates (
try { pid,
// Get configuration values for this product year,
const [configs] = await connection.query(` month,
WITH product_info AS (
SELECT
p.product_id,
p.vendor,
pc.category_id
FROM products p
LEFT JOIN product_categories pc ON p.product_id = pc.product_id
WHERE p.product_id = ?
),
threshold_options AS (
SELECT
st.*,
CASE
WHEN st.category_id = pi.category_id AND st.vendor = pi.vendor THEN 1
WHEN st.category_id = pi.category_id AND st.vendor IS NULL THEN 2
WHEN st.category_id IS NULL AND st.vendor = pi.vendor THEN 3
WHEN st.category_id IS NULL AND st.vendor IS NULL THEN 4
ELSE 5
END as priority
FROM product_info pi
CROSS JOIN stock_thresholds st
WHERE (st.category_id = pi.category_id OR st.category_id IS NULL)
AND (st.vendor = pi.vendor OR st.vendor IS NULL)
),
velocity_options AS (
SELECT
sv.*,
CASE
WHEN sv.category_id = pi.category_id AND sv.vendor = pi.vendor THEN 1
WHEN sv.category_id = pi.category_id AND sv.vendor IS NULL THEN 2
WHEN sv.category_id IS NULL AND sv.vendor = pi.vendor THEN 3
WHEN sv.category_id IS NULL AND sv.vendor IS NULL THEN 4
ELSE 5
END as priority
FROM product_info pi
CROSS JOIN sales_velocity_config sv
WHERE (sv.category_id = pi.category_id OR sv.category_id IS NULL)
AND (sv.vendor = pi.vendor OR sv.vendor IS NULL)
),
safety_options AS (
SELECT
ss.*,
CASE
WHEN ss.category_id = pi.category_id AND ss.vendor = pi.vendor THEN 1
WHEN ss.category_id = pi.category_id AND ss.vendor IS NULL THEN 2
WHEN ss.category_id IS NULL AND ss.vendor = pi.vendor THEN 3
WHEN ss.category_id IS NULL AND ss.vendor IS NULL THEN 4
ELSE 5
END as priority
FROM product_info pi
CROSS JOIN safety_stock_config ss
WHERE (ss.category_id = pi.category_id OR ss.category_id IS NULL)
AND (ss.vendor = pi.vendor OR ss.vendor IS NULL)
)
SELECT
COALESCE(
(SELECT critical_days
FROM threshold_options
ORDER BY priority LIMIT 1),
7
) as critical_days,
COALESCE(
(SELECT reorder_days
FROM threshold_options
ORDER BY priority LIMIT 1),
14
) as reorder_days,
COALESCE(
(SELECT overstock_days
FROM threshold_options
ORDER BY priority LIMIT 1),
90
) as overstock_days,
COALESCE(
(SELECT low_stock_threshold
FROM threshold_options
ORDER BY priority LIMIT 1),
5
) as low_stock_threshold,
COALESCE(
(SELECT daily_window_days
FROM velocity_options
ORDER BY priority LIMIT 1),
30
) as daily_window_days,
COALESCE(
(SELECT weekly_window_days
FROM velocity_options
ORDER BY priority LIMIT 1),
7
) as weekly_window_days,
COALESCE(
(SELECT monthly_window_days
FROM velocity_options
ORDER BY priority LIMIT 1),
90
) as monthly_window_days,
COALESCE(
(SELECT coverage_days
FROM safety_options
ORDER BY priority LIMIT 1),
14
) as safety_stock_days,
COALESCE(
(SELECT service_level
FROM safety_options
ORDER BY priority LIMIT 1),
95.0
) as service_level
`, [product.product_id]);
const config = configs[0];
// Calculate sales metrics
const [salesMetrics] = await connection.query(`
WITH sales_summary AS (
SELECT
SUM(o.quantity) as total_quantity_sold,
SUM((o.price - COALESCE(o.discount, 0)) * o.quantity) as total_revenue,
SUM(COALESCE(p.cost_price, 0) * o.quantity) as total_cost,
MAX(o.date) as last_sale_date,
MIN(o.date) as first_sale_date,
COUNT(DISTINCT o.order_number) as number_of_orders,
AVG(o.quantity) as avg_quantity_per_order,
SUM(CASE WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) THEN o.quantity ELSE 0 END) as last_30_days_qty,
CASE
WHEN SUM(CASE WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) THEN o.quantity ELSE 0 END) IS NULL THEN 0
ELSE SUM(CASE WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) THEN o.quantity ELSE 0 END)
END as rolling_weekly_avg,
SUM(CASE WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) THEN o.quantity ELSE 0 END) as last_month_qty
FROM orders o
JOIN products p ON o.product_id = p.product_id
WHERE o.canceled = 0 AND o.product_id = ?
GROUP BY o.product_id
)
SELECT
total_quantity_sold, total_quantity_sold,
total_revenue, total_revenue,
total_cost, total_cost,
last_sale_date, order_count,
first_sale_date, avg_price,
number_of_orders, profit_margin,
avg_quantity_per_order, inventory_value,
last_30_days_qty / ? as rolling_daily_avg, gmroi
rolling_weekly_avg / ? as rolling_weekly_avg,
last_month_qty / ? as rolling_monthly_avg
FROM sales_summary
`, [
config.daily_window_days,
config.weekly_window_days,
config.weekly_window_days,
config.monthly_window_days,
product.product_id,
config.daily_window_days,
config.weekly_window_days,
config.monthly_window_days
]);
// Calculate purchase metrics
const [purchaseMetrics] = await connection.query(`
WITH recent_orders AS (
SELECT
date,
received_date,
received,
cost_price,
DATEDIFF(received_date, date) as lead_time_days,
ROW_NUMBER() OVER (ORDER BY date DESC) as order_rank
FROM purchase_orders
WHERE status = 'closed'
AND product_id = ?
AND received > 0
AND received_date IS NOT NULL
),
lead_time_orders AS (
SELECT *
FROM recent_orders
WHERE order_rank <= 5
OR date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)
) )
SELECT SELECT
SUM(CASE WHEN received >= 0 THEN received ELSE 0 END) as total_quantity_purchased, p.pid,
SUM(CASE WHEN received >= 0 THEN cost_price * received ELSE 0 END) as total_cost, YEAR(o.date) as year,
MAX(date) as last_purchase_date, MONTH(o.date) as month,
MIN(received_date) as first_received_date, SUM(o.quantity) as total_quantity_sold,
MAX(received_date) as last_received_date, SUM(o.quantity * o.price) as total_revenue,
AVG(lead_time_days) as avg_lead_time_days SUM(o.quantity * p.cost_price) as total_cost,
FROM lead_time_orders COUNT(DISTINCT o.order_number) as order_count,
`, [product.product_id]); AVG(o.price) as avg_price,
// Get stock info
const [stockInfo] = await connection.query(`
SELECT
p.stock_quantity,
p.cost_price,
p.created_at,
p.replenishable,
p.moq,
DATEDIFF(CURDATE(), MIN(po.received_date)) as days_since_first_stock,
DATEDIFF(CURDATE(), COALESCE(
(SELECT MAX(o2.date)
FROM orders o2
WHERE o2.product_id = p.product_id
AND o2.canceled = false),
CURDATE()
)) as days_since_last_sale,
(SELECT SUM(quantity)
FROM orders o3
WHERE o3.product_id = p.product_id
AND o3.canceled = false) as total_quantity_sold,
CASE CASE
WHEN EXISTS ( WHEN SUM(o.quantity * o.price) > 0
SELECT 1 FROM orders o THEN ((SUM(o.quantity * o.price) - SUM(o.quantity * p.cost_price)) / SUM(o.quantity * o.price)) * 100
WHERE o.product_id = p.product_id ELSE 0
AND o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) END as profit_margin,
AND o.canceled = false p.cost_price * p.stock_quantity as inventory_value,
AND (SELECT SUM(quantity) FROM orders o2 CASE
WHERE o2.product_id = p.product_id WHEN p.cost_price * p.stock_quantity > 0
AND o2.date >= o.date THEN (SUM(o.quantity * (o.price - p.cost_price))) / (p.cost_price * p.stock_quantity)
AND o2.canceled = false) = 0 ELSE 0
) THEN true END as gmroi
ELSE false
END as had_recent_stockout
FROM products p FROM products p
LEFT JOIN purchase_orders po ON p.product_id = po.product_id LEFT JOIN orders o ON p.pid = o.pid AND o.canceled = false
AND po.status = 'closed' WHERE o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
AND po.received > 0 GROUP BY p.pid, YEAR(o.date), MONTH(o.date)
WHERE p.product_id = ?
GROUP BY p.product_id
`, [product.product_id]);
// Calculate metrics
const metrics = salesMetrics[0] || {};
const purchases = purchaseMetrics[0] || {};
const stock = stockInfo[0] || {};
const daily_sales_avg = metrics.rolling_daily_avg || 0;
const weekly_sales_avg = metrics.rolling_weekly_avg || 0;
const monthly_sales_avg = metrics.total_quantity_sold ? metrics.total_quantity_sold / 30 : 0;
// Calculate days of inventory
const days_of_inventory = daily_sales_avg > 0 ?
Math.ceil(
(stock.stock_quantity / daily_sales_avg) +
(purchases.avg_lead_time_days || config.reorder_days) *
(1 + (config.service_level / 100))
) : null;
const weeks_of_inventory = days_of_inventory ? Math.ceil(days_of_inventory / 7) : null;
// Calculate margin percent
const margin_percent = metrics.total_revenue > 0 ?
((metrics.total_revenue - metrics.total_cost) / metrics.total_revenue) * 100 :
null;
// Calculate inventory value
const inventory_value = (stock.stock_quantity || 0) * (stock.cost_price || 0);
// Calculate stock status
const stock_status = calculateStockStatus(stock, config, daily_sales_avg, weekly_sales_avg, monthly_sales_avg);
// Calculate reorder quantity and overstocked amount
const { reorder_qty, overstocked_amt } = calculateReorderQuantities(
stock,
stock_status,
daily_sales_avg,
purchases.avg_lead_time_days,
config
);
// Add to batch update
metricsUpdates.push([
product.product_id,
daily_sales_avg || null,
weekly_sales_avg || null,
monthly_sales_avg || null,
metrics.avg_quantity_per_order || null,
metrics.number_of_orders || 0,
metrics.first_sale_date || null,
metrics.last_sale_date || null,
days_of_inventory,
weeks_of_inventory,
daily_sales_avg > 0 ? Math.max(1, Math.ceil(daily_sales_avg * config.reorder_days)) : null,
margin_percent,
metrics.total_revenue || 0,
inventory_value || 0,
purchases.avg_lead_time_days || null,
purchases.last_purchase_date || null,
purchases.first_received_date || null,
purchases.last_received_date || null,
stock_status,
reorder_qty,
overstocked_amt
]);
} catch (err) {
logError(err, `Failed processing product ${product.product_id}`);
continue;
}
}
// Batch update metrics
if (metricsUpdates.length > 0) {
await connection.query(`
INSERT INTO product_metrics (
product_id,
daily_sales_avg,
weekly_sales_avg,
monthly_sales_avg,
avg_quantity_per_order,
number_of_orders,
first_sale_date,
last_sale_date,
days_of_inventory,
weeks_of_inventory,
reorder_point,
avg_margin_percent,
total_revenue,
inventory_value,
avg_lead_time_days,
last_purchase_date,
first_received_date,
last_received_date,
stock_status,
reorder_qty,
overstocked_amt
) VALUES ?
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
last_calculated_at = NOW(), total_quantity_sold = VALUES(total_quantity_sold),
daily_sales_avg = VALUES(daily_sales_avg),
weekly_sales_avg = VALUES(weekly_sales_avg),
monthly_sales_avg = VALUES(monthly_sales_avg),
avg_quantity_per_order = VALUES(avg_quantity_per_order),
number_of_orders = VALUES(number_of_orders),
first_sale_date = VALUES(first_sale_date),
last_sale_date = VALUES(last_sale_date),
days_of_inventory = VALUES(days_of_inventory),
weeks_of_inventory = VALUES(weeks_of_inventory),
reorder_point = VALUES(reorder_point),
avg_margin_percent = VALUES(avg_margin_percent),
total_revenue = VALUES(total_revenue), total_revenue = VALUES(total_revenue),
total_cost = VALUES(total_cost),
order_count = VALUES(order_count),
avg_price = VALUES(avg_price),
profit_margin = VALUES(profit_margin),
inventory_value = VALUES(inventory_value), inventory_value = VALUES(inventory_value),
avg_lead_time_days = VALUES(avg_lead_time_days), gmroi = VALUES(gmroi)
last_purchase_date = VALUES(last_purchase_date), `);
first_received_date = VALUES(first_received_date),
last_received_date = VALUES(last_received_date), processedCount = Math.floor(totalProducts * 0.6);
stock_status = VALUES(stock_status), } else {
reorder_qty = VALUES(reorder_qty), console.log('Skipping product time aggregates calculation');
overstocked_amt = VALUES(overstocked_amt) processedCount = Math.floor(totalProducts * 0.6);
`, [metricsUpdates]); outputProgress({
} status: 'running',
operation: 'Skipping product time aggregates calculation',
current: processedCount,
total: totalProducts,
elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, processedCount, totalProducts),
rate: calculateRate(startTime, processedCount),
percentage: '60'
});
} }
return processedCount; return processedCount;
} finally { } finally {
if (connection) {
connection.release(); connection.release();
} }
} }
}
function calculateStockStatus(stock, config, daily_sales_avg, weekly_sales_avg, monthly_sales_avg) { function calculateStockStatus(stock, config, daily_sales_avg, weekly_sales_avg, monthly_sales_avg) {
const days_since_first_stock = stock.days_since_first_stock || 0; if (stock <= 0) {
const days_since_last_sale = stock.days_since_last_sale || 9999; return 'Out of Stock';
const total_quantity_sold = stock.total_quantity_sold || 0;
const had_recent_stockout = stock.had_recent_stockout || false;
const dq = stock.stock_quantity || 0;
const ds = daily_sales_avg || 0;
const ws = weekly_sales_avg || 0;
const ms = monthly_sales_avg || 0;
// If no stock, return immediately
if (dq === 0) {
return had_recent_stockout ? 'Critical' : 'Out of Stock';
} }
// 1. Check if truly "New" (≤30 days and no sales) // Use the most appropriate sales average based on data quality
if (days_since_first_stock <= 30 && total_quantity_sold === 0) { let sales_avg = daily_sales_avg;
return 'New'; if (sales_avg === 0) {
sales_avg = weekly_sales_avg / 7;
}
if (sales_avg === 0) {
sales_avg = monthly_sales_avg / 30;
} }
// 2. Handle zero or very low sales velocity cases if (sales_avg === 0) {
if (ds === 0 || (ds < 0.1 && ws < 0.5)) { return stock <= config.low_stock_threshold ? 'Low Stock' : 'In Stock';
if (days_since_first_stock > config.overstock_days) {
return 'Overstocked';
}
if (days_since_first_stock > 30) {
return 'At Risk';
}
} }
// 3. Calculate days of supply and check velocity trends const days_of_stock = stock / sales_avg;
const days_of_supply = ds > 0 ? dq / ds : 999;
const velocity_trend = ds > 0 ? (ds / (ms || ds) - 1) * 100 : 0;
// Critical stock level if (days_of_stock <= config.critical_days) {
if (days_of_supply <= config.critical_days) {
return 'Critical'; return 'Critical';
} } else if (days_of_stock <= config.reorder_days) {
// Reorder cases
if (days_of_supply <= config.reorder_days ||
(had_recent_stockout && days_of_supply <= config.reorder_days * 1.5)) {
return 'Reorder'; return 'Reorder';
} } else if (days_of_stock > config.overstock_days) {
// At Risk cases
if (
(days_of_supply >= config.overstock_days * 0.8) ||
(velocity_trend <= -50 && days_of_supply > config.reorder_days * 2) ||
(days_since_last_sale > 45 && dq > 0) ||
(ds > 0 && ds < 0.2 && dq > ds * config.overstock_days * 0.5)
) {
return 'At Risk';
}
// Overstock cases
if (days_of_supply >= config.overstock_days) {
return 'Overstocked'; return 'Overstocked';
} }
// If none of the above conditions are met
return 'Healthy'; return 'Healthy';
} }
function calculateReorderQuantities(stock, stock_status, daily_sales_avg, avg_lead_time, config) { function calculateReorderQuantities(stock, stock_status, daily_sales_avg, avg_lead_time, config) {
// Calculate safety stock based on service level and lead time
const z_score = 1.96; // 95% service level
const lead_time = avg_lead_time || config.target_days;
const safety_stock = Math.ceil(daily_sales_avg * Math.sqrt(lead_time) * z_score);
// Calculate reorder point
const lead_time_demand = daily_sales_avg * lead_time;
const reorder_point = Math.ceil(lead_time_demand + safety_stock);
// Calculate reorder quantity using EOQ formula if we have the necessary data
let reorder_qty = 0; let reorder_qty = 0;
let overstocked_amt = 0; if (daily_sales_avg > 0) {
const annual_demand = daily_sales_avg * 365;
const order_cost = 25; // Fixed cost per order
const holding_cost_percent = 0.25; // 25% annual holding cost
// Only calculate reorder quantity for replenishable products reorder_qty = Math.ceil(Math.sqrt((2 * annual_demand * order_cost) / holding_cost_percent));
if (stock.replenishable && (stock_status === 'Critical' || stock_status === 'Reorder')) { } else {
const ds = daily_sales_avg || 0; // If no sales data, use a basic calculation
const lt = avg_lead_time || 14; reorder_qty = Math.max(safety_stock, config.low_stock_threshold);
const sc = config.safety_stock_days || 14;
const ss = config.safety_stock_days || 14;
const dq = stock.stock_quantity || 0;
const moq = stock.moq || 1;
// Calculate desired stock level
const desired_stock = (ds * (lt + sc)) + ss;
// Calculate raw reorder amount
const raw_reorder = Math.max(0, desired_stock - dq);
// Round up to nearest MOQ
reorder_qty = Math.ceil(raw_reorder / moq) * moq;
} }
// Calculate overstocked amount for overstocked products // Calculate overstocked amount
if (stock_status === 'Overstocked') { const overstocked_amt = stock_status === 'Overstocked' ?
const ds = daily_sales_avg || 0; stock - Math.ceil(daily_sales_avg * config.overstock_days) :
const dq = stock.stock_quantity || 0; 0;
const lt = avg_lead_time || 14;
const sc = config.safety_stock_days || 14;
const ss = config.safety_stock_days || 14;
// Calculate maximum desired stock return {
const max_desired_stock = (ds * config.overstock_days) + ss; safety_stock,
reorder_point,
// Calculate excess inventory reorder_qty,
overstocked_amt = Math.max(0, dq - max_desired_stock); overstocked_amt
} };
return { reorder_qty, overstocked_amt };
} }
module.exports = calculateProductMetrics; module.exports = calculateProductMetrics;

View File

@@ -15,102 +15,100 @@ async function calculateSalesForecasts(startTime, totalProducts, processedCount)
percentage: '98' percentage: '98'
}); });
// First, create a temporary table for forecast dates
await connection.query(`
CREATE TEMPORARY TABLE IF NOT EXISTS temp_forecast_dates (
forecast_date DATE,
day_of_week INT,
month INT,
PRIMARY KEY (forecast_date)
)
`);
await connection.query(`
INSERT INTO temp_forecast_dates
SELECT
DATE_ADD(CURRENT_DATE, INTERVAL n DAY) as forecast_date,
DAYOFWEEK(DATE_ADD(CURRENT_DATE, INTERVAL n DAY)) as day_of_week,
MONTH(DATE_ADD(CURRENT_DATE, INTERVAL n DAY)) as month
FROM (
SELECT a.N + b.N * 10 as n
FROM
(SELECT 0 as N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION
SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) a,
(SELECT 0 as N UNION SELECT 1 UNION SELECT 2) b
ORDER BY n
LIMIT 31
) numbers
`);
// Create temporary table for daily sales stats
await connection.query(`
CREATE TEMPORARY TABLE IF NOT EXISTS temp_daily_sales AS
SELECT
o.pid,
DAYOFWEEK(o.date) as day_of_week,
SUM(o.quantity) as daily_quantity,
SUM(o.price * o.quantity) as daily_revenue,
COUNT(DISTINCT DATE(o.date)) as day_count
FROM orders o
WHERE o.canceled = false
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 90 DAY)
GROUP BY o.pid, DAYOFWEEK(o.date)
`);
// Create temporary table for product stats
await connection.query(`
CREATE TEMPORARY TABLE IF NOT EXISTS temp_product_stats AS
SELECT
pid,
AVG(daily_revenue) as overall_avg_revenue,
SUM(day_count) as total_days
FROM temp_daily_sales
GROUP BY pid
`);
// Calculate product-level forecasts // Calculate product-level forecasts
await connection.query(` await connection.query(`
INSERT INTO sales_forecasts ( INSERT INTO sales_forecasts (
product_id, pid,
forecast_date, forecast_date,
forecast_units, forecast_units,
forecast_revenue, forecast_revenue,
confidence_level, confidence_level,
last_calculated_at last_calculated_at
) )
WITH daily_sales AS (
SELECT SELECT
o.product_id, ds.pid,
DATE(o.date) as sale_date,
SUM(o.quantity) as daily_quantity,
SUM(o.price * o.quantity) as daily_revenue
FROM orders o
WHERE o.canceled = false
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 90 DAY)
GROUP BY o.product_id, DATE(o.date)
),
forecast_dates AS (
SELECT
DATE_ADD(CURRENT_DATE, INTERVAL n DAY) as forecast_date
FROM (
SELECT 0 as n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION
SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 UNION
SELECT 10 UNION SELECT 11 UNION SELECT 12 UNION SELECT 13 UNION SELECT 14 UNION
SELECT 15 UNION SELECT 16 UNION SELECT 17 UNION SELECT 18 UNION SELECT 19 UNION
SELECT 20 UNION SELECT 21 UNION SELECT 22 UNION SELECT 23 UNION SELECT 24 UNION
SELECT 25 UNION SELECT 26 UNION SELECT 27 UNION SELECT 28 UNION SELECT 29 UNION
SELECT 30
) numbers
),
product_stats AS (
SELECT
ds.product_id,
AVG(ds.daily_quantity) as avg_daily_quantity,
STDDEV_SAMP(ds.daily_quantity) as std_daily_quantity,
AVG(ds.daily_revenue) as avg_daily_revenue,
STDDEV_SAMP(ds.daily_revenue) as std_daily_revenue,
COUNT(*) as data_points,
-- Calculate day-of-week averages
AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 1 THEN ds.daily_revenue END) as sunday_avg,
AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 2 THEN ds.daily_revenue END) as monday_avg,
AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 3 THEN ds.daily_revenue END) as tuesday_avg,
AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 4 THEN ds.daily_revenue END) as wednesday_avg,
AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 5 THEN ds.daily_revenue END) as thursday_avg,
AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 6 THEN ds.daily_revenue END) as friday_avg,
AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 7 THEN ds.daily_revenue END) as saturday_avg
FROM daily_sales ds
GROUP BY ds.product_id
)
SELECT
ps.product_id,
fd.forecast_date, fd.forecast_date,
GREATEST(0, GREATEST(0,
ps.avg_daily_quantity * AVG(ds.daily_quantity) *
(1 + COALESCE( (1 + COALESCE(sf.seasonality_factor, 0))
(SELECT seasonality_factor
FROM sales_seasonality
WHERE MONTH(fd.forecast_date) = month
LIMIT 1),
0
))
) as forecast_units, ) as forecast_units,
GREATEST(0, GREATEST(0,
CASE DAYOFWEEK(fd.forecast_date) COALESCE(
WHEN 1 THEN COALESCE(ps.sunday_avg, ps.avg_daily_revenue) CASE
WHEN 2 THEN COALESCE(ps.monday_avg, ps.avg_daily_revenue) WHEN SUM(ds.day_count) >= 4 THEN AVG(ds.daily_revenue)
WHEN 3 THEN COALESCE(ps.tuesday_avg, ps.avg_daily_revenue) ELSE ps.overall_avg_revenue
WHEN 4 THEN COALESCE(ps.wednesday_avg, ps.avg_daily_revenue)
WHEN 5 THEN COALESCE(ps.thursday_avg, ps.avg_daily_revenue)
WHEN 6 THEN COALESCE(ps.friday_avg, ps.avg_daily_revenue)
WHEN 7 THEN COALESCE(ps.saturday_avg, ps.avg_daily_revenue)
END * END *
(1 + COALESCE( (1 + COALESCE(sf.seasonality_factor, 0)) *
(SELECT seasonality_factor (0.95 + (RAND() * 0.1)),
FROM sales_seasonality
WHERE MONTH(fd.forecast_date) = month
LIMIT 1),
0 0
)) * )
-- Add some randomness within a small range (±5%)
(0.95 + (RAND() * 0.1))
) as forecast_revenue, ) as forecast_revenue,
CASE CASE
WHEN ps.data_points >= 60 THEN 90 WHEN ps.total_days >= 60 THEN 90
WHEN ps.data_points >= 30 THEN 80 WHEN ps.total_days >= 30 THEN 80
WHEN ps.data_points >= 14 THEN 70 WHEN ps.total_days >= 14 THEN 70
ELSE 60 ELSE 60
END as confidence_level, END as confidence_level,
NOW() as last_calculated_at NOW() as last_calculated_at
FROM product_stats ps FROM temp_daily_sales ds
CROSS JOIN forecast_dates fd JOIN temp_product_stats ps ON ds.pid = ps.pid
WHERE ps.avg_daily_quantity > 0 CROSS JOIN temp_forecast_dates fd
LEFT JOIN sales_seasonality sf ON fd.month = sf.month
GROUP BY ds.pid, fd.forecast_date, ps.overall_avg_revenue, ps.total_days, sf.seasonality_factor
HAVING AVG(ds.daily_quantity) > 0
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
forecast_units = VALUES(forecast_units), forecast_units = VALUES(forecast_units),
forecast_revenue = VALUES(forecast_revenue), forecast_revenue = VALUES(forecast_revenue),
@@ -118,6 +116,32 @@ async function calculateSalesForecasts(startTime, totalProducts, processedCount)
last_calculated_at = NOW() last_calculated_at = NOW()
`); `);
// Create temporary table for category stats
await connection.query(`
CREATE TEMPORARY TABLE IF NOT EXISTS temp_category_sales AS
SELECT
pc.cat_id,
DAYOFWEEK(o.date) as day_of_week,
SUM(o.quantity) as daily_quantity,
SUM(o.price * o.quantity) as daily_revenue,
COUNT(DISTINCT DATE(o.date)) as day_count
FROM orders o
JOIN product_categories pc ON o.pid = pc.pid
WHERE o.canceled = false
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 90 DAY)
GROUP BY pc.cat_id, DAYOFWEEK(o.date)
`);
await connection.query(`
CREATE TEMPORARY TABLE IF NOT EXISTS temp_category_stats AS
SELECT
cat_id,
AVG(daily_revenue) as overall_avg_revenue,
SUM(day_count) as total_days
FROM temp_category_sales
GROUP BY cat_id
`);
// Calculate category-level forecasts // Calculate category-level forecasts
await connection.query(` await connection.query(`
INSERT INTO category_forecasts ( INSERT INTO category_forecasts (
@@ -128,93 +152,37 @@ async function calculateSalesForecasts(startTime, totalProducts, processedCount)
confidence_level, confidence_level,
last_calculated_at last_calculated_at
) )
WITH category_daily_sales AS (
SELECT SELECT
pc.category_id, cs.cat_id as category_id,
DATE(o.date) as sale_date,
SUM(o.quantity) as daily_quantity,
SUM(o.price * o.quantity) as daily_revenue
FROM orders o
JOIN product_categories pc ON o.product_id = pc.product_id
WHERE o.canceled = false
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 90 DAY)
GROUP BY pc.category_id, DATE(o.date)
),
forecast_dates AS (
SELECT
DATE_ADD(CURRENT_DATE, INTERVAL n DAY) as forecast_date
FROM (
SELECT 0 as n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION
SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 UNION
SELECT 10 UNION SELECT 11 UNION SELECT 12 UNION SELECT 13 UNION SELECT 14 UNION
SELECT 15 UNION SELECT 16 UNION SELECT 17 UNION SELECT 18 UNION SELECT 19 UNION
SELECT 20 UNION SELECT 21 UNION SELECT 22 UNION SELECT 23 UNION SELECT 24 UNION
SELECT 25 UNION SELECT 26 UNION SELECT 27 UNION SELECT 28 UNION SELECT 29 UNION
SELECT 30
) numbers
),
category_stats AS (
SELECT
cds.category_id,
AVG(cds.daily_quantity) as avg_daily_quantity,
STDDEV_SAMP(cds.daily_quantity) as std_daily_quantity,
AVG(cds.daily_revenue) as avg_daily_revenue,
STDDEV_SAMP(cds.daily_revenue) as std_daily_revenue,
COUNT(*) as data_points,
-- Calculate day-of-week averages
AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 1 THEN cds.daily_revenue END) as sunday_avg,
AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 2 THEN cds.daily_revenue END) as monday_avg,
AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 3 THEN cds.daily_revenue END) as tuesday_avg,
AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 4 THEN cds.daily_revenue END) as wednesday_avg,
AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 5 THEN cds.daily_revenue END) as thursday_avg,
AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 6 THEN cds.daily_revenue END) as friday_avg,
AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 7 THEN cds.daily_revenue END) as saturday_avg
FROM category_daily_sales cds
GROUP BY cds.category_id
)
SELECT
cs.category_id,
fd.forecast_date, fd.forecast_date,
GREATEST(0, GREATEST(0,
cs.avg_daily_quantity * AVG(cs.daily_quantity) *
(1 + COALESCE( (1 + COALESCE(sf.seasonality_factor, 0))
(SELECT seasonality_factor
FROM sales_seasonality
WHERE MONTH(fd.forecast_date) = month
LIMIT 1),
0
))
) as forecast_units, ) as forecast_units,
GREATEST(0, GREATEST(0,
CASE DAYOFWEEK(fd.forecast_date) COALESCE(
WHEN 1 THEN COALESCE(cs.sunday_avg, cs.avg_daily_revenue) CASE
WHEN 2 THEN COALESCE(cs.monday_avg, cs.avg_daily_revenue) WHEN SUM(cs.day_count) >= 4 THEN AVG(cs.daily_revenue)
WHEN 3 THEN COALESCE(cs.tuesday_avg, cs.avg_daily_revenue) ELSE ct.overall_avg_revenue
WHEN 4 THEN COALESCE(cs.wednesday_avg, cs.avg_daily_revenue)
WHEN 5 THEN COALESCE(cs.thursday_avg, cs.avg_daily_revenue)
WHEN 6 THEN COALESCE(cs.friday_avg, cs.avg_daily_revenue)
WHEN 7 THEN COALESCE(cs.saturday_avg, cs.avg_daily_revenue)
END * END *
(1 + COALESCE( (1 + COALESCE(sf.seasonality_factor, 0)) *
(SELECT seasonality_factor (0.95 + (RAND() * 0.1)),
FROM sales_seasonality
WHERE MONTH(fd.forecast_date) = month
LIMIT 1),
0 0
)) * )
-- Add some randomness within a small range (±5%)
(0.95 + (RAND() * 0.1))
) as forecast_revenue, ) as forecast_revenue,
CASE CASE
WHEN cs.data_points >= 60 THEN 90 WHEN ct.total_days >= 60 THEN 90
WHEN cs.data_points >= 30 THEN 80 WHEN ct.total_days >= 30 THEN 80
WHEN cs.data_points >= 14 THEN 70 WHEN ct.total_days >= 14 THEN 70
ELSE 60 ELSE 60
END as confidence_level, END as confidence_level,
NOW() as last_calculated_at NOW() as last_calculated_at
FROM category_stats cs FROM temp_category_sales cs
CROSS JOIN forecast_dates fd JOIN temp_category_stats ct ON cs.cat_id = ct.cat_id
WHERE cs.avg_daily_quantity > 0 CROSS JOIN temp_forecast_dates fd
LEFT JOIN sales_seasonality sf ON fd.month = sf.month
GROUP BY cs.cat_id, fd.forecast_date, ct.overall_avg_revenue, ct.total_days, sf.seasonality_factor
HAVING AVG(cs.daily_quantity) > 0
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
forecast_units = VALUES(forecast_units), forecast_units = VALUES(forecast_units),
forecast_revenue = VALUES(forecast_revenue), forecast_revenue = VALUES(forecast_revenue),
@@ -222,6 +190,15 @@ async function calculateSalesForecasts(startTime, totalProducts, processedCount)
last_calculated_at = NOW() last_calculated_at = NOW()
`); `);
// Clean up temporary tables
await connection.query(`
DROP TEMPORARY TABLE IF EXISTS temp_forecast_dates;
DROP TEMPORARY TABLE IF EXISTS temp_daily_sales;
DROP TEMPORARY TABLE IF EXISTS temp_product_stats;
DROP TEMPORARY TABLE IF EXISTS temp_category_sales;
DROP TEMPORARY TABLE IF EXISTS temp_category_stats;
`);
return Math.floor(totalProducts * 1.0); return Math.floor(totalProducts * 1.0);
} finally { } finally {
connection.release(); connection.release();

View File

@@ -1,12 +1,24 @@
const { outputProgress, formatElapsedTime, estimateRemaining, calculateRate } = require('./utils/progress');
const { getConnection } = require('./utils/db'); const { getConnection } = require('./utils/db');
async function calculateTimeAggregates(startTime, totalProducts, processedCount) { async function calculateTimeAggregates(startTime, totalProducts, processedCount) {
const connection = await getConnection(); const connection = await getConnection();
try { try {
outputProgress({
status: 'running',
operation: 'Calculating time aggregates',
current: Math.floor(totalProducts * 0.95),
total: totalProducts,
elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, Math.floor(totalProducts * 0.95), totalProducts),
rate: calculateRate(startTime, Math.floor(totalProducts * 0.95)),
percentage: '95'
});
// Initial insert of time-based aggregates // Initial insert of time-based aggregates
await connection.query(` await connection.query(`
INSERT INTO product_time_aggregates ( INSERT INTO product_time_aggregates (
product_id, pid,
year, year,
month, month,
total_quantity_sold, total_quantity_sold,
@@ -20,7 +32,7 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount)
) )
WITH sales_data AS ( WITH sales_data AS (
SELECT SELECT
o.product_id, o.pid,
YEAR(o.date) as year, YEAR(o.date) as year,
MONTH(o.date) as month, MONTH(o.date) as month,
SUM(o.quantity) as total_quantity_sold, SUM(o.quantity) as total_quantity_sold,
@@ -35,23 +47,23 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount)
SUM((o.price - COALESCE(o.discount, 0)) * o.quantity)) * 100 SUM((o.price - COALESCE(o.discount, 0)) * o.quantity)) * 100
END as profit_margin END as profit_margin
FROM orders o FROM orders o
JOIN products p ON o.product_id = p.product_id JOIN products p ON o.pid = p.pid
WHERE o.canceled = 0 WHERE o.canceled = 0
GROUP BY o.product_id, YEAR(o.date), MONTH(o.date) GROUP BY o.pid, YEAR(o.date), MONTH(o.date)
), ),
purchase_data AS ( purchase_data AS (
SELECT SELECT
product_id, pid,
YEAR(date) as year, YEAR(date) as year,
MONTH(date) as month, MONTH(date) as month,
SUM(received) as stock_received, SUM(received) as stock_received,
SUM(ordered) as stock_ordered SUM(ordered) as stock_ordered
FROM purchase_orders FROM purchase_orders
WHERE status = 'closed' WHERE status = 50
GROUP BY product_id, YEAR(date), MONTH(date) GROUP BY pid, YEAR(date), MONTH(date)
) )
SELECT SELECT
s.product_id, s.pid,
s.year, s.year,
s.month, s.month,
s.total_quantity_sold, s.total_quantity_sold,
@@ -64,12 +76,12 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount)
s.profit_margin s.profit_margin
FROM sales_data s FROM sales_data s
LEFT JOIN purchase_data p LEFT JOIN purchase_data p
ON s.product_id = p.product_id ON s.pid = p.pid
AND s.year = p.year AND s.year = p.year
AND s.month = p.month AND s.month = p.month
UNION UNION
SELECT SELECT
p.product_id, p.pid,
p.year, p.year,
p.month, p.month,
0 as total_quantity_sold, 0 as total_quantity_sold,
@@ -82,10 +94,10 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount)
0 as profit_margin 0 as profit_margin
FROM purchase_data p FROM purchase_data p
LEFT JOIN sales_data s LEFT JOIN sales_data s
ON p.product_id = s.product_id ON p.pid = s.pid
AND p.year = s.year AND p.year = s.year
AND p.month = s.month AND p.month = s.month
WHERE s.product_id IS NULL WHERE s.pid IS NULL
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
total_quantity_sold = VALUES(total_quantity_sold), total_quantity_sold = VALUES(total_quantity_sold),
total_revenue = VALUES(total_revenue), total_revenue = VALUES(total_revenue),
@@ -102,17 +114,17 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount)
UPDATE product_time_aggregates pta UPDATE product_time_aggregates pta
JOIN ( JOIN (
SELECT SELECT
p.product_id, p.pid,
YEAR(o.date) as year, YEAR(o.date) as year,
MONTH(o.date) as month, MONTH(o.date) as month,
p.cost_price * p.stock_quantity as inventory_value, p.cost_price * p.stock_quantity as inventory_value,
SUM(o.quantity * (o.price - p.cost_price)) as gross_profit, SUM(o.quantity * (o.price - p.cost_price)) as gross_profit,
COUNT(DISTINCT DATE(o.date)) as days_in_period COUNT(DISTINCT DATE(o.date)) as days_in_period
FROM products p FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id LEFT JOIN orders o ON p.pid = o.pid
WHERE o.canceled = false WHERE o.canceled = false
GROUP BY p.product_id, YEAR(o.date), MONTH(o.date) GROUP BY p.pid, YEAR(o.date), MONTH(o.date)
) fin ON pta.product_id = fin.product_id ) fin ON pta.pid = fin.pid
AND pta.year = fin.year AND pta.year = fin.year
AND pta.month = fin.month AND pta.month = fin.month
SET SET
@@ -126,8 +138,10 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount)
return Math.floor(totalProducts * 0.65); return Math.floor(totalProducts * 0.65);
} finally { } finally {
if (connection) {
connection.release(); connection.release();
} }
} }
}
module.exports = calculateTimeAggregates; module.exports = calculateTimeAggregates;

View File

@@ -6,7 +6,7 @@ async function calculateVendorMetrics(startTime, totalProducts, processedCount)
try { try {
outputProgress({ outputProgress({
status: 'running', status: 'running',
operation: 'Calculating vendor metrics', operation: 'Ensuring vendors exist in vendor_details',
current: Math.floor(totalProducts * 0.7), current: Math.floor(totalProducts * 0.7),
total: totalProducts, total: totalProducts,
elapsed: formatElapsedTime(startTime), elapsed: formatElapsedTime(startTime),
@@ -15,157 +15,122 @@ async function calculateVendorMetrics(startTime, totalProducts, processedCount)
percentage: '70' percentage: '70'
}); });
// First, ensure all vendors exist in vendor_details // First ensure all vendors exist in vendor_details
await connection.query(` await connection.query(`
INSERT IGNORE INTO vendor_details (vendor, status) INSERT IGNORE INTO vendor_details (vendor, status, created_at, updated_at)
SELECT DISTINCT vendor, 'active' as status SELECT DISTINCT
vendor,
'active' as status,
NOW() as created_at,
NOW() as updated_at
FROM products FROM products
WHERE vendor IS NOT NULL WHERE vendor IS NOT NULL
AND vendor NOT IN (SELECT vendor FROM vendor_details)
`); `);
// Calculate vendor performance metrics outputProgress({
status: 'running',
operation: 'Calculating vendor metrics',
current: Math.floor(totalProducts * 0.8),
total: totalProducts,
elapsed: formatElapsedTime(startTime),
remaining: estimateRemaining(startTime, Math.floor(totalProducts * 0.8), totalProducts),
rate: calculateRate(startTime, Math.floor(totalProducts * 0.8)),
percentage: '80'
});
// Now calculate vendor metrics
await connection.query(` await connection.query(`
INSERT INTO vendor_metrics ( INSERT INTO vendor_metrics (
vendor, vendor,
total_revenue,
total_orders,
total_late_orders,
avg_lead_time_days, avg_lead_time_days,
on_time_delivery_rate, on_time_delivery_rate,
order_fill_rate, order_fill_rate,
total_orders,
total_late_orders,
total_purchase_value,
avg_order_value, avg_order_value,
active_products, active_products,
total_products, total_products,
total_revenue, status,
avg_margin_percent, last_calculated_at
status
) )
WITH vendor_orders AS ( WITH vendor_sales AS (
SELECT SELECT
po.vendor, p.vendor,
AVG(DATEDIFF(po.received_date, po.date)) as avg_lead_time_days, SUM(o.quantity * o.price) as total_revenue,
COUNT(*) as total_orders, COUNT(DISTINCT o.id) as total_orders,
COUNT(CASE WHEN po.received_date > po.expected_date THEN 1 END) as total_late_orders, COUNT(DISTINCT p.pid) as active_products
SUM(po.cost_price * po.ordered) as total_purchase_value, FROM products p
AVG(po.cost_price * po.ordered) as avg_order_value, JOIN orders o ON p.pid = o.pid
CASE WHERE o.canceled = false
WHEN COUNT(*) > 0 THEN AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
(COUNT(CASE WHEN po.received = po.ordered THEN 1 END) * 100.0) / COUNT(*) GROUP BY p.vendor
ELSE 0 ),
END as order_fill_rate vendor_po AS (
FROM purchase_orders po SELECT
WHERE po.status = 'closed' p.vendor,
GROUP BY po.vendor COUNT(DISTINCT CASE WHEN po.receiving_status = 40 THEN po.id END) as received_orders,
COUNT(DISTINCT po.id) as total_orders,
AVG(CASE
WHEN po.receiving_status = 40
THEN DATEDIFF(po.received_date, po.date)
END) as avg_lead_time_days
FROM products p
JOIN purchase_orders po ON p.pid = po.pid
WHERE po.date >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
GROUP BY p.vendor
), ),
vendor_products AS ( vendor_products AS (
SELECT SELECT
p.vendor, vendor,
COUNT(DISTINCT p.product_id) as total_products, COUNT(DISTINCT pid) as total_products
COUNT(DISTINCT CASE WHEN p.visible = true THEN p.product_id END) as active_products, FROM products
SUM(o.price * o.quantity) as total_revenue, GROUP BY vendor
CASE
WHEN SUM(o.price * o.quantity) > 0 THEN
(SUM((o.price - p.cost_price) * o.quantity) * 100.0) / SUM(o.price * o.quantity)
ELSE 0
END as avg_margin_percent
FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id AND o.canceled = false
GROUP BY p.vendor
) )
SELECT SELECT
vd.vendor, vs.vendor,
COALESCE(vo.avg_lead_time_days, 0) as avg_lead_time_days, COALESCE(vs.total_revenue, 0) as total_revenue,
COALESCE(vp.total_orders, 0) as total_orders,
COALESCE(vp.total_orders - vp.received_orders, 0) as total_late_orders,
COALESCE(vp.avg_lead_time_days, 0) as avg_lead_time_days,
CASE CASE
WHEN COALESCE(vo.total_orders, 0) > 0 THEN WHEN vp.total_orders > 0
((COALESCE(vo.total_orders, 0) - COALESCE(vo.total_late_orders, 0)) * 100.0) / COALESCE(vo.total_orders, 1) THEN (vp.received_orders / vp.total_orders) * 100
ELSE 0 ELSE 0
END as on_time_delivery_rate, END as on_time_delivery_rate,
COALESCE(vo.order_fill_rate, 0) as order_fill_rate, CASE
COALESCE(vo.total_orders, 0) as total_orders, WHEN vp.total_orders > 0
COALESCE(vo.total_late_orders, 0) as total_late_orders, THEN (vp.received_orders / vp.total_orders) * 100
COALESCE(vo.total_purchase_value, 0) as total_purchase_value, ELSE 0
COALESCE(vo.avg_order_value, 0) as avg_order_value, END as order_fill_rate,
COALESCE(vp.active_products, 0) as active_products, CASE
COALESCE(vp.total_products, 0) as total_products, WHEN vs.total_orders > 0
COALESCE(vp.total_revenue, 0) as total_revenue, THEN vs.total_revenue / vs.total_orders
COALESCE(vp.avg_margin_percent, 0) as avg_margin_percent, ELSE 0
vd.status END as avg_order_value,
FROM vendor_details vd COALESCE(vs.active_products, 0) as active_products,
LEFT JOIN vendor_orders vo ON vd.vendor = vo.vendor COALESCE(vpr.total_products, 0) as total_products,
LEFT JOIN vendor_products vp ON vd.vendor = vp.vendor 'active' as status,
NOW() as last_calculated_at
FROM vendor_sales vs
LEFT JOIN vendor_po vp ON vs.vendor = vp.vendor
LEFT JOIN vendor_products vpr ON vs.vendor = vpr.vendor
WHERE vs.vendor IS NOT NULL
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
total_revenue = VALUES(total_revenue),
total_orders = VALUES(total_orders),
total_late_orders = VALUES(total_late_orders),
avg_lead_time_days = VALUES(avg_lead_time_days), avg_lead_time_days = VALUES(avg_lead_time_days),
on_time_delivery_rate = VALUES(on_time_delivery_rate), on_time_delivery_rate = VALUES(on_time_delivery_rate),
order_fill_rate = VALUES(order_fill_rate), order_fill_rate = VALUES(order_fill_rate),
total_orders = VALUES(total_orders),
total_late_orders = VALUES(total_late_orders),
total_purchase_value = VALUES(total_purchase_value),
avg_order_value = VALUES(avg_order_value), avg_order_value = VALUES(avg_order_value),
active_products = VALUES(active_products), active_products = VALUES(active_products),
total_products = VALUES(total_products), total_products = VALUES(total_products),
total_revenue = VALUES(total_revenue),
avg_margin_percent = VALUES(avg_margin_percent),
status = VALUES(status), status = VALUES(status),
last_calculated_at = CURRENT_TIMESTAMP last_calculated_at = VALUES(last_calculated_at)
`); `);
// Calculate vendor time-based metrics return Math.floor(totalProducts * 0.9);
await connection.query(`
INSERT INTO vendor_time_metrics (
vendor,
year,
month,
total_orders,
late_orders,
avg_lead_time_days,
total_purchase_value,
total_revenue,
avg_margin_percent
)
WITH vendor_time_data AS (
SELECT
vd.vendor,
YEAR(po.date) as year,
MONTH(po.date) as month,
COUNT(DISTINCT po.po_id) as total_orders,
COUNT(DISTINCT CASE WHEN po.received_date > po.expected_date THEN po.po_id END) as late_orders,
AVG(DATEDIFF(po.received_date, po.date)) as avg_lead_time_days,
SUM(po.cost_price * po.ordered) as total_purchase_value,
SUM(o.price * o.quantity) as total_revenue,
CASE
WHEN SUM(o.price * o.quantity) > 0 THEN
(SUM((o.price - p.cost_price) * o.quantity) * 100.0) / SUM(o.price * o.quantity)
ELSE 0
END as avg_margin_percent
FROM vendor_details vd
LEFT JOIN products p ON vd.vendor = p.vendor
LEFT JOIN purchase_orders po ON p.product_id = po.product_id
LEFT JOIN orders o ON p.product_id = o.product_id AND o.canceled = false
WHERE po.date >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
GROUP BY vd.vendor, YEAR(po.date), MONTH(po.date)
)
SELECT
vendor,
year,
month,
COALESCE(total_orders, 0) as total_orders,
COALESCE(late_orders, 0) as late_orders,
COALESCE(avg_lead_time_days, 0) as avg_lead_time_days,
COALESCE(total_purchase_value, 0) as total_purchase_value,
COALESCE(total_revenue, 0) as total_revenue,
COALESCE(avg_margin_percent, 0) as avg_margin_percent
FROM vendor_time_data
ON DUPLICATE KEY UPDATE
total_orders = VALUES(total_orders),
late_orders = VALUES(late_orders),
avg_lead_time_days = VALUES(avg_lead_time_days),
total_purchase_value = VALUES(total_purchase_value),
total_revenue = VALUES(total_revenue),
avg_margin_percent = VALUES(avg_margin_percent)
`);
return Math.floor(totalProducts * 0.75);
} finally { } finally {
connection.release(); connection.release();
} }

View File

@@ -36,7 +36,7 @@ router.get('/stats', async (req, res) => {
0 0
) as averageOrderValue ) as averageOrderValue
FROM products p FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id LEFT JOIN orders o ON p.pid = o.pid
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
`); `);
@@ -62,22 +62,43 @@ router.get('/profit', async (req, res) => {
try { try {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
// Get profit margins by category // Get profit margins by category with full path
const [byCategory] = await pool.query(` const [byCategory] = await pool.query(`
WITH RECURSIVE category_path AS (
SELECT
c.cat_id,
c.name,
c.parent_id,
CAST(c.name AS CHAR(1000)) as path
FROM categories c
WHERE c.parent_id IS NULL
UNION ALL
SELECT
c.cat_id,
c.name,
c.parent_id,
CONCAT(cp.path, ' > ', c.name)
FROM categories c
JOIN category_path cp ON c.parent_id = cp.cat_id
)
SELECT SELECT
c.name as category, c.name as category,
cp.path as categoryPath,
ROUND( ROUND(
(SUM(o.price * o.quantity - p.cost_price * o.quantity) / (SUM(o.price * o.quantity - p.cost_price * o.quantity) /
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1 NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1
) as profitMargin, ) as profitMargin,
SUM(o.price * o.quantity) as revenue, CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as revenue,
SUM(p.cost_price * o.quantity) as cost CAST(SUM(p.cost_price * o.quantity) AS DECIMAL(15,3)) as cost
FROM products p FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id LEFT JOIN orders o ON p.pid = o.pid
JOIN product_categories pc ON p.product_id = pc.product_id JOIN product_categories pc ON p.pid = pc.pid
JOIN categories c ON pc.category_id = c.id JOIN categories c ON pc.cat_id = c.cat_id
JOIN category_path cp ON c.cat_id = cp.cat_id
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
GROUP BY c.name GROUP BY c.name, cp.path
ORDER BY profitMargin DESC ORDER BY profitMargin DESC
LIMIT 10 LIMIT 10
`); `);
@@ -90,10 +111,10 @@ router.get('/profit', async (req, res) => {
(SUM(o.price * o.quantity - p.cost_price * o.quantity) / (SUM(o.price * o.quantity - p.cost_price * o.quantity) /
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1 NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1
) as profitMargin, ) as profitMargin,
SUM(o.price * o.quantity) as revenue, CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as revenue,
SUM(p.cost_price * o.quantity) as cost CAST(SUM(p.cost_price * o.quantity) AS DECIMAL(15,3)) as cost
FROM products p FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id LEFT JOIN orders o ON p.pid = o.pid
CROSS JOIN ( CROSS JOIN (
SELECT DATE_FORMAT(o.date, '%Y-%m-%d') as formatted_date SELECT DATE_FORMAT(o.date, '%Y-%m-%d') as formatted_date
FROM orders o FROM orders o
@@ -106,20 +127,44 @@ router.get('/profit', async (req, res) => {
ORDER BY formatted_date ORDER BY formatted_date
`); `);
// Get top performing products // Get top performing products with category paths
const [topProducts] = await pool.query(` const [topProducts] = await pool.query(`
WITH RECURSIVE category_path AS (
SELECT
c.cat_id,
c.name,
c.parent_id,
CAST(c.name AS CHAR(1000)) as path
FROM categories c
WHERE c.parent_id IS NULL
UNION ALL
SELECT
c.cat_id,
c.name,
c.parent_id,
CONCAT(cp.path, ' > ', c.name)
FROM categories c
JOIN category_path cp ON c.parent_id = cp.cat_id
)
SELECT SELECT
p.title as product, p.title as product,
c.name as category,
cp.path as categoryPath,
ROUND( ROUND(
(SUM(o.price * o.quantity - p.cost_price * o.quantity) / (SUM(o.price * o.quantity - p.cost_price * o.quantity) /
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1 NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1
) as profitMargin, ) as profitMargin,
SUM(o.price * o.quantity) as revenue, CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as revenue,
SUM(p.cost_price * o.quantity) as cost CAST(SUM(p.cost_price * o.quantity) AS DECIMAL(15,3)) as cost
FROM products p FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id LEFT JOIN orders o ON p.pid = o.pid
JOIN product_categories pc ON p.pid = pc.pid
JOIN categories c ON pc.cat_id = c.cat_id
JOIN category_path cp ON c.cat_id = cp.cat_id
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
GROUP BY p.product_id, p.title GROUP BY p.pid, p.title, c.name, cp.path
HAVING revenue > 0 HAVING revenue > 0
ORDER BY profitMargin DESC ORDER BY profitMargin DESC
LIMIT 10 LIMIT 10
@@ -144,7 +189,7 @@ router.get('/vendors', async (req, res) => {
SELECT COUNT(DISTINCT p.vendor) as vendor_count, SELECT COUNT(DISTINCT p.vendor) as vendor_count,
COUNT(DISTINCT o.order_number) as order_count COUNT(DISTINCT o.order_number) as order_count
FROM products p FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id LEFT JOIN orders o ON p.pid = o.pid
WHERE p.vendor IS NOT NULL WHERE p.vendor IS NOT NULL
`); `);
@@ -155,26 +200,26 @@ router.get('/vendors', async (req, res) => {
WITH monthly_sales AS ( WITH monthly_sales AS (
SELECT SELECT
p.vendor, p.vendor,
SUM(CASE CAST(SUM(CASE
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
THEN o.price * o.quantity THEN o.price * o.quantity
ELSE 0 ELSE 0
END) as current_month, END) AS DECIMAL(15,3)) as current_month,
SUM(CASE CAST(SUM(CASE
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY) WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
AND o.date < DATE_SUB(CURDATE(), INTERVAL 30 DAY) AND o.date < DATE_SUB(CURDATE(), INTERVAL 30 DAY)
THEN o.price * o.quantity THEN o.price * o.quantity
ELSE 0 ELSE 0
END) as previous_month END) AS DECIMAL(15,3)) as previous_month
FROM products p FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id LEFT JOIN orders o ON p.pid = o.pid
WHERE p.vendor IS NOT NULL WHERE p.vendor IS NOT NULL
AND o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY) AND o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
GROUP BY p.vendor GROUP BY p.vendor
) )
SELECT SELECT
p.vendor, p.vendor,
SUM(o.price * o.quantity) as salesVolume, CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as salesVolume,
COALESCE(ROUND( COALESCE(ROUND(
(SUM(o.price * o.quantity - p.cost_price * o.quantity) / (SUM(o.price * o.quantity - p.cost_price * o.quantity) /
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1 NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1
@@ -182,13 +227,13 @@ router.get('/vendors', async (req, res) => {
COALESCE(ROUND( COALESCE(ROUND(
SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0), 1 SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0), 1
), 0) as stockTurnover, ), 0) as stockTurnover,
COUNT(DISTINCT p.product_id) as productCount, COUNT(DISTINCT p.pid) as productCount,
ROUND( ROUND(
((ms.current_month / NULLIF(ms.previous_month, 0)) - 1) * 100, ((ms.current_month / NULLIF(ms.previous_month, 0)) - 1) * 100,
1 1
) as growth ) as growth
FROM products p FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id LEFT JOIN orders o ON p.pid = o.pid
LEFT JOIN monthly_sales ms ON p.vendor = ms.vendor LEFT JOIN monthly_sales ms ON p.vendor = ms.vendor
WHERE p.vendor IS NOT NULL WHERE p.vendor IS NOT NULL
AND o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) AND o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
@@ -203,11 +248,11 @@ router.get('/vendors', async (req, res) => {
const [comparison] = await pool.query(` const [comparison] = await pool.query(`
SELECT SELECT
p.vendor, p.vendor,
COALESCE(ROUND(SUM(o.price * o.quantity) / NULLIF(COUNT(DISTINCT p.product_id), 0), 2), 0) as salesPerProduct, CAST(COALESCE(ROUND(SUM(o.price * o.quantity) / NULLIF(COUNT(DISTINCT p.pid), 0), 2), 0) AS DECIMAL(15,3)) as salesPerProduct,
COALESCE(ROUND(AVG((o.price - p.cost_price) / NULLIF(o.price, 0) * 100), 1), 0) as averageMargin, COALESCE(ROUND(AVG((o.price - p.cost_price) / NULLIF(o.price, 0) * 100), 1), 0) as averageMargin,
COUNT(DISTINCT p.product_id) as size COUNT(DISTINCT p.pid) as size
FROM products p FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id AND o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) LEFT JOIN orders o ON p.pid = o.pid AND o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
WHERE p.vendor IS NOT NULL WHERE p.vendor IS NOT NULL
GROUP BY p.vendor GROUP BY p.vendor
ORDER BY salesPerProduct DESC ORDER BY salesPerProduct DESC
@@ -221,9 +266,9 @@ router.get('/vendors', async (req, res) => {
SELECT SELECT
p.vendor, p.vendor,
DATE_FORMAT(o.date, '%b %Y') as month, DATE_FORMAT(o.date, '%b %Y') as month,
COALESCE(SUM(o.price * o.quantity), 0) as sales CAST(COALESCE(SUM(o.price * o.quantity), 0) AS DECIMAL(15,3)) as sales
FROM products p FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id LEFT JOIN orders o ON p.pid = o.pid
WHERE p.vendor IS NOT NULL WHERE p.vendor IS NOT NULL
AND o.date >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH) AND o.date >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH)
GROUP BY GROUP BY
@@ -272,9 +317,9 @@ router.get('/stock', async (req, res) => {
ROUND(AVG(p.stock_quantity), 0) as averageStock, ROUND(AVG(p.stock_quantity), 0) as averageStock,
SUM(o.quantity) as totalSales SUM(o.quantity) as totalSales
FROM products p FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id LEFT JOIN orders o ON p.pid = o.pid
JOIN product_categories pc ON p.product_id = pc.product_id JOIN product_categories pc ON p.pid = pc.pid
JOIN categories c ON pc.category_id = c.id JOIN categories c ON pc.cat_id = c.cat_id
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
GROUP BY c.name GROUP BY c.name
HAVING turnoverRate > 0 HAVING turnoverRate > 0
@@ -290,7 +335,7 @@ router.get('/stock', async (req, res) => {
SUM(CASE WHEN p.stock_quantity <= ? AND p.stock_quantity > 0 THEN 1 ELSE 0 END) as lowStock, SUM(CASE WHEN p.stock_quantity <= ? AND p.stock_quantity > 0 THEN 1 ELSE 0 END) as lowStock,
SUM(CASE WHEN p.stock_quantity = 0 THEN 1 ELSE 0 END) as outOfStock SUM(CASE WHEN p.stock_quantity = 0 THEN 1 ELSE 0 END) as outOfStock
FROM products p FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id LEFT JOIN orders o ON p.pid = o.pid
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
GROUP BY DATE_FORMAT(o.date, '%Y-%m-%d') GROUP BY DATE_FORMAT(o.date, '%Y-%m-%d')
ORDER BY date ORDER BY date
@@ -304,26 +349,14 @@ router.get('/stock', async (req, res) => {
const [criticalItems] = await pool.query(` const [criticalItems] = await pool.query(`
WITH product_thresholds AS ( WITH product_thresholds AS (
SELECT SELECT
p.product_id, p.pid,
COALESCE( COALESCE(
(SELECT reorder_days (SELECT reorder_days
FROM stock_thresholds st FROM stock_thresholds st
JOIN product_categories pc ON st.category_id = pc.category_id WHERE st.vendor = p.vendor LIMIT 1),
WHERE pc.product_id = p.product_id
AND st.vendor = p.vendor LIMIT 1),
(SELECT reorder_days (SELECT reorder_days
FROM stock_thresholds st FROM stock_thresholds st
JOIN product_categories pc ON st.category_id = pc.category_id WHERE st.vendor IS NULL LIMIT 1),
WHERE pc.product_id = p.product_id
AND st.vendor IS NULL LIMIT 1),
(SELECT reorder_days
FROM stock_thresholds st
WHERE st.category_id IS NULL
AND st.vendor = p.vendor LIMIT 1),
(SELECT reorder_days
FROM stock_thresholds st
WHERE st.category_id IS NULL
AND st.vendor IS NULL LIMIT 1),
14 14
) as reorder_days ) as reorder_days
FROM products p FROM products p
@@ -339,11 +372,11 @@ router.get('/stock', async (req, res) => {
ELSE ROUND(p.stock_quantity / NULLIF((SUM(o.quantity) / ?), 0)) ELSE ROUND(p.stock_quantity / NULLIF((SUM(o.quantity) / ?), 0))
END as daysUntilStockout END as daysUntilStockout
FROM products p FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id LEFT JOIN orders o ON p.pid = o.pid
JOIN product_thresholds pt ON p.product_id = pt.product_id JOIN product_thresholds pt ON p.pid = pt.pid
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
AND p.managing_stock = true AND p.managing_stock = true
GROUP BY p.product_id GROUP BY p.pid
HAVING daysUntilStockout < ? AND daysUntilStockout >= 0 HAVING daysUntilStockout < ? AND daysUntilStockout >= 0
ORDER BY daysUntilStockout ORDER BY daysUntilStockout
LIMIT 10 LIMIT 10
@@ -369,14 +402,16 @@ router.get('/pricing', async (req, res) => {
// Get price points analysis // Get price points analysis
const [pricePoints] = await pool.query(` const [pricePoints] = await pool.query(`
SELECT SELECT
p.price, CAST(p.price AS DECIMAL(15,3)) as price,
SUM(o.quantity) as salesVolume, CAST(SUM(o.quantity) AS DECIMAL(15,3)) as salesVolume,
SUM(o.price * o.quantity) as revenue, CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as revenue,
p.categories as category c.name as category
FROM products p FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id LEFT JOIN orders o ON p.pid = o.pid
JOIN product_categories pc ON p.pid = pc.pid
JOIN categories c ON pc.cat_id = c.cat_id
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
GROUP BY p.price, p.categories GROUP BY p.price, c.name
HAVING salesVolume > 0 HAVING salesVolume > 0
ORDER BY revenue DESC ORDER BY revenue DESC
LIMIT 50 LIMIT 50
@@ -386,8 +421,8 @@ router.get('/pricing', async (req, res) => {
const [elasticity] = await pool.query(` const [elasticity] = await pool.query(`
SELECT SELECT
DATE_FORMAT(o.date, '%Y-%m-%d') as date, DATE_FORMAT(o.date, '%Y-%m-%d') as date,
AVG(o.price) as price, CAST(AVG(o.price) AS DECIMAL(15,3)) as price,
SUM(o.quantity) as demand CAST(SUM(o.quantity) AS DECIMAL(15,3)) as demand
FROM orders o FROM orders o
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
GROUP BY DATE_FORMAT(o.date, '%Y-%m-%d') GROUP BY DATE_FORMAT(o.date, '%Y-%m-%d')
@@ -398,14 +433,17 @@ router.get('/pricing', async (req, res) => {
const [recommendations] = await pool.query(` const [recommendations] = await pool.query(`
SELECT SELECT
p.title as product, p.title as product,
p.price as currentPrice, CAST(p.price AS DECIMAL(15,3)) as currentPrice,
CAST(
ROUND( ROUND(
CASE CASE
WHEN AVG(o.quantity) > 10 THEN p.price * 1.1 WHEN AVG(o.quantity) > 10 THEN p.price * 1.1
WHEN AVG(o.quantity) < 2 THEN p.price * 0.9 WHEN AVG(o.quantity) < 2 THEN p.price * 0.9
ELSE p.price ELSE p.price
END, 2 END, 2
) AS DECIMAL(15,3)
) as recommendedPrice, ) as recommendedPrice,
CAST(
ROUND( ROUND(
SUM(o.price * o.quantity) * SUM(o.price * o.quantity) *
CASE CASE
@@ -413,6 +451,7 @@ router.get('/pricing', async (req, res) => {
WHEN AVG(o.quantity) < 2 THEN 0.95 WHEN AVG(o.quantity) < 2 THEN 0.95
ELSE 1 ELSE 1
END, 2 END, 2
) AS DECIMAL(15,3)
) as potentialRevenue, ) as potentialRevenue,
CASE CASE
WHEN AVG(o.quantity) > 10 THEN 85 WHEN AVG(o.quantity) > 10 THEN 85
@@ -420,11 +459,11 @@ router.get('/pricing', async (req, res) => {
ELSE 65 ELSE 65
END as confidence END as confidence
FROM products p FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id LEFT JOIN orders o ON p.pid = o.pid
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
GROUP BY p.product_id GROUP BY p.pid, p.price
HAVING ABS(recommendedPrice - currentPrice) > 0 HAVING ABS(recommendedPrice - currentPrice) > 0
ORDER BY potentialRevenue - SUM(o.price * o.quantity) DESC ORDER BY potentialRevenue - CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) DESC
LIMIT 10 LIMIT 10
`); `);
@@ -440,11 +479,36 @@ router.get('/categories', async (req, res) => {
try { try {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
// Get category performance metrics // Common CTE for category paths
const categoryPathCTE = `
WITH RECURSIVE category_path AS (
SELECT
c.cat_id,
c.name,
c.parent_id,
CAST(c.name AS CHAR(1000)) as path
FROM categories c
WHERE c.parent_id IS NULL
UNION ALL
SELECT
c.cat_id,
c.name,
c.parent_id,
CONCAT(cp.path, ' > ', c.name)
FROM categories c
JOIN category_path cp ON c.parent_id = cp.cat_id
)
`;
// Get category performance metrics with full path
const [performance] = await pool.query(` const [performance] = await pool.query(`
WITH monthly_sales AS ( ${categoryPathCTE},
monthly_sales AS (
SELECT SELECT
c.name, c.name,
cp.path,
SUM(CASE SUM(CASE
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
THEN o.price * o.quantity THEN o.price * o.quantity
@@ -457,62 +521,72 @@ router.get('/categories', async (req, res) => {
ELSE 0 ELSE 0
END) as previous_month END) as previous_month
FROM products p FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id LEFT JOIN orders o ON p.pid = o.pid
JOIN product_categories pc ON p.product_id = pc.product_id JOIN product_categories pc ON p.pid = pc.pid
JOIN categories c ON pc.category_id = c.id JOIN categories c ON pc.cat_id = c.cat_id
JOIN category_path cp ON c.cat_id = cp.cat_id
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY) WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
GROUP BY c.name GROUP BY c.name, cp.path
) )
SELECT SELECT
c.name as category, c.name as category,
cp.path as categoryPath,
SUM(o.price * o.quantity) as revenue, SUM(o.price * o.quantity) as revenue,
SUM(o.price * o.quantity - p.cost_price * o.quantity) as profit, SUM(o.price * o.quantity - p.cost_price * o.quantity) as profit,
ROUND( ROUND(
((ms.current_month / NULLIF(ms.previous_month, 0)) - 1) * 100, ((ms.current_month / NULLIF(ms.previous_month, 0)) - 1) * 100,
1 1
) as growth, ) as growth,
COUNT(DISTINCT p.product_id) as productCount COUNT(DISTINCT p.pid) as productCount
FROM products p FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id LEFT JOIN orders o ON p.pid = o.pid
JOIN product_categories pc ON p.product_id = pc.product_id JOIN product_categories pc ON p.pid = pc.pid
JOIN categories c ON pc.category_id = c.id JOIN categories c ON pc.cat_id = c.cat_id
LEFT JOIN monthly_sales ms ON c.name = ms.name JOIN category_path cp ON c.cat_id = cp.cat_id
LEFT JOIN monthly_sales ms ON c.name = ms.name AND cp.path = ms.path
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY) WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
GROUP BY c.name, ms.current_month, ms.previous_month GROUP BY c.name, cp.path, ms.current_month, ms.previous_month
HAVING revenue > 0 HAVING revenue > 0
ORDER BY revenue DESC ORDER BY revenue DESC
LIMIT 10 LIMIT 10
`); `);
// Get category revenue distribution // Get category revenue distribution with full path
const [distribution] = await pool.query(` const [distribution] = await pool.query(`
${categoryPathCTE}
SELECT SELECT
c.name as category, c.name as category,
cp.path as categoryPath,
SUM(o.price * o.quantity) as value SUM(o.price * o.quantity) as value
FROM products p FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id LEFT JOIN orders o ON p.pid = o.pid
JOIN product_categories pc ON p.product_id = pc.product_id JOIN product_categories pc ON p.pid = pc.pid
JOIN categories c ON pc.category_id = c.id JOIN categories c ON pc.cat_id = c.cat_id
JOIN category_path cp ON c.cat_id = cp.cat_id
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
GROUP BY c.name GROUP BY c.name, cp.path
HAVING value > 0 HAVING value > 0
ORDER BY value DESC ORDER BY value DESC
LIMIT 6 LIMIT 6
`); `);
// Get category sales trends // Get category sales trends with full path
const [trends] = await pool.query(` const [trends] = await pool.query(`
${categoryPathCTE}
SELECT SELECT
c.name as category, c.name as category,
cp.path as categoryPath,
DATE_FORMAT(o.date, '%b %Y') as month, DATE_FORMAT(o.date, '%b %Y') as month,
SUM(o.price * o.quantity) as sales SUM(o.price * o.quantity) as sales
FROM products p FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id LEFT JOIN orders o ON p.pid = o.pid
JOIN product_categories pc ON p.product_id = pc.product_id JOIN product_categories pc ON p.pid = pc.pid
JOIN categories c ON pc.category_id = c.id JOIN categories c ON pc.cat_id = c.cat_id
JOIN category_path cp ON c.cat_id = cp.cat_id
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH) WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH)
GROUP BY GROUP BY
c.name, c.name,
cp.path,
DATE_FORMAT(o.date, '%b %Y'), DATE_FORMAT(o.date, '%b %Y'),
DATE_FORMAT(o.date, '%Y-%m') DATE_FORMAT(o.date, '%Y-%m')
ORDER BY ORDER BY
@@ -534,65 +608,86 @@ router.get('/forecast', async (req, res) => {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
const [results] = await pool.query(` const [results] = await pool.query(`
WITH category_metrics AS ( WITH RECURSIVE category_path AS (
SELECT SELECT
c.id as category_id, c.cat_id,
c.name as category_name, c.name,
p.brand, c.parent_id,
COUNT(DISTINCT p.product_id) as num_products, CAST(c.name AS CHAR(1000)) as path
COALESCE(ROUND(SUM(o.quantity) / DATEDIFF(?, ?), 2), 0) as avg_daily_sales,
COALESCE(SUM(o.quantity), 0) as total_sold,
COALESCE(ROUND(SUM(o.quantity) / COUNT(DISTINCT p.product_id), 2), 0) as avgTotalSold,
COALESCE(ROUND(AVG(o.price), 2), 0) as avg_price
FROM categories c FROM categories c
JOIN product_categories pc ON c.id = pc.category_id WHERE c.parent_id IS NULL
JOIN products p ON pc.product_id = p.product_id
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id UNION ALL
LEFT JOIN orders o ON p.product_id = o.product_id
AND o.date BETWEEN ? AND ?
AND o.canceled = false
WHERE p.brand = ?
AND pm.first_received_date BETWEEN ? AND ?
GROUP BY c.id, c.name, p.brand
),
product_metrics AS (
SELECT SELECT
p.product_id, c.cat_id,
p.title, c.name,
p.sku, c.parent_id,
p.stock_quantity, CONCAT(cp.path, ' > ', c.name)
pc.category_id, FROM categories c
pm.first_received_date, JOIN category_path cp ON c.parent_id = cp.cat_id
),
category_metrics AS (
SELECT
c.cat_id,
c.name as category_name,
cp.path,
p.brand,
COUNT(DISTINCT p.pid) as num_products,
CAST(COALESCE(ROUND(SUM(o.quantity) / DATEDIFF(?, ?), 2), 0) AS DECIMAL(15,3)) as avg_daily_sales,
COALESCE(SUM(o.quantity), 0) as total_sold, COALESCE(SUM(o.quantity), 0) as total_sold,
COALESCE(ROUND(AVG(o.price), 2), 0) as avg_price CAST(COALESCE(ROUND(SUM(o.quantity) / COUNT(DISTINCT p.pid), 2), 0) AS DECIMAL(15,3)) as avgTotalSold,
FROM products p CAST(COALESCE(ROUND(AVG(o.price), 2), 0) AS DECIMAL(15,3)) as avg_price
JOIN product_categories pc ON p.product_id = pc.product_id FROM categories c
JOIN product_metrics pm ON p.product_id = pm.product_id JOIN product_categories pc ON c.cat_id = pc.cat_id
LEFT JOIN orders o ON p.product_id = o.product_id JOIN products p ON pc.pid = p.pid
JOIN category_path cp ON c.cat_id = cp.cat_id
LEFT JOIN product_metrics pmet ON p.pid = pmet.pid
LEFT JOIN orders o ON p.pid = o.pid
AND o.date BETWEEN ? AND ? AND o.date BETWEEN ? AND ?
AND o.canceled = false AND o.canceled = false
WHERE p.brand = ? WHERE p.brand = ?
AND pm.first_received_date BETWEEN ? AND ? AND pmet.first_received_date BETWEEN ? AND ?
GROUP BY p.product_id, p.title, p.sku, p.stock_quantity, pc.category_id, pm.first_received_date GROUP BY c.cat_id, c.name, cp.path, p.brand
),
product_details AS (
SELECT
p.pid,
p.title,
p.SKU,
p.stock_quantity,
pc.cat_id,
pmet.first_received_date,
COALESCE(SUM(o.quantity), 0) as total_sold,
CAST(COALESCE(ROUND(AVG(o.price), 2), 0) AS DECIMAL(15,3)) as avg_price
FROM products p
JOIN product_categories pc ON p.pid = pc.pid
JOIN product_metrics pmet ON p.pid = pmet.pid
LEFT JOIN orders o ON p.pid = o.pid
AND o.date BETWEEN ? AND ?
AND o.canceled = false
WHERE p.brand = ?
AND pmet.first_received_date BETWEEN ? AND ?
GROUP BY p.pid, p.title, p.SKU, p.stock_quantity, pc.cat_id, pmet.first_received_date
) )
SELECT SELECT
cm.*, cm.*,
JSON_ARRAYAGG( JSON_ARRAYAGG(
JSON_OBJECT( JSON_OBJECT(
'product_id', pm.product_id, 'pid', pd.pid,
'title', pm.title, 'title', pd.title,
'sku', pm.sku, 'SKU', pd.SKU,
'stock_quantity', pm.stock_quantity, 'stock_quantity', pd.stock_quantity,
'total_sold', pm.total_sold, 'total_sold', pd.total_sold,
'avg_price', pm.avg_price, 'avg_price', pd.avg_price,
'first_received_date', DATE_FORMAT(pm.first_received_date, '%Y-%m-%d') 'first_received_date', DATE_FORMAT(pd.first_received_date, '%Y-%m-%d')
) )
) as products ) as products
FROM category_metrics cm FROM category_metrics cm
JOIN product_metrics pm ON cm.category_id = pm.category_id JOIN product_details pd ON cm.cat_id = pd.cat_id
GROUP BY cm.category_id, cm.category_name, cm.brand, cm.num_products, cm.avg_daily_sales, cm.total_sold, cm.avgTotalSold, cm.avg_price GROUP BY cm.cat_id, cm.category_name, cm.path, cm.brand, cm.num_products, cm.avg_daily_sales, cm.total_sold, cm.avgTotalSold, cm.avg_price
ORDER BY cm.total_sold DESC ORDER BY cm.total_sold DESC
`, [startDate, endDate, startDate, endDate, brand, startDate, endDate, startDate, endDate, brand, startDate, endDate]); `, [endDate, startDate, startDate, endDate, brand, startDate, endDate, startDate, endDate, brand, startDate, endDate]);
res.json(results); res.json(results);
} catch (error) { } catch (error) {

View File

@@ -5,62 +5,90 @@ const router = express.Router();
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
try { try {
// Get parent categories for filter dropdown // Get all categories with metrics and hierarchy info
const [parentCategories] = await pool.query(`
SELECT DISTINCT c2.name as parent_name
FROM categories c1
JOIN categories c2 ON c1.parent_id = c2.id
WHERE c1.parent_id IS NOT NULL
ORDER BY c2.name
`);
// Get all categories with metrics
const [categories] = await pool.query(` const [categories] = await pool.query(`
SELECT SELECT
c.id as category_id, c.cat_id,
c.name, c.name,
c.type,
c.parent_id,
c.description, c.description,
COALESCE(p.name, '') as parent_name, c.status,
cm.product_count, p.name as parent_name,
cm.total_value, p.type as parent_type,
cm.avg_margin, COALESCE(cm.product_count, 0) as product_count,
cm.turnover_rate, COALESCE(cm.active_products, 0) as active_products,
cm.growth_rate, CAST(COALESCE(cm.total_value, 0) AS DECIMAL(15,3)) as total_value,
cm.status COALESCE(cm.avg_margin, 0) as avg_margin,
COALESCE(cm.turnover_rate, 0) as turnover_rate,
COALESCE(cm.growth_rate, 0) as growth_rate
FROM categories c FROM categories c
LEFT JOIN categories p ON c.parent_id = p.id LEFT JOIN categories p ON c.parent_id = p.cat_id
LEFT JOIN category_metrics cm ON c.id = cm.category_id LEFT JOIN category_metrics cm ON c.cat_id = cm.category_id
ORDER BY c.name ASC ORDER BY
CASE
WHEN c.type = 10 THEN 1 -- sections first
WHEN c.type = 11 THEN 2 -- categories second
WHEN c.type = 12 THEN 3 -- subcategories third
WHEN c.type = 13 THEN 4 -- subsubcategories fourth
WHEN c.type = 20 THEN 5 -- themes fifth
WHEN c.type = 21 THEN 6 -- subthemes last
ELSE 7
END,
c.name ASC
`); `);
// Get overall stats // Get overall stats
const [stats] = await pool.query(` const [stats] = await pool.query(`
SELECT SELECT
COUNT(DISTINCT c.id) as totalCategories, COUNT(DISTINCT c.cat_id) as totalCategories,
COUNT(DISTINCT CASE WHEN cm.status = 'active' THEN c.id END) as activeCategories, COUNT(DISTINCT CASE WHEN c.status = 'active' THEN c.cat_id END) as activeCategories,
COALESCE(SUM(cm.total_value), 0) as totalValue, CAST(COALESCE(SUM(cm.total_value), 0) AS DECIMAL(15,3)) as totalValue,
COALESCE(ROUND(AVG(NULLIF(cm.avg_margin, 0)), 1), 0) as avgMargin, COALESCE(ROUND(AVG(NULLIF(cm.avg_margin, 0)), 1), 0) as avgMargin,
COALESCE(ROUND(AVG(NULLIF(cm.growth_rate, 0)), 1), 0) as avgGrowth COALESCE(ROUND(AVG(NULLIF(cm.growth_rate, 0)), 1), 0) as avgGrowth
FROM categories c FROM categories c
LEFT JOIN category_metrics cm ON c.id = cm.category_id LEFT JOIN category_metrics cm ON c.cat_id = cm.category_id
`);
// Get type counts for filtering
const [typeCounts] = await pool.query(`
SELECT
type,
COUNT(*) as count
FROM categories
GROUP BY type
ORDER BY type
`); `);
res.json({ res.json({
categories: categories.map(cat => ({ categories: categories.map(cat => ({
...cat, cat_id: cat.cat_id,
parent_category: cat.parent_name, // Map parent_name to parent_category for frontend compatibility name: cat.name,
product_count: parseInt(cat.product_count || 0), type: cat.type,
total_value: parseFloat(cat.total_value || 0), parent_id: cat.parent_id,
avg_margin: parseFloat(cat.avg_margin || 0), parent_name: cat.parent_name,
turnover_rate: parseFloat(cat.turnover_rate || 0), parent_type: cat.parent_type,
growth_rate: parseFloat(cat.growth_rate || 0) description: cat.description,
status: cat.status,
metrics: {
product_count: parseInt(cat.product_count),
active_products: parseInt(cat.active_products),
total_value: parseFloat(cat.total_value),
avg_margin: parseFloat(cat.avg_margin),
turnover_rate: parseFloat(cat.turnover_rate),
growth_rate: parseFloat(cat.growth_rate)
}
})),
typeCounts: typeCounts.map(tc => ({
type: tc.type,
count: parseInt(tc.count)
})), })),
parentCategories: parentCategories.map(p => p.parent_name),
stats: { stats: {
...stats[0], totalCategories: parseInt(stats[0].totalCategories),
totalValue: parseFloat(stats[0].totalValue || 0), activeCategories: parseInt(stats[0].activeCategories),
avgMargin: parseFloat(stats[0].avgMargin || 0), totalValue: parseFloat(stats[0].totalValue),
avgGrowth: parseFloat(stats[0].avgGrowth || 0) avgMargin: parseFloat(stats[0].avgMargin),
avgGrowth: parseFloat(stats[0].avgGrowth)
} }
}); });
} catch (error) { } catch (error) {

View File

@@ -2,6 +2,9 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const db = require('../utils/db'); const db = require('../utils/db');
// Import status codes
const { ReceivingStatus } = require('../types/status-codes');
// Helper function to execute queries using the connection pool // Helper function to execute queries using the connection pool
async function executeQuery(sql, params = []) { async function executeQuery(sql, params = []) {
const pool = db.getPool(); const pool = db.getPool();
@@ -38,15 +41,14 @@ router.get('/stock/metrics', async (req, res) => {
const [brandValues] = await executeQuery(` const [brandValues] = await executeQuery(`
WITH brand_totals AS ( WITH brand_totals AS (
SELECT SELECT
brand, COALESCE(brand, 'Unbranded') as brand,
COUNT(DISTINCT product_id) as variant_count, COUNT(DISTINCT pid) as variant_count,
COALESCE(SUM(stock_quantity), 0) as stock_units, COALESCE(SUM(stock_quantity), 0) as stock_units,
COALESCE(SUM(stock_quantity * cost_price), 0) as stock_cost, CAST(COALESCE(SUM(stock_quantity * cost_price), 0) AS DECIMAL(15,3)) as stock_cost,
COALESCE(SUM(stock_quantity * price), 0) as stock_retail CAST(COALESCE(SUM(stock_quantity * price), 0) AS DECIMAL(15,3)) as stock_retail
FROM products FROM products
WHERE brand IS NOT NULL WHERE stock_quantity > 0
AND stock_quantity > 0 GROUP BY COALESCE(brand, 'Unbranded')
GROUP BY brand
HAVING stock_cost > 0 HAVING stock_cost > 0
), ),
other_brands AS ( other_brands AS (
@@ -54,8 +56,8 @@ router.get('/stock/metrics', async (req, res) => {
'Other' as brand, 'Other' as brand,
SUM(variant_count) as variant_count, SUM(variant_count) as variant_count,
SUM(stock_units) as stock_units, SUM(stock_units) as stock_units,
SUM(stock_cost) as stock_cost, CAST(SUM(stock_cost) AS DECIMAL(15,3)) as stock_cost,
SUM(stock_retail) as stock_retail CAST(SUM(stock_retail) AS DECIMAL(15,3)) as stock_retail
FROM brand_totals FROM brand_totals
WHERE stock_cost <= 5000 WHERE stock_cost <= 5000
), ),
@@ -101,49 +103,51 @@ router.get('/purchase/metrics', async (req, res) => {
try { try {
const [rows] = await executeQuery(` const [rows] = await executeQuery(`
SELECT SELECT
COALESCE(COUNT(DISTINCT CASE WHEN po.status = 'open' THEN po.po_id END), 0) as active_pos,
COALESCE(COUNT(DISTINCT CASE COALESCE(COUNT(DISTINCT CASE
WHEN po.status = 'open' AND po.expected_date < CURDATE() WHEN po.receiving_status < ${ReceivingStatus.PartialReceived}
THEN po.po_id
END), 0) as active_pos,
COALESCE(COUNT(DISTINCT CASE
WHEN po.receiving_status < ${ReceivingStatus.PartialReceived}
AND po.expected_date < CURDATE()
THEN po.po_id THEN po.po_id
END), 0) as overdue_pos, END), 0) as overdue_pos,
COALESCE(SUM(CASE WHEN po.status = 'open' THEN po.ordered ELSE 0 END), 0) as total_units,
COALESCE(SUM(CASE COALESCE(SUM(CASE
WHEN po.status = 'open' WHEN po.receiving_status < ${ReceivingStatus.PartialReceived}
THEN po.ordered
ELSE 0
END), 0) as total_units,
CAST(COALESCE(SUM(CASE
WHEN po.receiving_status < ${ReceivingStatus.PartialReceived}
THEN po.ordered * po.cost_price THEN po.ordered * po.cost_price
ELSE 0 ELSE 0
END), 0) as total_cost, END), 0) AS DECIMAL(15,3)) as total_cost,
COALESCE(SUM(CASE CAST(COALESCE(SUM(CASE
WHEN po.status = 'open' WHEN po.receiving_status < ${ReceivingStatus.PartialReceived}
THEN po.ordered * p.price THEN po.ordered * p.price
ELSE 0 ELSE 0
END), 0) as total_retail END), 0) AS DECIMAL(15,3)) as total_retail
FROM purchase_orders po FROM purchase_orders po
JOIN products p ON po.product_id = p.product_id JOIN products p ON po.pid = p.pid
`); `);
const poMetrics = rows[0]; const poMetrics = rows[0];
console.log('Raw poMetrics from database:', poMetrics);
console.log('poMetrics.active_pos:', poMetrics.active_pos);
console.log('poMetrics.overdue_pos:', poMetrics.overdue_pos);
console.log('poMetrics.total_units:', poMetrics.total_units);
console.log('poMetrics.total_cost:', poMetrics.total_cost);
console.log('poMetrics.total_retail:', poMetrics.total_retail);
const [vendorOrders] = await executeQuery(` const [vendorOrders] = await executeQuery(`
SELECT SELECT
po.vendor, po.vendor,
COUNT(DISTINCT po.po_id) as order_count, COUNT(DISTINCT po.po_id) as orders,
COALESCE(SUM(po.ordered), 0) as ordered_units, COALESCE(SUM(po.ordered), 0) as units,
COALESCE(SUM(po.ordered * po.cost_price), 0) as order_cost, CAST(COALESCE(SUM(po.ordered * po.cost_price), 0) AS DECIMAL(15,3)) as cost,
COALESCE(SUM(po.ordered * p.price), 0) as order_retail CAST(COALESCE(SUM(po.ordered * p.price), 0) AS DECIMAL(15,3)) as retail
FROM purchase_orders po FROM purchase_orders po
JOIN products p ON po.product_id = p.product_id JOIN products p ON po.pid = p.pid
WHERE po.status = 'open' WHERE po.receiving_status < ${ReceivingStatus.PartialReceived}
GROUP BY po.vendor GROUP BY po.vendor
HAVING order_cost > 0 HAVING cost > 0
ORDER BY order_cost DESC ORDER BY cost DESC
`); `);
// Format response to match PurchaseMetricsData interface
const response = { const response = {
activePurchaseOrders: parseInt(poMetrics.active_pos) || 0, activePurchaseOrders: parseInt(poMetrics.active_pos) || 0,
overduePurchaseOrders: parseInt(poMetrics.overdue_pos) || 0, overduePurchaseOrders: parseInt(poMetrics.overdue_pos) || 0,
@@ -152,10 +156,10 @@ router.get('/purchase/metrics', async (req, res) => {
onOrderRetail: parseFloat(poMetrics.total_retail) || 0, onOrderRetail: parseFloat(poMetrics.total_retail) || 0,
vendorOrders: vendorOrders.map(v => ({ vendorOrders: vendorOrders.map(v => ({
vendor: v.vendor, vendor: v.vendor,
orders: parseInt(v.order_count) || 0, orders: parseInt(v.orders) || 0,
units: parseInt(v.ordered_units) || 0, units: parseInt(v.units) || 0,
cost: parseFloat(v.order_cost) || 0, cost: parseFloat(v.cost) || 0,
retail: parseFloat(v.order_retail) || 0 retail: parseFloat(v.retail) || 0
})) }))
}; };
@@ -173,21 +177,21 @@ router.get('/replenishment/metrics', async (req, res) => {
// Get summary metrics // Get summary metrics
const [metrics] = await executeQuery(` const [metrics] = await executeQuery(`
SELECT SELECT
COUNT(DISTINCT p.product_id) as products_to_replenish, COUNT(DISTINCT p.pid) as products_to_replenish,
COALESCE(SUM(CASE COALESCE(SUM(CASE
WHEN p.stock_quantity < 0 THEN ABS(p.stock_quantity) + pm.reorder_qty WHEN p.stock_quantity < 0 THEN ABS(p.stock_quantity) + pm.reorder_qty
ELSE pm.reorder_qty ELSE pm.reorder_qty
END), 0) as total_units_needed, END), 0) as total_units_needed,
COALESCE(SUM(CASE CAST(COALESCE(SUM(CASE
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.cost_price WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.cost_price
ELSE pm.reorder_qty * p.cost_price ELSE pm.reorder_qty * p.cost_price
END), 0) as total_cost, END), 0) AS DECIMAL(15,3)) as total_cost,
COALESCE(SUM(CASE CAST(COALESCE(SUM(CASE
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.price WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.price
ELSE pm.reorder_qty * p.price ELSE pm.reorder_qty * p.price
END), 0) as total_retail END), 0) AS DECIMAL(15,3)) as total_retail
FROM products p FROM products p
JOIN product_metrics pm ON p.product_id = pm.product_id JOIN product_metrics pm ON p.pid = pm.pid
WHERE p.replenishable = true WHERE p.replenishable = true
AND (pm.stock_status IN ('Critical', 'Reorder') AND (pm.stock_status IN ('Critical', 'Reorder')
OR p.stock_quantity < 0) OR p.stock_quantity < 0)
@@ -197,24 +201,24 @@ router.get('/replenishment/metrics', async (req, res) => {
// Get top variants to replenish // Get top variants to replenish
const [variants] = await executeQuery(` const [variants] = await executeQuery(`
SELECT SELECT
p.product_id, p.pid,
p.title, p.title,
p.stock_quantity as current_stock, p.stock_quantity as current_stock,
CASE CASE
WHEN p.stock_quantity < 0 THEN ABS(p.stock_quantity) + pm.reorder_qty WHEN p.stock_quantity < 0 THEN ABS(p.stock_quantity) + pm.reorder_qty
ELSE pm.reorder_qty ELSE pm.reorder_qty
END as replenish_qty, END as replenish_qty,
CASE CAST(CASE
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.cost_price WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.cost_price
ELSE pm.reorder_qty * p.cost_price ELSE pm.reorder_qty * p.cost_price
END as replenish_cost, END AS DECIMAL(15,3)) as replenish_cost,
CASE CAST(CASE
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.price WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.price
ELSE pm.reorder_qty * p.price ELSE pm.reorder_qty * p.price
END as replenish_retail, END AS DECIMAL(15,3)) as replenish_retail,
pm.stock_status pm.stock_status
FROM products p FROM products p
JOIN product_metrics pm ON p.product_id = pm.product_id JOIN product_metrics pm ON p.pid = pm.pid
WHERE p.replenishable = true WHERE p.replenishable = true
AND (pm.stock_status IN ('Critical', 'Reorder') AND (pm.stock_status IN ('Critical', 'Reorder')
OR p.stock_quantity < 0) OR p.stock_quantity < 0)
@@ -235,7 +239,7 @@ router.get('/replenishment/metrics', async (req, res) => {
replenishmentCost: parseFloat(metrics[0].total_cost) || 0, replenishmentCost: parseFloat(metrics[0].total_cost) || 0,
replenishmentRetail: parseFloat(metrics[0].total_retail) || 0, replenishmentRetail: parseFloat(metrics[0].total_retail) || 0,
topVariants: variants.map(v => ({ topVariants: variants.map(v => ({
id: v.product_id, id: v.pid,
title: v.title, title: v.title,
currentStock: parseInt(v.current_stock) || 0, currentStock: parseInt(v.current_stock) || 0,
replenishQty: parseInt(v.replenish_qty) || 0, replenishQty: parseInt(v.replenish_qty) || 0,
@@ -287,9 +291,9 @@ router.get('/forecast/metrics', async (req, res) => {
COALESCE(SUM(cf.forecast_revenue), 0) as revenue, COALESCE(SUM(cf.forecast_revenue), 0) as revenue,
COALESCE(AVG(cf.confidence_level), 0) as confidence COALESCE(AVG(cf.confidence_level), 0) as confidence
FROM category_forecasts cf FROM category_forecasts cf
JOIN categories c ON cf.category_id = c.id JOIN categories c ON cf.category_id = c.cat_id
WHERE cf.forecast_date BETWEEN ? AND ? WHERE cf.forecast_date BETWEEN ? AND ?
GROUP BY c.id, c.name GROUP BY c.cat_id, c.name
ORDER BY revenue DESC ORDER BY revenue DESC
`, [startDate, endDate]); `, [startDate, endDate]);
@@ -325,11 +329,11 @@ router.get('/overstock/metrics', async (req, res) => {
const [rows] = await executeQuery(` const [rows] = await executeQuery(`
WITH category_overstock AS ( WITH category_overstock AS (
SELECT SELECT
c.id as category_id, c.cat_id,
c.name as category_name, c.name as category_name,
COUNT(DISTINCT CASE COUNT(DISTINCT CASE
WHEN pm.stock_status = 'Overstocked' WHEN pm.stock_status = 'Overstocked'
THEN p.product_id THEN p.pid
END) as overstocked_products, END) as overstocked_products,
SUM(CASE SUM(CASE
WHEN pm.stock_status = 'Overstocked' WHEN pm.stock_status = 'Overstocked'
@@ -347,10 +351,10 @@ router.get('/overstock/metrics', async (req, res) => {
ELSE 0 ELSE 0
END) as total_excess_retail END) as total_excess_retail
FROM categories c FROM categories c
JOIN product_categories pc ON c.id = pc.category_id JOIN product_categories pc ON c.cat_id = pc.cat_id
JOIN products p ON pc.product_id = p.product_id JOIN products p ON pc.pid = p.pid
JOIN product_metrics pm ON p.product_id = pm.product_id JOIN product_metrics pm ON p.pid = pm.pid
GROUP BY c.id, c.name GROUP BY c.cat_id, c.name
) )
SELECT SELECT
SUM(overstocked_products) as total_overstocked, SUM(overstocked_products) as total_overstocked,
@@ -405,7 +409,7 @@ router.get('/overstock/products', async (req, res) => {
try { try {
const [rows] = await executeQuery(` const [rows] = await executeQuery(`
SELECT SELECT
p.product_id, p.pid,
p.SKU, p.SKU,
p.title, p.title,
p.brand, p.brand,
@@ -420,11 +424,11 @@ router.get('/overstock/products', async (req, res) => {
(pm.overstocked_amt * p.price) as excess_retail, (pm.overstocked_amt * p.price) as excess_retail,
GROUP_CONCAT(c.name) as categories GROUP_CONCAT(c.name) as categories
FROM products p FROM products p
JOIN product_metrics pm ON p.product_id = pm.product_id JOIN product_metrics pm ON p.pid = pm.pid
LEFT JOIN product_categories pc ON p.product_id = pc.product_id LEFT JOIN product_categories pc ON p.pid = pc.pid
LEFT JOIN categories c ON pc.category_id = c.id LEFT JOIN categories c ON pc.cat_id = c.cat_id
WHERE pm.stock_status = 'Overstocked' WHERE pm.stock_status = 'Overstocked'
GROUP BY p.product_id GROUP BY p.pid
ORDER BY excess_cost DESC ORDER BY excess_cost DESC
LIMIT ? LIMIT ?
`, [limit]); `, [limit]);
@@ -439,196 +443,116 @@ router.get('/overstock/products', async (req, res) => {
// Returns best-selling products, vendors, and categories // Returns best-selling products, vendors, and categories
router.get('/best-sellers', async (req, res) => { router.get('/best-sellers', async (req, res) => {
try { try {
const [products] = await executeQuery(` const pool = req.app.locals.pool;
WITH product_sales AS (
// Common CTE for category paths
const categoryPathCTE = `
WITH RECURSIVE category_path AS (
SELECT SELECT
p.product_id, c.cat_id,
c.name,
c.parent_id,
CAST(c.name AS CHAR(1000)) as path
FROM categories c
WHERE c.parent_id IS NULL
UNION ALL
SELECT
c.cat_id,
c.name,
c.parent_id,
CONCAT(cp.path, ' > ', c.name)
FROM categories c
JOIN category_path cp ON c.parent_id = cp.cat_id
)
`;
// Get best selling products
const [products] = await pool.query(`
SELECT
p.pid,
p.SKU as sku, p.SKU as sku,
p.title, p.title,
-- Current period (last 30 days) SUM(o.quantity) as units_sold,
SUM(CASE CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as revenue,
WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY) CAST(SUM(o.price * o.quantity - p.cost_price * o.quantity) AS DECIMAL(15,3)) as profit
THEN o.quantity
ELSE 0
END) as units_sold,
SUM(CASE
WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY)
THEN o.price * o.quantity
ELSE 0
END) as revenue,
SUM(CASE
WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY)
THEN (o.price - p.cost_price) * o.quantity
ELSE 0
END) as profit,
-- Previous period (30-60 days ago)
SUM(CASE
WHEN o.date BETWEEN DATE_SUB(CURRENT_DATE, INTERVAL 60 DAY) AND DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY)
THEN o.price * o.quantity
ELSE 0
END) as previous_revenue
FROM products p FROM products p
JOIN orders o ON p.product_id = o.product_id JOIN orders o ON p.pid = o.pid
WHERE o.canceled = false WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 60 DAY) AND o.canceled = false
GROUP BY p.product_id, p.SKU, p.title GROUP BY p.pid
) ORDER BY units_sold DESC
SELECT LIMIT 10
product_id,
sku,
title,
units_sold,
revenue,
profit,
CASE
WHEN previous_revenue > 0
THEN ((revenue - previous_revenue) / previous_revenue * 100)
WHEN revenue > 0
THEN 100
ELSE 0
END as growth_rate
FROM product_sales
WHERE units_sold > 0
ORDER BY revenue DESC
LIMIT 50
`); `);
const [brands] = await executeQuery(` // Get best selling brands
WITH brand_sales AS ( const [brands] = await pool.query(`
SELECT SELECT
p.brand, p.brand,
-- Current period (last 30 days) SUM(o.quantity) as units_sold,
SUM(CASE CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as revenue,
WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY) CAST(SUM(o.price * o.quantity - p.cost_price * o.quantity) AS DECIMAL(15,3)) as profit,
THEN o.quantity ROUND(
ELSE 0 ((SUM(CASE
END) as units_sold, WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
SUM(CASE
WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY)
THEN o.price * o.quantity THEN o.price * o.quantity
ELSE 0 ELSE 0
END) as revenue, END) /
SUM(CASE NULLIF(SUM(CASE
WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY) WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
THEN (o.price - p.cost_price) * o.quantity AND o.date < DATE_SUB(CURDATE(), INTERVAL 30 DAY)
ELSE 0
END) as profit,
-- Previous period (30-60 days ago)
SUM(CASE
WHEN o.date BETWEEN DATE_SUB(CURRENT_DATE, INTERVAL 60 DAY) AND DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY)
THEN o.price * o.quantity THEN o.price * o.quantity
ELSE 0 ELSE 0
END) as previous_revenue END), 0)) - 1) * 100,
1
) as growth_rate
FROM products p FROM products p
JOIN orders o ON p.product_id = o.product_id JOIN orders o ON p.pid = o.pid
WHERE o.canceled = false WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 60 DAY) AND o.canceled = false
AND p.brand IS NOT NULL
GROUP BY p.brand GROUP BY p.brand
) ORDER BY units_sold DESC
SELECT LIMIT 10
brand,
units_sold,
revenue,
profit,
CASE
WHEN previous_revenue > 0
THEN ((revenue - previous_revenue) / previous_revenue * 100)
WHEN revenue > 0
THEN 100
ELSE 0
END as growth_rate
FROM brand_sales
WHERE units_sold > 0
ORDER BY revenue DESC
LIMIT 50
`); `);
const [categories] = await executeQuery(` // Get best selling categories with full path
WITH category_sales AS ( const [categories] = await pool.query(`
${categoryPathCTE}
SELECT SELECT
c.id as category_id, c.cat_id,
c.name, c.name,
-- Current period (last 30 days) cp.path as categoryPath,
SUM(CASE SUM(o.quantity) as units_sold,
WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY) CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as revenue,
THEN o.quantity CAST(SUM(o.price * o.quantity - p.cost_price * o.quantity) AS DECIMAL(15,3)) as profit,
ELSE 0 ROUND(
END) as units_sold, ((SUM(CASE
SUM(CASE WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY)
THEN o.price * o.quantity THEN o.price * o.quantity
ELSE 0 ELSE 0
END) as revenue, END) /
SUM(CASE NULLIF(SUM(CASE
WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY) WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
THEN (o.price - p.cost_price) * o.quantity AND o.date < DATE_SUB(CURDATE(), INTERVAL 30 DAY)
ELSE 0
END) as profit,
-- Previous period (30-60 days ago)
SUM(CASE
WHEN o.date BETWEEN DATE_SUB(CURRENT_DATE, INTERVAL 60 DAY) AND DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY)
THEN o.price * o.quantity THEN o.price * o.quantity
ELSE 0 ELSE 0
END) as previous_revenue END), 0)) - 1) * 100,
FROM categories c 1
JOIN product_categories pc ON c.id = pc.category_id ) as growth_rate
JOIN products p ON pc.product_id = p.product_id FROM products p
JOIN orders o ON p.product_id = o.product_id JOIN orders o ON p.pid = o.pid
WHERE o.canceled = false JOIN product_categories pc ON p.pid = pc.pid
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 60 DAY) JOIN categories c ON pc.cat_id = c.cat_id
GROUP BY c.id, c.name JOIN category_path cp ON c.cat_id = cp.cat_id
) WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
SELECT AND o.canceled = false
category_id, GROUP BY c.cat_id, c.name, cp.path
name, ORDER BY units_sold DESC
units_sold, LIMIT 10
revenue,
profit,
CASE
WHEN previous_revenue > 0
THEN ((revenue - previous_revenue) / previous_revenue * 100)
WHEN revenue > 0
THEN 100
ELSE 0
END as growth_rate
FROM category_sales
WHERE units_sold > 0
ORDER BY revenue DESC
LIMIT 50
`); `);
// Format response with explicit type conversion res.json({ products, brands, categories });
const formattedProducts = products.map(p => ({
...p,
units_sold: parseInt(p.units_sold) || 0,
revenue: parseFloat(p.revenue) || 0,
profit: parseFloat(p.profit) || 0,
growth_rate: parseFloat(p.growth_rate) || 0
}));
const formattedBrands = brands.map(b => ({
brand: b.brand,
units_sold: parseInt(b.units_sold) || 0,
revenue: parseFloat(b.revenue) || 0,
profit: parseFloat(b.profit) || 0,
growth_rate: parseFloat(b.growth_rate) || 0
}));
const formattedCategories = categories.map(c => ({
category_id: c.category_id,
name: c.name,
units_sold: parseInt(c.units_sold) || 0,
revenue: parseFloat(c.revenue) || 0,
profit: parseFloat(c.profit) || 0,
growth_rate: parseFloat(c.growth_rate) || 0
}));
res.json({
products: formattedProducts,
brands: formattedBrands,
categories: formattedCategories
});
} catch (err) { } catch (err) {
console.error('Error fetching best sellers:', err); console.error('Error fetching best sellers:', err);
res.status(500).json({ error: 'Failed to fetch best sellers' }); res.status(500).json({ error: 'Failed to fetch best sellers' });
@@ -650,7 +574,7 @@ router.get('/sales/metrics', async (req, res) => {
SUM(p.cost_price * o.quantity) as total_cogs, SUM(p.cost_price * o.quantity) as total_cogs,
SUM((o.price - p.cost_price) * o.quantity) as total_profit SUM((o.price - p.cost_price) * o.quantity) as total_profit
FROM orders o FROM orders o
JOIN products p ON o.product_id = p.product_id JOIN products p ON o.pid = p.pid
WHERE o.canceled = false WHERE o.canceled = false
AND o.date BETWEEN ? AND ? AND o.date BETWEEN ? AND ?
GROUP BY DATE(o.date) GROUP BY DATE(o.date)
@@ -666,7 +590,7 @@ router.get('/sales/metrics', async (req, res) => {
SUM(p.cost_price * o.quantity) as total_cogs, SUM(p.cost_price * o.quantity) as total_cogs,
SUM((o.price - p.cost_price) * o.quantity) as total_profit SUM((o.price - p.cost_price) * o.quantity) as total_profit
FROM orders o FROM orders o
JOIN products p ON o.product_id = p.product_id JOIN products p ON o.pid = p.pid
WHERE o.canceled = false WHERE o.canceled = false
AND o.date BETWEEN ? AND ? AND o.date BETWEEN ? AND ?
`, [startDate, endDate]); `, [startDate, endDate]);
@@ -698,7 +622,7 @@ router.get('/low-stock/products', async (req, res) => {
try { try {
const [rows] = await executeQuery(` const [rows] = await executeQuery(`
SELECT SELECT
p.product_id, p.pid,
p.SKU, p.SKU,
p.title, p.title,
p.brand, p.brand,
@@ -712,12 +636,12 @@ router.get('/low-stock/products', async (req, res) => {
(pm.reorder_qty * p.cost_price) as reorder_cost, (pm.reorder_qty * p.cost_price) as reorder_cost,
GROUP_CONCAT(c.name) as categories GROUP_CONCAT(c.name) as categories
FROM products p FROM products p
JOIN product_metrics pm ON p.product_id = pm.product_id JOIN product_metrics pm ON p.pid = pm.pid
LEFT JOIN product_categories pc ON p.product_id = pc.product_id LEFT JOIN product_categories pc ON p.pid = pc.pid
LEFT JOIN categories c ON pc.category_id = c.id LEFT JOIN categories c ON pc.cat_id = c.cat_id
WHERE pm.stock_status IN ('Critical', 'Reorder') WHERE pm.stock_status IN ('Critical', 'Reorder')
AND p.replenishable = true AND p.replenishable = true
GROUP BY p.product_id GROUP BY p.pid
ORDER BY ORDER BY
CASE pm.stock_status CASE pm.stock_status
WHEN 'Critical' THEN 1 WHEN 'Critical' THEN 1
@@ -742,17 +666,17 @@ router.get('/trending/products', async (req, res) => {
const [rows] = await executeQuery(` const [rows] = await executeQuery(`
WITH recent_sales AS ( WITH recent_sales AS (
SELECT SELECT
o.product_id, o.pid,
COUNT(DISTINCT o.order_number) as recent_orders, COUNT(DISTINCT o.order_number) as recent_orders,
SUM(o.quantity) as recent_units, SUM(o.quantity) as recent_units,
SUM(o.price * o.quantity) as recent_revenue SUM(o.price * o.quantity) as recent_revenue
FROM orders o FROM orders o
WHERE o.canceled = false WHERE o.canceled = false
AND o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) AND o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
GROUP BY o.product_id GROUP BY o.pid
) )
SELECT SELECT
p.product_id, p.pid,
p.SKU, p.SKU,
p.title, p.title,
p.brand, p.brand,
@@ -767,15 +691,15 @@ router.get('/trending/products', async (req, res) => {
((rs.recent_units / ?) - pm.daily_sales_avg) / pm.daily_sales_avg * 100 as velocity_change, ((rs.recent_units / ?) - pm.daily_sales_avg) / pm.daily_sales_avg * 100 as velocity_change,
GROUP_CONCAT(c.name) as categories GROUP_CONCAT(c.name) as categories
FROM recent_sales rs FROM recent_sales rs
JOIN products p ON rs.product_id = p.product_id JOIN products p ON rs.pid = p.pid
JOIN product_metrics pm ON p.product_id = pm.product_id JOIN product_metrics pm ON p.pid = pm.pid
LEFT JOIN product_categories pc ON p.product_id = pc.product_id LEFT JOIN product_categories pc ON p.pid = pc.pid
LEFT JOIN categories c ON pc.category_id = c.id LEFT JOIN categories c ON pc.cat_id = c.cat_id
GROUP BY p.product_id GROUP BY p.pid
HAVING velocity_change > 0 HAVING velocity_change > 0
ORDER BY velocity_change DESC ORDER BY velocity_change DESC
LIMIT ? LIMIT ?
`, [days, days, days, limit]); `, [days, days, limit]);
res.json(rows); res.json(rows);
} catch (err) { } catch (err) {
console.error('Error fetching trending products:', err); console.error('Error fetching trending products:', err);
@@ -859,7 +783,7 @@ router.get('/key-metrics', async (req, res) => {
COUNT(CASE WHEN pm.stock_status = 'Critical' THEN 1 END) as critical_stock_count, COUNT(CASE WHEN pm.stock_status = 'Critical' THEN 1 END) as critical_stock_count,
COUNT(CASE WHEN pm.stock_status = 'Overstocked' THEN 1 END) as overstock_count COUNT(CASE WHEN pm.stock_status = 'Overstocked' THEN 1 END) as overstock_count
FROM products p FROM products p
JOIN product_metrics pm ON p.product_id = pm.product_id JOIN product_metrics pm ON p.pid = pm.pid
), ),
sales_summary AS ( sales_summary AS (
SELECT SELECT
@@ -909,7 +833,7 @@ router.get('/inventory-health', async (req, res) => {
AVG(pm.turnover_rate) as avg_turnover_rate, AVG(pm.turnover_rate) as avg_turnover_rate,
AVG(pm.days_of_inventory) as avg_days_inventory AVG(pm.days_of_inventory) as avg_days_inventory
FROM products p FROM products p
JOIN product_metrics pm ON p.product_id = pm.product_id JOIN product_metrics pm ON p.pid = pm.pid
WHERE p.replenishable = true WHERE p.replenishable = true
), ),
value_distribution AS ( value_distribution AS (
@@ -931,7 +855,7 @@ router.get('/inventory-health', async (req, res) => {
ELSE 0 ELSE 0
END) * 100.0 / SUM(p.stock_quantity * p.cost_price) as overstock_value_percent END) * 100.0 / SUM(p.stock_quantity * p.cost_price) as overstock_value_percent
FROM products p FROM products p
JOIN product_metrics pm ON p.product_id = pm.product_id JOIN product_metrics pm ON p.pid = pm.pid
), ),
category_health AS ( category_health AS (
SELECT SELECT
@@ -940,11 +864,11 @@ router.get('/inventory-health', async (req, res) => {
SUM(CASE WHEN pm.stock_status = 'Healthy' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) as category_healthy_percent, SUM(CASE WHEN pm.stock_status = 'Healthy' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) as category_healthy_percent,
AVG(pm.turnover_rate) as category_turnover_rate AVG(pm.turnover_rate) as category_turnover_rate
FROM categories c FROM categories c
JOIN product_categories pc ON c.id = pc.category_id JOIN product_categories pc ON c.cat_id = pc.cat_id
JOIN products p ON pc.product_id = p.product_id JOIN products p ON pc.pid = p.pid
JOIN product_metrics pm ON p.product_id = pm.product_id JOIN product_metrics pm ON p.pid = pm.pid
WHERE p.replenishable = true WHERE p.replenishable = true
GROUP BY c.id, c.name GROUP BY c.cat_id, c.name
) )
SELECT SELECT
sd.*, sd.*,
@@ -975,20 +899,15 @@ router.get('/replenish/products', async (req, res) => {
try { try {
const [products] = await executeQuery(` const [products] = await executeQuery(`
SELECT SELECT
p.product_id, p.pid,
p.SKU, p.SKU as sku,
p.title, p.title,
p.stock_quantity as current_stock, p.stock_quantity,
pm.reorder_qty as replenish_qty, pm.daily_sales_avg,
(pm.reorder_qty * p.cost_price) as replenish_cost, pm.reorder_qty,
(pm.reorder_qty * p.price) as replenish_retail, pm.last_purchase_date
CASE
WHEN pm.daily_sales_avg > 0
THEN FLOOR(p.stock_quantity / pm.daily_sales_avg)
ELSE NULL
END as days_until_stockout
FROM products p FROM products p
JOIN product_metrics pm ON p.product_id = pm.product_id JOIN product_metrics pm ON p.pid = pm.pid
WHERE p.replenishable = true WHERE p.replenishable = true
AND pm.stock_status IN ('Critical', 'Reorder') AND pm.stock_status IN ('Critical', 'Reorder')
AND pm.reorder_qty > 0 AND pm.reorder_qty > 0
@@ -997,23 +916,16 @@ router.get('/replenish/products', async (req, res) => {
WHEN 'Critical' THEN 1 WHEN 'Critical' THEN 1
WHEN 'Reorder' THEN 2 WHEN 'Reorder' THEN 2
END, END,
replenish_cost DESC pm.reorder_qty * p.cost_price DESC
LIMIT ? LIMIT ?
`, [limit]); `, [limit]);
// Format response res.json(products.map(p => ({
const response = products.map(p => ({ ...p,
product_id: p.product_id, stock_quantity: parseInt(p.stock_quantity) || 0,
SKU: p.SKU, daily_sales_avg: parseFloat(p.daily_sales_avg) || 0,
title: p.title, reorder_qty: parseInt(p.reorder_qty) || 0
current_stock: parseInt(p.current_stock) || 0, })));
replenish_qty: parseInt(p.replenish_qty) || 0,
replenish_cost: parseFloat(p.replenish_cost) || 0,
replenish_retail: parseFloat(p.replenish_retail) || 0,
days_until_stockout: p.days_until_stockout
}));
res.json(response);
} catch (err) { } catch (err) {
console.error('Error fetching products to replenish:', err); console.error('Error fetching products to replenish:', err);
res.status(500).json({ error: 'Failed to fetch products to replenish' }); res.status(500).json({ error: 'Failed to fetch products to replenish' });

View File

@@ -9,25 +9,25 @@ router.get('/trends', async (req, res) => {
WITH MonthlyMetrics AS ( WITH MonthlyMetrics AS (
SELECT SELECT
DATE(CONCAT(pta.year, '-', LPAD(pta.month, 2, '0'), '-01')) as date, DATE(CONCAT(pta.year, '-', LPAD(pta.month, 2, '0'), '-01')) as date,
SUM(pta.total_revenue) as revenue, CAST(COALESCE(SUM(pta.total_revenue), 0) AS DECIMAL(15,3)) as revenue,
SUM(pta.total_cost) as cost, CAST(COALESCE(SUM(pta.total_cost), 0) AS DECIMAL(15,3)) as cost,
SUM(pm.inventory_value) as inventory_value, CAST(COALESCE(SUM(pm.inventory_value), 0) AS DECIMAL(15,3)) as inventory_value,
CASE CASE
WHEN SUM(pm.inventory_value) > 0 WHEN SUM(pm.inventory_value) > 0
THEN (SUM(pta.total_revenue - pta.total_cost) / SUM(pm.inventory_value)) * 100 THEN CAST((SUM(pta.total_revenue - pta.total_cost) / SUM(pm.inventory_value)) * 100 AS DECIMAL(15,3))
ELSE 0 ELSE 0
END as gmroi END as gmroi
FROM product_time_aggregates pta FROM product_time_aggregates pta
JOIN product_metrics pm ON pta.product_id = pm.product_id JOIN product_metrics pm ON pta.pid = pm.pid
WHERE (pta.year * 100 + pta.month) >= DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 12 MONTH), '%Y%m') WHERE (pta.year * 100 + pta.month) >= DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 12 MONTH), '%Y%m')
GROUP BY pta.year, pta.month GROUP BY pta.year, pta.month
ORDER BY date ASC ORDER BY date ASC
) )
SELECT SELECT
DATE_FORMAT(date, '%b %y') as date, DATE_FORMAT(date, '%b %y') as date,
ROUND(revenue, 2) as revenue, revenue,
ROUND(inventory_value, 2) as inventory_value, inventory_value,
ROUND(gmroi, 2) as gmroi gmroi
FROM MonthlyMetrics FROM MonthlyMetrics
`); `);
@@ -37,15 +37,15 @@ router.get('/trends', async (req, res) => {
const transformedData = { const transformedData = {
revenue: rows.map(row => ({ revenue: rows.map(row => ({
date: row.date, date: row.date,
value: parseFloat(row.revenue || 0) value: parseFloat(row.revenue)
})), })),
inventory_value: rows.map(row => ({ inventory_value: rows.map(row => ({
date: row.date, date: row.date,
value: parseFloat(row.inventory_value || 0) value: parseFloat(row.inventory_value)
})), })),
gmroi: rows.map(row => ({ gmroi: rows.map(row => ({
date: row.date, date: row.date,
value: parseFloat(row.gmroi || 0) value: parseFloat(row.gmroi)
})) }))
}; };

View File

@@ -74,8 +74,8 @@ router.get('/', async (req, res) => {
o1.status, o1.status,
o1.payment_method, o1.payment_method,
o1.shipping_method, o1.shipping_method,
COUNT(o2.product_id) as items_count, COUNT(o2.pid) as items_count,
SUM(o2.price * o2.quantity) as total_amount CAST(SUM(o2.price * o2.quantity) AS DECIMAL(15,3)) as total_amount
FROM orders o1 FROM orders o1
JOIN orders o2 ON o1.order_number = o2.order_number JOIN orders o2 ON o1.order_number = o2.order_number
WHERE ${conditions.join(' AND ')} WHERE ${conditions.join(' AND ')}
@@ -101,7 +101,7 @@ router.get('/', async (req, res) => {
WITH CurrentStats AS ( WITH CurrentStats AS (
SELECT SELECT
COUNT(DISTINCT order_number) as total_orders, COUNT(DISTINCT order_number) as total_orders,
SUM(price * quantity) as total_revenue CAST(SUM(price * quantity) AS DECIMAL(15,3)) as total_revenue
FROM orders FROM orders
WHERE canceled = false WHERE canceled = false
AND DATE(date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) AND DATE(date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
@@ -109,7 +109,7 @@ router.get('/', async (req, res) => {
PreviousStats AS ( PreviousStats AS (
SELECT SELECT
COUNT(DISTINCT order_number) as prev_orders, COUNT(DISTINCT order_number) as prev_orders,
SUM(price * quantity) as prev_revenue CAST(SUM(price * quantity) AS DECIMAL(15,3)) as prev_revenue
FROM orders FROM orders
WHERE canceled = false WHERE canceled = false
AND DATE(date) BETWEEN DATE_SUB(CURDATE(), INTERVAL 60 DAY) AND DATE_SUB(CURDATE(), INTERVAL 30 DAY) AND DATE(date) BETWEEN DATE_SUB(CURDATE(), INTERVAL 60 DAY) AND DATE_SUB(CURDATE(), INTERVAL 30 DAY)
@@ -117,7 +117,7 @@ router.get('/', async (req, res) => {
OrderValues AS ( OrderValues AS (
SELECT SELECT
order_number, order_number,
SUM(price * quantity) as order_value CAST(SUM(price * quantity) AS DECIMAL(15,3)) as order_value
FROM orders FROM orders
WHERE canceled = false WHERE canceled = false
AND DATE(date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) AND DATE(date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
@@ -138,12 +138,12 @@ router.get('/', async (req, res) => {
END as revenue_growth, END as revenue_growth,
CASE CASE
WHEN cs.total_orders > 0 WHEN cs.total_orders > 0
THEN (cs.total_revenue / cs.total_orders) THEN CAST((cs.total_revenue / cs.total_orders) AS DECIMAL(15,3))
ELSE 0 ELSE 0
END as average_order_value, END as average_order_value,
CASE CASE
WHEN ps.prev_orders > 0 WHEN ps.prev_orders > 0
THEN (ps.prev_revenue / ps.prev_orders) THEN CAST((ps.prev_revenue / ps.prev_orders) AS DECIMAL(15,3))
ELSE 0 ELSE 0
END as prev_average_order_value END as prev_average_order_value
FROM CurrentStats cs FROM CurrentStats cs
@@ -199,8 +199,8 @@ router.get('/:orderNumber', async (req, res) => {
o1.shipping_method, o1.shipping_method,
o1.shipping_address, o1.shipping_address,
o1.billing_address, o1.billing_address,
COUNT(o2.product_id) as items_count, COUNT(o2.pid) as items_count,
SUM(o2.price * o2.quantity) as total_amount CAST(SUM(o2.price * o2.quantity) AS DECIMAL(15,3)) as total_amount
FROM orders o1 FROM orders o1
JOIN orders o2 ON o1.order_number = o2.order_number JOIN orders o2 ON o1.order_number = o2.order_number
WHERE o1.order_number = ? AND o1.canceled = false WHERE o1.order_number = ? AND o1.canceled = false
@@ -222,14 +222,14 @@ router.get('/:orderNumber', async (req, res) => {
// Get order items // Get order items
const [itemRows] = await pool.query(` const [itemRows] = await pool.query(`
SELECT SELECT
o.product_id, o.pid,
p.title, p.title,
p.sku, p.SKU,
o.quantity, o.quantity,
o.price, o.price,
(o.price * o.quantity) as total CAST((o.price * o.quantity) AS DECIMAL(15,3)) as total
FROM orders o FROM orders o
JOIN products p ON o.product_id = p.product_id JOIN products p ON o.pid = p.pid
WHERE o.order_number = ? AND o.canceled = false WHERE o.order_number = ? AND o.canceled = false
`, [req.params.orderNumber]); `, [req.params.orderNumber]);

View File

@@ -2,6 +2,7 @@ const express = require('express');
const router = express.Router(); const router = express.Router();
const multer = require('multer'); const multer = require('multer');
const { importProductsFromCSV } = require('../utils/csvImporter'); const { importProductsFromCSV } = require('../utils/csvImporter');
const { PurchaseOrderStatus, ReceivingStatus } = require('../types/status-codes');
// Configure multer for file uploads // Configure multer for file uploads
const upload = multer({ dest: 'uploads/' }); const upload = multer({ dest: 'uploads/' });
@@ -20,15 +21,13 @@ router.get('/brands', async (req, res) => {
console.log('Fetching brands from database...'); console.log('Fetching brands from database...');
const [results] = await pool.query(` const [results] = await pool.query(`
SELECT DISTINCT p.brand SELECT DISTINCT COALESCE(p.brand, 'Unbranded') as brand
FROM products p FROM products p
JOIN purchase_orders po ON p.product_id = po.product_id JOIN purchase_orders po ON p.pid = po.pid
WHERE p.brand IS NOT NULL WHERE p.visible = true
AND p.brand != '' GROUP BY COALESCE(p.brand, 'Unbranded')
AND p.visible = true
GROUP BY p.brand
HAVING SUM(po.cost_price * po.received) >= 500 HAVING SUM(po.cost_price * po.received) >= 500
ORDER BY p.brand ORDER BY COALESCE(p.brand, 'Unbranded')
`); `);
console.log(`Found ${results.length} brands:`, results.slice(0, 3)); console.log(`Found ${results.length} brands:`, results.slice(0, 3));
@@ -147,9 +146,9 @@ router.get('/', async (req, res) => {
// Get total count for pagination // Get total count for pagination
const countQuery = ` const countQuery = `
SELECT COUNT(DISTINCT p.product_id) as total SELECT COUNT(DISTINCT p.pid) as total
FROM products p FROM products p
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id LEFT JOIN product_metrics pm ON p.pid = pm.pid
${whereClause} ${whereClause}
`; `;
const [countResult] = await pool.query(countQuery, params); const [countResult] = await pool.query(countQuery, params);
@@ -163,20 +162,40 @@ router.get('/', async (req, res) => {
'SELECT DISTINCT vendor FROM products WHERE visible = true AND vendor IS NOT NULL AND vendor != "" ORDER BY vendor' 'SELECT DISTINCT vendor FROM products WHERE visible = true AND vendor IS NOT NULL AND vendor != "" ORDER BY vendor'
); );
const [brands] = await pool.query( const [brands] = await pool.query(
'SELECT DISTINCT brand FROM products WHERE visible = true AND brand IS NOT NULL AND brand != "" ORDER BY brand' 'SELECT DISTINCT COALESCE(brand, \'Unbranded\') as brand FROM products WHERE visible = true ORDER BY brand'
); );
// Main query with all fields // Main query with all fields
const query = ` const query = `
WITH product_thresholds AS ( WITH RECURSIVE
category_path AS (
SELECT SELECT
p.product_id, c.cat_id,
c.name,
c.parent_id,
CAST(c.name AS CHAR(1000)) as path
FROM categories c
WHERE c.parent_id IS NULL
UNION ALL
SELECT
c.cat_id,
c.name,
c.parent_id,
CONCAT(cp.path, ' > ', c.name)
FROM categories c
JOIN category_path cp ON c.parent_id = cp.cat_id
),
product_thresholds AS (
SELECT
p.pid,
COALESCE( COALESCE(
(SELECT overstock_days FROM stock_thresholds st (SELECT overstock_days FROM stock_thresholds st
WHERE st.category_id IN ( WHERE st.category_id IN (
SELECT pc.category_id SELECT pc.cat_id
FROM product_categories pc FROM product_categories pc
WHERE pc.product_id = p.product_id WHERE pc.pid = p.pid
) )
AND (st.vendor = p.vendor OR st.vendor IS NULL) AND (st.vendor = p.vendor OR st.vendor IS NULL)
ORDER BY st.vendor IS NULL ORDER BY st.vendor IS NULL
@@ -189,10 +208,23 @@ router.get('/', async (req, res) => {
90 90
) as target_days ) as target_days
FROM products p FROM products p
),
product_leaf_categories AS (
-- Find categories that aren't parents to other categories for this product
SELECT DISTINCT pc.cat_id
FROM product_categories pc
WHERE NOT EXISTS (
SELECT 1
FROM categories child
JOIN product_categories child_pc ON child.cat_id = child_pc.cat_id
WHERE child.parent_id = pc.cat_id
AND child_pc.pid = pc.pid
)
) )
SELECT SELECT
p.*, p.*,
GROUP_CONCAT(DISTINCT c.name) as categories, COALESCE(p.brand, 'Unbranded') as brand,
GROUP_CONCAT(DISTINCT CONCAT(c.cat_id, ':', c.name)) as categories,
pm.daily_sales_avg, pm.daily_sales_avg,
pm.weekly_sales_avg, pm.weekly_sales_avg,
pm.monthly_sales_avg, pm.monthly_sales_avg,
@@ -205,10 +237,10 @@ router.get('/', async (req, res) => {
pm.reorder_point, pm.reorder_point,
pm.safety_stock, pm.safety_stock,
pm.avg_margin_percent, pm.avg_margin_percent,
pm.total_revenue, CAST(pm.total_revenue AS DECIMAL(15,3)) as total_revenue,
pm.inventory_value, CAST(pm.inventory_value AS DECIMAL(15,3)) as inventory_value,
pm.cost_of_goods_sold, CAST(pm.cost_of_goods_sold AS DECIMAL(15,3)) as cost_of_goods_sold,
pm.gross_profit, CAST(pm.gross_profit AS DECIMAL(15,3)) as gross_profit,
pm.gmroi, pm.gmroi,
pm.avg_lead_time_days, pm.avg_lead_time_days,
pm.last_purchase_date, pm.last_purchase_date,
@@ -223,12 +255,13 @@ router.get('/', async (req, res) => {
pm.overstocked_amt, pm.overstocked_amt,
COALESCE(pm.days_of_inventory / NULLIF(pt.target_days, 0), 0) as stock_coverage_ratio COALESCE(pm.days_of_inventory / NULLIF(pt.target_days, 0), 0) as stock_coverage_ratio
FROM products p FROM products p
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id LEFT JOIN product_metrics pm ON p.pid = pm.pid
LEFT JOIN product_categories pc ON p.product_id = pc.product_id LEFT JOIN product_categories pc ON p.pid = pc.pid
LEFT JOIN categories c ON pc.category_id = c.id LEFT JOIN categories c ON pc.cat_id = c.cat_id
LEFT JOIN product_thresholds pt ON p.product_id = pt.product_id LEFT JOIN product_thresholds pt ON p.pid = pt.pid
${whereClause} JOIN product_leaf_categories plc ON c.cat_id = plc.cat_id
GROUP BY p.product_id ${whereClause ? 'WHERE ' + whereClause.substring(6) : ''}
GROUP BY p.pid
ORDER BY ${sortColumn} ${sortDirection} ORDER BY ${sortColumn} ${sortDirection}
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
`; `;
@@ -308,7 +341,7 @@ router.get('/trending', async (req, res) => {
SELECT COUNT(*) as count, SELECT COUNT(*) as count,
MAX(total_revenue) as max_revenue, MAX(total_revenue) as max_revenue,
MAX(daily_sales_avg) as max_daily_sales, MAX(daily_sales_avg) as max_daily_sales,
COUNT(DISTINCT product_id) as products_with_metrics COUNT(DISTINCT pid) as products_with_metrics
FROM product_metrics FROM product_metrics
WHERE total_revenue > 0 OR daily_sales_avg > 0 WHERE total_revenue > 0 OR daily_sales_avg > 0
`); `);
@@ -322,7 +355,7 @@ router.get('/trending', async (req, res) => {
// Get trending products // Get trending products
const [rows] = await pool.query(` const [rows] = await pool.query(`
SELECT SELECT
p.product_id, p.pid,
p.sku, p.sku,
p.title, p.title,
COALESCE(pm.daily_sales_avg, 0) as daily_sales_avg, COALESCE(pm.daily_sales_avg, 0) as daily_sales_avg,
@@ -334,7 +367,7 @@ router.get('/trending', async (req, res) => {
END as growth_rate, END as growth_rate,
COALESCE(pm.total_revenue, 0) as total_revenue COALESCE(pm.total_revenue, 0) as total_revenue
FROM products p FROM products p
INNER JOIN product_metrics pm ON p.product_id = pm.product_id INNER JOIN product_metrics pm ON p.pid = pm.pid
WHERE (pm.total_revenue > 0 OR pm.daily_sales_avg > 0) WHERE (pm.total_revenue > 0 OR pm.daily_sales_avg > 0)
AND p.visible = true AND p.visible = true
ORDER BY growth_rate DESC ORDER BY growth_rate DESC
@@ -351,130 +384,160 @@ router.get('/trending', async (req, res) => {
// Get a single product // Get a single product
router.get('/:id', async (req, res) => { router.get('/:id', async (req, res) => {
const pool = req.app.locals.pool;
try { try {
// Get basic product data with metrics const pool = req.app.locals.pool;
const [rows] = await pool.query( const id = parseInt(req.params.id);
`SELECT
// Common CTE for category paths
const categoryPathCTE = `
WITH RECURSIVE category_path AS (
SELECT
c.cat_id,
c.name,
c.parent_id,
CAST(c.name AS CHAR(1000)) as path
FROM categories c
WHERE c.parent_id IS NULL
UNION ALL
SELECT
c.cat_id,
c.name,
c.parent_id,
CONCAT(cp.path, ' > ', c.name)
FROM categories c
JOIN category_path cp ON c.parent_id = cp.cat_id
)
`;
// Get product details with category paths
const [productRows] = await pool.query(`
SELECT
p.*, p.*,
GROUP_CONCAT(DISTINCT c.name) as categories,
pm.daily_sales_avg, pm.daily_sales_avg,
pm.weekly_sales_avg, pm.weekly_sales_avg,
pm.monthly_sales_avg, pm.monthly_sales_avg,
pm.days_of_inventory, pm.days_of_inventory,
pm.reorder_point, pm.reorder_point,
pm.safety_stock, pm.safety_stock,
pm.stock_status,
pm.abc_class,
pm.avg_margin_percent, pm.avg_margin_percent,
pm.total_revenue, pm.total_revenue,
pm.inventory_value, pm.inventory_value,
pm.turnover_rate, pm.turnover_rate,
pm.abc_class, pm.gmroi,
pm.stock_status, pm.cost_of_goods_sold,
pm.gross_profit,
pm.avg_lead_time_days, pm.avg_lead_time_days,
pm.current_lead_time, pm.current_lead_time,
pm.target_lead_time, pm.target_lead_time,
pm.lead_time_status, pm.lead_time_status,
pm.gmroi, pm.reorder_qty,
pm.cost_of_goods_sold, pm.overstocked_amt
pm.gross_profit
FROM products p FROM products p
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id LEFT JOIN product_metrics pm ON p.pid = pm.pid
LEFT JOIN product_categories pc ON p.product_id = pc.product_id WHERE p.pid = ?
LEFT JOIN categories c ON pc.category_id = c.id `, [id]);
WHERE p.product_id = ? AND p.visible = true
GROUP BY p.product_id`,
[req.params.id]
);
if (rows.length === 0) { if (!productRows.length) {
return res.status(404).json({ error: 'Product not found' }); return res.status(404).json({ error: 'Product not found' });
} }
// Get vendor performance metrics // Get categories and their paths separately to avoid GROUP BY issues
const [vendorMetrics] = await pool.query( const [categoryRows] = await pool.query(`
`SELECT * FROM vendor_metrics WHERE vendor = ?`, WITH RECURSIVE
[rows[0].vendor] category_path AS (
); SELECT
c.cat_id,
c.name,
c.parent_id,
CAST(c.name AS CHAR(1000)) as path
FROM categories c
WHERE c.parent_id IS NULL
UNION ALL
SELECT
c.cat_id,
c.name,
c.parent_id,
CONCAT(cp.path, ' > ', c.name)
FROM categories c
JOIN category_path cp ON c.parent_id = cp.cat_id
),
product_leaf_categories AS (
-- Find categories assigned to this product that aren't parents
-- of other categories assigned to this product
SELECT pc.cat_id
FROM product_categories pc
WHERE pc.pid = ?
AND NOT EXISTS (
-- Check if there are any child categories also assigned to this product
SELECT 1
FROM categories child
JOIN product_categories child_pc ON child.cat_id = child_pc.cat_id
WHERE child.parent_id = pc.cat_id
AND child_pc.pid = pc.pid
)
)
SELECT
c.cat_id,
c.name as category_name,
cp.path as full_path
FROM product_categories pc
JOIN categories c ON pc.cat_id = c.cat_id
JOIN category_path cp ON c.cat_id = cp.cat_id
JOIN product_leaf_categories plc ON c.cat_id = plc.cat_id
WHERE pc.pid = ?
ORDER BY cp.path
`, [id, id]);
// Transform the results
const categoryPathMap = categoryRows.reduce((acc, row) => {
// Use cat_id in the key to differentiate categories with the same name
acc[`${row.cat_id}:${row.category_name}`] = row.full_path;
return acc;
}, {});
// Transform the data to match frontend expectations
const product = { const product = {
// Basic product info ...productRows[0],
product_id: rows[0].product_id, // Include cat_id in categories array to match the keys in categoryPathMap
title: rows[0].title, categories: categoryRows.map(row => `${row.cat_id}:${row.category_name}`),
SKU: rows[0].SKU, category_paths: categoryPathMap,
barcode: rows[0].barcode, price: parseFloat(productRows[0].price),
created_at: rows[0].created_at, regular_price: parseFloat(productRows[0].regular_price),
updated_at: rows[0].updated_at, cost_price: parseFloat(productRows[0].cost_price),
landing_cost_price: parseFloat(productRows[0].landing_cost_price),
// Inventory fields stock_quantity: parseInt(productRows[0].stock_quantity),
stock_quantity: parseInt(rows[0].stock_quantity), moq: parseInt(productRows[0].moq),
moq: parseInt(rows[0].moq), uom: parseInt(productRows[0].uom),
uom: parseInt(rows[0].uom), managing_stock: Boolean(productRows[0].managing_stock),
managing_stock: Boolean(rows[0].managing_stock), replenishable: Boolean(productRows[0].replenishable),
replenishable: Boolean(rows[0].replenishable), daily_sales_avg: parseFloat(productRows[0].daily_sales_avg) || 0,
weekly_sales_avg: parseFloat(productRows[0].weekly_sales_avg) || 0,
// Pricing fields monthly_sales_avg: parseFloat(productRows[0].monthly_sales_avg) || 0,
price: parseFloat(rows[0].price), avg_quantity_per_order: parseFloat(productRows[0].avg_quantity_per_order) || 0,
regular_price: parseFloat(rows[0].regular_price), number_of_orders: parseInt(productRows[0].number_of_orders) || 0,
cost_price: parseFloat(rows[0].cost_price), first_sale_date: productRows[0].first_sale_date || null,
landing_cost_price: parseFloat(rows[0].landing_cost_price), last_sale_date: productRows[0].last_sale_date || null,
days_of_inventory: parseFloat(productRows[0].days_of_inventory) || 0,
// Categorization weeks_of_inventory: parseFloat(productRows[0].weeks_of_inventory) || 0,
categories: rows[0].categories ? rows[0].categories.split(',') : [], reorder_point: parseFloat(productRows[0].reorder_point) || 0,
tags: rows[0].tags ? rows[0].tags.split(',') : [], safety_stock: parseFloat(productRows[0].safety_stock) || 0,
options: rows[0].options ? JSON.parse(rows[0].options) : {}, avg_margin_percent: parseFloat(productRows[0].avg_margin_percent) || 0,
total_revenue: parseFloat(productRows[0].total_revenue) || 0,
// Vendor info inventory_value: parseFloat(productRows[0].inventory_value) || 0,
vendor: rows[0].vendor, cost_of_goods_sold: parseFloat(productRows[0].cost_of_goods_sold) || 0,
vendor_reference: rows[0].vendor_reference, gross_profit: parseFloat(productRows[0].gross_profit) || 0,
brand: rows[0].brand, gmroi: parseFloat(productRows[0].gmroi) || 0,
avg_lead_time_days: parseFloat(productRows[0].avg_lead_time_days) || 0,
// URLs current_lead_time: parseFloat(productRows[0].current_lead_time) || 0,
permalink: rows[0].permalink, target_lead_time: parseFloat(productRows[0].target_lead_time) || 0,
image: rows[0].image, lead_time_status: productRows[0].lead_time_status || null,
reorder_qty: parseInt(productRows[0].reorder_qty) || 0,
// Metrics overstocked_amt: parseInt(productRows[0].overstocked_amt) || 0
metrics: {
// Sales metrics
daily_sales_avg: parseFloat(rows[0].daily_sales_avg) || 0,
weekly_sales_avg: parseFloat(rows[0].weekly_sales_avg) || 0,
monthly_sales_avg: parseFloat(rows[0].monthly_sales_avg) || 0,
// Inventory metrics
days_of_inventory: parseInt(rows[0].days_of_inventory) || 0,
reorder_point: parseInt(rows[0].reorder_point) || 0,
safety_stock: parseInt(rows[0].safety_stock) || 0,
stock_status: rows[0].stock_status || 'Unknown',
abc_class: rows[0].abc_class || 'C',
// Financial metrics
avg_margin_percent: parseFloat(rows[0].avg_margin_percent) || 0,
total_revenue: parseFloat(rows[0].total_revenue) || 0,
inventory_value: parseFloat(rows[0].inventory_value) || 0,
turnover_rate: parseFloat(rows[0].turnover_rate) || 0,
gmroi: parseFloat(rows[0].gmroi) || 0,
cost_of_goods_sold: parseFloat(rows[0].cost_of_goods_sold) || 0,
gross_profit: parseFloat(rows[0].gross_profit) || 0,
// Lead time metrics
avg_lead_time_days: parseInt(rows[0].avg_lead_time_days) || 0,
current_lead_time: parseInt(rows[0].current_lead_time) || 0,
target_lead_time: parseInt(rows[0].target_lead_time) || 14,
lead_time_status: rows[0].lead_time_status || 'Unknown',
reorder_qty: parseInt(rows[0].reorder_qty) || 0,
overstocked_amt: parseInt(rows[0].overstocked_amt) || 0
},
// Vendor performance (if available)
vendor_performance: vendorMetrics.length ? {
avg_lead_time_days: parseFloat(vendorMetrics[0].avg_lead_time_days) || 0,
on_time_delivery_rate: parseFloat(vendorMetrics[0].on_time_delivery_rate) || 0,
order_fill_rate: parseFloat(vendorMetrics[0].order_fill_rate) || 0,
total_orders: parseInt(vendorMetrics[0].total_orders) || 0,
total_late_orders: parseInt(vendorMetrics[0].total_late_orders) || 0,
total_purchase_value: parseFloat(vendorMetrics[0].total_purchase_value) || 0,
avg_order_value: parseFloat(vendorMetrics[0].avg_order_value) || 0
} : null
}; };
res.json(product); res.json(product);
@@ -532,7 +595,7 @@ router.put('/:id', async (req, res) => {
categories = ?, categories = ?,
visible = ?, visible = ?,
managing_stock = ? managing_stock = ?
WHERE product_id = ?`, WHERE pid = ?`,
[ [
title, title,
sku, sku,
@@ -570,7 +633,7 @@ router.get('/:id/metrics', async (req, res) => {
const [metrics] = await pool.query(` const [metrics] = await pool.query(`
WITH inventory_status AS ( WITH inventory_status AS (
SELECT SELECT
p.product_id, p.pid,
CASE CASE
WHEN pm.daily_sales_avg = 0 THEN 'New' WHEN pm.daily_sales_avg = 0 THEN 'New'
WHEN p.stock_quantity <= CEIL(pm.daily_sales_avg * 7) THEN 'Critical' WHEN p.stock_quantity <= CEIL(pm.daily_sales_avg * 7) THEN 'Critical'
@@ -579,8 +642,8 @@ router.get('/:id/metrics', async (req, res) => {
ELSE 'Healthy' ELSE 'Healthy'
END as calculated_status END as calculated_status
FROM products p FROM products p
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id LEFT JOIN product_metrics pm ON p.pid = pm.pid
WHERE p.product_id = ? WHERE p.pid = ?
) )
SELECT SELECT
COALESCE(pm.daily_sales_avg, 0) as daily_sales_avg, COALESCE(pm.daily_sales_avg, 0) as daily_sales_avg,
@@ -604,9 +667,9 @@ router.get('/:id/metrics', async (req, res) => {
COALESCE(pm.reorder_qty, 0) as reorder_qty, COALESCE(pm.reorder_qty, 0) as reorder_qty,
COALESCE(pm.overstocked_amt, 0) as overstocked_amt COALESCE(pm.overstocked_amt, 0) as overstocked_amt
FROM products p FROM products p
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id LEFT JOIN product_metrics pm ON p.pid = pm.pid
LEFT JOIN inventory_status is ON p.product_id = is.product_id LEFT JOIN inventory_status is ON p.pid = is.pid
WHERE p.product_id = ? WHERE p.pid = ?
`, [id]); `, [id]);
if (!metrics.length) { if (!metrics.length) {
@@ -643,57 +706,35 @@ router.get('/:id/metrics', async (req, res) => {
// Get product time series data // Get product time series data
router.get('/:id/time-series', async (req, res) => { router.get('/:id/time-series', async (req, res) => {
const pool = req.app.locals.pool;
try {
const { id } = req.params; const { id } = req.params;
const months = parseInt(req.query.months) || 12; try {
const pool = req.app.locals.pool;
// Get monthly sales data with running totals and growth rates // Get monthly sales data
const [monthlySales] = await pool.query(` const [monthlySales] = await pool.query(`
WITH monthly_data AS (
SELECT SELECT
CONCAT(year, '-', LPAD(month, 2, '0')) as month, DATE_FORMAT(date, '%Y-%m') as month,
total_quantity_sold as quantity, COUNT(DISTINCT order_number) as order_count,
total_revenue as revenue, SUM(quantity) as units_sold,
total_cost as cost, CAST(SUM(price * quantity) AS DECIMAL(15,3)) as revenue
avg_price, FROM orders
profit_margin, WHERE pid = ?
inventory_value AND canceled = false
FROM product_time_aggregates GROUP BY DATE_FORMAT(date, '%Y-%m')
WHERE product_id = ? ORDER BY month DESC
ORDER BY year DESC, month DESC LIMIT 12
LIMIT ? `, [id]);
)
SELECT
month,
quantity,
revenue,
cost,
avg_price,
profit_margin,
inventory_value,
LAG(quantity) OVER (ORDER BY month) as prev_month_quantity,
LAG(revenue) OVER (ORDER BY month) as prev_month_revenue
FROM monthly_data
ORDER BY month ASC
`, [id, months]);
// Calculate growth rates and format data // Format monthly sales data
const formattedMonthlySales = monthlySales.map(row => ({ const formattedMonthlySales = monthlySales.map(month => ({
month: row.month, month: month.month,
quantity: parseInt(row.quantity) || 0, order_count: parseInt(month.order_count),
revenue: parseFloat(row.revenue) || 0, units_sold: parseInt(month.units_sold),
cost: parseFloat(row.cost) || 0, revenue: parseFloat(month.revenue),
avg_price: parseFloat(row.avg_price) || 0, profit: 0 // Set to 0 since we don't have cost data in orders table
profit_margin: parseFloat(row.profit_margin) || 0,
inventory_value: parseFloat(row.inventory_value) || 0,
quantity_growth: row.prev_month_quantity ?
((row.quantity - row.prev_month_quantity) / row.prev_month_quantity) * 100 : 0,
revenue_growth: row.prev_month_revenue ?
((row.revenue - row.prev_month_revenue) / row.prev_month_revenue) * 100 : 0
})); }));
// Get recent orders with customer info and status // Get recent orders
const [recentOrders] = await pool.query(` const [recentOrders] = await pool.query(`
SELECT SELECT
DATE_FORMAT(date, '%Y-%m-%d') as date, DATE_FORMAT(date, '%Y-%m-%d') as date,
@@ -703,11 +744,10 @@ router.get('/:id/time-series', async (req, res) => {
discount, discount,
tax, tax,
shipping, shipping,
customer, customer_name as customer,
status, status
payment_method
FROM orders FROM orders
WHERE product_id = ? WHERE pid = ?
AND canceled = false AND canceled = false
ORDER BY date DESC ORDER BY date DESC
LIMIT 10 LIMIT 10
@@ -723,17 +763,19 @@ router.get('/:id/time-series', async (req, res) => {
ordered, ordered,
received, received,
status, status,
receiving_status,
cost_price, cost_price,
notes, notes,
CASE CASE
WHEN received_date IS NOT NULL THEN WHEN received_date IS NOT NULL THEN
DATEDIFF(received_date, date) DATEDIFF(received_date, date)
WHEN expected_date < CURDATE() AND status != 'received' THEN WHEN expected_date < CURDATE() AND status < ${PurchaseOrderStatus.ReceivingStarted} THEN
DATEDIFF(CURDATE(), expected_date) DATEDIFF(CURDATE(), expected_date)
ELSE NULL ELSE NULL
END as lead_time_days END as lead_time_days
FROM purchase_orders FROM purchase_orders
WHERE product_id = ? WHERE pid = ?
AND status != ${PurchaseOrderStatus.Canceled}
ORDER BY date DESC ORDER BY date DESC
LIMIT 10 LIMIT 10
`, [id]); `, [id]);
@@ -752,6 +794,8 @@ router.get('/:id/time-series', async (req, res) => {
...po, ...po,
ordered: parseInt(po.ordered), ordered: parseInt(po.ordered),
received: parseInt(po.received), received: parseInt(po.received),
status: parseInt(po.status),
receiving_status: parseInt(po.receiving_status),
cost_price: parseFloat(po.cost_price), cost_price: parseFloat(po.cost_price),
lead_time_days: po.lead_time_days ? parseInt(po.lead_time_days) : null lead_time_days: po.lead_time_days ? parseInt(po.lead_time_days) : null
})) }))

View File

@@ -1,6 +1,26 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
// Status code constants
const STATUS = {
CANCELED: 0,
CREATED: 1,
ELECTRONICALLY_READY_SEND: 10,
ORDERED: 11,
PREORDERED: 12,
ELECTRONICALLY_SENT: 13,
RECEIVING_STARTED: 15,
DONE: 50
};
const RECEIVING_STATUS = {
CANCELED: 0,
CREATED: 1,
PARTIAL_RECEIVED: 30,
FULL_RECEIVED: 40,
PAID: 50
};
// Get all purchase orders with summary metrics // Get all purchase orders with summary metrics
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { try {
@@ -11,13 +31,13 @@ router.get('/', async (req, res) => {
const params = []; const params = [];
if (search) { if (search) {
whereClause += ' AND (po.po_id LIKE ? OR po.vendor LIKE ? OR po.status LIKE ?)'; whereClause += ' AND (po.po_id LIKE ? OR po.vendor LIKE ?)';
params.push(`%${search}%`, `%${search}%`, `%${search}%`); params.push(`%${search}%`, `%${search}%`);
} }
if (status && status !== 'all') { if (status && status !== 'all') {
whereClause += ' AND po.status = ?'; whereClause += ' AND po.status = ?';
params.push(status); params.push(Number(status));
} }
if (vendor && vendor !== 'all') { if (vendor && vendor !== 'all') {
@@ -42,7 +62,7 @@ router.get('/', async (req, res) => {
po_id, po_id,
SUM(ordered) as total_ordered, SUM(ordered) as total_ordered,
SUM(received) as total_received, SUM(received) as total_received,
SUM(ordered * cost_price) as total_cost CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_cost
FROM purchase_orders po FROM purchase_orders po
WHERE ${whereClause} WHERE ${whereClause}
GROUP BY po_id GROUP BY po_id
@@ -54,8 +74,8 @@ router.get('/', async (req, res) => {
ROUND( ROUND(
SUM(total_received) / NULLIF(SUM(total_ordered), 0), 3 SUM(total_received) / NULLIF(SUM(total_ordered), 0), 3
) as fulfillment_rate, ) as fulfillment_rate,
SUM(total_cost) as total_value, CAST(SUM(total_cost) AS DECIMAL(15,3)) as total_value,
ROUND(AVG(total_cost), 2) as avg_cost CAST(AVG(total_cost) AS DECIMAL(15,3)) as avg_cost
FROM po_totals FROM po_totals
`, params); `, params);
@@ -78,22 +98,24 @@ router.get('/', async (req, res) => {
vendor, vendor,
date, date,
status, status,
COUNT(DISTINCT product_id) as total_items, receiving_status,
COUNT(DISTINCT pid) as total_items,
SUM(ordered) as total_quantity, SUM(ordered) as total_quantity,
SUM(ordered * cost_price) as total_cost, CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_cost,
SUM(received) as total_received, SUM(received) as total_received,
ROUND( ROUND(
SUM(received) / NULLIF(SUM(ordered), 0), 3 SUM(received) / NULLIF(SUM(ordered), 0), 3
) as fulfillment_rate ) as fulfillment_rate
FROM purchase_orders po FROM purchase_orders po
WHERE ${whereClause} WHERE ${whereClause}
GROUP BY po_id, vendor, date, status GROUP BY po_id, vendor, date, status, receiving_status
) )
SELECT SELECT
po_id as id, po_id as id,
vendor as vendor_name, vendor as vendor_name,
DATE_FORMAT(date, '%Y-%m-%d') as order_date, DATE_FORMAT(date, '%Y-%m-%d') as order_date,
status, status,
receiving_status,
total_items, total_items,
total_quantity, total_quantity,
total_cost, total_cost,
@@ -104,8 +126,8 @@ router.get('/', async (req, res) => {
CASE CASE
WHEN ? = 'order_date' THEN date WHEN ? = 'order_date' THEN date
WHEN ? = 'vendor_name' THEN vendor WHEN ? = 'vendor_name' THEN vendor
WHEN ? = 'total_cost' THEN CAST(total_cost AS DECIMAL(15,2)) WHEN ? = 'total_cost' THEN CAST(total_cost AS DECIMAL(15,3))
WHEN ? = 'total_received' THEN CAST(total_received AS DECIMAL(15,2)) WHEN ? = 'total_received' THEN CAST(total_received AS DECIMAL(15,3))
WHEN ? = 'total_items' THEN CAST(total_items AS SIGNED) WHEN ? = 'total_items' THEN CAST(total_items AS SIGNED)
WHEN ? = 'total_quantity' THEN CAST(total_quantity AS SIGNED) WHEN ? = 'total_quantity' THEN CAST(total_quantity AS SIGNED)
WHEN ? = 'fulfillment_rate' THEN CAST(fulfillment_rate AS DECIMAL(5,3)) WHEN ? = 'fulfillment_rate' THEN CAST(fulfillment_rate AS DECIMAL(5,3))
@@ -127,7 +149,7 @@ router.get('/', async (req, res) => {
const [statuses] = await pool.query(` const [statuses] = await pool.query(`
SELECT DISTINCT status SELECT DISTINCT status
FROM purchase_orders FROM purchase_orders
WHERE status IS NOT NULL AND status != '' WHERE status IS NOT NULL
ORDER BY status ORDER BY status
`); `);
@@ -136,7 +158,8 @@ router.get('/', async (req, res) => {
id: order.id, id: order.id,
vendor_name: order.vendor_name, vendor_name: order.vendor_name,
order_date: order.order_date, order_date: order.order_date,
status: order.status, status: Number(order.status),
receiving_status: Number(order.receiving_status),
total_items: Number(order.total_items) || 0, total_items: Number(order.total_items) || 0,
total_quantity: Number(order.total_quantity) || 0, total_quantity: Number(order.total_quantity) || 0,
total_cost: Number(order.total_cost) || 0, total_cost: Number(order.total_cost) || 0,
@@ -165,7 +188,7 @@ router.get('/', async (req, res) => {
}, },
filters: { filters: {
vendors: vendors.map(v => v.vendor), vendors: vendors.map(v => v.vendor),
statuses: statuses.map(s => s.status) statuses: statuses.map(s => Number(s.status))
} }
}); });
} catch (error) { } catch (error) {
@@ -188,12 +211,14 @@ router.get('/vendor-metrics', async (req, res) => {
received, received,
cost_price, cost_price,
CASE CASE
WHEN status = 'received' AND received_date IS NOT NULL AND date IS NOT NULL WHEN status >= ${STATUS.RECEIVING_STARTED} AND receiving_status >= ${RECEIVING_STATUS.PARTIAL_RECEIVED}
AND received_date IS NOT NULL AND date IS NOT NULL
THEN DATEDIFF(received_date, date) THEN DATEDIFF(received_date, date)
ELSE NULL ELSE NULL
END as delivery_days END as delivery_days
FROM purchase_orders FROM purchase_orders
WHERE vendor IS NOT NULL AND vendor != '' WHERE vendor IS NOT NULL AND vendor != ''
AND status != ${STATUS.CANCELED} -- Exclude canceled orders
) )
SELECT SELECT
vendor as vendor_name, vendor as vendor_name,
@@ -203,10 +228,10 @@ router.get('/vendor-metrics', async (req, res) => {
ROUND( ROUND(
SUM(received) / NULLIF(SUM(ordered), 0), 3 SUM(received) / NULLIF(SUM(ordered), 0), 3
) as fulfillment_rate, ) as fulfillment_rate,
ROUND( CAST(ROUND(
SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2 SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2
) as avg_unit_cost, ) AS DECIMAL(15,3)) as avg_unit_cost,
SUM(ordered * cost_price) as total_spend, CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_spend,
ROUND( ROUND(
AVG(NULLIF(delivery_days, 0)), 1 AVG(NULLIF(delivery_days, 0)), 1
) as avg_delivery_days ) as avg_delivery_days
@@ -242,47 +267,47 @@ router.get('/cost-analysis', async (req, res) => {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
const [analysis] = await pool.query(` const [analysis] = await pool.query(`
WITH category_costs AS (
SELECT SELECT
c.name as categories, c.name as category,
COUNT(DISTINCT po.product_id) as unique_products, po.pid,
ROUND(AVG(po.cost_price), 2) as avg_cost, po.cost_price,
MIN(po.cost_price) as min_cost, po.ordered,
MAX(po.cost_price) as max_cost, po.received,
ROUND( po.status,
STDDEV(po.cost_price), 2 po.receiving_status
) as cost_variance,
SUM(po.ordered * po.cost_price) as total_spend
FROM purchase_orders po FROM purchase_orders po
JOIN products p ON po.product_id = p.product_id JOIN product_categories pc ON po.pid = pc.pid
JOIN product_categories pc ON p.product_id = pc.product_id JOIN categories c ON pc.cat_id = c.cat_id
JOIN categories c ON pc.category_id = c.id WHERE po.status != ${STATUS.CANCELED} -- Exclude canceled orders
GROUP BY c.name )
SELECT
category,
COUNT(DISTINCT pid) as unique_products,
CAST(AVG(cost_price) AS DECIMAL(15,3)) as avg_cost,
CAST(MIN(cost_price) AS DECIMAL(15,3)) as min_cost,
CAST(MAX(cost_price) AS DECIMAL(15,3)) as max_cost,
CAST(STDDEV(cost_price) AS DECIMAL(15,3)) as cost_variance,
CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_spend
FROM category_costs
GROUP BY category
ORDER BY total_spend DESC ORDER BY total_spend DESC
`); `);
// Parse numeric values and add ids for React keys // Parse numeric values
const parsedAnalysis = analysis.map(item => ({ const parsedAnalysis = {
id: item.categories || 'Uncategorized', categories: analysis.map(cat => ({
categories: item.categories || 'Uncategorized', category: cat.category,
unique_products: Number(item.unique_products) || 0, unique_products: Number(cat.unique_products) || 0,
avg_cost: Number(item.avg_cost) || 0, avg_cost: Number(cat.avg_cost) || 0,
min_cost: Number(item.min_cost) || 0, min_cost: Number(cat.min_cost) || 0,
max_cost: Number(item.max_cost) || 0, max_cost: Number(cat.max_cost) || 0,
cost_variance: Number(item.cost_variance) || 0, cost_variance: Number(cat.cost_variance) || 0,
total_spend: Number(item.total_spend) || 0 total_spend: Number(cat.total_spend) || 0
}));
// Transform the data with parsed values
const transformedAnalysis = {
...parsedAnalysis[0],
total_spend_by_category: parsedAnalysis.map(item => ({
id: item.categories,
category: item.categories,
total_spend: Number(item.total_spend)
})) }))
}; };
res.json(transformedAnalysis); res.json(parsedAnalysis);
} catch (error) { } catch (error) {
console.error('Error fetching cost analysis:', error); console.error('Error fetching cost analysis:', error);
res.status(500).json({ error: 'Failed to fetch cost analysis' }); res.status(500).json({ error: 'Failed to fetch cost analysis' });
@@ -298,11 +323,14 @@ router.get('/receiving-status', async (req, res) => {
WITH po_totals AS ( WITH po_totals AS (
SELECT SELECT
po_id, po_id,
status,
receiving_status,
SUM(ordered) as total_ordered, SUM(ordered) as total_ordered,
SUM(received) as total_received, SUM(received) as total_received,
SUM(ordered * cost_price) as total_cost CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_cost
FROM purchase_orders FROM purchase_orders
GROUP BY po_id WHERE status != ${STATUS.CANCELED}
GROUP BY po_id, status, receiving_status
) )
SELECT SELECT
COUNT(DISTINCT po_id) as order_count, COUNT(DISTINCT po_id) as order_count,
@@ -311,8 +339,20 @@ router.get('/receiving-status', async (req, res) => {
ROUND( ROUND(
SUM(total_received) / NULLIF(SUM(total_ordered), 0), 3 SUM(total_received) / NULLIF(SUM(total_ordered), 0), 3
) as fulfillment_rate, ) as fulfillment_rate,
SUM(total_cost) as total_value, CAST(SUM(total_cost) AS DECIMAL(15,3)) as total_value,
ROUND(AVG(total_cost), 2) as avg_cost CAST(AVG(total_cost) AS DECIMAL(15,3)) as avg_cost,
COUNT(DISTINCT CASE
WHEN receiving_status = ${RECEIVING_STATUS.CREATED} THEN po_id
END) as pending_count,
COUNT(DISTINCT CASE
WHEN receiving_status = ${RECEIVING_STATUS.PARTIAL_RECEIVED} THEN po_id
END) as partial_count,
COUNT(DISTINCT CASE
WHEN receiving_status >= ${RECEIVING_STATUS.FULL_RECEIVED} THEN po_id
END) as completed_count,
COUNT(DISTINCT CASE
WHEN receiving_status = ${RECEIVING_STATUS.CANCELED} THEN po_id
END) as canceled_count
FROM po_totals FROM po_totals
`); `);
@@ -323,7 +363,13 @@ router.get('/receiving-status', async (req, res) => {
total_received: Number(status[0].total_received) || 0, total_received: Number(status[0].total_received) || 0,
fulfillment_rate: Number(status[0].fulfillment_rate) || 0, fulfillment_rate: Number(status[0].fulfillment_rate) || 0,
total_value: Number(status[0].total_value) || 0, total_value: Number(status[0].total_value) || 0,
avg_cost: Number(status[0].avg_cost) || 0 avg_cost: Number(status[0].avg_cost) || 0,
status_breakdown: {
pending: Number(status[0].pending_count) || 0,
partial: Number(status[0].partial_count) || 0,
completed: Number(status[0].completed_count) || 0,
canceled: Number(status[0].canceled_count) || 0
}
}; };
res.json(parsedStatus); res.json(parsedStatus);

View File

@@ -29,8 +29,8 @@ router.get('/', async (req, res) => {
const [costMetrics] = await pool.query(` const [costMetrics] = await pool.query(`
SELECT SELECT
vendor, vendor,
ROUND(SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2) as avg_unit_cost, CAST(ROUND(SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2) AS DECIMAL(15,3)) as avg_unit_cost,
SUM(ordered * cost_price) as total_spend CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_spend
FROM purchase_orders FROM purchase_orders
WHERE status = 'closed' WHERE status = 'closed'
AND cost_price IS NOT NULL AND cost_price IS NOT NULL
@@ -56,9 +56,9 @@ router.get('/', async (req, res) => {
WHEN COALESCE(vm.total_orders, 0) > 0 AND COALESCE(vm.order_fill_rate, 0) >= 75 WHEN COALESCE(vm.total_orders, 0) > 0 AND COALESCE(vm.order_fill_rate, 0) >= 75
THEN p.vendor THEN p.vendor
END) as activeVendors, END) as activeVendors,
ROUND(AVG(NULLIF(vm.avg_lead_time_days, 0)), 1) as avgLeadTime, COALESCE(ROUND(AVG(NULLIF(vm.avg_lead_time_days, 0)), 1), 0) as avgLeadTime,
ROUND(AVG(NULLIF(vm.order_fill_rate, 0)), 1) as avgFillRate, COALESCE(ROUND(AVG(NULLIF(vm.order_fill_rate, 0)), 1), 0) as avgFillRate,
ROUND(AVG(NULLIF(vm.on_time_delivery_rate, 0)), 1) as avgOnTimeDelivery COALESCE(ROUND(AVG(NULLIF(vm.on_time_delivery_rate, 0)), 1), 0) as avgOnTimeDelivery
FROM products p FROM products p
LEFT JOIN vendor_metrics vm ON p.vendor = vm.vendor LEFT JOIN vendor_metrics vm ON p.vendor = vm.vendor
WHERE p.vendor IS NOT NULL AND p.vendor != '' WHERE p.vendor IS NOT NULL AND p.vendor != ''
@@ -67,8 +67,8 @@ router.get('/', async (req, res) => {
// Get overall cost metrics // Get overall cost metrics
const [overallCostMetrics] = await pool.query(` const [overallCostMetrics] = await pool.query(`
SELECT SELECT
ROUND(SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2) as avg_unit_cost, CAST(ROUND(SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2) AS DECIMAL(15,3)) as avg_unit_cost,
SUM(ordered * cost_price) as total_spend CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_spend
FROM purchase_orders FROM purchase_orders
WHERE status = 'closed' WHERE status = 'closed'
AND cost_price IS NOT NULL AND cost_price IS NOT NULL
@@ -78,25 +78,25 @@ router.get('/', async (req, res) => {
res.json({ res.json({
vendors: vendors.map(vendor => ({ vendors: vendors.map(vendor => ({
vendor_id: vendor.vendor_id || vendor.name, vendor_id: vendor.name,
name: vendor.name, name: vendor.name,
status: vendor.status, status: vendor.status,
avg_lead_time_days: parseFloat(vendor.avg_lead_time_days || 0), avg_lead_time_days: parseFloat(vendor.avg_lead_time_days),
on_time_delivery_rate: parseFloat(vendor.on_time_delivery_rate || 0), on_time_delivery_rate: parseFloat(vendor.on_time_delivery_rate),
order_fill_rate: parseFloat(vendor.order_fill_rate || 0), order_fill_rate: parseFloat(vendor.order_fill_rate),
total_orders: parseInt(vendor.total_orders || 0), total_orders: parseInt(vendor.total_orders),
active_products: parseInt(vendor.active_products || 0), active_products: parseInt(vendor.active_products),
avg_unit_cost: parseFloat(costMetricsMap[vendor.name]?.avg_unit_cost || 0), avg_unit_cost: parseFloat(costMetricsMap[vendor.name]?.avg_unit_cost || 0),
total_spend: parseFloat(costMetricsMap[vendor.name]?.total_spend || 0) total_spend: parseFloat(costMetricsMap[vendor.name]?.total_spend || 0)
})), })),
stats: { stats: {
totalVendors: parseInt(stats[0].totalVendors || 0), totalVendors: parseInt(stats[0].totalVendors),
activeVendors: parseInt(stats[0].activeVendors || 0), activeVendors: parseInt(stats[0].activeVendors),
avgLeadTime: parseFloat(stats[0].avgLeadTime || 0), avgLeadTime: parseFloat(stats[0].avgLeadTime),
avgFillRate: parseFloat(stats[0].avgFillRate || 0), avgFillRate: parseFloat(stats[0].avgFillRate),
avgOnTimeDelivery: parseFloat(stats[0].avgOnTimeDelivery || 0), avgOnTimeDelivery: parseFloat(stats[0].avgOnTimeDelivery),
avgUnitCost: parseFloat(overallCostMetrics[0].avg_unit_cost || 0), avgUnitCost: parseFloat(overallCostMetrics[0].avg_unit_cost),
totalSpend: parseFloat(overallCostMetrics[0].total_spend || 0) totalSpend: parseFloat(overallCostMetrics[0].total_spend)
} }
}); });
} catch (error) { } catch (error) {

View File

@@ -0,0 +1,79 @@
// Purchase Order Status Codes
const PurchaseOrderStatus = {
Canceled: 0,
Created: 1,
ElectronicallyReadySend: 10,
Ordered: 11,
Preordered: 12,
ElectronicallySent: 13,
ReceivingStarted: 15,
Done: 50
};
// Receiving Status Codes
const ReceivingStatus = {
Canceled: 0,
Created: 1,
PartialReceived: 30,
FullReceived: 40,
Paid: 50
};
// Status Code Display Names
const PurchaseOrderStatusLabels = {
[PurchaseOrderStatus.Canceled]: 'Canceled',
[PurchaseOrderStatus.Created]: 'Created',
[PurchaseOrderStatus.ElectronicallyReadySend]: 'Ready to Send',
[PurchaseOrderStatus.Ordered]: 'Ordered',
[PurchaseOrderStatus.Preordered]: 'Preordered',
[PurchaseOrderStatus.ElectronicallySent]: 'Sent',
[PurchaseOrderStatus.ReceivingStarted]: 'Receiving Started',
[PurchaseOrderStatus.Done]: 'Done'
};
const ReceivingStatusLabels = {
[ReceivingStatus.Canceled]: 'Canceled',
[ReceivingStatus.Created]: 'Created',
[ReceivingStatus.PartialReceived]: 'Partially Received',
[ReceivingStatus.FullReceived]: 'Fully Received',
[ReceivingStatus.Paid]: 'Paid'
};
// Helper functions
function getPurchaseOrderStatusLabel(status) {
return PurchaseOrderStatusLabels[status] || 'Unknown';
}
function getReceivingStatusLabel(status) {
return ReceivingStatusLabels[status] || 'Unknown';
}
// Status checks
function isReceivingComplete(status) {
return status >= ReceivingStatus.PartialReceived;
}
function isPurchaseOrderComplete(status) {
return status === PurchaseOrderStatus.Done;
}
function isPurchaseOrderCanceled(status) {
return status === PurchaseOrderStatus.Canceled;
}
function isReceivingCanceled(status) {
return status === ReceivingStatus.Canceled;
}
module.exports = {
PurchaseOrderStatus,
ReceivingStatus,
PurchaseOrderStatusLabels,
ReceivingStatusLabels,
getPurchaseOrderStatusLabel,
getReceivingStatusLabel,
isReceivingComplete,
isPurchaseOrderComplete,
isPurchaseOrderCanceled,
isReceivingCanceled
};

View File

@@ -6,6 +6,7 @@ import config from '../../config';
interface CategoryData { interface CategoryData {
performance: { performance: {
category: string; category: string;
categoryPath: string; // Full hierarchy path
revenue: number; revenue: number;
profit: number; profit: number;
growth: number; growth: number;
@@ -13,10 +14,12 @@ interface CategoryData {
}[]; }[];
distribution: { distribution: {
category: string; category: string;
categoryPath: string; // Full hierarchy path
value: number; value: number;
}[]; }[];
trends: { trends: {
category: string; category: string;
categoryPath: string; // Full hierarchy path
month: string; month: string;
sales: number; sales: number;
}[]; }[];
@@ -36,6 +39,7 @@ export function CategoryPerformance() {
return { return {
performance: rawData.performance.map((item: any) => ({ performance: rawData.performance.map((item: any) => ({
...item, ...item,
categoryPath: item.categoryPath || item.category,
revenue: Number(item.revenue) || 0, revenue: Number(item.revenue) || 0,
profit: Number(item.profit) || 0, profit: Number(item.profit) || 0,
growth: Number(item.growth) || 0, growth: Number(item.growth) || 0,
@@ -43,10 +47,12 @@ export function CategoryPerformance() {
})), })),
distribution: rawData.distribution.map((item: any) => ({ distribution: rawData.distribution.map((item: any) => ({
...item, ...item,
categoryPath: item.categoryPath || item.category,
value: Number(item.value) || 0 value: Number(item.value) || 0
})), })),
trends: rawData.trends.map((item: any) => ({ trends: rawData.trends.map((item: any) => ({
...item, ...item,
categoryPath: item.categoryPath || item.category,
sales: Number(item.sales) || 0 sales: Number(item.sales) || 0
})) }))
}; };
@@ -63,6 +69,8 @@ export function CategoryPerformance() {
return <span className={color}>{value}</span>; return <span className={color}>{value}</span>;
}; };
const getShortCategoryName = (path: string) => path.split(' > ').pop() || path;
return ( return (
<div className="grid gap-4"> <div className="grid gap-4">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
@@ -76,24 +84,34 @@ export function CategoryPerformance() {
<Pie <Pie
data={data.distribution} data={data.distribution}
dataKey="value" dataKey="value"
nameKey="category" nameKey="categoryPath"
cx="50%" cx="50%"
cy="50%" cy="50%"
outerRadius={100} outerRadius={100}
fill="#8884d8" fill="#8884d8"
label={(entry) => entry.category} label={({ categoryPath }) => getShortCategoryName(categoryPath)}
> >
{data.distribution.map((entry, index) => ( {data.distribution.map((entry, index) => (
<Cell <Cell
key={entry.category} key={`${entry.category}-${entry.value}-${index}`}
fill={COLORS[index % COLORS.length]} fill={COLORS[index % COLORS.length]}
/> />
))} ))}
</Pie> </Pie>
<Tooltip <Tooltip
formatter={(value: number) => [`$${value.toLocaleString()}`, 'Revenue']} formatter={(value: number, name: string, props: any) => [
`$${value.toLocaleString()}`,
<div key="tooltip">
<div className="font-medium">Category Path:</div>
<div className="text-sm text-muted-foreground">{props.payload.categoryPath}</div>
<div className="mt-1">Revenue</div>
</div>
]}
/>
<Legend
formatter={(value) => getShortCategoryName(value)}
wrapperStyle={{ fontSize: '12px' }}
/> />
<Legend />
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>
</CardContent> </CardContent>
@@ -106,10 +124,33 @@ export function CategoryPerformance() {
<CardContent> <CardContent>
<ResponsiveContainer width="100%" height={300}> <ResponsiveContainer width="100%" height={300}>
<BarChart data={data.performance}> <BarChart data={data.performance}>
<XAxis dataKey="category" /> <XAxis
dataKey="categoryPath"
tick={({ x, y, payload }) => (
<g transform={`translate(${x},${y})`}>
<text
x={0}
y={0}
dy={16}
textAnchor="end"
fill="#888888"
transform="rotate(-35)"
>
{getShortCategoryName(payload.value)}
</text>
</g>
)}
/>
<YAxis tickFormatter={(value) => `${value}%`} /> <YAxis tickFormatter={(value) => `${value}%`} />
<Tooltip <Tooltip
formatter={(value: number) => [`${value.toFixed(1)}%`, 'Growth Rate']} formatter={(value: number, name: string, props: any) => [
`${value.toFixed(1)}%`,
<div key="tooltip">
<div className="font-medium">Category Path:</div>
<div className="text-sm text-muted-foreground">{props.payload.categoryPath}</div>
<div className="mt-1">Growth Rate</div>
</div>
]}
/> />
<Bar <Bar
dataKey="growth" dataKey="growth"
@@ -129,10 +170,13 @@ export function CategoryPerformance() {
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{data.performance.map((category) => ( {data.performance.map((category) => (
<div key={category.category} className="flex items-center"> <div key={`${category.category}-${category.revenue}`} className="flex items-center">
<div className="flex-1"> <div className="flex-1">
<p className="text-sm font-medium">{category.category}</p> <div className="space-y-1">
<p className="text-sm text-muted-foreground"> <p className="text-sm font-medium">{getShortCategoryName(category.categoryPath)}</p>
<p className="text-xs text-muted-foreground">{category.categoryPath}</p>
</div>
<p className="text-sm text-muted-foreground mt-1">
{category.productCount} products {category.productCount} products
</p> </p>
</div> </div>

View File

@@ -154,7 +154,7 @@ export function PriceAnalysis() {
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{data.recommendations.map((item) => ( {data.recommendations.map((item) => (
<div key={item.product} className="flex items-center"> <div key={`${item.product}-${item.currentPrice}`} className="flex items-center">
<div className="flex-1"> <div className="flex-1">
<p className="text-sm font-medium">{item.product}</p> <p className="text-sm font-medium">{item.product}</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">

View File

@@ -6,6 +6,7 @@ import config from '../../config';
interface ProfitData { interface ProfitData {
byCategory: { byCategory: {
category: string; category: string;
categoryPath: string; // Full hierarchy path
profitMargin: number; profitMargin: number;
revenue: number; revenue: number;
cost: number; cost: number;
@@ -18,6 +19,8 @@ interface ProfitData {
}[]; }[];
topProducts: { topProducts: {
product: string; product: string;
category: string;
categoryPath: string; // Full hierarchy path
profitMargin: number; profitMargin: number;
revenue: number; revenue: number;
cost: number; cost: number;
@@ -36,6 +39,7 @@ export function ProfitAnalysis() {
return { return {
byCategory: rawData.byCategory.map((item: any) => ({ byCategory: rawData.byCategory.map((item: any) => ({
...item, ...item,
categoryPath: item.categoryPath || item.category,
profitMargin: Number(item.profitMargin) || 0, profitMargin: Number(item.profitMargin) || 0,
revenue: Number(item.revenue) || 0, revenue: Number(item.revenue) || 0,
cost: Number(item.cost) || 0 cost: Number(item.cost) || 0
@@ -48,6 +52,7 @@ export function ProfitAnalysis() {
})), })),
topProducts: rawData.topProducts.map((item: any) => ({ topProducts: rawData.topProducts.map((item: any) => ({
...item, ...item,
categoryPath: item.categoryPath || item.category,
profitMargin: Number(item.profitMargin) || 0, profitMargin: Number(item.profitMargin) || 0,
revenue: Number(item.revenue) || 0, revenue: Number(item.revenue) || 0,
cost: Number(item.cost) || 0 cost: Number(item.cost) || 0
@@ -60,6 +65,8 @@ export function ProfitAnalysis() {
return <div>Loading profit analysis...</div>; return <div>Loading profit analysis...</div>;
} }
const getShortCategoryName = (path: string) => path.split(' > ').pop() || path;
return ( return (
<div className="grid gap-4"> <div className="grid gap-4">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
@@ -70,10 +77,33 @@ export function ProfitAnalysis() {
<CardContent> <CardContent>
<ResponsiveContainer width="100%" height={300}> <ResponsiveContainer width="100%" height={300}>
<BarChart data={data.byCategory}> <BarChart data={data.byCategory}>
<XAxis dataKey="category" /> <XAxis
dataKey="categoryPath"
tick={({ x, y, payload }) => (
<g transform={`translate(${x},${y})`}>
<text
x={0}
y={0}
dy={16}
textAnchor="end"
fill="#888888"
transform="rotate(-35)"
>
{getShortCategoryName(payload.value)}
</text>
</g>
)}
/>
<YAxis tickFormatter={(value) => `${value}%`} /> <YAxis tickFormatter={(value) => `${value}%`} />
<Tooltip <Tooltip
formatter={(value: number) => [`${value.toFixed(1)}%`, 'Profit Margin']} formatter={(value: number, name: string, props: any) => [
`${value.toFixed(1)}%`,
<div key="tooltip">
<div className="font-medium">Category Path:</div>
<div className="text-sm text-muted-foreground">{props.payload.categoryPath}</div>
<div className="mt-1">Profit Margin</div>
</div>
]}
/> />
<Bar <Bar
dataKey="profitMargin" dataKey="profitMargin"
@@ -120,10 +150,14 @@ export function ProfitAnalysis() {
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{data.topProducts.map((product) => ( {data.topProducts.map((product) => (
<div key={product.product} className="flex items-center"> <div key={`${product.product}-${product.category}`} className="flex items-center">
<div className="flex-1"> <div className="flex-1">
<p className="text-sm font-medium">{product.product}</p> <p className="text-sm font-medium">{product.product}</p>
<p className="text-sm text-muted-foreground"> <div className="text-xs text-muted-foreground space-y-1">
<p className="font-medium">Category:</p>
<p>{product.categoryPath}</p>
</div>
<p className="text-sm text-muted-foreground mt-1">
Revenue: ${product.revenue.toLocaleString()} Revenue: ${product.revenue.toLocaleString()}
</p> </p>
</div> </div>

View File

@@ -145,7 +145,7 @@ export function StockAnalysis() {
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{data.criticalItems.map((item) => ( {data.criticalItems.map((item) => (
<div key={item.sku} className="flex items-center"> <div key={`${item.sku}-${item.product}`} className="flex items-center">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="text-sm font-medium">{item.product}</p> <p className="text-sm font-medium">{item.product}</p>

View File

@@ -131,7 +131,7 @@ export function VendorPerformance() {
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{data.performance.map((vendor) => ( {data.performance.map((vendor) => (
<div key={vendor.vendor} className="flex items-center"> <div key={`${vendor.vendor}-${vendor.salesVolume}`} className="flex items-center">
<div className="flex-1"> <div className="flex-1">
<p className="text-sm font-medium">{vendor.vendor}</p> <p className="text-sm font-medium">{vendor.vendor}</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">

View File

@@ -6,37 +6,46 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import config from "@/config" import config from "@/config"
import { formatCurrency } from "@/lib/utils" import { formatCurrency } from "@/lib/utils"
interface BestSellerProduct { interface Product {
product_id: number pid: number;
sku: string sku: string;
title: string title: string;
units_sold: number units_sold: number;
revenue: number revenue: string;
profit: number profit: string;
growth_rate: number }
interface Category {
cat_id: number;
name: string;
categoryPath: string;
units_sold: number;
revenue: string;
profit: string;
growth_rate: string;
} }
interface BestSellerBrand { interface BestSellerBrand {
brand: string brand: string
units_sold: number units_sold: number
revenue: number revenue: string
profit: number profit: string
growth_rate: number growth_rate: string
} }
interface BestSellerCategory { interface BestSellerCategory {
category_id: number cat_id: number;
name: string name: string;
units_sold: number units_sold: number;
revenue: number revenue: string;
profit: number profit: string;
growth_rate: number growth_rate: string;
} }
interface BestSellersData { interface BestSellersData {
products: BestSellerProduct[] products: Product[]
brands: BestSellerBrand[] brands: BestSellerBrand[]
categories: BestSellerCategory[] categories: Category[]
} }
export function BestSellers() { export function BestSellers() {
@@ -70,41 +79,29 @@ export function BestSellers() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-[40%]">Product</TableHead> <TableHead>Product</TableHead>
<TableHead className="w-[15%] text-right">Sales</TableHead> <TableHead className="text-right">Units Sold</TableHead>
<TableHead className="w-[15%] text-right">Revenue</TableHead> <TableHead className="text-right">Revenue</TableHead>
<TableHead className="w-[15%] text-right">Profit</TableHead> <TableHead className="text-right">Profit</TableHead>
<TableHead className="w-[15%] text-right">Growth</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data?.products.map((product) => ( {data?.products.map((product) => (
<TableRow key={product.product_id}> <TableRow key={product.pid}>
<TableCell className="w-[40%]"> <TableCell>
<div>
<a <a
href={`https://backend.acherryontop.com/product/${product.product_id}`} href={`https://backend.acherryontop.com/product/${product.pid}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="font-medium hover:underline" className="hover:underline"
> >
{product.title} {product.title}
</a> </a>
<p className="text-sm text-muted-foreground">{product.sku}</p> <div className="text-sm text-muted-foreground">{product.sku}</div>
</div>
</TableCell>
<TableCell className="w-[15%] text-right">
{product.units_sold.toLocaleString()}
</TableCell>
<TableCell className="w-[15%] text-right">
{formatCurrency(product.revenue)}
</TableCell>
<TableCell className="w-[15%] text-right">
{formatCurrency(product.profit)}
</TableCell>
<TableCell className="w-[15%] text-right">
{product.growth_rate > 0 ? '+' : ''}{product.growth_rate.toFixed(1)}%
</TableCell> </TableCell>
<TableCell className="text-right">{product.units_sold}</TableCell>
<TableCell className="text-right">{formatCurrency(Number(product.revenue))}</TableCell>
<TableCell className="text-right">{formatCurrency(Number(product.profit))}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
@@ -134,13 +131,13 @@ export function BestSellers() {
{brand.units_sold.toLocaleString()} {brand.units_sold.toLocaleString()}
</TableCell> </TableCell>
<TableCell className="w-[15%] text-right"> <TableCell className="w-[15%] text-right">
{formatCurrency(brand.revenue)} {formatCurrency(Number(brand.revenue))}
</TableCell> </TableCell>
<TableCell className="w-[15%] text-right"> <TableCell className="w-[15%] text-right">
{formatCurrency(brand.profit)} {formatCurrency(Number(brand.profit))}
</TableCell> </TableCell>
<TableCell className="w-[15%] text-right"> <TableCell className="w-[15%] text-right">
{brand.growth_rate > 0 ? '+' : ''}{brand.growth_rate.toFixed(1)}% {Number(brand.growth_rate) > 0 ? '+' : ''}{Number(brand.growth_rate).toFixed(1)}%
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@@ -154,31 +151,26 @@ export function BestSellers() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-[40%]">Category</TableHead> <TableHead>Category</TableHead>
<TableHead className="w-[15%] text-right">Sales</TableHead> <TableHead className="text-right">Units Sold</TableHead>
<TableHead className="w-[15%] text-right">Revenue</TableHead> <TableHead className="text-right">Revenue</TableHead>
<TableHead className="w-[15%] text-right">Profit</TableHead> <TableHead className="text-right">Profit</TableHead>
<TableHead className="w-[15%] text-right">Growth</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data?.categories.map((category) => ( {data?.categories.map((category) => (
<TableRow key={category.category_id}> <TableRow key={category.cat_id}>
<TableCell className="w-[40%]"> <TableCell>
<p className="font-medium">{category.name}</p> <div className="font-medium">{category.name}</div>
</TableCell> {category.categoryPath && (
<TableCell className="w-[15%] text-right"> <div className="text-sm text-muted-foreground">
{category.units_sold.toLocaleString()} {category.categoryPath}
</TableCell> </div>
<TableCell className="w-[15%] text-right"> )}
{formatCurrency(category.revenue)}
</TableCell>
<TableCell className="w-[15%] text-right">
{formatCurrency(category.profit)}
</TableCell>
<TableCell className="w-[15%] text-right">
{category.growth_rate > 0 ? '+' : ''}{category.growth_rate.toFixed(1)}%
</TableCell> </TableCell>
<TableCell className="text-right">{category.units_sold}</TableCell>
<TableCell className="text-right">{formatCurrency(Number(category.revenue))}</TableCell>
<TableCell className="text-right">{formatCurrency(Number(category.profit))}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>

View File

@@ -11,18 +11,18 @@ import { DateRangePicker } from "@/components/ui/date-range-picker-narrow"
interface ForecastData { interface ForecastData {
forecastSales: number forecastSales: number
forecastRevenue: number forecastRevenue: string
confidenceLevel: number confidenceLevel: number
dailyForecasts: { dailyForecasts: {
date: string date: string
units: number units: number
revenue: number revenue: string
confidence: number confidence: number
}[] }[]
categoryForecasts: { categoryForecasts: {
category: string category: string
units: number units: number
revenue: number revenue: string
confidence: number confidence: number
}[] }[]
} }
@@ -86,7 +86,7 @@ export function ForecastMetrics() {
<DollarSign className="h-4 w-4 text-muted-foreground" /> <DollarSign className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Forecast Revenue</p> <p className="text-sm font-medium text-muted-foreground">Forecast Revenue</p>
</div> </div>
<p className="text-lg font-bold">{formatCurrency(data?.forecastRevenue || 0)}</p> <p className="text-lg font-bold">{formatCurrency(Number(data?.forecastRevenue) || 0)}</p>
</div> </div>
</div> </div>
@@ -108,7 +108,7 @@ export function ForecastMetrics() {
tick={false} tick={false}
/> />
<Tooltip <Tooltip
formatter={(value: number) => [formatCurrency(value), "Revenue"]} formatter={(value: string) => [formatCurrency(Number(value)), "Revenue"]}
labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')} labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')}
/> />
<Area <Area

View File

@@ -13,11 +13,11 @@ interface InventoryMetrics {
topVendors: { topVendors: {
vendor: string; vendor: string;
productCount: number; productCount: number;
averageStockLevel: number; averageStockLevel: string;
}[]; }[];
stockTurnover: { stockTurnover: {
category: string; category: string;
rate: number; rate: string;
}[]; }[];
} }
@@ -70,7 +70,7 @@ export function InventoryStats() {
<BarChart data={data?.stockTurnover}> <BarChart data={data?.stockTurnover}>
<XAxis dataKey="category" /> <XAxis dataKey="category" />
<YAxis /> <YAxis />
<Tooltip /> <Tooltip formatter={(value: string) => [Number(value).toFixed(2), "Rate"]} />
<Bar dataKey="rate" name="Turnover Rate" fill="#60a5fa" /> <Bar dataKey="rate" name="Turnover Rate" fill="#60a5fa" />
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
@@ -93,7 +93,7 @@ export function InventoryStats() {
</div> </div>
<div className="ml-4 text-right"> <div className="ml-4 text-right">
<p className="text-sm font-medium"> <p className="text-sm font-medium">
Avg. Stock: {vendor.averageStockLevel.toFixed(0)} Avg. Stock: {Number(vendor.averageStockLevel).toFixed(0)}
</p> </p>
</div> </div>
</div> </div>

View File

@@ -12,19 +12,20 @@ import { Badge } from "@/components/ui/badge"
import { AlertCircle, AlertTriangle } from "lucide-react" import { AlertCircle, AlertTriangle } from "lucide-react"
import config from "@/config" import config from "@/config"
interface LowStockProduct { interface Product {
product_id: number pid: number;
SKU: string sku: string;
title: string title: string;
stock_quantity: number stock_quantity: number;
reorder_qty: number daily_sales_avg: string;
days_of_inventory: number days_of_inventory: string;
stock_status: "Critical" | "Reorder" reorder_qty: number;
daily_sales_avg: number last_purchase_date: string | null;
lead_time_status: string;
} }
export function LowStockAlerts() { export function LowStockAlerts() {
const { data: products } = useQuery<LowStockProduct[]>({ const { data: products } = useQuery<Product[]>({
queryKey: ["low-stock"], queryKey: ["low-stock"],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/low-stock/products`) const response = await fetch(`${config.apiUrl}/dashboard/low-stock/products`)
@@ -45,35 +46,37 @@ export function LowStockAlerts() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>SKU</TableHead>
<TableHead>Product</TableHead> <TableHead>Product</TableHead>
<TableHead className="text-right">Stock</TableHead> <TableHead className="text-right">Stock</TableHead>
<TableHead className="text-right">Status</TableHead> <TableHead className="text-right">Daily Sales</TableHead>
<TableHead className="text-right">Days Left</TableHead>
<TableHead className="text-right">Reorder Qty</TableHead>
<TableHead>Last Purchase</TableHead>
<TableHead>Lead Time</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{products?.map((product) => ( {products?.map((product) => (
<TableRow key={product.product_id}> <TableRow key={product.pid}>
<TableCell className="font-medium">{product.SKU}</TableCell> <TableCell>
<TableCell>{product.title}</TableCell> <a
<TableCell className="text-right"> href={`https://backend.acherryontop.com/product/${product.pid}`}
{product.stock_quantity} / {product.reorder_qty} target="_blank"
</TableCell> rel="noopener noreferrer"
<TableCell className="text-right"> className="hover:underline"
<Badge
variant="outline"
className={
product.stock_status === "Critical"
? "border-destructive text-destructive"
: "border-warning text-warning"
}
> >
{product.stock_status === "Critical" ? ( {product.title}
<AlertCircle className="mr-1 h-3 w-3" /> </a>
) : ( <div className="text-sm text-muted-foreground">{product.sku}</div>
<AlertTriangle className="mr-1 h-3 w-3" /> </TableCell>
)} <TableCell className="text-right">{product.stock_quantity}</TableCell>
{product.stock_status} <TableCell className="text-right">{Number(product.daily_sales_avg).toFixed(1)}</TableCell>
<TableCell className="text-right">{Number(product.days_of_inventory).toFixed(1)}</TableCell>
<TableCell className="text-right">{product.reorder_qty}</TableCell>
<TableCell>{product.last_purchase_date ? formatDate(product.last_purchase_date) : '-'}</TableCell>
<TableCell>
<Badge variant={getLeadTimeVariant(product.lead_time_status)}>
{product.lead_time_status}
</Badge> </Badge>
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@@ -5,13 +5,14 @@ import config from "@/config"
import { formatCurrency } from "@/lib/utils" import { formatCurrency } from "@/lib/utils"
import { ClipboardList, AlertCircle, Layers, DollarSign, ShoppingCart } from "lucide-react" // Importing icons import { ClipboardList, AlertCircle, Layers, DollarSign, ShoppingCart } from "lucide-react" // Importing icons
import { useState } from "react" import { useState } from "react"
import { PurchaseOrderStatus, ReceivingStatus } from "@/types/status-codes"
interface PurchaseMetricsData { interface PurchaseMetricsData {
activePurchaseOrders: number activePurchaseOrders: number // Orders that are not canceled, done, or fully received
overduePurchaseOrders: number overduePurchaseOrders: number // Orders past their expected delivery date
onOrderUnits: number onOrderUnits: number // Total units across all active orders
onOrderCost: number onOrderCost: number // Total cost across all active orders
onOrderRetail: number onOrderRetail: number // Total retail value across all active orders
vendorOrders: { vendorOrders: {
vendor: string vendor: string
orders: number orders: number

View File

@@ -12,13 +12,13 @@ import { DateRangePicker } from "@/components/ui/date-range-picker-narrow"
interface SalesData { interface SalesData {
totalOrders: number totalOrders: number
totalUnitsSold: number totalUnitsSold: number
totalCogs: number totalCogs: string
totalRevenue: number totalRevenue: string
dailySales: { dailySales: {
date: string date: string
units: number units: number
revenue: number revenue: string
cogs: number cogs: string
}[] }[]
} }
@@ -78,14 +78,14 @@ export function SalesMetrics() {
<DollarSign className="h-4 w-4 text-muted-foreground" /> <DollarSign className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Cost of Goods</p> <p className="text-sm font-medium text-muted-foreground">Cost of Goods</p>
</div> </div>
<p className="text-lg font-bold">{formatCurrency(data?.totalCogs || 0)}</p> <p className="text-lg font-bold">{formatCurrency(Number(data?.totalCogs) || 0)}</p>
</div> </div>
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ShoppingCart className="h-4 w-4 text-muted-foreground" /> <ShoppingCart className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Revenue</p> <p className="text-sm font-medium text-muted-foreground">Revenue</p>
</div> </div>
<p className="text-lg font-bold">{formatCurrency(data?.totalRevenue || 0)}</p> <p className="text-lg font-bold">{formatCurrency(Number(data?.totalRevenue) || 0)}</p>
</div> </div>
</div> </div>
@@ -107,7 +107,7 @@ export function SalesMetrics() {
tick={false} tick={false}
/> />
<Tooltip <Tooltip
formatter={(value: number) => [formatCurrency(value), "Revenue"]} formatter={(value: string) => [formatCurrency(Number(value)), "Revenue"]}
labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')} labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')}
/> />
<Area <Area

View File

@@ -10,14 +10,14 @@ interface StockMetricsData {
totalProducts: number totalProducts: number
productsInStock: number productsInStock: number
totalStockUnits: number totalStockUnits: number
totalStockCost: number totalStockCost: string
totalStockRetail: number totalStockRetail: string
brandStock: { brandStock: {
brand: string brand: string
variants: number variants: number
units: number units: number
cost: number cost: string
retail: number retail: string
}[] }[]
} }
@@ -91,7 +91,7 @@ const renderActiveShape = (props: any) => {
fill="#000000" fill="#000000"
className="text-base font-medium" className="text-base font-medium"
> >
{formatCurrency(retail)} {formatCurrency(Number(retail))}
</text> </text>
</g> </g>
); );
@@ -154,14 +154,14 @@ export function StockMetrics() {
<DollarSign className="h-4 w-4 text-muted-foreground" /> <DollarSign className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Stock Cost</p> <p className="text-sm font-medium text-muted-foreground">Stock Cost</p>
</div> </div>
<p className="text-lg font-bold">{formatCurrency(data?.totalStockCost || 0)}</p> <p className="text-lg font-bold">{formatCurrency(Number(data?.totalStockCost) || 0)}</p>
</div> </div>
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ShoppingCart className="h-4 w-4 text-muted-foreground" /> <ShoppingCart className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium text-muted-foreground">Stock Retail</p> <p className="text-sm font-medium text-muted-foreground">Stock Retail</p>
</div> </div>
<p className="text-lg font-bold">{formatCurrency(data?.totalStockRetail || 0)}</p> <p className="text-lg font-bold">{formatCurrency(Number(data?.totalStockRetail) || 0)}</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -5,18 +5,18 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import config from "@/config" import config from "@/config"
import { formatCurrency } from "@/lib/utils" import { formatCurrency } from "@/lib/utils"
interface OverstockedProduct { interface Product {
product_id: number pid: number;
SKU: string sku: string;
title: string title: string;
stock_quantity: number stock_quantity: number;
overstocked_amt: number overstocked_amt: number;
excess_cost: number excess_cost: number;
excess_retail: number excess_retail: number;
} }
export function TopOverstockedProducts() { export function TopOverstockedProducts() {
const { data } = useQuery<OverstockedProduct[]>({ const { data } = useQuery<Product[]>({
queryKey: ["top-overstocked-products"], queryKey: ["top-overstocked-products"],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/overstock/products?limit=50`) const response = await fetch(`${config.apiUrl}/dashboard/overstock/products?limit=50`)
@@ -38,40 +38,30 @@ export function TopOverstockedProducts() {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Product</TableHead> <TableHead>Product</TableHead>
<TableHead className="text-right">Current Stock</TableHead> <TableHead className="text-right">Stock</TableHead>
<TableHead className="text-right">Overstock Amt</TableHead> <TableHead className="text-right">Excess</TableHead>
<TableHead className="text-right">Overstock Cost</TableHead> <TableHead className="text-right">Cost</TableHead>
<TableHead className="text-right">Overstock Retail</TableHead> <TableHead className="text-right">Retail</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data?.map((product) => ( {data?.map((product) => (
<TableRow key={product.product_id}> <TableRow key={product.pid}>
<TableCell> <TableCell>
<div>
<a <a
href={`https://backend.acherryontop.com/product/${product.product_id}`} href={`https://backend.acherryontop.com/product/${product.pid}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="font-medium hover:underline" className="hover:underline"
> >
{product.title} {product.title}
</a> </a>
<p className="text-sm text-muted-foreground">{product.SKU}</p> <div className="text-sm text-muted-foreground">{product.sku}</div>
</div>
</TableCell>
<TableCell className="text-right">
{product.stock_quantity.toLocaleString()}
</TableCell>
<TableCell className="text-right">
{product.overstocked_amt.toLocaleString()}
</TableCell>
<TableCell className="text-right">
{formatCurrency(product.excess_cost)}
</TableCell>
<TableCell className="text-right">
{formatCurrency(product.excess_retail)}
</TableCell> </TableCell>
<TableCell className="text-right">{product.stock_quantity}</TableCell>
<TableCell className="text-right">{product.overstocked_amt}</TableCell>
<TableCell className="text-right">{formatCurrency(product.excess_cost)}</TableCell>
<TableCell className="text-right">{formatCurrency(product.excess_retail)}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>

View File

@@ -3,20 +3,19 @@ import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import config from "@/config" import config from "@/config"
import { formatCurrency } from "@/lib/utils"
interface ReplenishProduct { interface Product {
product_id: number pid: number;
SKU: string sku: string;
title: string title: string;
current_stock: number stock_quantity: number;
replenish_qty: number daily_sales_avg: string;
replenish_cost: number reorder_qty: number;
replenish_retail: number last_purchase_date: string | null;
} }
export function TopReplenishProducts() { export function TopReplenishProducts() {
const { data } = useQuery<ReplenishProduct[]>({ const { data } = useQuery<Product[]>({
queryKey: ["top-replenish-products"], queryKey: ["top-replenish-products"],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${config.apiUrl}/dashboard/replenish/products?limit=50`) const response = await fetch(`${config.apiUrl}/dashboard/replenish/products?limit=50`)
@@ -39,39 +38,29 @@ export function TopReplenishProducts() {
<TableRow> <TableRow>
<TableHead>Product</TableHead> <TableHead>Product</TableHead>
<TableHead className="text-right">Stock</TableHead> <TableHead className="text-right">Stock</TableHead>
<TableHead className="text-right">Replenish</TableHead> <TableHead className="text-right">Daily Sales</TableHead>
<TableHead className="text-right">Cost</TableHead> <TableHead className="text-right">Reorder Qty</TableHead>
<TableHead className="text-right">Retail</TableHead> <TableHead>Last Purchase</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data?.map((product) => ( {data?.map((product) => (
<TableRow key={product.product_id}> <TableRow key={product.pid}>
<TableCell> <TableCell>
<div>
<a <a
href={`https://backend.acherryontop.com/product/${product.product_id}`} href={`https://backend.acherryontop.com/product/${product.pid}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="font-medium hover:underline" className="hover:underline"
> >
{product.title} {product.title}
</a> </a>
<p className="text-sm text-muted-foreground">{product.SKU}</p> <div className="text-sm text-muted-foreground">{product.sku}</div>
</div>
</TableCell>
<TableCell className="text-right">
{product.current_stock.toLocaleString()}
</TableCell>
<TableCell className="text-right">
{product.replenish_qty.toLocaleString()}
</TableCell>
<TableCell className="text-right">
{formatCurrency(product.replenish_cost)}
</TableCell>
<TableCell className="text-right">
{formatCurrency(product.replenish_retail)}
</TableCell> </TableCell>
<TableCell className="text-right">{product.stock_quantity}</TableCell>
<TableCell className="text-right">{Number(product.daily_sales_avg).toFixed(1)}</TableCell>
<TableCell className="text-right">{product.reorder_qty}</TableCell>
<TableCell>{product.last_purchase_date ? product.last_purchase_date : '-'}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>

View File

@@ -11,18 +11,18 @@ import {
import { TrendingUp, TrendingDown } from "lucide-react" import { TrendingUp, TrendingDown } from "lucide-react"
import config from "@/config" import config from "@/config"
interface TrendingProduct { interface Product {
product_id: number pid: number;
sku: string sku: string;
title: string title: string;
daily_sales_avg: string daily_sales_avg: string;
weekly_sales_avg: string weekly_sales_avg: string;
growth_rate: string growth_rate: string;
total_revenue: string total_revenue: string;
} }
export function TrendingProducts() { export function TrendingProducts() {
const { data: products } = useQuery<TrendingProduct[]>({ const { data: products } = useQuery<Product[]>({
queryKey: ["trending-products"], queryKey: ["trending-products"],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${config.apiUrl}/products/trending`) const response = await fetch(`${config.apiUrl}/products/trending`)
@@ -33,7 +33,6 @@ export function TrendingProducts() {
}, },
}) })
const formatPercent = (value: number) => const formatPercent = (value: number) =>
new Intl.NumberFormat("en-US", { new Intl.NumberFormat("en-US", {
style: "percent", style: "percent",
@@ -42,6 +41,14 @@ export function TrendingProducts() {
signDisplay: "exceptZero", signDisplay: "exceptZero",
}).format(value / 100) }).format(value / 100)
const formatCurrency = (value: number) =>
new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value)
return ( return (
<> <>
<CardHeader> <CardHeader>
@@ -59,7 +66,7 @@ export function TrendingProducts() {
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{products?.map((product) => ( {products?.map((product) => (
<TableRow key={product.product_id}> <TableRow key={product.pid}>
<TableCell className="font-medium"> <TableCell className="font-medium">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium">{product.title}</span> <span className="font-medium">{product.title}</span>
@@ -68,20 +75,20 @@ export function TrendingProducts() {
</span> </span>
</div> </div>
</TableCell> </TableCell>
<TableCell>{parseFloat(product.daily_sales_avg).toFixed(1)}</TableCell> <TableCell>{Number(product.daily_sales_avg).toFixed(1)}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
{parseFloat(product.growth_rate) > 0 ? ( {Number(product.growth_rate) > 0 ? (
<TrendingUp className="h-4 w-4 text-success" /> <TrendingUp className="h-4 w-4 text-success" />
) : ( ) : (
<TrendingDown className="h-4 w-4 text-destructive" /> <TrendingDown className="h-4 w-4 text-destructive" />
)} )}
<span <span
className={ className={
parseFloat(product.growth_rate) > 0 ? "text-success" : "text-destructive" Number(product.growth_rate) > 0 ? "text-success" : "text-destructive"
} }
> >
{formatPercent(parseFloat(product.growth_rate))} {formatPercent(Number(product.growth_rate))}
</span> </span>
</div> </div>
</TableCell> </TableCell>

View File

@@ -3,24 +3,27 @@ import { ArrowUpDown, ChevronDown, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
interface ProductDetail {
product_id: string; interface Product {
name: string; pid: string;
sku: string; sku: string;
title: string;
stock_quantity: number; stock_quantity: number;
total_sold: number; daily_sales_avg: number;
avg_price: number; forecast_units: number;
first_received_date: string; forecast_revenue: number;
confidence_level: number;
} }
export interface ForecastItem { export interface ForecastItem {
category: string; category: string;
categoryPath: string;
avgDailySales: number; avgDailySales: number;
totalSold: number; totalSold: number;
numProducts: number; numProducts: number;
avgPrice: number; avgPrice: number;
avgTotalSold: number; avgTotalSold: number;
products?: ProductDetail[]; products?: Product[];
} }
export const columns: ColumnDef<ForecastItem>[] = [ export const columns: ColumnDef<ForecastItem>[] = [
@@ -42,6 +45,16 @@ export const columns: ColumnDef<ForecastItem>[] = [
{ {
accessorKey: "category", accessorKey: "category",
header: "Category", header: "Category",
cell: ({ row }) => (
<div>
<div className="font-medium">{row.original.category}</div>
{row.original.categoryPath && (
<div className="text-sm text-muted-foreground">
{row.original.categoryPath}
</div>
)}
</div>
),
}, },
{ {
accessorKey: "avgDailySales", accessorKey: "avgDailySales",
@@ -147,23 +160,33 @@ export const renderSubComponent = ({ row }: { row: any }) => {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Product Name</TableHead> <TableHead>Product</TableHead>
<TableHead>SKU</TableHead> <TableHead className="text-right">Stock</TableHead>
<TableHead>First Received</TableHead> <TableHead className="text-right">Daily Sales</TableHead>
<TableHead>Stock Quantity</TableHead> <TableHead className="text-right">Forecast Units</TableHead>
<TableHead>Total Sold</TableHead> <TableHead className="text-right">Forecast Revenue</TableHead>
<TableHead>Average Price</TableHead> <TableHead className="text-right">Confidence</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{products.map((product: ProductDetail) => ( {products.map((product) => (
<TableRow key={product.product_id}> <TableRow key={product.pid}>
<TableCell>{product.name}</TableCell> <TableCell>
<TableCell>{product.sku}</TableCell> <a
<TableCell>{product.first_received_date}</TableCell> href={`https://backend.acherryontop.com/product/${product.pid}`}
<TableCell>{product.stock_quantity.toLocaleString()}</TableCell> target="_blank"
<TableCell>{product.total_sold.toLocaleString()}</TableCell> rel="noopener noreferrer"
<TableCell>${product.avg_price.toFixed(2)}</TableCell> className="hover:underline"
>
{product.title}
</a>
<div className="text-sm text-muted-foreground">{product.sku}</div>
</TableCell>
<TableCell className="text-right">{product.stock_quantity}</TableCell>
<TableCell className="text-right">{product.daily_sales_avg.toFixed(1)}</TableCell>
<TableCell className="text-right">{product.forecast_units.toFixed(1)}</TableCell>
<TableCell className="text-right">{product.forecast_revenue.toFixed(2)}</TableCell>
<TableCell className="text-right">{product.confidence_level.toFixed(1)}%</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>

View File

@@ -10,7 +10,7 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
import config from "@/config"; import config from "@/config";
interface Product { interface Product {
product_id: number; pid: number;
title: string; title: string;
SKU: string; SKU: string;
barcode: string; barcode: string;
@@ -38,7 +38,7 @@ interface Product {
// Vendor info // Vendor info
vendor: string; vendor: string;
vendor_reference: string; vendor_reference: string;
brand: string; brand: string | 'Unbranded';
// URLs // URLs
permalink: string; permalink: string;
@@ -123,6 +123,8 @@ interface Product {
notes: string; notes: string;
lead_time_days: number | null; lead_time_days: number | null;
}>; }>;
category_paths?: Record<string, string>;
} }
interface ProductDetailProps { interface ProductDetailProps {
@@ -205,8 +207,8 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
</div> </div>
)} )}
<div> <div>
<h2 className="text-xl font-semibold">{product?.title || 'Loading...'}</h2> <VaulDrawer.Title className="text-xl font-semibold">{product?.title || 'Loading...'}</VaulDrawer.Title>
<p className="text-sm text-muted-foreground">{product?.SKU || ''}</p> <VaulDrawer.Description className="text-sm text-muted-foreground">{product?.SKU || ''}</VaulDrawer.Description>
</div> </div>
</div> </div>
<Button variant="ghost" size="icon" onClick={onClose}> <Button variant="ghost" size="icon" onClick={onClose}>
@@ -255,22 +257,28 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
</div> </div>
<div> <div>
<dt className="text-sm text-muted-foreground">Categories</dt> <dt className="text-sm text-muted-foreground">Categories</dt>
<dd className="flex flex-wrap gap-2"> <dd className="flex flex-col gap-2">
{product?.categories?.map(category => ( {product?.category_paths ?
<span key={category} className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium ring-1 ring-inset ring-muted"> Object.entries(product.category_paths).map(([key, fullPath], index) => {
{category} const [, leafCategory] = key.split(':');
return (
<div key={key} className="flex flex-col">
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium ring-1 ring-inset ring-muted">
{leafCategory}
</span> </span>
)) || "N/A"} <span className="text-xs text-muted-foreground ml-2 mt-1">
{fullPath}
</span>
</div>
);
})
: "N/A"}
</dd> </dd>
</div> </div>
<div> <div>
<dt className="text-sm text-muted-foreground">Tags</dt> <dt className="text-sm text-muted-foreground">Tags</dt>
<dd className="flex flex-wrap gap-2"> <dd className="flex flex-wrap gap-2">
{product?.tags?.map(tag => ( N/A
<span key={tag} className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium ring-1 ring-inset ring-muted">
{tag}
</span>
)) || "N/A"}
</dd> </dd>
</div> </div>
</dl> </dl>
@@ -307,11 +315,11 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
</div> </div>
<div> <div>
<dt className="text-sm text-muted-foreground">Status</dt> <dt className="text-sm text-muted-foreground">Status</dt>
<dd>{product?.metrics?.stock_status}</dd> <dd>{product?.stock_status || "N/A"}</dd>
</div> </div>
<div> <div>
<dt className="text-sm text-muted-foreground">Days of Stock</dt> <dt className="text-sm text-muted-foreground">Days of Stock</dt>
<dd>{product?.metrics?.days_of_inventory} days</dd> <dd>{product?.days_of_inventory || 0} days</dd>
</div> </div>
</dl> </dl>
</Card> </Card>
@@ -321,15 +329,15 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
<dl className="space-y-2"> <dl className="space-y-2">
<div> <div>
<dt className="text-sm text-muted-foreground">Daily Sales</dt> <dt className="text-sm text-muted-foreground">Daily Sales</dt>
<dd>{product?.metrics?.daily_sales_avg?.toFixed(1)} units</dd> <dd>{product?.daily_sales_avg?.toFixed(1) || "0.0"} units</dd>
</div> </div>
<div> <div>
<dt className="text-sm text-muted-foreground">Weekly Sales</dt> <dt className="text-sm text-muted-foreground">Weekly Sales</dt>
<dd>{product?.metrics?.weekly_sales_avg?.toFixed(1)} units</dd> <dd>{product?.weekly_sales_avg?.toFixed(1) || "0.0"} units</dd>
</div> </div>
<div> <div>
<dt className="text-sm text-muted-foreground">Monthly Sales</dt> <dt className="text-sm text-muted-foreground">Monthly Sales</dt>
<dd>{product?.metrics?.monthly_sales_avg?.toFixed(1)} units</dd> <dd>{product?.monthly_sales_avg?.toFixed(1) || "0.0"} units</dd>
</div> </div>
</dl> </dl>
</Card> </Card>
@@ -356,19 +364,19 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
<dl className="space-y-2"> <dl className="space-y-2">
<div> <div>
<dt className="text-sm text-muted-foreground">Total Revenue</dt> <dt className="text-sm text-muted-foreground">Total Revenue</dt>
<dd>${formatPrice(product?.metrics.total_revenue)}</dd> <dd>${formatPrice(product?.total_revenue)}</dd>
</div> </div>
<div> <div>
<dt className="text-sm text-muted-foreground">Gross Profit</dt> <dt className="text-sm text-muted-foreground">Gross Profit</dt>
<dd>${formatPrice(product?.metrics.gross_profit)}</dd> <dd>${formatPrice(product?.gross_profit)}</dd>
</div> </div>
<div> <div>
<dt className="text-sm text-muted-foreground">Margin</dt> <dt className="text-sm text-muted-foreground">Margin</dt>
<dd>{product?.metrics.avg_margin_percent.toFixed(2)}%</dd> <dd>{product?.avg_margin_percent?.toFixed(2) || "0.00"}%</dd>
</div> </div>
<div> <div>
<dt className="text-sm text-muted-foreground">GMROI</dt> <dt className="text-sm text-muted-foreground">GMROI</dt>
<dd>{product?.metrics.gmroi.toFixed(2)}</dd> <dd>{product?.gmroi?.toFixed(2) || "0.00"}</dd>
</div> </div>
</dl> </dl>
</Card> </Card>
@@ -378,15 +386,15 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
<dl className="space-y-2"> <dl className="space-y-2">
<div> <div>
<dt className="text-sm text-muted-foreground">Current Lead Time</dt> <dt className="text-sm text-muted-foreground">Current Lead Time</dt>
<dd>{product?.metrics.current_lead_time}</dd> <dd>{product?.current_lead_time || "N/A"}</dd>
</div> </div>
<div> <div>
<dt className="text-sm text-muted-foreground">Target Lead Time</dt> <dt className="text-sm text-muted-foreground">Target Lead Time</dt>
<dd>{product?.metrics.target_lead_time}</dd> <dd>{product?.target_lead_time || "N/A"}</dd>
</div> </div>
<div> <div>
<dt className="text-sm text-muted-foreground">Lead Time Status</dt> <dt className="text-sm text-muted-foreground">Lead Time Status</dt>
<dd>{product?.metrics.lead_time_status}</dd> <dd>{product?.lead_time_status || "N/A"}</dd>
</div> </div>
</dl> </dl>
</Card> </Card>
@@ -408,11 +416,11 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
</div> </div>
<div> <div>
<dt className="text-sm text-muted-foreground">Days of Inventory</dt> <dt className="text-sm text-muted-foreground">Days of Inventory</dt>
<dd className="text-2xl font-semibold">{product?.metrics?.days_of_inventory || 0}</dd> <dd className="text-2xl font-semibold">{product?.days_of_inventory || 0}</dd>
</div> </div>
<div> <div>
<dt className="text-sm text-muted-foreground">Status</dt> <dt className="text-sm text-muted-foreground">Status</dt>
<dd className="text-2xl font-semibold">{product?.metrics?.stock_status || "N/A"}</dd> <dd className="text-2xl font-semibold">{product?.stock_status || "N/A"}</dd>
</div> </div>
</dl> </dl>
</Card> </Card>
@@ -422,15 +430,15 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
<dl className="grid grid-cols-3 gap-4"> <dl className="grid grid-cols-3 gap-4">
<div> <div>
<dt className="text-sm text-muted-foreground">Reorder Point</dt> <dt className="text-sm text-muted-foreground">Reorder Point</dt>
<dd>{product?.metrics?.reorder_point || 0}</dd> <dd>{product?.reorder_point || 0}</dd>
</div> </div>
<div> <div>
<dt className="text-sm text-muted-foreground">Safety Stock</dt> <dt className="text-sm text-muted-foreground">Safety Stock</dt>
<dd>{product?.metrics?.safety_stock || 0}</dd> <dd>{product?.safety_stock || 0}</dd>
</div> </div>
<div> <div>
<dt className="text-sm text-muted-foreground">ABC Class</dt> <dt className="text-sm text-muted-foreground">ABC Class</dt>
<dd>{product?.metrics?.abc_class || "N/A"}</dd> <dd>{product?.abc_class || "N/A"}</dd>
</div> </div>
</dl> </dl>
</Card> </Card>
@@ -551,15 +559,15 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
<dl className="grid grid-cols-3 gap-4"> <dl className="grid grid-cols-3 gap-4">
<div> <div>
<dt className="text-sm text-muted-foreground">Gross Profit</dt> <dt className="text-sm text-muted-foreground">Gross Profit</dt>
<dd className="text-2xl font-semibold">${formatPrice(product?.metrics.gross_profit)}</dd> <dd className="text-2xl font-semibold">${formatPrice(product?.gross_profit)}</dd>
</div> </div>
<div> <div>
<dt className="text-sm text-muted-foreground">GMROI</dt> <dt className="text-sm text-muted-foreground">GMROI</dt>
<dd className="text-2xl font-semibold">{product?.metrics.gmroi.toFixed(2)}</dd> <dd className="text-2xl font-semibold">{product?.gmroi?.toFixed(2) || "0.00"}</dd>
</div> </div>
<div> <div>
<dt className="text-sm text-muted-foreground">Margin %</dt> <dt className="text-sm text-muted-foreground">Margin %</dt>
<dd className="text-2xl font-semibold">{product?.metrics.avg_margin_percent.toFixed(2)}%</dd> <dd className="text-2xl font-semibold">{product?.avg_margin_percent?.toFixed(2) || "0.00"}%</dd>
</div> </div>
</dl> </dl>
</Card> </Card>
@@ -569,7 +577,7 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
<dl className="grid grid-cols-2 gap-4"> <dl className="grid grid-cols-2 gap-4">
<div> <div>
<dt className="text-sm text-muted-foreground">Cost of Goods Sold</dt> <dt className="text-sm text-muted-foreground">Cost of Goods Sold</dt>
<dd>${formatPrice(product?.metrics.cost_of_goods_sold)}</dd> <dd>${formatPrice(product?.cost_of_goods_sold)}</dd>
</div> </div>
<div> <div>
<dt className="text-sm text-muted-foreground">Landing Cost</dt> <dt className="text-sm text-muted-foreground">Landing Cost</dt>

View File

@@ -24,7 +24,7 @@ type FilterValue = string | number | boolean;
type ComparisonOperator = "=" | ">" | ">=" | "<" | "<=" | "between"; type ComparisonOperator = "=" | ">" | ">=" | "<" | "<=" | "between";
interface FilterValueWithOperator { interface FilterValueWithOperator {
value: FilterValue | [number, number]; value: FilterValue | [string, string];
operator: ComparisonOperator; operator: ComparisonOperator;
} }
@@ -317,18 +317,32 @@ export function ProductFilters({
}); });
}, []); }, []);
const handleApplyFilter = (value: FilterValue | [number, number]) => { const handleApplyFilter = (value: FilterValue | [string, string]) => {
if (!selectedFilter) return; if (!selectedFilter) return;
const newFilters = { let filterValue: ActiveFilterValue;
...activeFilters,
[selectedFilter.id]: { if (selectedFilter.type === "number") {
value, if (selectedOperator === "between" && Array.isArray(value)) {
operator: selectedOperator, filterValue = {
}, value: [value[0].toString(), value[1].toString()],
}; operator: selectedOperator,
};
} else {
filterValue = {
value: value.toString(),
operator: selectedOperator,
};
}
} else {
filterValue = value;
}
onFilterChange({
...activeFilters,
[selectedFilter.id]: filterValue,
});
onFilterChange(newFilters as Record<string, ActiveFilterValue>);
handlePopoverClose(); handlePopoverClose();
}; };
@@ -394,38 +408,14 @@ export function ProductFilters({
const getFilterDisplayValue = (filter: ActiveFilter) => { const getFilterDisplayValue = (filter: ActiveFilter) => {
const filterValue = activeFilters[filter.id]; if (typeof filter.value === "object" && "operator" in filter.value) {
const filterOption = filterOptions.find((opt) => opt.id === filter.id); const { operator, value } = filter.value;
if (Array.isArray(value)) {
// For between ranges return `${operator} ${value[0]} and ${value[1]}`;
if (Array.isArray(filterValue)) {
return `${filter.label} between ${filterValue[0]} and ${filterValue[1]}`;
} }
return `${operator} ${value}`;
// For direct selections (select type) or text search
if (
filterOption?.type === "select" ||
filterOption?.type === "text" ||
typeof filterValue !== "object"
) {
const value =
typeof filterValue === "object" ? filterValue.value : filterValue;
return `${filter.label}: ${value}`;
} }
return filter.value.toString();
// For numeric filters with operators
const operator = filterValue.operator;
const value = filterValue.value;
const operatorDisplay = {
"=": "=",
">": ">",
">=": "≥",
"<": "<",
"<=": "≤",
between: "between",
}[operator];
return `${filter.label} ${operatorDisplay} ${value}`;
}; };
return ( return (

View File

@@ -230,7 +230,7 @@ export function ProductTable({
return ( return (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{Array.from(new Set(value as string[])).map((category) => ( {Array.from(new Set(value as string[])).map((category) => (
<Badge key={`${product.product_id}-${category}`} variant="outline">{category}</Badge> <Badge key={`${product.pid}-${category}`} variant="outline">{category}</Badge>
)) || '-'} )) || '-'}
</div> </div>
); );
@@ -261,6 +261,11 @@ export function ProductTable({
return columnDef.format(num); return columnDef.format(num);
} }
} }
// If the value is already a number, format it directly
if (typeof value === 'number') {
return columnDef.format(value);
}
// For other formats (e.g., date formatting), pass the value as is
return columnDef.format(value); return columnDef.format(value);
} }
return value ?? '-'; return value ?? '-';
@@ -297,12 +302,12 @@ export function ProductTable({
<TableBody> <TableBody>
{products.map((product) => ( {products.map((product) => (
<TableRow <TableRow
key={product.product_id} key={product.pid}
onClick={() => onRowClick?.(product)} onClick={() => onRowClick?.(product)}
className="cursor-pointer" className="cursor-pointer"
> >
{orderedColumns.map((column) => ( {orderedColumns.map((column) => (
<TableCell key={`${product.product_id}-${column}`}> <TableCell key={`${product.pid}-${column}`}>
{formatColumnValue(product, column)} {formatColumnValue(product, column)}
</TableCell> </TableCell>
))} ))}

View File

@@ -8,7 +8,7 @@ import config from '../../config';
interface SalesVelocityConfig { interface SalesVelocityConfig {
id: number; id: number;
category_id: number | null; cat_id: number | null;
vendor: string | null; vendor: string | null;
daily_window_days: number; daily_window_days: number;
weekly_window_days: number; weekly_window_days: number;
@@ -18,7 +18,7 @@ interface SalesVelocityConfig {
export function CalculationSettings() { export function CalculationSettings() {
const [salesVelocityConfig, setSalesVelocityConfig] = useState<SalesVelocityConfig>({ const [salesVelocityConfig, setSalesVelocityConfig] = useState<SalesVelocityConfig>({
id: 1, id: 1,
category_id: null, cat_id: null,
vendor: null, vendor: null,
daily_window_days: 30, daily_window_days: 30,
weekly_window_days: 7, weekly_window_days: 7,

View File

@@ -6,10 +6,11 @@ import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { toast } from "sonner"; import { toast } from "sonner";
import config from '../../config'; import config from '../../config';
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/components/ui/table";
interface StockThreshold { interface StockThreshold {
id: number; id: number;
category_id: number | null; cat_id: number | null;
vendor: string | null; vendor: string | null;
critical_days: number; critical_days: number;
reorder_days: number; reorder_days: number;
@@ -22,7 +23,7 @@ interface StockThreshold {
interface LeadTimeThreshold { interface LeadTimeThreshold {
id: number; id: number;
category_id: number | null; cat_id: number | null;
vendor: string | null; vendor: string | null;
target_days: number; target_days: number;
warning_days: number; warning_days: number;
@@ -31,7 +32,7 @@ interface LeadTimeThreshold {
interface SalesVelocityConfig { interface SalesVelocityConfig {
id: number; id: number;
category_id: number | null; cat_id: number | null;
vendor: string | null; vendor: string | null;
daily_window_days: number; daily_window_days: number;
weekly_window_days: number; weekly_window_days: number;
@@ -47,7 +48,7 @@ interface ABCClassificationConfig {
interface SafetyStockConfig { interface SafetyStockConfig {
id: number; id: number;
category_id: number | null; cat_id: number | null;
vendor: string | null; vendor: string | null;
coverage_days: number; coverage_days: number;
service_level: number; service_level: number;
@@ -55,7 +56,7 @@ interface SafetyStockConfig {
interface TurnoverConfig { interface TurnoverConfig {
id: number; id: number;
category_id: number | null; cat_id: number | null;
vendor: string | null; vendor: string | null;
calculation_period_days: number; calculation_period_days: number;
target_rate: number; target_rate: number;
@@ -64,7 +65,7 @@ interface TurnoverConfig {
export function Configuration() { export function Configuration() {
const [stockThresholds, setStockThresholds] = useState<StockThreshold>({ const [stockThresholds, setStockThresholds] = useState<StockThreshold>({
id: 1, id: 1,
category_id: null, cat_id: null,
vendor: null, vendor: null,
critical_days: 7, critical_days: 7,
reorder_days: 14, reorder_days: 14,
@@ -75,7 +76,7 @@ export function Configuration() {
const [leadTimeThresholds, setLeadTimeThresholds] = useState<LeadTimeThreshold>({ const [leadTimeThresholds, setLeadTimeThresholds] = useState<LeadTimeThreshold>({
id: 1, id: 1,
category_id: null, cat_id: null,
vendor: null, vendor: null,
target_days: 14, target_days: 14,
warning_days: 21, warning_days: 21,
@@ -84,7 +85,7 @@ export function Configuration() {
const [salesVelocityConfig, setSalesVelocityConfig] = useState<SalesVelocityConfig>({ const [salesVelocityConfig, setSalesVelocityConfig] = useState<SalesVelocityConfig>({
id: 1, id: 1,
category_id: null, cat_id: null,
vendor: null, vendor: null,
daily_window_days: 30, daily_window_days: 30,
weekly_window_days: 7, weekly_window_days: 7,
@@ -100,7 +101,7 @@ export function Configuration() {
const [safetyStockConfig, setSafetyStockConfig] = useState<SafetyStockConfig>({ const [safetyStockConfig, setSafetyStockConfig] = useState<SafetyStockConfig>({
id: 1, id: 1,
category_id: null, cat_id: null,
vendor: null, vendor: null,
coverage_days: 14, coverage_days: 14,
service_level: 95.0 service_level: 95.0
@@ -108,7 +109,7 @@ export function Configuration() {
const [turnoverConfig, setTurnoverConfig] = useState<TurnoverConfig>({ const [turnoverConfig, setTurnoverConfig] = useState<TurnoverConfig>({
id: 1, id: 1,
category_id: null, cat_id: null,
vendor: null, vendor: null,
calculation_period_days: 30, calculation_period_days: 30,
target_rate: 1.0 target_rate: 1.0

View File

@@ -5,10 +5,11 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { toast } from "sonner"; import { toast } from "sonner";
import config from '../../config'; import config from '../../config';
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/components/ui/table";
interface LeadTimeThreshold { interface LeadTimeThreshold {
id: number; id: number;
category_id: number | null; cat_id: number | null;
vendor: string | null; vendor: string | null;
target_days: number; target_days: number;
warning_days: number; warning_days: number;
@@ -17,6 +18,8 @@ interface LeadTimeThreshold {
interface ABCClassificationConfig { interface ABCClassificationConfig {
id: number; id: number;
cat_id: number | null;
vendor: string | null;
a_threshold: number; a_threshold: number;
b_threshold: number; b_threshold: number;
classification_period_days: number; classification_period_days: number;
@@ -24,7 +27,7 @@ interface ABCClassificationConfig {
interface TurnoverConfig { interface TurnoverConfig {
id: number; id: number;
category_id: number | null; cat_id: number | null;
vendor: string | null; vendor: string | null;
calculation_period_days: number; calculation_period_days: number;
target_rate: number; target_rate: number;
@@ -33,27 +36,16 @@ interface TurnoverConfig {
export function PerformanceMetrics() { export function PerformanceMetrics() {
const [leadTimeThresholds, setLeadTimeThresholds] = useState<LeadTimeThreshold>({ const [leadTimeThresholds, setLeadTimeThresholds] = useState<LeadTimeThreshold>({
id: 1, id: 1,
category_id: null, cat_id: null,
vendor: null, vendor: null,
target_days: 14, target_days: 14,
warning_days: 21, warning_days: 21,
critical_days: 30 critical_days: 30
}); });
const [abcConfig, setAbcConfig] = useState<ABCClassificationConfig>({ const [abcConfigs, setAbcConfigs] = useState<ABCClassificationConfig[]>([]);
id: 1,
a_threshold: 20.0,
b_threshold: 50.0,
classification_period_days: 90
});
const [turnoverConfig, setTurnoverConfig] = useState<TurnoverConfig>({ const [turnoverConfigs, setTurnoverConfigs] = useState<TurnoverConfig[]>([]);
id: 1,
category_id: null,
vendor: null,
calculation_period_days: 30,
target_rate: 1.0
});
useEffect(() => { useEffect(() => {
const loadConfig = async () => { const loadConfig = async () => {
@@ -66,8 +58,8 @@ export function PerformanceMetrics() {
} }
const data = await response.json(); const data = await response.json();
setLeadTimeThresholds(data.leadTimeThresholds); setLeadTimeThresholds(data.leadTimeThresholds);
setAbcConfig(data.abcConfig); setAbcConfigs(data.abcConfigs);
setTurnoverConfig(data.turnoverConfig); setTurnoverConfigs(data.turnoverConfigs);
} catch (error) { } catch (error) {
toast.error(`Failed to load configuration: ${error instanceof Error ? error.message : 'Unknown error'}`); toast.error(`Failed to load configuration: ${error instanceof Error ? error.message : 'Unknown error'}`);
} }
@@ -105,7 +97,7 @@ export function PerformanceMetrics() {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
credentials: 'include', credentials: 'include',
body: JSON.stringify(abcConfig) body: JSON.stringify(abcConfigs)
}); });
if (!response.ok) { if (!response.ok) {
@@ -127,7 +119,7 @@ export function PerformanceMetrics() {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
credentials: 'include', credentials: 'include',
body: JSON.stringify(turnoverConfig) body: JSON.stringify(turnoverConfigs)
}); });
if (!response.ok) { if (!response.ok) {
@@ -210,54 +202,28 @@ export function PerformanceMetrics() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-3 gap-4"> <Table>
<div> <TableHeader>
<Label htmlFor="a-threshold">A Threshold (%)</Label> <TableRow>
<Input <TableHead>Category</TableHead>
id="a-threshold" <TableHead>Vendor</TableHead>
type="number" <TableHead className="text-right">A Threshold</TableHead>
min="0" <TableHead className="text-right">B Threshold</TableHead>
max="100" <TableHead className="text-right">Period Days</TableHead>
step="0.1" </TableRow>
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" </TableHeader>
value={abcConfig.a_threshold} <TableBody>
onChange={(e) => setAbcConfig(prev => ({ {abcConfigs.map((config) => (
...prev, <TableRow key={`${config.cat_id}-${config.vendor}`}>
a_threshold: parseFloat(e.target.value) || 0 <TableCell>{config.cat_id ? getCategoryName(config.cat_id) : 'Global'}</TableCell>
}))} <TableCell>{config.vendor || 'All Vendors'}</TableCell>
/> <TableCell className="text-right">{config.a_threshold}%</TableCell>
</div> <TableCell className="text-right">{config.b_threshold}%</TableCell>
<div> <TableCell className="text-right">{config.classification_period_days}</TableCell>
<Label htmlFor="b-threshold">B Threshold (%)</Label> </TableRow>
<Input ))}
id="b-threshold" </TableBody>
type="number" </Table>
min="0"
max="100"
step="0.1"
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={abcConfig.b_threshold}
onChange={(e) => setAbcConfig(prev => ({
...prev,
b_threshold: parseFloat(e.target.value) || 0
}))}
/>
</div>
<div>
<Label htmlFor="classification-period">Classification Period (days)</Label>
<Input
id="classification-period"
type="number"
min="1"
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={abcConfig.classification_period_days}
onChange={(e) => setAbcConfig(prev => ({
...prev,
classification_period_days: parseInt(e.target.value) || 1
}))}
/>
</div>
</div>
<Button onClick={handleUpdateABCConfig}> <Button onClick={handleUpdateABCConfig}>
Update ABC Classification Update ABC Classification
</Button> </Button>
@@ -273,37 +239,26 @@ export function PerformanceMetrics() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
<div className="grid grid-cols-2 gap-4"> <Table>
<div> <TableHeader>
<Label htmlFor="calculation-period">Calculation Period (days)</Label> <TableRow>
<Input <TableHead>Category</TableHead>
id="calculation-period" <TableHead>Vendor</TableHead>
type="number" <TableHead className="text-right">Period Days</TableHead>
min="1" <TableHead className="text-right">Target Rate</TableHead>
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" </TableRow>
value={turnoverConfig.calculation_period_days} </TableHeader>
onChange={(e) => setTurnoverConfig(prev => ({ <TableBody>
...prev, {turnoverConfigs.map((config) => (
calculation_period_days: parseInt(e.target.value) || 1 <TableRow key={`${config.cat_id}-${config.vendor}`}>
}))} <TableCell>{config.cat_id ? getCategoryName(config.cat_id) : 'Global'}</TableCell>
/> <TableCell>{config.vendor || 'All Vendors'}</TableCell>
</div> <TableCell className="text-right">{config.calculation_period_days}</TableCell>
<div> <TableCell className="text-right">{config.target_rate.toFixed(2)}</TableCell>
<Label htmlFor="target-rate">Target Rate</Label> </TableRow>
<Input ))}
id="target-rate" </TableBody>
type="number" </Table>
min="0"
step="0.1"
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={turnoverConfig.target_rate}
onChange={(e) => setTurnoverConfig(prev => ({
...prev,
target_rate: parseFloat(e.target.value) || 0
}))}
/>
</div>
</div>
<Button onClick={handleUpdateTurnoverConfig}> <Button onClick={handleUpdateTurnoverConfig}>
Update Turnover Configuration Update Turnover Configuration
</Button> </Button>

View File

@@ -5,10 +5,11 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { toast } from "sonner"; import { toast } from "sonner";
import config from '../../config'; import config from '../../config';
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/components/ui/table";
interface StockThreshold { interface StockThreshold {
id: number; id: number;
category_id: number | null; cat_id: number | null;
vendor: string | null; vendor: string | null;
critical_days: number; critical_days: number;
reorder_days: number; reorder_days: number;
@@ -19,7 +20,7 @@ interface StockThreshold {
interface SafetyStockConfig { interface SafetyStockConfig {
id: number; id: number;
category_id: number | null; cat_id: number | null;
vendor: string | null; vendor: string | null;
coverage_days: number; coverage_days: number;
service_level: number; service_level: number;
@@ -28,7 +29,7 @@ interface SafetyStockConfig {
export function StockManagement() { export function StockManagement() {
const [stockThresholds, setStockThresholds] = useState<StockThreshold>({ const [stockThresholds, setStockThresholds] = useState<StockThreshold>({
id: 1, id: 1,
category_id: null, cat_id: null,
vendor: null, vendor: null,
critical_days: 7, critical_days: 7,
reorder_days: 14, reorder_days: 14,
@@ -39,7 +40,7 @@ export function StockManagement() {
const [safetyStockConfig, setSafetyStockConfig] = useState<SafetyStockConfig>({ const [safetyStockConfig, setSafetyStockConfig] = useState<SafetyStockConfig>({
id: 1, id: 1,
category_id: null, cat_id: null,
vendor: null, vendor: null,
coverage_days: 14, coverage_days: 14,
service_level: 95.0 service_level: 95.0
@@ -243,6 +244,54 @@ export function StockManagement() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Table>
<TableHeader>
<TableRow>
<TableHead>Category</TableHead>
<TableHead>Vendor</TableHead>
<TableHead className="text-right">Critical Days</TableHead>
<TableHead className="text-right">Reorder Days</TableHead>
<TableHead className="text-right">Overstock Days</TableHead>
<TableHead className="text-right">Low Stock</TableHead>
<TableHead className="text-right">Min Reorder</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stockThresholds.map((threshold) => (
<TableRow key={`${threshold.cat_id}-${threshold.vendor}`}>
<TableCell>{threshold.cat_id ? getCategoryName(threshold.cat_id) : 'Global'}</TableCell>
<TableCell>{threshold.vendor || 'All Vendors'}</TableCell>
<TableCell className="text-right">{threshold.critical_days}</TableCell>
<TableCell className="text-right">{threshold.reorder_days}</TableCell>
<TableCell className="text-right">{threshold.overstock_days}</TableCell>
<TableCell className="text-right">{threshold.low_stock_threshold}</TableCell>
<TableCell className="text-right">{threshold.min_reorder_quantity}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Table>
<TableHeader>
<TableRow>
<TableHead>Category</TableHead>
<TableHead>Vendor</TableHead>
<TableHead className="text-right">Coverage Days</TableHead>
<TableHead className="text-right">Service Level</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{safetyStockConfigs.map((config) => (
<TableRow key={`${config.cat_id}-${config.vendor}`}>
<TableCell>{config.cat_id ? getCategoryName(config.cat_id) : 'Global'}</TableCell>
<TableCell>{config.vendor || 'All Vendors'}</TableCell>
<TableCell className="text-right">{config.coverage_days}</TableCell>
<TableCell className="text-right">{config.service_level}%</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div> </div>
); );
} }

View File

@@ -10,37 +10,66 @@ import { motion } from "motion/react";
import config from "../config"; import config from "../config";
interface Category { interface Category {
category_id: number; cat_id: number;
name: string; name: string;
description: string; type: number;
parent_category?: string; parent_id: number | null;
parent_name: string | null;
parent_type: number | null;
description: string | null;
status: string;
metrics?: {
product_count: number; product_count: number;
active_products: number;
total_value: number; total_value: number;
avg_margin: number; avg_margin: number;
turnover_rate: number; turnover_rate: number;
growth_rate: number; growth_rate: number;
status: string; };
}
interface TypeCount {
type: number;
count: number;
} }
interface CategoryFilters { interface CategoryFilters {
search: string; search: string;
parent: string; type: string;
performance: string; performance: string;
} }
const TYPE_LABELS: Record<number, string> = {
10: 'Section',
11: 'Category',
12: 'Subcategory',
13: 'Sub-subcategory',
20: 'Theme',
21: 'Subtheme'
};
function getCategoryStatusVariant(status: string): "default" | "secondary" | "destructive" | "outline" {
switch (status.toLowerCase()) {
case 'active':
return 'default';
case 'inactive':
return 'secondary';
case 'archived':
return 'destructive';
default:
return 'outline';
}
}
export function Categories() { export function Categories() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [sortColumn, setSortColumn] = useState<keyof Category>("name"); const [sortColumn] = useState<keyof Category>("name");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); const [sortDirection] = useState<"asc" | "desc">("asc");
const [filters, setFilters] = useState<CategoryFilters>({ const [filters, setFilters] = useState<CategoryFilters>({
search: "", search: "",
parent: "all", type: "all",
performance: "all", performance: "all",
}); });
const [] = useState({
column: 'name',
direction: 'asc'
});
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ["categories"], queryKey: ["categories"],
@@ -68,19 +97,15 @@ export function Categories() {
); );
} }
// Apply parent filter // Apply type filter
if (filters.parent !== 'all') { if (filters.type !== 'all') {
if (filters.parent === 'none') { filtered = filtered.filter(category => category.type === parseInt(filters.type));
filtered = filtered.filter(category => !category.parent_category);
} else {
filtered = filtered.filter(category => category.parent_category === filters.parent);
}
} }
// Apply performance filter // Apply performance filter
if (filters.performance !== 'all') { if (filters.performance !== 'all') {
filtered = filtered.filter(category => { filtered = filtered.filter(category => {
const growth = category.growth_rate ?? 0; const growth = category.metrics?.growth_rate ?? 0;
switch (filters.performance) { switch (filters.performance) {
case 'high_growth': return growth >= 20; case 'high_growth': return growth >= 20;
case 'growing': return growth >= 5 && growth < 20; case 'growing': return growth >= 5 && growth < 20;
@@ -93,6 +118,19 @@ export function Categories() {
// Apply sorting // Apply sorting
filtered.sort((a, b) => { filtered.sort((a, b) => {
// First sort by type if not explicitly sorting by another column
if (sortColumn === "name") {
if (a.type !== b.type) {
return a.type - b.type;
}
// Then by parent hierarchy
if (a.parent_id !== b.parent_id) {
if (!a.parent_id) return -1;
if (!b.parent_id) return 1;
return a.parent_id - b.parent_id;
}
}
const aVal = a[sortColumn]; const aVal = a[sortColumn];
const bVal = b[sortColumn]; const bVal = b[sortColumn];
@@ -123,9 +161,9 @@ export function Categories() {
if (!filteredData.length) return data?.stats; if (!filteredData.length) return data?.stats;
const activeCategories = filteredData.filter(c => c.status === 'active').length; const activeCategories = filteredData.filter(c => c.status === 'active').length;
const totalValue = filteredData.reduce((sum, c) => sum + (c.total_value || 0), 0); const totalValue = filteredData.reduce((sum, c) => sum + (c.metrics?.total_value || 0), 0);
const margins = filteredData.map(c => c.avg_margin || 0).filter(m => m !== 0); const margins = filteredData.map(c => c.metrics?.avg_margin || 0).filter(m => m !== 0);
const growthRates = filteredData.map(c => c.growth_rate || 0).filter(g => g !== 0); const growthRates = filteredData.map(c => c.metrics?.growth_rate || 0).filter(g => g !== 0);
return { return {
totalCategories: filteredData.length, totalCategories: filteredData.length,
@@ -136,20 +174,7 @@ export function Categories() {
}; };
}, [filteredData, data?.stats]); }, [filteredData, data?.stats]);
const handleSort = (column: keyof Category) => {
setSortDirection(prev => {
if (sortColumn !== column) return "asc";
return prev === "asc" ? "desc" : "asc";
});
setSortColumn(column);
};
const getPerformanceBadge = (growth: number) => {
if (growth >= 20) return <Badge variant="default">High Growth</Badge>;
if (growth >= 5) return <Badge variant="secondary">Growing</Badge>;
if (growth >= -5) return <Badge variant="outline">Stable</Badge>;
return <Badge variant="destructive">Declining</Badge>;
};
const formatCurrency = (value: number) => { const formatCurrency = (value: number) => {
return new Intl.NumberFormat('en-US', { return new Intl.NumberFormat('en-US', {
@@ -245,17 +270,18 @@ export function Categories() {
className="h-8 w-[150px] lg:w-[250px]" className="h-8 w-[150px] lg:w-[250px]"
/> />
<Select <Select
value={filters.parent} value={filters.type}
onValueChange={(value) => setFilters(prev => ({ ...prev, parent: value }))} onValueChange={(value) => setFilters(prev => ({ ...prev, type: value }))}
> >
<SelectTrigger className="h-8 w-[180px]"> <SelectTrigger className="h-8 w-[180px]">
<SelectValue placeholder="Parent Category" /> <SelectValue placeholder="Category Type" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All Categories</SelectItem> <SelectItem value="all">All Types</SelectItem>
<SelectItem value="none">Top Level Only</SelectItem> {data?.typeCounts?.map((tc: TypeCount) => (
{data?.parentCategories?.map((parent: string) => ( <SelectItem key={tc.type} value={tc.type.toString()}>
<SelectItem key={parent} value={parent}>{parent}</SelectItem> {TYPE_LABELS[tc.type]} ({tc.count})
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -281,48 +307,66 @@ export function Categories() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead onClick={() => handleSort("name")} className="cursor-pointer">Name</TableHead> <TableHead>Type</TableHead>
<TableHead onClick={() => handleSort("parent_category")} className="cursor-pointer">Parent</TableHead> <TableHead>Name</TableHead>
<TableHead onClick={() => handleSort("product_count")} className="cursor-pointer">Products</TableHead> <TableHead>Parent</TableHead>
<TableHead onClick={() => handleSort("total_value")} className="cursor-pointer">Value</TableHead> <TableHead className="text-right">Products</TableHead>
<TableHead onClick={() => handleSort("avg_margin")} className="cursor-pointer">Margin</TableHead> <TableHead className="text-right">Active</TableHead>
<TableHead onClick={() => handleSort("turnover_rate")} className="cursor-pointer">Turnover</TableHead> <TableHead className="text-right">Value</TableHead>
<TableHead onClick={() => handleSort("growth_rate")} className="cursor-pointer">Growth</TableHead> <TableHead className="text-right">Margin</TableHead>
<TableHead onClick={() => handleSort("status")} className="cursor-pointer">Status</TableHead> <TableHead className="text-right">Turnover</TableHead>
<TableHead className="text-right">Growth</TableHead>
<TableHead>Status</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{isLoading ? ( {isLoading ? (
<TableRow> <TableRow>
<TableCell colSpan={8} className="text-center py-8"> <TableCell colSpan={10} className="text-center py-8">
Loading categories... Loading categories...
</TableCell> </TableCell>
</TableRow> </TableRow>
) : paginatedData.map((category: Category) => ( ) : paginatedData.map((category: Category) => (
<TableRow key={category.category_id}> <TableRow key={category.cat_id}>
<TableCell> <TableCell>
<div className="font-medium">{category.name}</div> <Badge variant="outline">
<div className="text-sm text-muted-foreground">{category.description}</div> {TYPE_LABELS[category.type]}
</Badge>
</TableCell> </TableCell>
<TableCell>{category.parent_category || "—"}</TableCell>
<TableCell>{category.product_count?.toLocaleString() ?? 0}</TableCell>
<TableCell>{formatCurrency(category.total_value ?? 0)}</TableCell>
<TableCell>{typeof category.avg_margin === 'number' ? category.avg_margin.toFixed(1) : "0.0"}%</TableCell>
<TableCell>{typeof category.turnover_rate === 'number' ? category.turnover_rate.toFixed(1) : "0.0"}x</TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-2" style={{ minWidth: '120px' }}> <div className="flex flex-col gap-1">
<div style={{ width: '50px', textAlign: 'right' }}> <div className="flex items-center gap-2">
{typeof category.growth_rate === 'number' ? category.growth_rate.toFixed(1) : "0.0"}% <span className="font-medium">{category.name}</span>
</div> </div>
{getPerformanceBadge(category.growth_rate ?? 0)} {category.description && (
<div className="text-xs text-muted-foreground">{category.description}</div>
)}
</div> </div>
</TableCell> </TableCell>
<TableCell>{category.status}</TableCell> <TableCell className="text-sm text-muted-foreground">
{category.type === 10 ? category.name : // Section
category.type === 11 ? `${category.parent_name}` : // Category
category.type === 12 ? `${category.parent_name} > ${category.name}` : // Subcategory
category.type === 13 ? `${category.parent_name} > ${category.name}` : // Sub-subcategory
category.parent_name ? `${category.parent_name} > ${category.name}` : category.name}
</TableCell>
<TableCell className="text-right">{category.metrics?.product_count || 0}</TableCell>
<TableCell className="text-right">{category.metrics?.active_products || 0}</TableCell>
<TableCell className="text-right">{formatCurrency(category.metrics?.total_value || 0)}</TableCell>
<TableCell className="text-right">{category.metrics?.avg_margin?.toFixed(1)}%</TableCell>
<TableCell className="text-right">{category.metrics?.turnover_rate?.toFixed(2)}</TableCell>
<TableCell className="text-right">{category.metrics?.growth_rate?.toFixed(1)}%</TableCell>
<TableCell>
<Badge variant={getCategoryStatusVariant(category.status)}>
{category.status}
</Badge>
</TableCell>
</TableRow> </TableRow>
))} ))}
{!isLoading && !paginatedData.length && ( {!isLoading && !paginatedData.length && (
<TableRow> <TableRow>
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground"> <TableCell colSpan={10} className="text-center py-8 text-muted-foreground">
No categories found No categories found
</TableCell> </TableCell>
</TableRow> </TableRow>

View File

@@ -60,19 +60,23 @@ export default function Forecasting() {
const data = await response.json(); const data = await response.json();
return data.map((item: any) => ({ return data.map((item: any) => ({
category: item.category_name, category: item.category_name,
categoryPath: item.path,
avgDailySales: Number(item.avg_daily_sales) || 0, avgDailySales: Number(item.avg_daily_sales) || 0,
totalSold: Number(item.total_sold) || 0, totalSold: Number(item.total_sold) || 0,
numProducts: Number(item.num_products) || 0, numProducts: Number(item.num_products) || 0,
avgPrice: Number(item.avg_price) || 0, avgPrice: Number(item.avg_price) || 0,
avgTotalSold: Number(item.avgTotalSold) || 0, avgTotalSold: Number(item.avgTotalSold) || 0,
products: item.products?.map((p: any) => ({ products: item.products?.map((p: any) => ({
product_id: p.product_id, pid: p.pid,
name: p.title, title: p.title,
sku: p.sku, sku: p.sku,
stock_quantity: Number(p.stock_quantity) || 0, stock_quantity: Number(p.stock_quantity) || 0,
total_sold: Number(p.total_sold) || 0, total_sold: Number(p.total_sold) || 0,
avg_price: Number(p.avg_price) || 0, daily_sales_avg: Number(p.daily_sales_avg) || 0,
first_received_date: p.first_received_date, forecast_units: Number(p.forecast_units) || 0,
forecast_revenue: Number(p.forecast_revenue) || 0,
confidence_level: Number(p.confidence_level) || 0,
categoryPath: item.path
})) }))
})); }));
}, },

View File

@@ -503,7 +503,7 @@ export function Products() {
columnDefs={AVAILABLE_COLUMNS} columnDefs={AVAILABLE_COLUMNS}
columnOrder={columnOrder} columnOrder={columnOrder}
onColumnOrderChange={handleColumnOrderChange} onColumnOrderChange={handleColumnOrderChange}
onRowClick={(product) => setSelectedProductId(product.product_id)} onRowClick={(product) => setSelectedProductId(product.pid)}
/> />
{totalPages > 1 && ( {totalPages > 1 && (

View File

@@ -20,12 +20,21 @@ import {
PaginationPrevious, PaginationPrevious,
} from '../components/ui/pagination'; } from '../components/ui/pagination';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import {
PurchaseOrderStatus,
ReceivingStatus as ReceivingStatusCode,
getPurchaseOrderStatusLabel,
getReceivingStatusLabel,
getPurchaseOrderStatusVariant,
getReceivingStatusVariant
} from '../types/status-codes';
interface PurchaseOrder { interface PurchaseOrder {
id: number; id: number;
vendor_name: string; vendor_name: string;
order_date: string; order_date: string;
status: string; status: number;
receiving_status: number;
total_items: number; total_items: number;
total_quantity: number; total_quantity: number;
total_cost: number; total_cost: number;
@@ -113,6 +122,16 @@ export default function PurchaseOrders() {
limit: 100, limit: 100,
}); });
const STATUS_FILTER_OPTIONS = [
{ value: 'all', label: 'All Statuses' },
{ value: String(PurchaseOrderStatus.Created), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Created) },
{ value: String(PurchaseOrderStatus.ElectronicallyReadySend), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.ElectronicallyReadySend) },
{ value: String(PurchaseOrderStatus.Ordered), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Ordered) },
{ value: String(PurchaseOrderStatus.ReceivingStarted), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.ReceivingStarted) },
{ value: String(PurchaseOrderStatus.Done), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Done) },
{ value: String(PurchaseOrderStatus.Canceled), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Canceled) },
];
const fetchData = async () => { const fetchData = async () => {
try { try {
const searchParams = new URLSearchParams({ const searchParams = new URLSearchParams({
@@ -171,16 +190,25 @@ export default function PurchaseOrders() {
} }
}; };
const getStatusBadge = (status: string) => { const getStatusBadge = (status: number, receivingStatus: number) => {
const variants: Record<string, { variant: "default" | "secondary" | "destructive" | "outline"; label: string }> = { // If the PO is canceled, show that status
pending: { variant: "outline", label: "Pending" }, if (status === PurchaseOrderStatus.Canceled) {
received: { variant: "default", label: "Received" }, return <Badge variant={getPurchaseOrderStatusVariant(status)}>
partial: { variant: "secondary", label: "Partial" }, {getPurchaseOrderStatusLabel(status)}
cancelled: { variant: "destructive", label: "Cancelled" }, </Badge>;
}; }
const statusConfig = variants[status.toLowerCase()] || variants.pending; // If receiving has started, show receiving status
return <Badge variant={statusConfig.variant}>{statusConfig.label}</Badge>; if (status >= PurchaseOrderStatus.ReceivingStarted) {
return <Badge variant={getReceivingStatusVariant(receivingStatus)}>
{getReceivingStatusLabel(receivingStatus)}
</Badge>;
}
// Otherwise show PO status
return <Badge variant={getPurchaseOrderStatusVariant(status)}>
{getPurchaseOrderStatusLabel(status)}
</Badge>;
}; };
const formatNumber = (value: number) => { const formatNumber = (value: number) => {
@@ -252,27 +280,25 @@ export default function PurchaseOrders() {
</div> </div>
{/* Filters */} {/* Filters */}
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-center"> <div className="mb-4 flex items-center gap-4">
<div className="flex items-center gap-2 flex-1">
<Input <Input
placeholder="Search orders..." placeholder="Search orders..."
value={filters.search} value={filters.search}
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))} onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
className="h-8 w-[300px]" className="max-w-xs"
/> />
</div>
<div className="flex flex-wrap items-center gap-2">
<Select <Select
value={filters.status} value={filters.status}
onValueChange={(value) => setFilters(prev => ({ ...prev, status: value }))} onValueChange={(value) => setFilters(prev => ({ ...prev, status: value }))}
> >
<SelectTrigger className="h-8 w-[180px]"> <SelectTrigger className="w-[180px]">
<SelectValue placeholder="Status" /> <SelectValue placeholder="Select status" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All Statuses</SelectItem> {STATUS_FILTER_OPTIONS.map(option => (
{filterOptions.statuses.map(status => ( <SelectItem key={option.value} value={option.value}>
<SelectItem key={status} value={status}>{status}</SelectItem> {option.label}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -280,18 +306,19 @@ export default function PurchaseOrders() {
value={filters.vendor} value={filters.vendor}
onValueChange={(value) => setFilters(prev => ({ ...prev, vendor: value }))} onValueChange={(value) => setFilters(prev => ({ ...prev, vendor: value }))}
> >
<SelectTrigger className="h-8 w-[180px]"> <SelectTrigger className="w-[180px]">
<SelectValue placeholder="Vendor" /> <SelectValue placeholder="Select vendor" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All Vendors</SelectItem> <SelectItem value="all">All Vendors</SelectItem>
{filterOptions.vendors.map(vendor => ( {filterOptions.vendors.map(vendor => (
<SelectItem key={vendor} value={vendor}>{vendor}</SelectItem> <SelectItem key={vendor} value={vendor}>
{vendor}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</div>
{/* Purchase Orders Table */} {/* Purchase Orders Table */}
<Card className="mb-6"> <Card className="mb-6">
@@ -343,7 +370,7 @@ export default function PurchaseOrders() {
<TableCell>{po.id}</TableCell> <TableCell>{po.id}</TableCell>
<TableCell>{po.vendor_name}</TableCell> <TableCell>{po.vendor_name}</TableCell>
<TableCell>{new Date(po.order_date).toLocaleDateString()}</TableCell> <TableCell>{new Date(po.order_date).toLocaleDateString()}</TableCell>
<TableCell>{getStatusBadge(po.status)}</TableCell> <TableCell>{getStatusBadge(po.status, po.receiving_status)}</TableCell>
<TableCell>{po.total_items.toLocaleString()}</TableCell> <TableCell>{po.total_items.toLocaleString()}</TableCell>
<TableCell>{po.total_quantity.toLocaleString()}</TableCell> <TableCell>{po.total_quantity.toLocaleString()}</TableCell>
<TableCell>${formatNumber(po.total_cost)}</TableCell> <TableCell>${formatNumber(po.total_cost)}</TableCell>

View File

@@ -1,16 +1,16 @@
export interface Product { export interface Product {
product_id: number; pid: number;
title: string; title: string;
SKU: string; SKU: string;
stock_quantity: number; stock_quantity: number;
price: number; price: string; // DECIMAL(15,3)
regular_price: number; regular_price: string; // DECIMAL(15,3)
cost_price: number; cost_price: string; // DECIMAL(15,3)
landing_cost_price: number | null; landing_cost_price: string | null; // DECIMAL(15,3)
barcode: string; barcode: string;
vendor: string; vendor: string;
vendor_reference: string; vendor_reference: string;
brand: string; brand: string | 'Unbranded';
categories: string[]; categories: string[];
tags: string[]; tags: string[];
options: Record<string, any>; options: Record<string, any>;
@@ -24,32 +24,32 @@ export interface Product {
updated_at: string; updated_at: string;
// Metrics // Metrics
daily_sales_avg?: number; daily_sales_avg?: string; // DECIMAL(15,3)
weekly_sales_avg?: number; weekly_sales_avg?: string; // DECIMAL(15,3)
monthly_sales_avg?: number; monthly_sales_avg?: string; // DECIMAL(15,3)
avg_quantity_per_order?: number; avg_quantity_per_order?: string; // DECIMAL(15,3)
number_of_orders?: number; number_of_orders?: number;
first_sale_date?: string; first_sale_date?: string;
last_sale_date?: string; last_sale_date?: string;
last_purchase_date?: string; last_purchase_date?: string;
days_of_inventory?: number; days_of_inventory?: string; // DECIMAL(15,3)
weeks_of_inventory?: number; weeks_of_inventory?: string; // DECIMAL(15,3)
reorder_point?: number; reorder_point?: string; // DECIMAL(15,3)
safety_stock?: number; safety_stock?: string; // DECIMAL(15,3)
avg_margin_percent?: number; avg_margin_percent?: string; // DECIMAL(15,3)
total_revenue?: number; total_revenue?: string; // DECIMAL(15,3)
inventory_value?: number; inventory_value?: string; // DECIMAL(15,3)
cost_of_goods_sold?: number; cost_of_goods_sold?: string; // DECIMAL(15,3)
gross_profit?: number; gross_profit?: string; // DECIMAL(15,3)
gmroi?: number; gmroi?: string; // DECIMAL(15,3)
avg_lead_time_days?: number; avg_lead_time_days?: string; // DECIMAL(15,3)
last_received_date?: string; last_received_date?: string;
abc_class?: string; abc_class?: string;
stock_status?: string; stock_status?: string;
turnover_rate?: number; turnover_rate?: string; // DECIMAL(15,3)
current_lead_time?: number; current_lead_time?: string; // DECIMAL(15,3)
target_lead_time?: number; target_lead_time?: string; // DECIMAL(15,3)
lead_time_status?: string; lead_time_status?: string;
reorder_qty?: number; reorder_qty?: number;
overstocked_amt?: number; overstocked_amt?: string; // DECIMAL(15,3)
} }

View File

@@ -0,0 +1,81 @@
// Purchase Order Status Codes
export enum PurchaseOrderStatus {
Canceled = 0,
Created = 1,
ElectronicallyReadySend = 10,
Ordered = 11,
Preordered = 12,
ElectronicallySent = 13,
ReceivingStarted = 15,
Done = 50
}
// Receiving Status Codes
export enum ReceivingStatus {
Canceled = 0,
Created = 1,
PartialReceived = 30,
FullReceived = 40,
Paid = 50
}
// Status Code Display Names
export const PurchaseOrderStatusLabels: Record<PurchaseOrderStatus, string> = {
[PurchaseOrderStatus.Canceled]: 'Canceled',
[PurchaseOrderStatus.Created]: 'Created',
[PurchaseOrderStatus.ElectronicallyReadySend]: 'Ready to Send',
[PurchaseOrderStatus.Ordered]: 'Ordered',
[PurchaseOrderStatus.Preordered]: 'Preordered',
[PurchaseOrderStatus.ElectronicallySent]: 'Sent',
[PurchaseOrderStatus.ReceivingStarted]: 'Receiving Started',
[PurchaseOrderStatus.Done]: 'Done'
};
export const ReceivingStatusLabels: Record<ReceivingStatus, string> = {
[ReceivingStatus.Canceled]: 'Canceled',
[ReceivingStatus.Created]: 'Created',
[ReceivingStatus.PartialReceived]: 'Partially Received',
[ReceivingStatus.FullReceived]: 'Fully Received',
[ReceivingStatus.Paid]: 'Paid'
};
// Helper functions
export function getPurchaseOrderStatusLabel(status: number): string {
return PurchaseOrderStatusLabels[status as PurchaseOrderStatus] || 'Unknown';
}
export function getReceivingStatusLabel(status: number): string {
return ReceivingStatusLabels[status as ReceivingStatus] || 'Unknown';
}
// Status checks
export function isReceivingComplete(status: number): boolean {
return status >= ReceivingStatus.PartialReceived;
}
export function isPurchaseOrderComplete(status: number): boolean {
return status === PurchaseOrderStatus.Done;
}
export function isPurchaseOrderCanceled(status: number): boolean {
return status === PurchaseOrderStatus.Canceled;
}
export function isReceivingCanceled(status: number): boolean {
return status === ReceivingStatus.Canceled;
}
// Badge variants for different statuses
export function getPurchaseOrderStatusVariant(status: number): 'default' | 'secondary' | 'destructive' | 'outline' {
if (isPurchaseOrderCanceled(status)) return 'destructive';
if (isPurchaseOrderComplete(status)) return 'default';
if (status >= PurchaseOrderStatus.ElectronicallyReadySend) return 'secondary';
return 'outline';
}
export function getReceivingStatusVariant(status: number): 'default' | 'secondary' | 'destructive' | 'outline' {
if (isReceivingCanceled(status)) return 'destructive';
if (status === ReceivingStatus.Paid) return 'default';
if (status >= ReceivingStatus.PartialReceived) return 'secondary';
return 'outline';
}