Compare commits
10 Commits
44d9ae2aad
...
a1e3803ca3
| Author | SHA1 | Date | |
|---|---|---|---|
| a1e3803ca3 | |||
| a661b6a11e | |||
| 1410dc5571 | |||
| b1f252bea8 | |||
| 7e341a152c | |||
| 25a0bc8d4c | |||
| 57b0e9a120 | |||
| 64d9ab2f83 | |||
| 8323ae7703 | |||
| 5781b45f37 |
181
docs/metrics-changes.md
Normal file
181
docs/metrics-changes.md
Normal 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
|
||||
270
docs/schema-update-changes.md
Normal file
270
docs/schema-update-changes.md
Normal 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
|
||||
@@ -14,7 +14,8 @@ CREATE TABLE IF NOT EXISTS stock_thresholds (
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
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
|
||||
@@ -44,7 +45,8 @@ CREATE TABLE IF NOT EXISTS sales_velocity_config (
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
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
|
||||
@@ -68,7 +70,8 @@ CREATE TABLE IF NOT EXISTS safety_stock_config (
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
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
|
||||
|
||||
@@ -152,7 +152,7 @@ CREATE TABLE IF NOT EXISTS category_metrics (
|
||||
product_count INT DEFAULT 0,
|
||||
active_products INT DEFAULT 0,
|
||||
-- Financial metrics
|
||||
total_value DECIMAL(10,3) DEFAULT 0,
|
||||
total_value DECIMAL(15,3) DEFAULT 0,
|
||||
avg_margin DECIMAL(5,2),
|
||||
turnover_rate DECIMAL(12,3),
|
||||
growth_rate DECIMAL(5,2),
|
||||
@@ -193,8 +193,8 @@ CREATE TABLE IF NOT EXISTS category_time_metrics (
|
||||
product_count INT DEFAULT 0,
|
||||
active_products INT DEFAULT 0,
|
||||
-- Financial metrics
|
||||
total_value DECIMAL(10,3) DEFAULT 0,
|
||||
total_revenue DECIMAL(10,3) DEFAULT 0,
|
||||
total_value DECIMAL(15,3) DEFAULT 0,
|
||||
total_revenue DECIMAL(15,3) DEFAULT 0,
|
||||
avg_margin DECIMAL(5,2),
|
||||
turnover_rate DECIMAL(12,3),
|
||||
PRIMARY KEY (category_id, year, month),
|
||||
@@ -228,10 +228,10 @@ CREATE TABLE IF NOT EXISTS brand_metrics (
|
||||
active_products INT DEFAULT 0,
|
||||
-- Stock metrics
|
||||
total_stock_units INT DEFAULT 0,
|
||||
total_stock_cost DECIMAL(10,2) DEFAULT 0,
|
||||
total_stock_retail DECIMAL(10,2) DEFAULT 0,
|
||||
total_stock_cost DECIMAL(15,2) DEFAULT 0,
|
||||
total_stock_retail DECIMAL(15,2) DEFAULT 0,
|
||||
-- Sales metrics
|
||||
total_revenue DECIMAL(10,2) DEFAULT 0,
|
||||
total_revenue DECIMAL(15,2) DEFAULT 0,
|
||||
avg_margin DECIMAL(5,2) DEFAULT 0,
|
||||
growth_rate DECIMAL(5,2) DEFAULT 0,
|
||||
PRIMARY KEY (brand),
|
||||
@@ -250,10 +250,10 @@ CREATE TABLE IF NOT EXISTS brand_time_metrics (
|
||||
active_products INT DEFAULT 0,
|
||||
-- Stock metrics
|
||||
total_stock_units INT DEFAULT 0,
|
||||
total_stock_cost DECIMAL(10,2) DEFAULT 0,
|
||||
total_stock_retail DECIMAL(10,2) DEFAULT 0,
|
||||
total_stock_cost DECIMAL(15,2) DEFAULT 0,
|
||||
total_stock_retail DECIMAL(15,2) DEFAULT 0,
|
||||
-- Sales metrics
|
||||
total_revenue DECIMAL(10,2) DEFAULT 0,
|
||||
total_revenue DECIMAL(15,2) DEFAULT 0,
|
||||
avg_margin DECIMAL(5,2) DEFAULT 0,
|
||||
PRIMARY KEY (brand, year, month),
|
||||
INDEX idx_brand_date (year, month)
|
||||
|
||||
@@ -122,7 +122,8 @@ CREATE TABLE IF NOT EXISTS orders (
|
||||
KEY pid (pid),
|
||||
KEY customer (customer),
|
||||
KEY date (date),
|
||||
KEY status (status)
|
||||
KEY status (status),
|
||||
INDEX idx_orders_metrics (pid, date, canceled)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Create purchase_orders table with its indexes
|
||||
@@ -152,6 +153,7 @@ CREATE TABLE purchase_orders (
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_receiving_status (receiving_status),
|
||||
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_status (pid, status),
|
||||
UNIQUE KEY unique_po_product (po_id, pid)
|
||||
|
||||
@@ -5,6 +5,16 @@ process.chdir(path.dirname(__filename));
|
||||
|
||||
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
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('Uncaught Exception:', error);
|
||||
@@ -43,9 +53,6 @@ const calculateCategoryMetrics = require('./metrics/category-metrics');
|
||||
const calculateBrandMetrics = require('./metrics/brand-metrics');
|
||||
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
|
||||
let isCancelled = false;
|
||||
|
||||
@@ -137,50 +144,136 @@ async function calculateMetrics() {
|
||||
}
|
||||
|
||||
// Calculate time-based aggregates
|
||||
processedCount = await calculateTimeAggregates(startTime, totalProducts, processedCount);
|
||||
if (!SKIP_TIME_AGGREGATES) {
|
||||
processedCount = await calculateTimeAggregates(startTime, totalProducts, processedCount);
|
||||
} else {
|
||||
console.log('Skipping time aggregates calculation');
|
||||
}
|
||||
|
||||
// Calculate financial metrics
|
||||
processedCount = await calculateFinancialMetrics(startTime, totalProducts, processedCount);
|
||||
if (!SKIP_FINANCIAL_METRICS) {
|
||||
processedCount = await calculateFinancialMetrics(startTime, totalProducts, processedCount);
|
||||
} else {
|
||||
console.log('Skipping financial metrics calculation');
|
||||
}
|
||||
|
||||
// Calculate vendor metrics
|
||||
processedCount = await calculateVendorMetrics(startTime, totalProducts, processedCount);
|
||||
if (!SKIP_VENDOR_METRICS) {
|
||||
processedCount = await calculateVendorMetrics(startTime, totalProducts, processedCount);
|
||||
} else {
|
||||
console.log('Skipping vendor metrics calculation');
|
||||
}
|
||||
|
||||
// Calculate category metrics
|
||||
processedCount = await calculateCategoryMetrics(startTime, totalProducts, processedCount);
|
||||
if (!SKIP_CATEGORY_METRICS) {
|
||||
processedCount = await calculateCategoryMetrics(startTime, totalProducts, processedCount);
|
||||
} else {
|
||||
console.log('Skipping category metrics calculation');
|
||||
}
|
||||
|
||||
// Calculate brand metrics
|
||||
processedCount = await calculateBrandMetrics(startTime, totalProducts, processedCount);
|
||||
if (!SKIP_BRAND_METRICS) {
|
||||
processedCount = await calculateBrandMetrics(startTime, totalProducts, processedCount);
|
||||
} else {
|
||||
console.log('Skipping brand metrics calculation');
|
||||
}
|
||||
|
||||
// Calculate sales forecasts
|
||||
processedCount = await calculateSalesForecasts(startTime, totalProducts, processedCount);
|
||||
if (!SKIP_SALES_FORECASTS) {
|
||||
processedCount = await calculateSalesForecasts(startTime, totalProducts, processedCount);
|
||||
} else {
|
||||
console.log('Skipping sales forecasts calculation');
|
||||
}
|
||||
|
||||
// Calculate ABC classification
|
||||
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 };
|
||||
|
||||
// First, create and populate the rankings table with an index
|
||||
await connection.query('DROP TEMPORARY TABLE IF EXISTS temp_revenue_ranks');
|
||||
await connection.query(`
|
||||
WITH revenue_rankings AS (
|
||||
SELECT
|
||||
product_id,
|
||||
total_revenue,
|
||||
PERCENT_RANK() OVER (ORDER BY COALESCE(total_revenue, 0) DESC) * 100 as revenue_percentile
|
||||
FROM product_metrics
|
||||
),
|
||||
classification_update AS (
|
||||
SELECT
|
||||
product_id,
|
||||
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
|
||||
pid,
|
||||
total_revenue,
|
||||
@rank := @rank + 1 as rank_num,
|
||||
@total_count := @rank as total_count
|
||||
FROM (
|
||||
SELECT pid, total_revenue
|
||||
FROM product_metrics
|
||||
WHERE total_revenue > 0
|
||||
ORDER BY total_revenue DESC
|
||||
) ranked,
|
||||
(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
|
||||
WHEN revenue_percentile <= ? THEN 'A'
|
||||
WHEN revenue_percentile <= ? THEN 'B'
|
||||
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 as abc_class
|
||||
FROM revenue_rankings
|
||||
)
|
||||
UPDATE product_metrics pm
|
||||
JOIN classification_update cu ON pm.product_id = cu.product_id
|
||||
SET pm.abc_class = cu.abc_class,
|
||||
pm.last_calculated_at = NOW()
|
||||
`, [abcThresholds.a_threshold, abcThresholds.b_threshold]);
|
||||
END
|
||||
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
|
||||
LEFT JOIN temp_revenue_ranks tr ON pm.pid = tr.pid
|
||||
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()
|
||||
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
|
||||
global.outputProgress({
|
||||
|
||||
@@ -15,7 +15,7 @@ async function calculateBrandMetrics(startTime, totalProducts, processedCount) {
|
||||
percentage: '95'
|
||||
});
|
||||
|
||||
// Calculate brand metrics
|
||||
// Calculate brand metrics with optimized queries
|
||||
await connection.query(`
|
||||
INSERT INTO brand_metrics (
|
||||
brand,
|
||||
@@ -28,59 +28,77 @@ async function calculateBrandMetrics(startTime, totalProducts, processedCount) {
|
||||
avg_margin,
|
||||
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
|
||||
p.brand,
|
||||
COUNT(DISTINCT p.product_id) as product_count,
|
||||
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,
|
||||
SUM(o.quantity * o.price) as period_revenue,
|
||||
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)
|
||||
ELSE 0
|
||||
END as avg_margin,
|
||||
-- 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
|
||||
FROM products p
|
||||
LEFT JOIN orders o ON p.product_id = o.product_id AND o.canceled = false
|
||||
WHERE p.brand IS NOT NULL
|
||||
END as avg_margin
|
||||
FROM filtered_products p
|
||||
LEFT JOIN orders o ON p.pid = o.pid AND o.canceled = false
|
||||
GROUP BY p.brand
|
||||
)
|
||||
SELECT
|
||||
brand,
|
||||
product_count,
|
||||
active_products,
|
||||
total_stock_units,
|
||||
total_stock_cost,
|
||||
total_stock_retail,
|
||||
total_revenue,
|
||||
avg_margin,
|
||||
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,
|
||||
CASE
|
||||
WHEN previous_year_period_sales = 0 AND current_period_sales > 0 THEN 100.0
|
||||
WHEN previous_year_period_sales = 0 THEN 0.0
|
||||
WHEN MAX(CASE WHEN sp.period_type = 'previous' THEN sp.period_revenue END) = 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(
|
||||
GREATEST(
|
||||
((current_period_sales - previous_year_period_sales) /
|
||||
NULLIF(previous_year_period_sales, 0)) * 100.0,
|
||||
((MAX(CASE WHEN sp.period_type = 'current' THEN sp.period_revenue END) -
|
||||
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
|
||||
),
|
||||
999.99
|
||||
)
|
||||
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
|
||||
product_count = VALUES(product_count),
|
||||
active_products = VALUES(active_products),
|
||||
@@ -93,7 +111,7 @@ async function calculateBrandMetrics(startTime, totalProducts, processedCount) {
|
||||
last_calculated_at = CURRENT_TIMESTAMP
|
||||
`);
|
||||
|
||||
// Calculate brand time-based metrics
|
||||
// Calculate brand time-based metrics with optimized query
|
||||
await connection.query(`
|
||||
INSERT INTO brand_time_metrics (
|
||||
brand,
|
||||
@@ -107,26 +125,41 @@ async function calculateBrandMetrics(startTime, totalProducts, processedCount) {
|
||||
total_revenue,
|
||||
avg_margin
|
||||
)
|
||||
SELECT
|
||||
p.brand,
|
||||
YEAR(o.date) as year,
|
||||
MONTH(o.date) as month,
|
||||
COUNT(DISTINCT p.product_id) as product_count,
|
||||
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
|
||||
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
|
||||
FROM products p
|
||||
LEFT JOIN orders o ON p.product_id = o.product_id AND o.canceled = false
|
||||
WHERE p.brand IS NOT NULL
|
||||
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
|
||||
GROUP BY p.brand, YEAR(o.date), MONTH(o.date)
|
||||
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
|
||||
p.brand,
|
||||
YEAR(o.date) as year,
|
||||
MONTH(o.date) as month,
|
||||
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,
|
||||
SUM(o.quantity * o.price) 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)
|
||||
ELSE 0
|
||||
END as avg_margin
|
||||
FROM filtered_products p
|
||||
LEFT JOIN orders o ON p.pid = o.pid AND o.canceled = false
|
||||
WHERE o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
|
||||
GROUP BY p.brand, YEAR(o.date), MONTH(o.date)
|
||||
)
|
||||
SELECT *
|
||||
FROM monthly_metrics
|
||||
ON DUPLICATE KEY UPDATE
|
||||
product_count = VALUES(product_count),
|
||||
active_products = VALUES(active_products),
|
||||
|
||||
@@ -15,98 +15,104 @@ async function calculateCategoryMetrics(startTime, totalProducts, processedCount
|
||||
percentage: '85'
|
||||
});
|
||||
|
||||
// Calculate category performance metrics
|
||||
// First, calculate base category metrics
|
||||
await connection.query(`
|
||||
INSERT INTO category_metrics (
|
||||
category_id,
|
||||
product_count,
|
||||
active_products,
|
||||
total_value,
|
||||
avg_margin,
|
||||
turnover_rate,
|
||||
growth_rate,
|
||||
status
|
||||
)
|
||||
WITH category_sales AS (
|
||||
SELECT
|
||||
c.id as category_id,
|
||||
COUNT(DISTINCT p.product_id) as product_count,
|
||||
COUNT(DISTINCT CASE WHEN p.visible = true THEN p.product_id END) as active_products,
|
||||
SUM(p.stock_quantity * p.cost_price) as total_value,
|
||||
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,
|
||||
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
|
||||
LEFT JOIN product_categories pc ON c.id = pc.category_id
|
||||
LEFT JOIN products p ON pc.product_id = p.product_id
|
||||
LEFT JOIN orders o ON p.product_id = o.product_id AND o.canceled = false
|
||||
GROUP BY c.id, c.status
|
||||
status,
|
||||
last_calculated_at
|
||||
)
|
||||
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
|
||||
c.cat_id,
|
||||
COUNT(DISTINCT p.pid) as product_count,
|
||||
COUNT(DISTINCT CASE WHEN p.visible = true THEN p.pid END) as active_products,
|
||||
COALESCE(SUM(p.stock_quantity * p.cost_price), 0) as total_value,
|
||||
c.status,
|
||||
NOW() as last_calculated_at
|
||||
FROM categories c
|
||||
LEFT JOIN product_categories pc ON c.cat_id = pc.cat_id
|
||||
LEFT JOIN products p ON pc.pid = p.pid
|
||||
GROUP BY c.cat_id, c.status
|
||||
ON DUPLICATE KEY UPDATE
|
||||
product_count = VALUES(product_count),
|
||||
active_products = VALUES(active_products),
|
||||
total_value = VALUES(total_value),
|
||||
avg_margin = VALUES(avg_margin),
|
||||
turnover_rate = VALUES(turnover_rate),
|
||||
growth_rate = VALUES(growth_rate),
|
||||
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(`
|
||||
INSERT INTO category_time_metrics (
|
||||
category_id,
|
||||
@@ -120,29 +126,28 @@ async function calculateCategoryMetrics(startTime, totalProducts, processedCount
|
||||
turnover_rate
|
||||
)
|
||||
SELECT
|
||||
c.id as category_id,
|
||||
pc.cat_id,
|
||||
YEAR(o.date) as year,
|
||||
MONTH(o.date) as month,
|
||||
COUNT(DISTINCT p.product_id) as product_count,
|
||||
COUNT(DISTINCT CASE WHEN p.visible = true THEN p.product_id END) as active_products,
|
||||
COUNT(DISTINCT p.pid) as product_count,
|
||||
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(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,
|
||||
CASE
|
||||
WHEN AVG(p.stock_quantity) > 0
|
||||
THEN SUM(o.quantity) / AVG(p.stock_quantity)
|
||||
ELSE 0
|
||||
END as turnover_rate
|
||||
FROM categories c
|
||||
LEFT JOIN product_categories pc ON c.id = pc.category_id
|
||||
LEFT JOIN products p ON pc.product_id = p.product_id
|
||||
LEFT JOIN orders o ON p.product_id = o.product_id AND o.canceled = false
|
||||
WHERE o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
|
||||
GROUP BY c.id, YEAR(o.date), MONTH(o.date)
|
||||
SUM(o.quantity * o.price) as total_revenue,
|
||||
COALESCE(
|
||||
SUM(o.quantity * (o.price - p.cost_price)) * 100.0 /
|
||||
NULLIF(SUM(o.quantity * o.price), 0),
|
||||
0
|
||||
) as avg_margin,
|
||||
COALESCE(
|
||||
SUM(o.quantity) / NULLIF(AVG(GREATEST(p.stock_quantity, 0)), 0),
|
||||
0
|
||||
) as turnover_rate
|
||||
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 12 MONTH)
|
||||
GROUP BY pc.cat_id, YEAR(o.date), MONTH(o.date)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
product_count = VALUES(product_count),
|
||||
active_products = VALUES(active_products),
|
||||
@@ -152,73 +157,45 @@ async function calculateCategoryMetrics(startTime, totalProducts, processedCount
|
||||
turnover_rate = VALUES(turnover_rate)
|
||||
`);
|
||||
|
||||
// Calculate category sales metrics
|
||||
await connection.query(`
|
||||
INSERT INTO category_sales_metrics (
|
||||
category_id,
|
||||
brand,
|
||||
period_start,
|
||||
period_end,
|
||||
avg_daily_sales,
|
||||
total_sold,
|
||||
num_products,
|
||||
avg_price,
|
||||
last_calculated_at
|
||||
)
|
||||
WITH date_ranges AS (
|
||||
// Calculate sales metrics for different time periods
|
||||
const periods = [30, 90, 180, 365];
|
||||
for (const days of periods) {
|
||||
await connection.query(`
|
||||
INSERT INTO category_sales_metrics (
|
||||
category_id,
|
||||
brand,
|
||||
period_start,
|
||||
period_end,
|
||||
avg_daily_sales,
|
||||
total_sold,
|
||||
num_products,
|
||||
avg_price,
|
||||
last_calculated_at
|
||||
)
|
||||
SELECT
|
||||
DATE_SUB(CURDATE(), INTERVAL 30 DAY) as period_start,
|
||||
CURDATE() as period_end
|
||||
UNION ALL
|
||||
SELECT
|
||||
DATE_SUB(CURDATE(), INTERVAL 90 DAY),
|
||||
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,
|
||||
pc.cat_id as category_id,
|
||||
COALESCE(p.brand, 'Unbranded') as brand,
|
||||
DATE_SUB(CURDATE(), INTERVAL ? DAY) as period_start,
|
||||
CURDATE() as period_end,
|
||||
COALESCE(SUM(o.quantity), 0) / ? as avg_daily_sales,
|
||||
COALESCE(SUM(o.quantity), 0) as total_sold,
|
||||
COALESCE(AVG(o.price), 0) as avg_price
|
||||
FROM categories c
|
||||
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
|
||||
COUNT(DISTINCT p.pid) as num_products,
|
||||
COALESCE(AVG(o.price), 0) as avg_price,
|
||||
NOW() as last_calculated_at
|
||||
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 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
|
||||
FROM category_metrics
|
||||
ON DUPLICATE KEY UPDATE
|
||||
avg_daily_sales = VALUES(avg_daily_sales),
|
||||
total_sold = VALUES(total_sold),
|
||||
num_products = VALUES(num_products),
|
||||
avg_price = VALUES(avg_price),
|
||||
last_calculated_at = NOW()
|
||||
`);
|
||||
GROUP BY pc.cat_id, p.brand
|
||||
ON DUPLICATE KEY UPDATE
|
||||
avg_daily_sales = VALUES(avg_daily_sales),
|
||||
total_sold = VALUES(total_sold),
|
||||
num_products = VALUES(num_products),
|
||||
avg_price = VALUES(avg_price),
|
||||
last_calculated_at = NOW()
|
||||
`, [days, days, days]);
|
||||
}
|
||||
|
||||
return Math.floor(totalProducts * 0.9);
|
||||
} finally {
|
||||
|
||||
@@ -15,59 +15,65 @@ async function calculateFinancialMetrics(startTime, totalProducts, processedCoun
|
||||
percentage: '60'
|
||||
});
|
||||
|
||||
// Calculate financial metrics with optimized query
|
||||
await connection.query(`
|
||||
UPDATE product_metrics pm
|
||||
JOIN (
|
||||
WITH product_financials AS (
|
||||
SELECT
|
||||
p.product_id,
|
||||
p.pid,
|
||||
p.cost_price * p.stock_quantity as inventory_value,
|
||||
SUM(o.quantity * o.price) as total_revenue,
|
||||
SUM(o.quantity * p.cost_price) as cost_of_goods_sold,
|
||||
SUM(o.quantity * (o.price - p.cost_price)) as gross_profit,
|
||||
MIN(o.date) as first_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
|
||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
||||
LEFT JOIN orders o ON p.pid = o.pid
|
||||
WHERE o.canceled = false
|
||||
AND DATE(o.date) >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH)
|
||||
GROUP BY p.product_id
|
||||
) fin ON pm.product_id = fin.product_id
|
||||
GROUP BY p.pid
|
||||
)
|
||||
UPDATE product_metrics pm
|
||||
JOIN product_financials pf ON pm.pid = pf.pid
|
||||
SET
|
||||
pm.inventory_value = COALESCE(fin.inventory_value, 0),
|
||||
pm.total_revenue = COALESCE(fin.total_revenue, 0),
|
||||
pm.cost_of_goods_sold = COALESCE(fin.cost_of_goods_sold, 0),
|
||||
pm.gross_profit = COALESCE(fin.gross_profit, 0),
|
||||
pm.inventory_value = COALESCE(pf.inventory_value, 0),
|
||||
pm.total_revenue = COALESCE(pf.total_revenue, 0),
|
||||
pm.cost_of_goods_sold = COALESCE(pf.cost_of_goods_sold, 0),
|
||||
pm.gross_profit = COALESCE(pf.gross_profit, 0),
|
||||
pm.gmroi = CASE
|
||||
WHEN COALESCE(fin.inventory_value, 0) > 0 AND fin.calculation_period_days > 0 THEN
|
||||
(COALESCE(fin.gross_profit, 0) * (365.0 / fin.calculation_period_days)) / COALESCE(fin.inventory_value, 0)
|
||||
WHEN COALESCE(pf.inventory_value, 0) > 0 AND pf.active_days > 0 THEN
|
||||
(COALESCE(pf.gross_profit, 0) * (365.0 / pf.active_days)) / COALESCE(pf.inventory_value, 0)
|
||||
ELSE 0
|
||||
END
|
||||
`);
|
||||
|
||||
// Update time-based aggregates with financial metrics
|
||||
// Update time-based aggregates with optimized query
|
||||
await connection.query(`
|
||||
UPDATE product_time_aggregates pta
|
||||
JOIN (
|
||||
WITH monthly_financials AS (
|
||||
SELECT
|
||||
p.product_id,
|
||||
p.pid,
|
||||
YEAR(o.date) as year,
|
||||
MONTH(o.date) as month,
|
||||
p.cost_price * p.stock_quantity as inventory_value,
|
||||
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
|
||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
||||
LEFT JOIN orders o ON p.pid = o.pid
|
||||
WHERE o.canceled = false
|
||||
GROUP BY p.product_id, YEAR(o.date), MONTH(o.date)
|
||||
) fin ON pta.product_id = fin.product_id
|
||||
AND pta.year = fin.year
|
||||
AND pta.month = fin.month
|
||||
GROUP BY p.pid, YEAR(o.date), MONTH(o.date)
|
||||
)
|
||||
UPDATE product_time_aggregates pta
|
||||
JOIN monthly_financials mf ON pta.pid = mf.pid
|
||||
AND pta.year = mf.year
|
||||
AND pta.month = mf.month
|
||||
SET
|
||||
pta.inventory_value = COALESCE(fin.inventory_value, 0),
|
||||
pta.inventory_value = COALESCE(mf.inventory_value, 0),
|
||||
pta.gmroi = CASE
|
||||
WHEN COALESCE(fin.inventory_value, 0) > 0 AND fin.days_in_period > 0 THEN
|
||||
(COALESCE(fin.gross_profit, 0) * (365.0 / fin.days_in_period)) / COALESCE(fin.inventory_value, 0)
|
||||
WHEN COALESCE(mf.inventory_value, 0) > 0 AND mf.active_days > 0 THEN
|
||||
(COALESCE(mf.gross_profit, 0) * (365.0 / mf.active_days)) / COALESCE(mf.inventory_value, 0)
|
||||
ELSE 0
|
||||
END
|
||||
`);
|
||||
|
||||
@@ -1,498 +1,244 @@
|
||||
const { outputProgress, logError } = require('./utils/progress');
|
||||
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) {
|
||||
const connection = await getConnection();
|
||||
try {
|
||||
// Process in batches of 250
|
||||
const batchSize = 250;
|
||||
for (let offset = 0; offset < totalProducts; offset += batchSize) {
|
||||
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;
|
||||
// Skip flags are inherited from the parent scope
|
||||
const SKIP_PRODUCT_BASE_METRICS = 0;
|
||||
const SKIP_PRODUCT_TIME_AGGREGATES =0;
|
||||
|
||||
// Update progress after each batch
|
||||
// Calculate base product metrics
|
||||
if (!SKIP_PRODUCT_BASE_METRICS) {
|
||||
outputProgress({
|
||||
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,
|
||||
total: totalProducts,
|
||||
elapsed: formatElapsedTime(startTime),
|
||||
remaining: estimateRemaining(startTime, processedCount, totalProducts),
|
||||
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
|
||||
const metricsUpdates = [];
|
||||
for (const product of products) {
|
||||
try {
|
||||
// Get configuration values for this product
|
||||
const [configs] = await connection.query(`
|
||||
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]);
|
||||
// Calculate time-based aggregates
|
||||
await connection.query(`
|
||||
INSERT INTO product_time_aggregates (
|
||||
pid,
|
||||
year,
|
||||
month,
|
||||
total_quantity_sold,
|
||||
total_revenue,
|
||||
total_cost,
|
||||
order_count,
|
||||
avg_price,
|
||||
profit_margin,
|
||||
inventory_value,
|
||||
gmroi
|
||||
)
|
||||
SELECT
|
||||
p.pid,
|
||||
YEAR(o.date) as year,
|
||||
MONTH(o.date) as month,
|
||||
SUM(o.quantity) as total_quantity_sold,
|
||||
SUM(o.quantity * o.price) as total_revenue,
|
||||
SUM(o.quantity * p.cost_price) as total_cost,
|
||||
COUNT(DISTINCT o.order_number) as order_count,
|
||||
AVG(o.price) as avg_price,
|
||||
CASE
|
||||
WHEN SUM(o.quantity * o.price) > 0
|
||||
THEN ((SUM(o.quantity * o.price) - SUM(o.quantity * p.cost_price)) / SUM(o.quantity * o.price)) * 100
|
||||
ELSE 0
|
||||
END as profit_margin,
|
||||
p.cost_price * p.stock_quantity as inventory_value,
|
||||
CASE
|
||||
WHEN p.cost_price * p.stock_quantity > 0
|
||||
THEN (SUM(o.quantity * (o.price - p.cost_price))) / (p.cost_price * p.stock_quantity)
|
||||
ELSE 0
|
||||
END as gmroi
|
||||
FROM products p
|
||||
LEFT JOIN orders o ON p.pid = o.pid AND o.canceled = false
|
||||
WHERE o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
|
||||
GROUP BY p.pid, YEAR(o.date), MONTH(o.date)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
total_quantity_sold = VALUES(total_quantity_sold),
|
||||
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),
|
||||
gmroi = VALUES(gmroi)
|
||||
`);
|
||||
|
||||
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_revenue,
|
||||
total_cost,
|
||||
last_sale_date,
|
||||
first_sale_date,
|
||||
number_of_orders,
|
||||
avg_quantity_per_order,
|
||||
last_30_days_qty / ? as rolling_daily_avg,
|
||||
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
|
||||
SUM(CASE WHEN received >= 0 THEN received ELSE 0 END) as total_quantity_purchased,
|
||||
SUM(CASE WHEN received >= 0 THEN cost_price * received ELSE 0 END) as total_cost,
|
||||
MAX(date) as last_purchase_date,
|
||||
MIN(received_date) as first_received_date,
|
||||
MAX(received_date) as last_received_date,
|
||||
AVG(lead_time_days) as avg_lead_time_days
|
||||
FROM lead_time_orders
|
||||
`, [product.product_id]);
|
||||
|
||||
// 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
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM orders o
|
||||
WHERE o.product_id = p.product_id
|
||||
AND o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
AND o.canceled = false
|
||||
AND (SELECT SUM(quantity) FROM orders o2
|
||||
WHERE o2.product_id = p.product_id
|
||||
AND o2.date >= o.date
|
||||
AND o2.canceled = false) = 0
|
||||
) THEN true
|
||||
ELSE false
|
||||
END as had_recent_stockout
|
||||
FROM products p
|
||||
LEFT JOIN purchase_orders po ON p.product_id = po.product_id
|
||||
AND po.status = 'closed'
|
||||
AND po.received > 0
|
||||
WHERE p.product_id = ?
|
||||
GROUP BY p.product_id
|
||||
`, [product.product_id]);
|
||||
|
||||
// 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
|
||||
last_calculated_at = NOW(),
|
||||
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),
|
||||
inventory_value = VALUES(inventory_value),
|
||||
avg_lead_time_days = VALUES(avg_lead_time_days),
|
||||
last_purchase_date = VALUES(last_purchase_date),
|
||||
first_received_date = VALUES(first_received_date),
|
||||
last_received_date = VALUES(last_received_date),
|
||||
stock_status = VALUES(stock_status),
|
||||
reorder_qty = VALUES(reorder_qty),
|
||||
overstocked_amt = VALUES(overstocked_amt)
|
||||
`, [metricsUpdates]);
|
||||
}
|
||||
processedCount = Math.floor(totalProducts * 0.6);
|
||||
} else {
|
||||
console.log('Skipping product time aggregates calculation');
|
||||
processedCount = Math.floor(totalProducts * 0.6);
|
||||
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;
|
||||
} finally {
|
||||
connection.release();
|
||||
if (connection) {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function calculateStockStatus(stock, config, daily_sales_avg, weekly_sales_avg, monthly_sales_avg) {
|
||||
const days_since_first_stock = stock.days_since_first_stock || 0;
|
||||
const days_since_last_sale = stock.days_since_last_sale || 9999;
|
||||
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';
|
||||
if (stock <= 0) {
|
||||
return 'Out of Stock';
|
||||
}
|
||||
|
||||
// 1. Check if truly "New" (≤30 days and no sales)
|
||||
if (days_since_first_stock <= 30 && total_quantity_sold === 0) {
|
||||
return 'New';
|
||||
// Use the most appropriate sales average based on data quality
|
||||
let sales_avg = daily_sales_avg;
|
||||
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 (ds === 0 || (ds < 0.1 && ws < 0.5)) {
|
||||
if (days_since_first_stock > config.overstock_days) {
|
||||
return 'Overstocked';
|
||||
}
|
||||
if (days_since_first_stock > 30) {
|
||||
return 'At Risk';
|
||||
}
|
||||
if (sales_avg === 0) {
|
||||
return stock <= config.low_stock_threshold ? 'Low Stock' : 'In Stock';
|
||||
}
|
||||
|
||||
// 3. Calculate days of supply and check velocity trends
|
||||
const days_of_supply = ds > 0 ? dq / ds : 999;
|
||||
const velocity_trend = ds > 0 ? (ds / (ms || ds) - 1) * 100 : 0;
|
||||
const days_of_stock = stock / sales_avg;
|
||||
|
||||
// Critical stock level
|
||||
if (days_of_supply <= config.critical_days) {
|
||||
if (days_of_stock <= config.critical_days) {
|
||||
return 'Critical';
|
||||
}
|
||||
|
||||
// Reorder cases
|
||||
if (days_of_supply <= config.reorder_days ||
|
||||
(had_recent_stockout && days_of_supply <= config.reorder_days * 1.5)) {
|
||||
} else if (days_of_stock <= config.reorder_days) {
|
||||
return 'Reorder';
|
||||
}
|
||||
|
||||
// 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) {
|
||||
} else if (days_of_stock > config.overstock_days) {
|
||||
return 'Overstocked';
|
||||
}
|
||||
|
||||
// If none of the above conditions are met
|
||||
return 'Healthy';
|
||||
}
|
||||
|
||||
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 overstocked_amt = 0;
|
||||
|
||||
// Only calculate reorder quantity for replenishable products
|
||||
if (stock.replenishable && (stock_status === 'Critical' || stock_status === 'Reorder')) {
|
||||
const ds = daily_sales_avg || 0;
|
||||
const lt = avg_lead_time || 14;
|
||||
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;
|
||||
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
|
||||
|
||||
// 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;
|
||||
reorder_qty = Math.ceil(Math.sqrt((2 * annual_demand * order_cost) / holding_cost_percent));
|
||||
} else {
|
||||
// If no sales data, use a basic calculation
|
||||
reorder_qty = Math.max(safety_stock, config.low_stock_threshold);
|
||||
}
|
||||
|
||||
// Calculate overstocked amount for overstocked products
|
||||
if (stock_status === 'Overstocked') {
|
||||
const ds = daily_sales_avg || 0;
|
||||
const dq = stock.stock_quantity || 0;
|
||||
const lt = avg_lead_time || 14;
|
||||
const sc = config.safety_stock_days || 14;
|
||||
const ss = config.safety_stock_days || 14;
|
||||
// Calculate overstocked amount
|
||||
const overstocked_amt = stock_status === 'Overstocked' ?
|
||||
stock - Math.ceil(daily_sales_avg * config.overstock_days) :
|
||||
0;
|
||||
|
||||
// Calculate maximum desired stock
|
||||
const max_desired_stock = (ds * config.overstock_days) + ss;
|
||||
|
||||
// Calculate excess inventory
|
||||
overstocked_amt = Math.max(0, dq - max_desired_stock);
|
||||
}
|
||||
|
||||
return { reorder_qty, overstocked_amt };
|
||||
return {
|
||||
safety_stock,
|
||||
reorder_point,
|
||||
reorder_qty,
|
||||
overstocked_amt
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = calculateProductMetrics;
|
||||
@@ -15,102 +15,100 @@ async function calculateSalesForecasts(startTime, totalProducts, processedCount)
|
||||
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
|
||||
await connection.query(`
|
||||
INSERT INTO sales_forecasts (
|
||||
product_id,
|
||||
pid,
|
||||
forecast_date,
|
||||
forecast_units,
|
||||
forecast_revenue,
|
||||
confidence_level,
|
||||
last_calculated_at
|
||||
)
|
||||
WITH daily_sales AS (
|
||||
SELECT
|
||||
o.product_id,
|
||||
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,
|
||||
ds.pid,
|
||||
fd.forecast_date,
|
||||
GREATEST(0,
|
||||
ps.avg_daily_quantity *
|
||||
(1 + COALESCE(
|
||||
(SELECT seasonality_factor
|
||||
FROM sales_seasonality
|
||||
WHERE MONTH(fd.forecast_date) = month
|
||||
LIMIT 1),
|
||||
0
|
||||
))
|
||||
AVG(ds.daily_quantity) *
|
||||
(1 + COALESCE(sf.seasonality_factor, 0))
|
||||
) as forecast_units,
|
||||
GREATEST(0,
|
||||
CASE DAYOFWEEK(fd.forecast_date)
|
||||
WHEN 1 THEN COALESCE(ps.sunday_avg, ps.avg_daily_revenue)
|
||||
WHEN 2 THEN COALESCE(ps.monday_avg, ps.avg_daily_revenue)
|
||||
WHEN 3 THEN COALESCE(ps.tuesday_avg, ps.avg_daily_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 *
|
||||
(1 + COALESCE(
|
||||
(SELECT seasonality_factor
|
||||
FROM sales_seasonality
|
||||
WHERE MONTH(fd.forecast_date) = month
|
||||
LIMIT 1),
|
||||
COALESCE(
|
||||
CASE
|
||||
WHEN SUM(ds.day_count) >= 4 THEN AVG(ds.daily_revenue)
|
||||
ELSE ps.overall_avg_revenue
|
||||
END *
|
||||
(1 + COALESCE(sf.seasonality_factor, 0)) *
|
||||
(0.95 + (RAND() * 0.1)),
|
||||
0
|
||||
)) *
|
||||
-- Add some randomness within a small range (±5%)
|
||||
(0.95 + (RAND() * 0.1))
|
||||
)
|
||||
) as forecast_revenue,
|
||||
CASE
|
||||
WHEN ps.data_points >= 60 THEN 90
|
||||
WHEN ps.data_points >= 30 THEN 80
|
||||
WHEN ps.data_points >= 14 THEN 70
|
||||
WHEN ps.total_days >= 60 THEN 90
|
||||
WHEN ps.total_days >= 30 THEN 80
|
||||
WHEN ps.total_days >= 14 THEN 70
|
||||
ELSE 60
|
||||
END as confidence_level,
|
||||
NOW() as last_calculated_at
|
||||
FROM product_stats ps
|
||||
CROSS JOIN forecast_dates fd
|
||||
WHERE ps.avg_daily_quantity > 0
|
||||
FROM temp_daily_sales ds
|
||||
JOIN temp_product_stats ps ON ds.pid = ps.pid
|
||||
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
|
||||
forecast_units = VALUES(forecast_units),
|
||||
forecast_revenue = VALUES(forecast_revenue),
|
||||
@@ -118,6 +116,32 @@ async function calculateSalesForecasts(startTime, totalProducts, processedCount)
|
||||
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
|
||||
await connection.query(`
|
||||
INSERT INTO category_forecasts (
|
||||
@@ -128,93 +152,37 @@ async function calculateSalesForecasts(startTime, totalProducts, processedCount)
|
||||
confidence_level,
|
||||
last_calculated_at
|
||||
)
|
||||
WITH category_daily_sales AS (
|
||||
SELECT
|
||||
pc.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,
|
||||
cs.cat_id as category_id,
|
||||
fd.forecast_date,
|
||||
GREATEST(0,
|
||||
cs.avg_daily_quantity *
|
||||
(1 + COALESCE(
|
||||
(SELECT seasonality_factor
|
||||
FROM sales_seasonality
|
||||
WHERE MONTH(fd.forecast_date) = month
|
||||
LIMIT 1),
|
||||
0
|
||||
))
|
||||
AVG(cs.daily_quantity) *
|
||||
(1 + COALESCE(sf.seasonality_factor, 0))
|
||||
) as forecast_units,
|
||||
GREATEST(0,
|
||||
CASE DAYOFWEEK(fd.forecast_date)
|
||||
WHEN 1 THEN COALESCE(cs.sunday_avg, cs.avg_daily_revenue)
|
||||
WHEN 2 THEN COALESCE(cs.monday_avg, cs.avg_daily_revenue)
|
||||
WHEN 3 THEN COALESCE(cs.tuesday_avg, cs.avg_daily_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 *
|
||||
(1 + COALESCE(
|
||||
(SELECT seasonality_factor
|
||||
FROM sales_seasonality
|
||||
WHERE MONTH(fd.forecast_date) = month
|
||||
LIMIT 1),
|
||||
COALESCE(
|
||||
CASE
|
||||
WHEN SUM(cs.day_count) >= 4 THEN AVG(cs.daily_revenue)
|
||||
ELSE ct.overall_avg_revenue
|
||||
END *
|
||||
(1 + COALESCE(sf.seasonality_factor, 0)) *
|
||||
(0.95 + (RAND() * 0.1)),
|
||||
0
|
||||
)) *
|
||||
-- Add some randomness within a small range (±5%)
|
||||
(0.95 + (RAND() * 0.1))
|
||||
)
|
||||
) as forecast_revenue,
|
||||
CASE
|
||||
WHEN cs.data_points >= 60 THEN 90
|
||||
WHEN cs.data_points >= 30 THEN 80
|
||||
WHEN cs.data_points >= 14 THEN 70
|
||||
WHEN ct.total_days >= 60 THEN 90
|
||||
WHEN ct.total_days >= 30 THEN 80
|
||||
WHEN ct.total_days >= 14 THEN 70
|
||||
ELSE 60
|
||||
END as confidence_level,
|
||||
NOW() as last_calculated_at
|
||||
FROM category_stats cs
|
||||
CROSS JOIN forecast_dates fd
|
||||
WHERE cs.avg_daily_quantity > 0
|
||||
FROM temp_category_sales cs
|
||||
JOIN temp_category_stats ct ON cs.cat_id = ct.cat_id
|
||||
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
|
||||
forecast_units = VALUES(forecast_units),
|
||||
forecast_revenue = VALUES(forecast_revenue),
|
||||
@@ -222,6 +190,15 @@ async function calculateSalesForecasts(startTime, totalProducts, processedCount)
|
||||
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);
|
||||
} finally {
|
||||
connection.release();
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
const { outputProgress, formatElapsedTime, estimateRemaining, calculateRate } = require('./utils/progress');
|
||||
const { getConnection } = require('./utils/db');
|
||||
|
||||
async function calculateTimeAggregates(startTime, totalProducts, processedCount) {
|
||||
const connection = await getConnection();
|
||||
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
|
||||
await connection.query(`
|
||||
INSERT INTO product_time_aggregates (
|
||||
product_id,
|
||||
pid,
|
||||
year,
|
||||
month,
|
||||
total_quantity_sold,
|
||||
@@ -20,7 +32,7 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount)
|
||||
)
|
||||
WITH sales_data AS (
|
||||
SELECT
|
||||
o.product_id,
|
||||
o.pid,
|
||||
YEAR(o.date) as year,
|
||||
MONTH(o.date) as month,
|
||||
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
|
||||
END as profit_margin
|
||||
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
|
||||
GROUP BY o.product_id, YEAR(o.date), MONTH(o.date)
|
||||
GROUP BY o.pid, YEAR(o.date), MONTH(o.date)
|
||||
),
|
||||
purchase_data AS (
|
||||
SELECT
|
||||
product_id,
|
||||
pid,
|
||||
YEAR(date) as year,
|
||||
MONTH(date) as month,
|
||||
SUM(received) as stock_received,
|
||||
SUM(ordered) as stock_ordered
|
||||
FROM purchase_orders
|
||||
WHERE status = 'closed'
|
||||
GROUP BY product_id, YEAR(date), MONTH(date)
|
||||
WHERE status = 50
|
||||
GROUP BY pid, YEAR(date), MONTH(date)
|
||||
)
|
||||
SELECT
|
||||
s.product_id,
|
||||
s.pid,
|
||||
s.year,
|
||||
s.month,
|
||||
s.total_quantity_sold,
|
||||
@@ -64,12 +76,12 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount)
|
||||
s.profit_margin
|
||||
FROM sales_data s
|
||||
LEFT JOIN purchase_data p
|
||||
ON s.product_id = p.product_id
|
||||
ON s.pid = p.pid
|
||||
AND s.year = p.year
|
||||
AND s.month = p.month
|
||||
UNION
|
||||
SELECT
|
||||
p.product_id,
|
||||
p.pid,
|
||||
p.year,
|
||||
p.month,
|
||||
0 as total_quantity_sold,
|
||||
@@ -82,10 +94,10 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount)
|
||||
0 as profit_margin
|
||||
FROM purchase_data p
|
||||
LEFT JOIN sales_data s
|
||||
ON p.product_id = s.product_id
|
||||
ON p.pid = s.pid
|
||||
AND p.year = s.year
|
||||
AND p.month = s.month
|
||||
WHERE s.product_id IS NULL
|
||||
WHERE s.pid IS NULL
|
||||
ON DUPLICATE KEY UPDATE
|
||||
total_quantity_sold = VALUES(total_quantity_sold),
|
||||
total_revenue = VALUES(total_revenue),
|
||||
@@ -102,17 +114,17 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount)
|
||||
UPDATE product_time_aggregates pta
|
||||
JOIN (
|
||||
SELECT
|
||||
p.product_id,
|
||||
p.pid,
|
||||
YEAR(o.date) as year,
|
||||
MONTH(o.date) as month,
|
||||
p.cost_price * p.stock_quantity as inventory_value,
|
||||
SUM(o.quantity * (o.price - p.cost_price)) as gross_profit,
|
||||
COUNT(DISTINCT DATE(o.date)) as days_in_period
|
||||
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
|
||||
GROUP BY p.product_id, YEAR(o.date), MONTH(o.date)
|
||||
) fin ON pta.product_id = fin.product_id
|
||||
GROUP BY p.pid, YEAR(o.date), MONTH(o.date)
|
||||
) fin ON pta.pid = fin.pid
|
||||
AND pta.year = fin.year
|
||||
AND pta.month = fin.month
|
||||
SET
|
||||
@@ -126,7 +138,9 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount)
|
||||
|
||||
return Math.floor(totalProducts * 0.65);
|
||||
} finally {
|
||||
connection.release();
|
||||
if (connection) {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ async function calculateVendorMetrics(startTime, totalProducts, processedCount)
|
||||
try {
|
||||
outputProgress({
|
||||
status: 'running',
|
||||
operation: 'Calculating vendor metrics',
|
||||
operation: 'Ensuring vendors exist in vendor_details',
|
||||
current: Math.floor(totalProducts * 0.7),
|
||||
total: totalProducts,
|
||||
elapsed: formatElapsedTime(startTime),
|
||||
@@ -15,160 +15,125 @@ async function calculateVendorMetrics(startTime, totalProducts, processedCount)
|
||||
percentage: '70'
|
||||
});
|
||||
|
||||
// First, ensure all vendors exist in vendor_details
|
||||
// First ensure all vendors exist in vendor_details
|
||||
await connection.query(`
|
||||
INSERT IGNORE INTO vendor_details (vendor, status)
|
||||
SELECT DISTINCT vendor, 'active' as status
|
||||
INSERT IGNORE INTO vendor_details (vendor, status, created_at, updated_at)
|
||||
SELECT DISTINCT
|
||||
vendor,
|
||||
'active' as status,
|
||||
NOW() as created_at,
|
||||
NOW() as updated_at
|
||||
FROM products
|
||||
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(`
|
||||
INSERT INTO vendor_metrics (
|
||||
vendor,
|
||||
total_revenue,
|
||||
total_orders,
|
||||
total_late_orders,
|
||||
avg_lead_time_days,
|
||||
on_time_delivery_rate,
|
||||
order_fill_rate,
|
||||
total_orders,
|
||||
total_late_orders,
|
||||
total_purchase_value,
|
||||
avg_order_value,
|
||||
active_products,
|
||||
total_products,
|
||||
total_revenue,
|
||||
avg_margin_percent,
|
||||
status
|
||||
status,
|
||||
last_calculated_at
|
||||
)
|
||||
WITH vendor_orders AS (
|
||||
WITH vendor_sales AS (
|
||||
SELECT
|
||||
po.vendor,
|
||||
AVG(DATEDIFF(po.received_date, po.date)) as avg_lead_time_days,
|
||||
COUNT(*) as total_orders,
|
||||
COUNT(CASE WHEN po.received_date > po.expected_date THEN 1 END) as total_late_orders,
|
||||
SUM(po.cost_price * po.ordered) as total_purchase_value,
|
||||
AVG(po.cost_price * po.ordered) as avg_order_value,
|
||||
CASE
|
||||
WHEN COUNT(*) > 0 THEN
|
||||
(COUNT(CASE WHEN po.received = po.ordered THEN 1 END) * 100.0) / COUNT(*)
|
||||
ELSE 0
|
||||
END as order_fill_rate
|
||||
FROM purchase_orders po
|
||||
WHERE po.status = 'closed'
|
||||
GROUP BY po.vendor
|
||||
p.vendor,
|
||||
SUM(o.quantity * o.price) as total_revenue,
|
||||
COUNT(DISTINCT o.id) as total_orders,
|
||||
COUNT(DISTINCT p.pid) as active_products
|
||||
FROM products p
|
||||
JOIN orders o ON p.pid = o.pid
|
||||
WHERE o.canceled = false
|
||||
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
|
||||
GROUP BY p.vendor
|
||||
),
|
||||
vendor_po AS (
|
||||
SELECT
|
||||
p.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 (
|
||||
SELECT
|
||||
p.vendor,
|
||||
COUNT(DISTINCT p.product_id) as total_products,
|
||||
COUNT(DISTINCT CASE WHEN p.visible = true THEN p.product_id END) as active_products,
|
||||
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 products p
|
||||
LEFT JOIN orders o ON p.product_id = o.product_id AND o.canceled = false
|
||||
GROUP BY p.vendor
|
||||
vendor,
|
||||
COUNT(DISTINCT pid) as total_products
|
||||
FROM products
|
||||
GROUP BY vendor
|
||||
)
|
||||
SELECT
|
||||
vd.vendor,
|
||||
COALESCE(vo.avg_lead_time_days, 0) as avg_lead_time_days,
|
||||
vs.vendor,
|
||||
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
|
||||
WHEN COALESCE(vo.total_orders, 0) > 0 THEN
|
||||
((COALESCE(vo.total_orders, 0) - COALESCE(vo.total_late_orders, 0)) * 100.0) / COALESCE(vo.total_orders, 1)
|
||||
WHEN vp.total_orders > 0
|
||||
THEN (vp.received_orders / vp.total_orders) * 100
|
||||
ELSE 0
|
||||
END as on_time_delivery_rate,
|
||||
COALESCE(vo.order_fill_rate, 0) as order_fill_rate,
|
||||
COALESCE(vo.total_orders, 0) as total_orders,
|
||||
COALESCE(vo.total_late_orders, 0) as total_late_orders,
|
||||
COALESCE(vo.total_purchase_value, 0) as total_purchase_value,
|
||||
COALESCE(vo.avg_order_value, 0) as avg_order_value,
|
||||
COALESCE(vp.active_products, 0) as active_products,
|
||||
COALESCE(vp.total_products, 0) as total_products,
|
||||
COALESCE(vp.total_revenue, 0) as total_revenue,
|
||||
COALESCE(vp.avg_margin_percent, 0) as avg_margin_percent,
|
||||
vd.status
|
||||
FROM vendor_details vd
|
||||
LEFT JOIN vendor_orders vo ON vd.vendor = vo.vendor
|
||||
LEFT JOIN vendor_products vp ON vd.vendor = vp.vendor
|
||||
CASE
|
||||
WHEN vp.total_orders > 0
|
||||
THEN (vp.received_orders / vp.total_orders) * 100
|
||||
ELSE 0
|
||||
END as order_fill_rate,
|
||||
CASE
|
||||
WHEN vs.total_orders > 0
|
||||
THEN vs.total_revenue / vs.total_orders
|
||||
ELSE 0
|
||||
END as avg_order_value,
|
||||
COALESCE(vs.active_products, 0) as active_products,
|
||||
COALESCE(vpr.total_products, 0) as total_products,
|
||||
'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
|
||||
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),
|
||||
on_time_delivery_rate = VALUES(on_time_delivery_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),
|
||||
active_products = VALUES(active_products),
|
||||
total_products = VALUES(total_products),
|
||||
total_revenue = VALUES(total_revenue),
|
||||
avg_margin_percent = VALUES(avg_margin_percent),
|
||||
status = VALUES(status),
|
||||
last_calculated_at = CURRENT_TIMESTAMP
|
||||
last_calculated_at = VALUES(last_calculated_at)
|
||||
`);
|
||||
|
||||
// Calculate vendor time-based metrics
|
||||
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);
|
||||
return Math.floor(totalProducts * 0.9);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = calculateVendorMetrics;
|
||||
module.exports = calculateVendorMetrics;
|
||||
@@ -36,7 +36,7 @@ router.get('/stats', async (req, res) => {
|
||||
0
|
||||
) as averageOrderValue
|
||||
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)
|
||||
`);
|
||||
|
||||
@@ -62,22 +62,43 @@ router.get('/profit', async (req, res) => {
|
||||
try {
|
||||
const pool = req.app.locals.pool;
|
||||
|
||||
// Get profit margins by category
|
||||
// Get profit margins by category with full path
|
||||
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
|
||||
c.name as category,
|
||||
cp.path as categoryPath,
|
||||
ROUND(
|
||||
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
||||
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1
|
||||
) as profitMargin,
|
||||
SUM(o.price * o.quantity) as revenue,
|
||||
SUM(p.cost_price * o.quantity) as cost
|
||||
CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as revenue,
|
||||
CAST(SUM(p.cost_price * o.quantity) AS DECIMAL(15,3)) as cost
|
||||
FROM products p
|
||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
||||
JOIN product_categories pc ON p.product_id = pc.product_id
|
||||
JOIN categories c ON pc.category_id = c.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)
|
||||
GROUP BY c.name
|
||||
GROUP BY c.name, cp.path
|
||||
ORDER BY profitMargin DESC
|
||||
LIMIT 10
|
||||
`);
|
||||
@@ -90,10 +111,10 @@ router.get('/profit', async (req, res) => {
|
||||
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
||||
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1
|
||||
) as profitMargin,
|
||||
SUM(o.price * o.quantity) as revenue,
|
||||
SUM(p.cost_price * o.quantity) as cost
|
||||
CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as revenue,
|
||||
CAST(SUM(p.cost_price * o.quantity) AS DECIMAL(15,3)) as cost
|
||||
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 (
|
||||
SELECT DATE_FORMAT(o.date, '%Y-%m-%d') as formatted_date
|
||||
FROM orders o
|
||||
@@ -106,20 +127,44 @@ router.get('/profit', async (req, res) => {
|
||||
ORDER BY formatted_date
|
||||
`);
|
||||
|
||||
// Get top performing products
|
||||
// Get top performing products with category paths
|
||||
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
|
||||
p.title as product,
|
||||
c.name as category,
|
||||
cp.path as categoryPath,
|
||||
ROUND(
|
||||
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
||||
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1
|
||||
) as profitMargin,
|
||||
SUM(o.price * o.quantity) as revenue,
|
||||
SUM(p.cost_price * o.quantity) as cost
|
||||
CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as revenue,
|
||||
CAST(SUM(p.cost_price * o.quantity) AS DECIMAL(15,3)) as cost
|
||||
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)
|
||||
GROUP BY p.product_id, p.title
|
||||
GROUP BY p.pid, p.title, c.name, cp.path
|
||||
HAVING revenue > 0
|
||||
ORDER BY profitMargin DESC
|
||||
LIMIT 10
|
||||
@@ -144,7 +189,7 @@ router.get('/vendors', async (req, res) => {
|
||||
SELECT COUNT(DISTINCT p.vendor) as vendor_count,
|
||||
COUNT(DISTINCT o.order_number) as order_count
|
||||
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
|
||||
`);
|
||||
|
||||
@@ -155,26 +200,26 @@ router.get('/vendors', async (req, res) => {
|
||||
WITH monthly_sales AS (
|
||||
SELECT
|
||||
p.vendor,
|
||||
SUM(CASE
|
||||
CAST(SUM(CASE
|
||||
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
THEN o.price * o.quantity
|
||||
ELSE 0
|
||||
END) as current_month,
|
||||
SUM(CASE
|
||||
END) AS DECIMAL(15,3)) as current_month,
|
||||
CAST(SUM(CASE
|
||||
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
||||
AND o.date < DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
THEN o.price * o.quantity
|
||||
ELSE 0
|
||||
END) as previous_month
|
||||
END) AS DECIMAL(15,3)) as previous_month
|
||||
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
|
||||
AND o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
||||
GROUP BY p.vendor
|
||||
)
|
||||
SELECT
|
||||
p.vendor,
|
||||
SUM(o.price * o.quantity) as salesVolume,
|
||||
CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as salesVolume,
|
||||
COALESCE(ROUND(
|
||||
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
||||
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1
|
||||
@@ -182,13 +227,13 @@ router.get('/vendors', async (req, res) => {
|
||||
COALESCE(ROUND(
|
||||
SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0), 1
|
||||
), 0) as stockTurnover,
|
||||
COUNT(DISTINCT p.product_id) as productCount,
|
||||
COUNT(DISTINCT p.pid) as productCount,
|
||||
ROUND(
|
||||
((ms.current_month / NULLIF(ms.previous_month, 0)) - 1) * 100,
|
||||
1
|
||||
) as growth
|
||||
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
|
||||
WHERE p.vendor IS NOT NULL
|
||||
AND o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
@@ -203,11 +248,11 @@ router.get('/vendors', async (req, res) => {
|
||||
const [comparison] = await pool.query(`
|
||||
SELECT
|
||||
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,
|
||||
COUNT(DISTINCT p.product_id) as size
|
||||
COUNT(DISTINCT p.pid) as size
|
||||
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
|
||||
GROUP BY p.vendor
|
||||
ORDER BY salesPerProduct DESC
|
||||
@@ -221,9 +266,9 @@ router.get('/vendors', async (req, res) => {
|
||||
SELECT
|
||||
p.vendor,
|
||||
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
|
||||
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
|
||||
AND o.date >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH)
|
||||
GROUP BY
|
||||
@@ -272,9 +317,9 @@ router.get('/stock', async (req, res) => {
|
||||
ROUND(AVG(p.stock_quantity), 0) as averageStock,
|
||||
SUM(o.quantity) as totalSales
|
||||
FROM products p
|
||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
||||
JOIN product_categories pc ON p.product_id = pc.product_id
|
||||
JOIN categories c ON pc.category_id = c.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 ? DAY)
|
||||
GROUP BY c.name
|
||||
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 = 0 THEN 1 ELSE 0 END) as outOfStock
|
||||
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)
|
||||
GROUP BY DATE_FORMAT(o.date, '%Y-%m-%d')
|
||||
ORDER BY date
|
||||
@@ -304,26 +349,14 @@ router.get('/stock', async (req, res) => {
|
||||
const [criticalItems] = await pool.query(`
|
||||
WITH product_thresholds AS (
|
||||
SELECT
|
||||
p.product_id,
|
||||
p.pid,
|
||||
COALESCE(
|
||||
(SELECT reorder_days
|
||||
FROM stock_thresholds st
|
||||
JOIN product_categories pc ON st.category_id = pc.category_id
|
||||
WHERE pc.product_id = p.product_id
|
||||
AND st.vendor = p.vendor LIMIT 1),
|
||||
WHERE st.vendor = p.vendor LIMIT 1),
|
||||
(SELECT reorder_days
|
||||
FROM stock_thresholds st
|
||||
JOIN product_categories pc ON st.category_id = pc.category_id
|
||||
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),
|
||||
WHERE st.vendor IS NULL LIMIT 1),
|
||||
14
|
||||
) as reorder_days
|
||||
FROM products p
|
||||
@@ -339,11 +372,11 @@ router.get('/stock', async (req, res) => {
|
||||
ELSE ROUND(p.stock_quantity / NULLIF((SUM(o.quantity) / ?), 0))
|
||||
END as daysUntilStockout
|
||||
FROM products p
|
||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
||||
JOIN product_thresholds pt ON p.product_id = pt.product_id
|
||||
LEFT JOIN orders o ON p.pid = o.pid
|
||||
JOIN product_thresholds pt ON p.pid = pt.pid
|
||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
||||
AND p.managing_stock = true
|
||||
GROUP BY p.product_id
|
||||
GROUP BY p.pid
|
||||
HAVING daysUntilStockout < ? AND daysUntilStockout >= 0
|
||||
ORDER BY daysUntilStockout
|
||||
LIMIT 10
|
||||
@@ -369,14 +402,16 @@ router.get('/pricing', async (req, res) => {
|
||||
// Get price points analysis
|
||||
const [pricePoints] = await pool.query(`
|
||||
SELECT
|
||||
p.price,
|
||||
SUM(o.quantity) as salesVolume,
|
||||
SUM(o.price * o.quantity) as revenue,
|
||||
p.categories as category
|
||||
CAST(p.price AS DECIMAL(15,3)) as price,
|
||||
CAST(SUM(o.quantity) AS DECIMAL(15,3)) as salesVolume,
|
||||
CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as revenue,
|
||||
c.name as category
|
||||
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)
|
||||
GROUP BY p.price, p.categories
|
||||
GROUP BY p.price, c.name
|
||||
HAVING salesVolume > 0
|
||||
ORDER BY revenue DESC
|
||||
LIMIT 50
|
||||
@@ -386,8 +421,8 @@ router.get('/pricing', async (req, res) => {
|
||||
const [elasticity] = await pool.query(`
|
||||
SELECT
|
||||
DATE_FORMAT(o.date, '%Y-%m-%d') as date,
|
||||
AVG(o.price) as price,
|
||||
SUM(o.quantity) as demand
|
||||
CAST(AVG(o.price) AS DECIMAL(15,3)) as price,
|
||||
CAST(SUM(o.quantity) AS DECIMAL(15,3)) as demand
|
||||
FROM orders o
|
||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
GROUP BY DATE_FORMAT(o.date, '%Y-%m-%d')
|
||||
@@ -398,21 +433,25 @@ router.get('/pricing', async (req, res) => {
|
||||
const [recommendations] = await pool.query(`
|
||||
SELECT
|
||||
p.title as product,
|
||||
p.price as currentPrice,
|
||||
ROUND(
|
||||
CASE
|
||||
WHEN AVG(o.quantity) > 10 THEN p.price * 1.1
|
||||
WHEN AVG(o.quantity) < 2 THEN p.price * 0.9
|
||||
ELSE p.price
|
||||
END, 2
|
||||
CAST(p.price AS DECIMAL(15,3)) as currentPrice,
|
||||
CAST(
|
||||
ROUND(
|
||||
CASE
|
||||
WHEN AVG(o.quantity) > 10 THEN p.price * 1.1
|
||||
WHEN AVG(o.quantity) < 2 THEN p.price * 0.9
|
||||
ELSE p.price
|
||||
END, 2
|
||||
) AS DECIMAL(15,3)
|
||||
) as recommendedPrice,
|
||||
ROUND(
|
||||
SUM(o.price * o.quantity) *
|
||||
CASE
|
||||
WHEN AVG(o.quantity) > 10 THEN 1.15
|
||||
WHEN AVG(o.quantity) < 2 THEN 0.95
|
||||
ELSE 1
|
||||
END, 2
|
||||
CAST(
|
||||
ROUND(
|
||||
SUM(o.price * o.quantity) *
|
||||
CASE
|
||||
WHEN AVG(o.quantity) > 10 THEN 1.15
|
||||
WHEN AVG(o.quantity) < 2 THEN 0.95
|
||||
ELSE 1
|
||||
END, 2
|
||||
) AS DECIMAL(15,3)
|
||||
) as potentialRevenue,
|
||||
CASE
|
||||
WHEN AVG(o.quantity) > 10 THEN 85
|
||||
@@ -420,11 +459,11 @@ router.get('/pricing', async (req, res) => {
|
||||
ELSE 65
|
||||
END as confidence
|
||||
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)
|
||||
GROUP BY p.product_id
|
||||
GROUP BY p.pid, p.price
|
||||
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
|
||||
`);
|
||||
|
||||
@@ -440,11 +479,36 @@ router.get('/categories', async (req, res) => {
|
||||
try {
|
||||
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(`
|
||||
WITH monthly_sales AS (
|
||||
${categoryPathCTE},
|
||||
monthly_sales AS (
|
||||
SELECT
|
||||
c.name,
|
||||
cp.path,
|
||||
SUM(CASE
|
||||
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
THEN o.price * o.quantity
|
||||
@@ -457,62 +521,72 @@ router.get('/categories', async (req, res) => {
|
||||
ELSE 0
|
||||
END) as previous_month
|
||||
FROM products p
|
||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
||||
JOIN product_categories pc ON p.product_id = pc.product_id
|
||||
JOIN categories c ON pc.category_id = c.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 60 DAY)
|
||||
GROUP BY c.name
|
||||
GROUP BY c.name, cp.path
|
||||
)
|
||||
SELECT
|
||||
c.name as category,
|
||||
cp.path as categoryPath,
|
||||
SUM(o.price * o.quantity) as revenue,
|
||||
SUM(o.price * o.quantity - p.cost_price * o.quantity) as profit,
|
||||
ROUND(
|
||||
((ms.current_month / NULLIF(ms.previous_month, 0)) - 1) * 100,
|
||||
1
|
||||
) as growth,
|
||||
COUNT(DISTINCT p.product_id) as productCount
|
||||
COUNT(DISTINCT p.pid) as productCount
|
||||
FROM products p
|
||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
||||
JOIN product_categories pc ON p.product_id = pc.product_id
|
||||
JOIN categories c ON pc.category_id = c.id
|
||||
LEFT JOIN monthly_sales ms ON c.name = ms.name
|
||||
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
|
||||
LEFT JOIN monthly_sales ms ON c.name = ms.name AND cp.path = ms.path
|
||||
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
|
||||
ORDER BY revenue DESC
|
||||
LIMIT 10
|
||||
`);
|
||||
|
||||
// Get category revenue distribution
|
||||
// Get category revenue distribution with full path
|
||||
const [distribution] = await pool.query(`
|
||||
${categoryPathCTE}
|
||||
SELECT
|
||||
c.name as category,
|
||||
cp.path as categoryPath,
|
||||
SUM(o.price * o.quantity) as value
|
||||
FROM products p
|
||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
||||
JOIN product_categories pc ON p.product_id = pc.product_id
|
||||
JOIN categories c ON pc.category_id = c.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)
|
||||
GROUP BY c.name
|
||||
GROUP BY c.name, cp.path
|
||||
HAVING value > 0
|
||||
ORDER BY value DESC
|
||||
LIMIT 6
|
||||
`);
|
||||
|
||||
// Get category sales trends
|
||||
// Get category sales trends with full path
|
||||
const [trends] = await pool.query(`
|
||||
${categoryPathCTE}
|
||||
SELECT
|
||||
c.name as category,
|
||||
cp.path as categoryPath,
|
||||
DATE_FORMAT(o.date, '%b %Y') as month,
|
||||
SUM(o.price * o.quantity) as sales
|
||||
FROM products p
|
||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
||||
JOIN product_categories pc ON p.product_id = pc.product_id
|
||||
JOIN categories c ON pc.category_id = c.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 6 MONTH)
|
||||
GROUP BY
|
||||
c.name,
|
||||
cp.path,
|
||||
DATE_FORMAT(o.date, '%b %Y'),
|
||||
DATE_FORMAT(o.date, '%Y-%m')
|
||||
ORDER BY
|
||||
@@ -529,76 +603,97 @@ router.get('/categories', async (req, res) => {
|
||||
|
||||
// Forecast endpoint
|
||||
router.get('/forecast', async (req, res) => {
|
||||
try {
|
||||
const { brand, startDate, endDate } = req.query;
|
||||
const pool = req.app.locals.pool;
|
||||
try {
|
||||
const { brand, startDate, endDate } = req.query;
|
||||
const pool = req.app.locals.pool;
|
||||
|
||||
const [results] = await pool.query(`
|
||||
WITH category_metrics AS (
|
||||
SELECT
|
||||
c.id as category_id,
|
||||
c.name as category_name,
|
||||
p.brand,
|
||||
COUNT(DISTINCT p.product_id) as num_products,
|
||||
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
|
||||
JOIN product_categories pc ON c.id = pc.category_id
|
||||
JOIN products p ON pc.product_id = p.product_id
|
||||
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id
|
||||
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
|
||||
p.product_id,
|
||||
p.title,
|
||||
p.sku,
|
||||
p.stock_quantity,
|
||||
pc.category_id,
|
||||
pm.first_received_date,
|
||||
COALESCE(SUM(o.quantity), 0) as total_sold,
|
||||
COALESCE(ROUND(AVG(o.price), 2), 0) as avg_price
|
||||
FROM products p
|
||||
JOIN product_categories pc ON p.product_id = pc.product_id
|
||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
||||
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 p.product_id, p.title, p.sku, p.stock_quantity, pc.category_id, pm.first_received_date
|
||||
)
|
||||
SELECT
|
||||
cm.*,
|
||||
JSON_ARRAYAGG(
|
||||
JSON_OBJECT(
|
||||
'product_id', pm.product_id,
|
||||
'title', pm.title,
|
||||
'sku', pm.sku,
|
||||
'stock_quantity', pm.stock_quantity,
|
||||
'total_sold', pm.total_sold,
|
||||
'avg_price', pm.avg_price,
|
||||
'first_received_date', DATE_FORMAT(pm.first_received_date, '%Y-%m-%d')
|
||||
)
|
||||
) as products
|
||||
FROM category_metrics cm
|
||||
JOIN product_metrics pm ON cm.category_id = pm.category_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
|
||||
ORDER BY cm.total_sold DESC
|
||||
`, [startDate, endDate, startDate, endDate, brand, startDate, endDate, startDate, endDate, brand, startDate, endDate]);
|
||||
const [results] = 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
|
||||
),
|
||||
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,
|
||||
CAST(COALESCE(ROUND(SUM(o.quantity) / COUNT(DISTINCT p.pid), 2), 0) AS DECIMAL(15,3)) as avgTotalSold,
|
||||
CAST(COALESCE(ROUND(AVG(o.price), 2), 0) AS DECIMAL(15,3)) as avg_price
|
||||
FROM categories c
|
||||
JOIN product_categories pc ON c.cat_id = pc.cat_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.canceled = false
|
||||
WHERE p.brand = ?
|
||||
AND pmet.first_received_date BETWEEN ? AND ?
|
||||
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
|
||||
cm.*,
|
||||
JSON_ARRAYAGG(
|
||||
JSON_OBJECT(
|
||||
'pid', pd.pid,
|
||||
'title', pd.title,
|
||||
'SKU', pd.SKU,
|
||||
'stock_quantity', pd.stock_quantity,
|
||||
'total_sold', pd.total_sold,
|
||||
'avg_price', pd.avg_price,
|
||||
'first_received_date', DATE_FORMAT(pd.first_received_date, '%Y-%m-%d')
|
||||
)
|
||||
) as products
|
||||
FROM category_metrics cm
|
||||
JOIN product_details pd ON cm.cat_id = pd.cat_id
|
||||
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
|
||||
`, [endDate, startDate, startDate, endDate, brand, startDate, endDate, startDate, endDate, brand, startDate, endDate]);
|
||||
|
||||
res.json(results);
|
||||
} catch (error) {
|
||||
console.error('Error fetching forecast data:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch forecast data' });
|
||||
}
|
||||
res.json(results);
|
||||
} catch (error) {
|
||||
console.error('Error fetching forecast data:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch forecast data' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -5,62 +5,90 @@ const router = express.Router();
|
||||
router.get('/', async (req, res) => {
|
||||
const pool = req.app.locals.pool;
|
||||
try {
|
||||
// Get parent categories for filter dropdown
|
||||
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
|
||||
// Get all categories with metrics and hierarchy info
|
||||
const [categories] = await pool.query(`
|
||||
SELECT
|
||||
c.id as category_id,
|
||||
c.cat_id,
|
||||
c.name,
|
||||
c.type,
|
||||
c.parent_id,
|
||||
c.description,
|
||||
COALESCE(p.name, '') as parent_name,
|
||||
cm.product_count,
|
||||
cm.total_value,
|
||||
cm.avg_margin,
|
||||
cm.turnover_rate,
|
||||
cm.growth_rate,
|
||||
cm.status
|
||||
c.status,
|
||||
p.name as parent_name,
|
||||
p.type as parent_type,
|
||||
COALESCE(cm.product_count, 0) as product_count,
|
||||
COALESCE(cm.active_products, 0) as active_products,
|
||||
CAST(COALESCE(cm.total_value, 0) AS DECIMAL(15,3)) as total_value,
|
||||
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
|
||||
LEFT JOIN categories p ON c.parent_id = p.id
|
||||
LEFT JOIN category_metrics cm ON c.id = cm.category_id
|
||||
ORDER BY c.name ASC
|
||||
LEFT JOIN categories p ON c.parent_id = p.cat_id
|
||||
LEFT JOIN category_metrics cm ON c.cat_id = cm.category_id
|
||||
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
|
||||
const [stats] = await pool.query(`
|
||||
SELECT
|
||||
COUNT(DISTINCT c.id) as totalCategories,
|
||||
COUNT(DISTINCT CASE WHEN cm.status = 'active' THEN c.id END) as activeCategories,
|
||||
COALESCE(SUM(cm.total_value), 0) as totalValue,
|
||||
COUNT(DISTINCT c.cat_id) as totalCategories,
|
||||
COUNT(DISTINCT CASE WHEN c.status = 'active' THEN c.cat_id END) as activeCategories,
|
||||
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.growth_rate, 0)), 1), 0) as avgGrowth
|
||||
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({
|
||||
categories: categories.map(cat => ({
|
||||
...cat,
|
||||
parent_category: cat.parent_name, // Map parent_name to parent_category for frontend compatibility
|
||||
product_count: parseInt(cat.product_count || 0),
|
||||
total_value: parseFloat(cat.total_value || 0),
|
||||
avg_margin: parseFloat(cat.avg_margin || 0),
|
||||
turnover_rate: parseFloat(cat.turnover_rate || 0),
|
||||
growth_rate: parseFloat(cat.growth_rate || 0)
|
||||
cat_id: cat.cat_id,
|
||||
name: cat.name,
|
||||
type: cat.type,
|
||||
parent_id: cat.parent_id,
|
||||
parent_name: cat.parent_name,
|
||||
parent_type: cat.parent_type,
|
||||
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[0],
|
||||
totalValue: parseFloat(stats[0].totalValue || 0),
|
||||
avgMargin: parseFloat(stats[0].avgMargin || 0),
|
||||
avgGrowth: parseFloat(stats[0].avgGrowth || 0)
|
||||
totalCategories: parseInt(stats[0].totalCategories),
|
||||
activeCategories: parseInt(stats[0].activeCategories),
|
||||
totalValue: parseFloat(stats[0].totalValue),
|
||||
avgMargin: parseFloat(stats[0].avgMargin),
|
||||
avgGrowth: parseFloat(stats[0].avgGrowth)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,6 +2,9 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../utils/db');
|
||||
|
||||
// Import status codes
|
||||
const { ReceivingStatus } = require('../types/status-codes');
|
||||
|
||||
// Helper function to execute queries using the connection pool
|
||||
async function executeQuery(sql, params = []) {
|
||||
const pool = db.getPool();
|
||||
@@ -38,15 +41,14 @@ router.get('/stock/metrics', async (req, res) => {
|
||||
const [brandValues] = await executeQuery(`
|
||||
WITH brand_totals AS (
|
||||
SELECT
|
||||
brand,
|
||||
COUNT(DISTINCT product_id) as variant_count,
|
||||
COALESCE(brand, 'Unbranded') as brand,
|
||||
COUNT(DISTINCT pid) as variant_count,
|
||||
COALESCE(SUM(stock_quantity), 0) as stock_units,
|
||||
COALESCE(SUM(stock_quantity * cost_price), 0) as stock_cost,
|
||||
COALESCE(SUM(stock_quantity * price), 0) as stock_retail
|
||||
CAST(COALESCE(SUM(stock_quantity * cost_price), 0) AS DECIMAL(15,3)) as stock_cost,
|
||||
CAST(COALESCE(SUM(stock_quantity * price), 0) AS DECIMAL(15,3)) as stock_retail
|
||||
FROM products
|
||||
WHERE brand IS NOT NULL
|
||||
AND stock_quantity > 0
|
||||
GROUP BY brand
|
||||
WHERE stock_quantity > 0
|
||||
GROUP BY COALESCE(brand, 'Unbranded')
|
||||
HAVING stock_cost > 0
|
||||
),
|
||||
other_brands AS (
|
||||
@@ -54,8 +56,8 @@ router.get('/stock/metrics', async (req, res) => {
|
||||
'Other' as brand,
|
||||
SUM(variant_count) as variant_count,
|
||||
SUM(stock_units) as stock_units,
|
||||
SUM(stock_cost) as stock_cost,
|
||||
SUM(stock_retail) as stock_retail
|
||||
CAST(SUM(stock_cost) AS DECIMAL(15,3)) as stock_cost,
|
||||
CAST(SUM(stock_retail) AS DECIMAL(15,3)) as stock_retail
|
||||
FROM brand_totals
|
||||
WHERE stock_cost <= 5000
|
||||
),
|
||||
@@ -101,49 +103,51 @@ router.get('/purchase/metrics', async (req, res) => {
|
||||
try {
|
||||
const [rows] = await executeQuery(`
|
||||
SELECT
|
||||
COALESCE(COUNT(DISTINCT CASE WHEN po.status = 'open' THEN po.po_id END), 0) as active_pos,
|
||||
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
|
||||
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
|
||||
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
|
||||
ELSE 0
|
||||
END), 0) as total_cost,
|
||||
COALESCE(SUM(CASE
|
||||
WHEN po.status = 'open'
|
||||
END), 0) AS DECIMAL(15,3)) as total_cost,
|
||||
CAST(COALESCE(SUM(CASE
|
||||
WHEN po.receiving_status < ${ReceivingStatus.PartialReceived}
|
||||
THEN po.ordered * p.price
|
||||
ELSE 0
|
||||
END), 0) as total_retail
|
||||
END), 0) AS DECIMAL(15,3)) as total_retail
|
||||
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];
|
||||
|
||||
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(`
|
||||
SELECT
|
||||
po.vendor,
|
||||
COUNT(DISTINCT po.po_id) as order_count,
|
||||
COALESCE(SUM(po.ordered), 0) as ordered_units,
|
||||
COALESCE(SUM(po.ordered * po.cost_price), 0) as order_cost,
|
||||
COALESCE(SUM(po.ordered * p.price), 0) as order_retail
|
||||
COUNT(DISTINCT po.po_id) as orders,
|
||||
COALESCE(SUM(po.ordered), 0) as units,
|
||||
CAST(COALESCE(SUM(po.ordered * po.cost_price), 0) AS DECIMAL(15,3)) as cost,
|
||||
CAST(COALESCE(SUM(po.ordered * p.price), 0) AS DECIMAL(15,3)) as retail
|
||||
FROM purchase_orders po
|
||||
JOIN products p ON po.product_id = p.product_id
|
||||
WHERE po.status = 'open'
|
||||
JOIN products p ON po.pid = p.pid
|
||||
WHERE po.receiving_status < ${ReceivingStatus.PartialReceived}
|
||||
GROUP BY po.vendor
|
||||
HAVING order_cost > 0
|
||||
ORDER BY order_cost DESC
|
||||
HAVING cost > 0
|
||||
ORDER BY cost DESC
|
||||
`);
|
||||
|
||||
// Format response to match PurchaseMetricsData interface
|
||||
const response = {
|
||||
activePurchaseOrders: parseInt(poMetrics.active_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,
|
||||
vendorOrders: vendorOrders.map(v => ({
|
||||
vendor: v.vendor,
|
||||
orders: parseInt(v.order_count) || 0,
|
||||
units: parseInt(v.ordered_units) || 0,
|
||||
cost: parseFloat(v.order_cost) || 0,
|
||||
retail: parseFloat(v.order_retail) || 0
|
||||
orders: parseInt(v.orders) || 0,
|
||||
units: parseInt(v.units) || 0,
|
||||
cost: parseFloat(v.cost) || 0,
|
||||
retail: parseFloat(v.retail) || 0
|
||||
}))
|
||||
};
|
||||
|
||||
@@ -173,21 +177,21 @@ router.get('/replenishment/metrics', async (req, res) => {
|
||||
// Get summary metrics
|
||||
const [metrics] = await executeQuery(`
|
||||
SELECT
|
||||
COUNT(DISTINCT p.product_id) as products_to_replenish,
|
||||
COUNT(DISTINCT p.pid) as products_to_replenish,
|
||||
COALESCE(SUM(CASE
|
||||
WHEN p.stock_quantity < 0 THEN ABS(p.stock_quantity) + pm.reorder_qty
|
||||
ELSE pm.reorder_qty
|
||||
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
|
||||
ELSE pm.reorder_qty * p.cost_price
|
||||
END), 0) as total_cost,
|
||||
COALESCE(SUM(CASE
|
||||
END), 0) AS DECIMAL(15,3)) as total_cost,
|
||||
CAST(COALESCE(SUM(CASE
|
||||
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + 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
|
||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
||||
JOIN product_metrics pm ON p.pid = pm.pid
|
||||
WHERE p.replenishable = true
|
||||
AND (pm.stock_status IN ('Critical', 'Reorder')
|
||||
OR p.stock_quantity < 0)
|
||||
@@ -197,24 +201,24 @@ router.get('/replenishment/metrics', async (req, res) => {
|
||||
// Get top variants to replenish
|
||||
const [variants] = await executeQuery(`
|
||||
SELECT
|
||||
p.product_id,
|
||||
p.pid,
|
||||
p.title,
|
||||
p.stock_quantity as current_stock,
|
||||
CASE
|
||||
WHEN p.stock_quantity < 0 THEN ABS(p.stock_quantity) + pm.reorder_qty
|
||||
ELSE pm.reorder_qty
|
||||
END as replenish_qty,
|
||||
CASE
|
||||
CAST(CASE
|
||||
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.cost_price
|
||||
ELSE pm.reorder_qty * p.cost_price
|
||||
END as replenish_cost,
|
||||
CASE
|
||||
END AS DECIMAL(15,3)) as replenish_cost,
|
||||
CAST(CASE
|
||||
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + 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
|
||||
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
|
||||
AND (pm.stock_status IN ('Critical', 'Reorder')
|
||||
OR p.stock_quantity < 0)
|
||||
@@ -235,7 +239,7 @@ router.get('/replenishment/metrics', async (req, res) => {
|
||||
replenishmentCost: parseFloat(metrics[0].total_cost) || 0,
|
||||
replenishmentRetail: parseFloat(metrics[0].total_retail) || 0,
|
||||
topVariants: variants.map(v => ({
|
||||
id: v.product_id,
|
||||
id: v.pid,
|
||||
title: v.title,
|
||||
currentStock: parseInt(v.current_stock) || 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(AVG(cf.confidence_level), 0) as confidence
|
||||
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 ?
|
||||
GROUP BY c.id, c.name
|
||||
GROUP BY c.cat_id, c.name
|
||||
ORDER BY revenue DESC
|
||||
`, [startDate, endDate]);
|
||||
|
||||
@@ -325,11 +329,11 @@ router.get('/overstock/metrics', async (req, res) => {
|
||||
const [rows] = await executeQuery(`
|
||||
WITH category_overstock AS (
|
||||
SELECT
|
||||
c.id as category_id,
|
||||
c.cat_id,
|
||||
c.name as category_name,
|
||||
COUNT(DISTINCT CASE
|
||||
WHEN pm.stock_status = 'Overstocked'
|
||||
THEN p.product_id
|
||||
THEN p.pid
|
||||
END) as overstocked_products,
|
||||
SUM(CASE
|
||||
WHEN pm.stock_status = 'Overstocked'
|
||||
@@ -347,10 +351,10 @@ router.get('/overstock/metrics', async (req, res) => {
|
||||
ELSE 0
|
||||
END) as total_excess_retail
|
||||
FROM categories c
|
||||
JOIN product_categories pc ON c.id = pc.category_id
|
||||
JOIN products p ON pc.product_id = p.product_id
|
||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
||||
GROUP BY c.id, c.name
|
||||
JOIN product_categories pc ON c.cat_id = pc.cat_id
|
||||
JOIN products p ON pc.pid = p.pid
|
||||
JOIN product_metrics pm ON p.pid = pm.pid
|
||||
GROUP BY c.cat_id, c.name
|
||||
)
|
||||
SELECT
|
||||
SUM(overstocked_products) as total_overstocked,
|
||||
@@ -405,7 +409,7 @@ router.get('/overstock/products', async (req, res) => {
|
||||
try {
|
||||
const [rows] = await executeQuery(`
|
||||
SELECT
|
||||
p.product_id,
|
||||
p.pid,
|
||||
p.SKU,
|
||||
p.title,
|
||||
p.brand,
|
||||
@@ -420,11 +424,11 @@ router.get('/overstock/products', async (req, res) => {
|
||||
(pm.overstocked_amt * p.price) as excess_retail,
|
||||
GROUP_CONCAT(c.name) as categories
|
||||
FROM products p
|
||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
||||
LEFT JOIN product_categories pc ON p.product_id = pc.product_id
|
||||
LEFT JOIN categories c ON pc.category_id = c.id
|
||||
JOIN product_metrics pm ON p.pid = pm.pid
|
||||
LEFT JOIN product_categories pc ON p.pid = pc.pid
|
||||
LEFT JOIN categories c ON pc.cat_id = c.cat_id
|
||||
WHERE pm.stock_status = 'Overstocked'
|
||||
GROUP BY p.product_id
|
||||
GROUP BY p.pid
|
||||
ORDER BY excess_cost DESC
|
||||
LIMIT ?
|
||||
`, [limit]);
|
||||
@@ -439,196 +443,116 @@ router.get('/overstock/products', async (req, res) => {
|
||||
// Returns best-selling products, vendors, and categories
|
||||
router.get('/best-sellers', async (req, res) => {
|
||||
try {
|
||||
const [products] = await executeQuery(`
|
||||
WITH product_sales AS (
|
||||
SELECT
|
||||
p.product_id,
|
||||
p.SKU as sku,
|
||||
p.title,
|
||||
-- Current period (last 30 days)
|
||||
SUM(CASE
|
||||
WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY)
|
||||
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
|
||||
JOIN orders o ON p.product_id = o.product_id
|
||||
WHERE o.canceled = false
|
||||
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 60 DAY)
|
||||
GROUP BY p.product_id, p.SKU, p.title
|
||||
)
|
||||
SELECT
|
||||
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 pool = req.app.locals.pool;
|
||||
|
||||
const [brands] = await executeQuery(`
|
||||
WITH brand_sales AS (
|
||||
// Common CTE for category paths
|
||||
const categoryPathCTE = `
|
||||
WITH RECURSIVE category_path AS (
|
||||
SELECT
|
||||
p.brand,
|
||||
-- Current period (last 30 days)
|
||||
SUM(CASE
|
||||
WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY)
|
||||
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
|
||||
JOIN orders o ON p.product_id = o.product_id
|
||||
WHERE o.canceled = false
|
||||
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 60 DAY)
|
||||
AND p.brand IS NOT NULL
|
||||
GROUP BY p.brand
|
||||
)
|
||||
SELECT
|
||||
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(`
|
||||
WITH category_sales AS (
|
||||
SELECT
|
||||
c.id as category_id,
|
||||
c.cat_id,
|
||||
c.name,
|
||||
-- Current period (last 30 days)
|
||||
SUM(CASE
|
||||
WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY)
|
||||
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
|
||||
c.parent_id,
|
||||
CAST(c.name AS CHAR(1000)) as path
|
||||
FROM categories c
|
||||
JOIN product_categories pc ON c.id = pc.category_id
|
||||
JOIN products p ON pc.product_id = p.product_id
|
||||
JOIN orders o ON p.product_id = o.product_id
|
||||
WHERE o.canceled = false
|
||||
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 60 DAY)
|
||||
GROUP BY c.id, c.name
|
||||
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
|
||||
category_id,
|
||||
name,
|
||||
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 category_sales
|
||||
WHERE units_sold > 0
|
||||
ORDER BY revenue DESC
|
||||
LIMIT 50
|
||||
p.pid,
|
||||
p.SKU as sku,
|
||||
p.title,
|
||||
SUM(o.quantity) as units_sold,
|
||||
CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as revenue,
|
||||
CAST(SUM(o.price * o.quantity - p.cost_price * o.quantity) AS DECIMAL(15,3)) as profit
|
||||
FROM products p
|
||||
JOIN orders o ON p.pid = o.pid
|
||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
AND o.canceled = false
|
||||
GROUP BY p.pid
|
||||
ORDER BY units_sold DESC
|
||||
LIMIT 10
|
||||
`);
|
||||
|
||||
// Format response with explicit type conversion
|
||||
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
|
||||
}));
|
||||
// Get best selling brands
|
||||
const [brands] = await pool.query(`
|
||||
SELECT
|
||||
p.brand,
|
||||
SUM(o.quantity) as units_sold,
|
||||
CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as revenue,
|
||||
CAST(SUM(o.price * o.quantity - p.cost_price * o.quantity) AS DECIMAL(15,3)) as profit,
|
||||
ROUND(
|
||||
((SUM(CASE
|
||||
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
THEN o.price * o.quantity
|
||||
ELSE 0
|
||||
END) /
|
||||
NULLIF(SUM(CASE
|
||||
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
||||
AND o.date < DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
THEN o.price * o.quantity
|
||||
ELSE 0
|
||||
END), 0)) - 1) * 100,
|
||||
1
|
||||
) as growth_rate
|
||||
FROM products p
|
||||
JOIN orders o ON p.pid = o.pid
|
||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
||||
AND o.canceled = false
|
||||
GROUP BY p.brand
|
||||
ORDER BY units_sold DESC
|
||||
LIMIT 10
|
||||
`);
|
||||
|
||||
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
|
||||
}));
|
||||
// Get best selling categories with full path
|
||||
const [categories] = await pool.query(`
|
||||
${categoryPathCTE}
|
||||
SELECT
|
||||
c.cat_id,
|
||||
c.name,
|
||||
cp.path as categoryPath,
|
||||
SUM(o.quantity) as units_sold,
|
||||
CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as revenue,
|
||||
CAST(SUM(o.price * o.quantity - p.cost_price * o.quantity) AS DECIMAL(15,3)) as profit,
|
||||
ROUND(
|
||||
((SUM(CASE
|
||||
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
THEN o.price * o.quantity
|
||||
ELSE 0
|
||||
END) /
|
||||
NULLIF(SUM(CASE
|
||||
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
||||
AND o.date < DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
THEN o.price * o.quantity
|
||||
ELSE 0
|
||||
END), 0)) - 1) * 100,
|
||||
1
|
||||
) as growth_rate
|
||||
FROM products p
|
||||
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 60 DAY)
|
||||
AND o.canceled = false
|
||||
GROUP BY c.cat_id, c.name, cp.path
|
||||
ORDER BY units_sold DESC
|
||||
LIMIT 10
|
||||
`);
|
||||
|
||||
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
|
||||
});
|
||||
res.json({ products, brands, categories });
|
||||
} catch (err) {
|
||||
console.error('Error fetching best sellers:', err);
|
||||
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((o.price - p.cost_price) * o.quantity) as total_profit
|
||||
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
|
||||
AND o.date BETWEEN ? AND ?
|
||||
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((o.price - p.cost_price) * o.quantity) as total_profit
|
||||
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
|
||||
AND o.date BETWEEN ? AND ?
|
||||
`, [startDate, endDate]);
|
||||
@@ -698,7 +622,7 @@ router.get('/low-stock/products', async (req, res) => {
|
||||
try {
|
||||
const [rows] = await executeQuery(`
|
||||
SELECT
|
||||
p.product_id,
|
||||
p.pid,
|
||||
p.SKU,
|
||||
p.title,
|
||||
p.brand,
|
||||
@@ -712,12 +636,12 @@ router.get('/low-stock/products', async (req, res) => {
|
||||
(pm.reorder_qty * p.cost_price) as reorder_cost,
|
||||
GROUP_CONCAT(c.name) as categories
|
||||
FROM products p
|
||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
||||
LEFT JOIN product_categories pc ON p.product_id = pc.product_id
|
||||
LEFT JOIN categories c ON pc.category_id = c.id
|
||||
JOIN product_metrics pm ON p.pid = pm.pid
|
||||
LEFT JOIN product_categories pc ON p.pid = pc.pid
|
||||
LEFT JOIN categories c ON pc.cat_id = c.cat_id
|
||||
WHERE pm.stock_status IN ('Critical', 'Reorder')
|
||||
AND p.replenishable = true
|
||||
GROUP BY p.product_id
|
||||
GROUP BY p.pid
|
||||
ORDER BY
|
||||
CASE pm.stock_status
|
||||
WHEN 'Critical' THEN 1
|
||||
@@ -742,17 +666,17 @@ router.get('/trending/products', async (req, res) => {
|
||||
const [rows] = await executeQuery(`
|
||||
WITH recent_sales AS (
|
||||
SELECT
|
||||
o.product_id,
|
||||
o.pid,
|
||||
COUNT(DISTINCT o.order_number) as recent_orders,
|
||||
SUM(o.quantity) as recent_units,
|
||||
SUM(o.price * o.quantity) as recent_revenue
|
||||
FROM orders o
|
||||
WHERE o.canceled = false
|
||||
AND o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
||||
GROUP BY o.product_id
|
||||
GROUP BY o.pid
|
||||
)
|
||||
SELECT
|
||||
p.product_id,
|
||||
p.pid,
|
||||
p.SKU,
|
||||
p.title,
|
||||
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,
|
||||
GROUP_CONCAT(c.name) as categories
|
||||
FROM recent_sales rs
|
||||
JOIN products p ON rs.product_id = p.product_id
|
||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
||||
LEFT JOIN product_categories pc ON p.product_id = pc.product_id
|
||||
LEFT JOIN categories c ON pc.category_id = c.id
|
||||
GROUP BY p.product_id
|
||||
JOIN products p ON rs.pid = p.pid
|
||||
JOIN product_metrics pm ON p.pid = pm.pid
|
||||
LEFT JOIN product_categories pc ON p.pid = pc.pid
|
||||
LEFT JOIN categories c ON pc.cat_id = c.cat_id
|
||||
GROUP BY p.pid
|
||||
HAVING velocity_change > 0
|
||||
ORDER BY velocity_change DESC
|
||||
LIMIT ?
|
||||
`, [days, days, days, limit]);
|
||||
`, [days, days, limit]);
|
||||
res.json(rows);
|
||||
} catch (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 = 'Overstocked' THEN 1 END) as overstock_count
|
||||
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 (
|
||||
SELECT
|
||||
@@ -909,7 +833,7 @@ router.get('/inventory-health', async (req, res) => {
|
||||
AVG(pm.turnover_rate) as avg_turnover_rate,
|
||||
AVG(pm.days_of_inventory) as avg_days_inventory
|
||||
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
|
||||
),
|
||||
value_distribution AS (
|
||||
@@ -931,7 +855,7 @@ router.get('/inventory-health', async (req, res) => {
|
||||
ELSE 0
|
||||
END) * 100.0 / SUM(p.stock_quantity * p.cost_price) as overstock_value_percent
|
||||
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 (
|
||||
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,
|
||||
AVG(pm.turnover_rate) as category_turnover_rate
|
||||
FROM categories c
|
||||
JOIN product_categories pc ON c.id = pc.category_id
|
||||
JOIN products p ON pc.product_id = p.product_id
|
||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
||||
JOIN product_categories pc ON c.cat_id = pc.cat_id
|
||||
JOIN products p ON pc.pid = p.pid
|
||||
JOIN product_metrics pm ON p.pid = pm.pid
|
||||
WHERE p.replenishable = true
|
||||
GROUP BY c.id, c.name
|
||||
GROUP BY c.cat_id, c.name
|
||||
)
|
||||
SELECT
|
||||
sd.*,
|
||||
@@ -975,20 +899,15 @@ router.get('/replenish/products', async (req, res) => {
|
||||
try {
|
||||
const [products] = await executeQuery(`
|
||||
SELECT
|
||||
p.product_id,
|
||||
p.SKU,
|
||||
p.pid,
|
||||
p.SKU as sku,
|
||||
p.title,
|
||||
p.stock_quantity as current_stock,
|
||||
pm.reorder_qty as replenish_qty,
|
||||
(pm.reorder_qty * p.cost_price) as replenish_cost,
|
||||
(pm.reorder_qty * p.price) as replenish_retail,
|
||||
CASE
|
||||
WHEN pm.daily_sales_avg > 0
|
||||
THEN FLOOR(p.stock_quantity / pm.daily_sales_avg)
|
||||
ELSE NULL
|
||||
END as days_until_stockout
|
||||
p.stock_quantity,
|
||||
pm.daily_sales_avg,
|
||||
pm.reorder_qty,
|
||||
pm.last_purchase_date
|
||||
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
|
||||
AND pm.stock_status IN ('Critical', 'Reorder')
|
||||
AND pm.reorder_qty > 0
|
||||
@@ -997,23 +916,16 @@ router.get('/replenish/products', async (req, res) => {
|
||||
WHEN 'Critical' THEN 1
|
||||
WHEN 'Reorder' THEN 2
|
||||
END,
|
||||
replenish_cost DESC
|
||||
pm.reorder_qty * p.cost_price DESC
|
||||
LIMIT ?
|
||||
`, [limit]);
|
||||
|
||||
// Format response
|
||||
const response = products.map(p => ({
|
||||
product_id: p.product_id,
|
||||
SKU: p.SKU,
|
||||
title: p.title,
|
||||
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);
|
||||
res.json(products.map(p => ({
|
||||
...p,
|
||||
stock_quantity: parseInt(p.stock_quantity) || 0,
|
||||
daily_sales_avg: parseFloat(p.daily_sales_avg) || 0,
|
||||
reorder_qty: parseInt(p.reorder_qty) || 0
|
||||
})));
|
||||
} catch (err) {
|
||||
console.error('Error fetching products to replenish:', err);
|
||||
res.status(500).json({ error: 'Failed to fetch products to replenish' });
|
||||
|
||||
@@ -9,25 +9,25 @@ router.get('/trends', async (req, res) => {
|
||||
WITH MonthlyMetrics AS (
|
||||
SELECT
|
||||
DATE(CONCAT(pta.year, '-', LPAD(pta.month, 2, '0'), '-01')) as date,
|
||||
SUM(pta.total_revenue) as revenue,
|
||||
SUM(pta.total_cost) as cost,
|
||||
SUM(pm.inventory_value) as inventory_value,
|
||||
CAST(COALESCE(SUM(pta.total_revenue), 0) AS DECIMAL(15,3)) as revenue,
|
||||
CAST(COALESCE(SUM(pta.total_cost), 0) AS DECIMAL(15,3)) as cost,
|
||||
CAST(COALESCE(SUM(pm.inventory_value), 0) AS DECIMAL(15,3)) as inventory_value,
|
||||
CASE
|
||||
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
|
||||
END as gmroi
|
||||
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')
|
||||
GROUP BY pta.year, pta.month
|
||||
ORDER BY date ASC
|
||||
)
|
||||
SELECT
|
||||
DATE_FORMAT(date, '%b %y') as date,
|
||||
ROUND(revenue, 2) as revenue,
|
||||
ROUND(inventory_value, 2) as inventory_value,
|
||||
ROUND(gmroi, 2) as gmroi
|
||||
revenue,
|
||||
inventory_value,
|
||||
gmroi
|
||||
FROM MonthlyMetrics
|
||||
`);
|
||||
|
||||
@@ -37,15 +37,15 @@ router.get('/trends', async (req, res) => {
|
||||
const transformedData = {
|
||||
revenue: rows.map(row => ({
|
||||
date: row.date,
|
||||
value: parseFloat(row.revenue || 0)
|
||||
value: parseFloat(row.revenue)
|
||||
})),
|
||||
inventory_value: rows.map(row => ({
|
||||
date: row.date,
|
||||
value: parseFloat(row.inventory_value || 0)
|
||||
value: parseFloat(row.inventory_value)
|
||||
})),
|
||||
gmroi: rows.map(row => ({
|
||||
date: row.date,
|
||||
value: parseFloat(row.gmroi || 0)
|
||||
value: parseFloat(row.gmroi)
|
||||
}))
|
||||
};
|
||||
|
||||
|
||||
@@ -74,8 +74,8 @@ router.get('/', async (req, res) => {
|
||||
o1.status,
|
||||
o1.payment_method,
|
||||
o1.shipping_method,
|
||||
COUNT(o2.product_id) as items_count,
|
||||
SUM(o2.price * o2.quantity) as total_amount
|
||||
COUNT(o2.pid) as items_count,
|
||||
CAST(SUM(o2.price * o2.quantity) AS DECIMAL(15,3)) as total_amount
|
||||
FROM orders o1
|
||||
JOIN orders o2 ON o1.order_number = o2.order_number
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
@@ -101,7 +101,7 @@ router.get('/', async (req, res) => {
|
||||
WITH CurrentStats AS (
|
||||
SELECT
|
||||
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
|
||||
WHERE canceled = false
|
||||
AND DATE(date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
@@ -109,7 +109,7 @@ router.get('/', async (req, res) => {
|
||||
PreviousStats AS (
|
||||
SELECT
|
||||
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
|
||||
WHERE canceled = false
|
||||
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 (
|
||||
SELECT
|
||||
order_number,
|
||||
SUM(price * quantity) as order_value
|
||||
CAST(SUM(price * quantity) AS DECIMAL(15,3)) as order_value
|
||||
FROM orders
|
||||
WHERE canceled = false
|
||||
AND DATE(date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
@@ -138,12 +138,12 @@ router.get('/', async (req, res) => {
|
||||
END as revenue_growth,
|
||||
CASE
|
||||
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
|
||||
END as average_order_value,
|
||||
CASE
|
||||
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
|
||||
END as prev_average_order_value
|
||||
FROM CurrentStats cs
|
||||
@@ -199,8 +199,8 @@ router.get('/:orderNumber', async (req, res) => {
|
||||
o1.shipping_method,
|
||||
o1.shipping_address,
|
||||
o1.billing_address,
|
||||
COUNT(o2.product_id) as items_count,
|
||||
SUM(o2.price * o2.quantity) as total_amount
|
||||
COUNT(o2.pid) as items_count,
|
||||
CAST(SUM(o2.price * o2.quantity) AS DECIMAL(15,3)) as total_amount
|
||||
FROM orders o1
|
||||
JOIN orders o2 ON o1.order_number = o2.order_number
|
||||
WHERE o1.order_number = ? AND o1.canceled = false
|
||||
@@ -222,14 +222,14 @@ router.get('/:orderNumber', async (req, res) => {
|
||||
// Get order items
|
||||
const [itemRows] = await pool.query(`
|
||||
SELECT
|
||||
o.product_id,
|
||||
o.pid,
|
||||
p.title,
|
||||
p.sku,
|
||||
p.SKU,
|
||||
o.quantity,
|
||||
o.price,
|
||||
(o.price * o.quantity) as total
|
||||
CAST((o.price * o.quantity) AS DECIMAL(15,3)) as total
|
||||
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
|
||||
`, [req.params.orderNumber]);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const multer = require('multer');
|
||||
const { importProductsFromCSV } = require('../utils/csvImporter');
|
||||
const { PurchaseOrderStatus, ReceivingStatus } = require('../types/status-codes');
|
||||
|
||||
// Configure multer for file uploads
|
||||
const upload = multer({ dest: 'uploads/' });
|
||||
@@ -20,15 +21,13 @@ router.get('/brands', async (req, res) => {
|
||||
console.log('Fetching brands from database...');
|
||||
|
||||
const [results] = await pool.query(`
|
||||
SELECT DISTINCT p.brand
|
||||
SELECT DISTINCT COALESCE(p.brand, 'Unbranded') as brand
|
||||
FROM products p
|
||||
JOIN purchase_orders po ON p.product_id = po.product_id
|
||||
WHERE p.brand IS NOT NULL
|
||||
AND p.brand != ''
|
||||
AND p.visible = true
|
||||
GROUP BY p.brand
|
||||
JOIN purchase_orders po ON p.pid = po.pid
|
||||
WHERE p.visible = true
|
||||
GROUP BY COALESCE(p.brand, 'Unbranded')
|
||||
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));
|
||||
@@ -147,9 +146,9 @@ router.get('/', async (req, res) => {
|
||||
|
||||
// Get total count for pagination
|
||||
const countQuery = `
|
||||
SELECT COUNT(DISTINCT p.product_id) as total
|
||||
SELECT COUNT(DISTINCT p.pid) as total
|
||||
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}
|
||||
`;
|
||||
const [countResult] = await pool.query(countQuery, params);
|
||||
@@ -163,36 +162,69 @@ router.get('/', async (req, res) => {
|
||||
'SELECT DISTINCT vendor FROM products WHERE visible = true AND vendor IS NOT NULL AND vendor != "" ORDER BY vendor'
|
||||
);
|
||||
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
|
||||
const query = `
|
||||
WITH product_thresholds AS (
|
||||
SELECT
|
||||
p.product_id,
|
||||
COALESCE(
|
||||
(SELECT overstock_days FROM stock_thresholds st
|
||||
WHERE st.category_id IN (
|
||||
SELECT pc.category_id
|
||||
FROM product_categories pc
|
||||
WHERE pc.product_id = p.product_id
|
||||
)
|
||||
AND (st.vendor = p.vendor OR st.vendor IS NULL)
|
||||
ORDER BY st.vendor IS NULL
|
||||
LIMIT 1),
|
||||
(SELECT overstock_days FROM stock_thresholds st
|
||||
WHERE st.category_id IS NULL
|
||||
AND (st.vendor = p.vendor OR st.vendor IS NULL)
|
||||
ORDER BY st.vendor IS NULL
|
||||
LIMIT 1),
|
||||
90
|
||||
) as target_days
|
||||
FROM products p
|
||||
)
|
||||
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
|
||||
),
|
||||
product_thresholds AS (
|
||||
SELECT
|
||||
p.pid,
|
||||
COALESCE(
|
||||
(SELECT overstock_days FROM stock_thresholds st
|
||||
WHERE st.category_id IN (
|
||||
SELECT pc.cat_id
|
||||
FROM product_categories pc
|
||||
WHERE pc.pid = p.pid
|
||||
)
|
||||
AND (st.vendor = p.vendor OR st.vendor IS NULL)
|
||||
ORDER BY st.vendor IS NULL
|
||||
LIMIT 1),
|
||||
(SELECT overstock_days FROM stock_thresholds st
|
||||
WHERE st.category_id IS NULL
|
||||
AND (st.vendor = p.vendor OR st.vendor IS NULL)
|
||||
ORDER BY st.vendor IS NULL
|
||||
LIMIT 1),
|
||||
90
|
||||
) as target_days
|
||||
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
|
||||
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.weekly_sales_avg,
|
||||
pm.monthly_sales_avg,
|
||||
@@ -205,10 +237,10 @@ router.get('/', async (req, res) => {
|
||||
pm.reorder_point,
|
||||
pm.safety_stock,
|
||||
pm.avg_margin_percent,
|
||||
pm.total_revenue,
|
||||
pm.inventory_value,
|
||||
pm.cost_of_goods_sold,
|
||||
pm.gross_profit,
|
||||
CAST(pm.total_revenue AS DECIMAL(15,3)) as total_revenue,
|
||||
CAST(pm.inventory_value AS DECIMAL(15,3)) as inventory_value,
|
||||
CAST(pm.cost_of_goods_sold AS DECIMAL(15,3)) as cost_of_goods_sold,
|
||||
CAST(pm.gross_profit AS DECIMAL(15,3)) as gross_profit,
|
||||
pm.gmroi,
|
||||
pm.avg_lead_time_days,
|
||||
pm.last_purchase_date,
|
||||
@@ -223,12 +255,13 @@ router.get('/', async (req, res) => {
|
||||
pm.overstocked_amt,
|
||||
COALESCE(pm.days_of_inventory / NULLIF(pt.target_days, 0), 0) as stock_coverage_ratio
|
||||
FROM products p
|
||||
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id
|
||||
LEFT JOIN product_categories pc ON p.product_id = pc.product_id
|
||||
LEFT JOIN categories c ON pc.category_id = c.id
|
||||
LEFT JOIN product_thresholds pt ON p.product_id = pt.product_id
|
||||
${whereClause}
|
||||
GROUP BY p.product_id
|
||||
LEFT JOIN product_metrics pm ON p.pid = pm.pid
|
||||
LEFT JOIN product_categories pc ON p.pid = pc.pid
|
||||
LEFT JOIN categories c ON pc.cat_id = c.cat_id
|
||||
LEFT JOIN product_thresholds pt ON p.pid = pt.pid
|
||||
JOIN product_leaf_categories plc ON c.cat_id = plc.cat_id
|
||||
${whereClause ? 'WHERE ' + whereClause.substring(6) : ''}
|
||||
GROUP BY p.pid
|
||||
ORDER BY ${sortColumn} ${sortDirection}
|
||||
LIMIT ? OFFSET ?
|
||||
`;
|
||||
@@ -308,7 +341,7 @@ router.get('/trending', async (req, res) => {
|
||||
SELECT COUNT(*) as count,
|
||||
MAX(total_revenue) as max_revenue,
|
||||
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
|
||||
WHERE total_revenue > 0 OR daily_sales_avg > 0
|
||||
`);
|
||||
@@ -322,7 +355,7 @@ router.get('/trending', async (req, res) => {
|
||||
// Get trending products
|
||||
const [rows] = await pool.query(`
|
||||
SELECT
|
||||
p.product_id,
|
||||
p.pid,
|
||||
p.sku,
|
||||
p.title,
|
||||
COALESCE(pm.daily_sales_avg, 0) as daily_sales_avg,
|
||||
@@ -334,7 +367,7 @@ router.get('/trending', async (req, res) => {
|
||||
END as growth_rate,
|
||||
COALESCE(pm.total_revenue, 0) as total_revenue
|
||||
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)
|
||||
AND p.visible = true
|
||||
ORDER BY growth_rate DESC
|
||||
@@ -351,130 +384,160 @@ router.get('/trending', async (req, res) => {
|
||||
|
||||
// Get a single product
|
||||
router.get('/:id', async (req, res) => {
|
||||
const pool = req.app.locals.pool;
|
||||
try {
|
||||
// Get basic product data with metrics
|
||||
const [rows] = await pool.query(
|
||||
`SELECT
|
||||
const pool = req.app.locals.pool;
|
||||
const id = parseInt(req.params.id);
|
||||
|
||||
// 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.*,
|
||||
GROUP_CONCAT(DISTINCT c.name) as categories,
|
||||
pm.daily_sales_avg,
|
||||
pm.weekly_sales_avg,
|
||||
pm.monthly_sales_avg,
|
||||
pm.days_of_inventory,
|
||||
pm.reorder_point,
|
||||
pm.safety_stock,
|
||||
pm.stock_status,
|
||||
pm.abc_class,
|
||||
pm.avg_margin_percent,
|
||||
pm.total_revenue,
|
||||
pm.inventory_value,
|
||||
pm.turnover_rate,
|
||||
pm.abc_class,
|
||||
pm.stock_status,
|
||||
pm.gmroi,
|
||||
pm.cost_of_goods_sold,
|
||||
pm.gross_profit,
|
||||
pm.avg_lead_time_days,
|
||||
pm.current_lead_time,
|
||||
pm.target_lead_time,
|
||||
pm.lead_time_status,
|
||||
pm.gmroi,
|
||||
pm.cost_of_goods_sold,
|
||||
pm.gross_profit
|
||||
pm.reorder_qty,
|
||||
pm.overstocked_amt
|
||||
FROM products p
|
||||
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id
|
||||
LEFT JOIN product_categories pc ON p.product_id = pc.product_id
|
||||
LEFT JOIN categories c ON pc.category_id = c.id
|
||||
WHERE p.product_id = ? AND p.visible = true
|
||||
GROUP BY p.product_id`,
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
if (rows.length === 0) {
|
||||
LEFT JOIN product_metrics pm ON p.pid = pm.pid
|
||||
WHERE p.pid = ?
|
||||
`, [id]);
|
||||
|
||||
if (!productRows.length) {
|
||||
return res.status(404).json({ error: 'Product not found' });
|
||||
}
|
||||
|
||||
// Get vendor performance metrics
|
||||
const [vendorMetrics] = await pool.query(
|
||||
`SELECT * FROM vendor_metrics WHERE vendor = ?`,
|
||||
[rows[0].vendor]
|
||||
);
|
||||
// Get categories and their paths separately to avoid GROUP BY issues
|
||||
const [categoryRows] = 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
|
||||
),
|
||||
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 = {
|
||||
// Basic product info
|
||||
product_id: rows[0].product_id,
|
||||
title: rows[0].title,
|
||||
SKU: rows[0].SKU,
|
||||
barcode: rows[0].barcode,
|
||||
created_at: rows[0].created_at,
|
||||
updated_at: rows[0].updated_at,
|
||||
|
||||
// Inventory fields
|
||||
stock_quantity: parseInt(rows[0].stock_quantity),
|
||||
moq: parseInt(rows[0].moq),
|
||||
uom: parseInt(rows[0].uom),
|
||||
managing_stock: Boolean(rows[0].managing_stock),
|
||||
replenishable: Boolean(rows[0].replenishable),
|
||||
|
||||
// Pricing fields
|
||||
price: parseFloat(rows[0].price),
|
||||
regular_price: parseFloat(rows[0].regular_price),
|
||||
cost_price: parseFloat(rows[0].cost_price),
|
||||
landing_cost_price: parseFloat(rows[0].landing_cost_price),
|
||||
|
||||
// Categorization
|
||||
categories: rows[0].categories ? rows[0].categories.split(',') : [],
|
||||
tags: rows[0].tags ? rows[0].tags.split(',') : [],
|
||||
options: rows[0].options ? JSON.parse(rows[0].options) : {},
|
||||
|
||||
// Vendor info
|
||||
vendor: rows[0].vendor,
|
||||
vendor_reference: rows[0].vendor_reference,
|
||||
brand: rows[0].brand,
|
||||
|
||||
// URLs
|
||||
permalink: rows[0].permalink,
|
||||
image: rows[0].image,
|
||||
|
||||
// Metrics
|
||||
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
|
||||
...productRows[0],
|
||||
// Include cat_id in categories array to match the keys in categoryPathMap
|
||||
categories: categoryRows.map(row => `${row.cat_id}:${row.category_name}`),
|
||||
category_paths: categoryPathMap,
|
||||
price: parseFloat(productRows[0].price),
|
||||
regular_price: parseFloat(productRows[0].regular_price),
|
||||
cost_price: parseFloat(productRows[0].cost_price),
|
||||
landing_cost_price: parseFloat(productRows[0].landing_cost_price),
|
||||
stock_quantity: parseInt(productRows[0].stock_quantity),
|
||||
moq: parseInt(productRows[0].moq),
|
||||
uom: parseInt(productRows[0].uom),
|
||||
managing_stock: Boolean(productRows[0].managing_stock),
|
||||
replenishable: Boolean(productRows[0].replenishable),
|
||||
daily_sales_avg: parseFloat(productRows[0].daily_sales_avg) || 0,
|
||||
weekly_sales_avg: parseFloat(productRows[0].weekly_sales_avg) || 0,
|
||||
monthly_sales_avg: parseFloat(productRows[0].monthly_sales_avg) || 0,
|
||||
avg_quantity_per_order: parseFloat(productRows[0].avg_quantity_per_order) || 0,
|
||||
number_of_orders: parseInt(productRows[0].number_of_orders) || 0,
|
||||
first_sale_date: productRows[0].first_sale_date || null,
|
||||
last_sale_date: productRows[0].last_sale_date || null,
|
||||
days_of_inventory: parseFloat(productRows[0].days_of_inventory) || 0,
|
||||
weeks_of_inventory: parseFloat(productRows[0].weeks_of_inventory) || 0,
|
||||
reorder_point: parseFloat(productRows[0].reorder_point) || 0,
|
||||
safety_stock: parseFloat(productRows[0].safety_stock) || 0,
|
||||
avg_margin_percent: parseFloat(productRows[0].avg_margin_percent) || 0,
|
||||
total_revenue: parseFloat(productRows[0].total_revenue) || 0,
|
||||
inventory_value: parseFloat(productRows[0].inventory_value) || 0,
|
||||
cost_of_goods_sold: parseFloat(productRows[0].cost_of_goods_sold) || 0,
|
||||
gross_profit: parseFloat(productRows[0].gross_profit) || 0,
|
||||
gmroi: parseFloat(productRows[0].gmroi) || 0,
|
||||
avg_lead_time_days: parseFloat(productRows[0].avg_lead_time_days) || 0,
|
||||
current_lead_time: parseFloat(productRows[0].current_lead_time) || 0,
|
||||
target_lead_time: parseFloat(productRows[0].target_lead_time) || 0,
|
||||
lead_time_status: productRows[0].lead_time_status || null,
|
||||
reorder_qty: parseInt(productRows[0].reorder_qty) || 0,
|
||||
overstocked_amt: parseInt(productRows[0].overstocked_amt) || 0
|
||||
};
|
||||
|
||||
res.json(product);
|
||||
@@ -532,7 +595,7 @@ router.put('/:id', async (req, res) => {
|
||||
categories = ?,
|
||||
visible = ?,
|
||||
managing_stock = ?
|
||||
WHERE product_id = ?`,
|
||||
WHERE pid = ?`,
|
||||
[
|
||||
title,
|
||||
sku,
|
||||
@@ -570,7 +633,7 @@ router.get('/:id/metrics', async (req, res) => {
|
||||
const [metrics] = await pool.query(`
|
||||
WITH inventory_status AS (
|
||||
SELECT
|
||||
p.product_id,
|
||||
p.pid,
|
||||
CASE
|
||||
WHEN pm.daily_sales_avg = 0 THEN 'New'
|
||||
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'
|
||||
END as calculated_status
|
||||
FROM products p
|
||||
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id
|
||||
WHERE p.product_id = ?
|
||||
LEFT JOIN product_metrics pm ON p.pid = pm.pid
|
||||
WHERE p.pid = ?
|
||||
)
|
||||
SELECT
|
||||
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.overstocked_amt, 0) as overstocked_amt
|
||||
FROM products p
|
||||
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id
|
||||
LEFT JOIN inventory_status is ON p.product_id = is.product_id
|
||||
WHERE p.product_id = ?
|
||||
LEFT JOIN product_metrics pm ON p.pid = pm.pid
|
||||
LEFT JOIN inventory_status is ON p.pid = is.pid
|
||||
WHERE p.pid = ?
|
||||
`, [id]);
|
||||
|
||||
if (!metrics.length) {
|
||||
@@ -643,57 +706,35 @@ router.get('/:id/metrics', async (req, res) => {
|
||||
|
||||
// Get product time series data
|
||||
router.get('/:id/time-series', async (req, res) => {
|
||||
const pool = req.app.locals.pool;
|
||||
const { id } = req.params;
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const months = parseInt(req.query.months) || 12;
|
||||
|
||||
// Get monthly sales data with running totals and growth rates
|
||||
const [monthlySales] = await pool.query(`
|
||||
WITH monthly_data AS (
|
||||
SELECT
|
||||
CONCAT(year, '-', LPAD(month, 2, '0')) as month,
|
||||
total_quantity_sold as quantity,
|
||||
total_revenue as revenue,
|
||||
total_cost as cost,
|
||||
avg_price,
|
||||
profit_margin,
|
||||
inventory_value
|
||||
FROM product_time_aggregates
|
||||
WHERE product_id = ?
|
||||
ORDER BY year DESC, month DESC
|
||||
LIMIT ?
|
||||
)
|
||||
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]);
|
||||
const pool = req.app.locals.pool;
|
||||
|
||||
// Calculate growth rates and format data
|
||||
const formattedMonthlySales = monthlySales.map(row => ({
|
||||
month: row.month,
|
||||
quantity: parseInt(row.quantity) || 0,
|
||||
revenue: parseFloat(row.revenue) || 0,
|
||||
cost: parseFloat(row.cost) || 0,
|
||||
avg_price: parseFloat(row.avg_price) || 0,
|
||||
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 monthly sales data
|
||||
const [monthlySales] = await pool.query(`
|
||||
SELECT
|
||||
DATE_FORMAT(date, '%Y-%m') as month,
|
||||
COUNT(DISTINCT order_number) as order_count,
|
||||
SUM(quantity) as units_sold,
|
||||
CAST(SUM(price * quantity) AS DECIMAL(15,3)) as revenue
|
||||
FROM orders
|
||||
WHERE pid = ?
|
||||
AND canceled = false
|
||||
GROUP BY DATE_FORMAT(date, '%Y-%m')
|
||||
ORDER BY month DESC
|
||||
LIMIT 12
|
||||
`, [id]);
|
||||
|
||||
// Format monthly sales data
|
||||
const formattedMonthlySales = monthlySales.map(month => ({
|
||||
month: month.month,
|
||||
order_count: parseInt(month.order_count),
|
||||
units_sold: parseInt(month.units_sold),
|
||||
revenue: parseFloat(month.revenue),
|
||||
profit: 0 // Set to 0 since we don't have cost data in orders table
|
||||
}));
|
||||
|
||||
// Get recent orders with customer info and status
|
||||
// Get recent orders
|
||||
const [recentOrders] = await pool.query(`
|
||||
SELECT
|
||||
DATE_FORMAT(date, '%Y-%m-%d') as date,
|
||||
@@ -703,11 +744,10 @@ router.get('/:id/time-series', async (req, res) => {
|
||||
discount,
|
||||
tax,
|
||||
shipping,
|
||||
customer,
|
||||
status,
|
||||
payment_method
|
||||
customer_name as customer,
|
||||
status
|
||||
FROM orders
|
||||
WHERE product_id = ?
|
||||
WHERE pid = ?
|
||||
AND canceled = false
|
||||
ORDER BY date DESC
|
||||
LIMIT 10
|
||||
@@ -723,17 +763,19 @@ router.get('/:id/time-series', async (req, res) => {
|
||||
ordered,
|
||||
received,
|
||||
status,
|
||||
receiving_status,
|
||||
cost_price,
|
||||
notes,
|
||||
CASE
|
||||
WHEN received_date IS NOT NULL THEN
|
||||
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)
|
||||
ELSE NULL
|
||||
END as lead_time_days
|
||||
FROM purchase_orders
|
||||
WHERE product_id = ?
|
||||
WHERE pid = ?
|
||||
AND status != ${PurchaseOrderStatus.Canceled}
|
||||
ORDER BY date DESC
|
||||
LIMIT 10
|
||||
`, [id]);
|
||||
@@ -752,6 +794,8 @@ router.get('/:id/time-series', async (req, res) => {
|
||||
...po,
|
||||
ordered: parseInt(po.ordered),
|
||||
received: parseInt(po.received),
|
||||
status: parseInt(po.status),
|
||||
receiving_status: parseInt(po.receiving_status),
|
||||
cost_price: parseFloat(po.cost_price),
|
||||
lead_time_days: po.lead_time_days ? parseInt(po.lead_time_days) : null
|
||||
}))
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
const express = require('express');
|
||||
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
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
@@ -11,13 +31,13 @@ router.get('/', async (req, res) => {
|
||||
const params = [];
|
||||
|
||||
if (search) {
|
||||
whereClause += ' AND (po.po_id LIKE ? OR po.vendor LIKE ? OR po.status LIKE ?)';
|
||||
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
|
||||
whereClause += ' AND (po.po_id LIKE ? OR po.vendor LIKE ?)';
|
||||
params.push(`%${search}%`, `%${search}%`);
|
||||
}
|
||||
|
||||
if (status && status !== 'all') {
|
||||
whereClause += ' AND po.status = ?';
|
||||
params.push(status);
|
||||
params.push(Number(status));
|
||||
}
|
||||
|
||||
if (vendor && vendor !== 'all') {
|
||||
@@ -42,7 +62,7 @@ router.get('/', async (req, res) => {
|
||||
po_id,
|
||||
SUM(ordered) as total_ordered,
|
||||
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
|
||||
WHERE ${whereClause}
|
||||
GROUP BY po_id
|
||||
@@ -54,8 +74,8 @@ router.get('/', async (req, res) => {
|
||||
ROUND(
|
||||
SUM(total_received) / NULLIF(SUM(total_ordered), 0), 3
|
||||
) as fulfillment_rate,
|
||||
SUM(total_cost) as total_value,
|
||||
ROUND(AVG(total_cost), 2) as avg_cost
|
||||
CAST(SUM(total_cost) AS DECIMAL(15,3)) as total_value,
|
||||
CAST(AVG(total_cost) AS DECIMAL(15,3)) as avg_cost
|
||||
FROM po_totals
|
||||
`, params);
|
||||
|
||||
@@ -78,22 +98,24 @@ router.get('/', async (req, res) => {
|
||||
vendor,
|
||||
date,
|
||||
status,
|
||||
COUNT(DISTINCT product_id) as total_items,
|
||||
receiving_status,
|
||||
COUNT(DISTINCT pid) as total_items,
|
||||
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,
|
||||
ROUND(
|
||||
SUM(received) / NULLIF(SUM(ordered), 0), 3
|
||||
) as fulfillment_rate
|
||||
FROM purchase_orders po
|
||||
WHERE ${whereClause}
|
||||
GROUP BY po_id, vendor, date, status
|
||||
GROUP BY po_id, vendor, date, status, receiving_status
|
||||
)
|
||||
SELECT
|
||||
po_id as id,
|
||||
vendor as vendor_name,
|
||||
DATE_FORMAT(date, '%Y-%m-%d') as order_date,
|
||||
status,
|
||||
receiving_status,
|
||||
total_items,
|
||||
total_quantity,
|
||||
total_cost,
|
||||
@@ -104,8 +126,8 @@ router.get('/', async (req, res) => {
|
||||
CASE
|
||||
WHEN ? = 'order_date' THEN date
|
||||
WHEN ? = 'vendor_name' THEN vendor
|
||||
WHEN ? = 'total_cost' THEN CAST(total_cost AS DECIMAL(15,2))
|
||||
WHEN ? = 'total_received' THEN CAST(total_received 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,3))
|
||||
WHEN ? = 'total_items' THEN CAST(total_items AS SIGNED)
|
||||
WHEN ? = 'total_quantity' THEN CAST(total_quantity AS SIGNED)
|
||||
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(`
|
||||
SELECT DISTINCT status
|
||||
FROM purchase_orders
|
||||
WHERE status IS NOT NULL AND status != ''
|
||||
WHERE status IS NOT NULL
|
||||
ORDER BY status
|
||||
`);
|
||||
|
||||
@@ -136,7 +158,8 @@ router.get('/', async (req, res) => {
|
||||
id: order.id,
|
||||
vendor_name: order.vendor_name,
|
||||
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_quantity: Number(order.total_quantity) || 0,
|
||||
total_cost: Number(order.total_cost) || 0,
|
||||
@@ -165,7 +188,7 @@ router.get('/', async (req, res) => {
|
||||
},
|
||||
filters: {
|
||||
vendors: vendors.map(v => v.vendor),
|
||||
statuses: statuses.map(s => s.status)
|
||||
statuses: statuses.map(s => Number(s.status))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -188,12 +211,14 @@ router.get('/vendor-metrics', async (req, res) => {
|
||||
received,
|
||||
cost_price,
|
||||
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)
|
||||
ELSE NULL
|
||||
END as delivery_days
|
||||
FROM purchase_orders
|
||||
WHERE vendor IS NOT NULL AND vendor != ''
|
||||
AND status != ${STATUS.CANCELED} -- Exclude canceled orders
|
||||
)
|
||||
SELECT
|
||||
vendor as vendor_name,
|
||||
@@ -203,10 +228,10 @@ router.get('/vendor-metrics', async (req, res) => {
|
||||
ROUND(
|
||||
SUM(received) / NULLIF(SUM(ordered), 0), 3
|
||||
) as fulfillment_rate,
|
||||
ROUND(
|
||||
CAST(ROUND(
|
||||
SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2
|
||||
) as avg_unit_cost,
|
||||
SUM(ordered * cost_price) as total_spend,
|
||||
) AS DECIMAL(15,3)) as avg_unit_cost,
|
||||
CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_spend,
|
||||
ROUND(
|
||||
AVG(NULLIF(delivery_days, 0)), 1
|
||||
) as avg_delivery_days
|
||||
@@ -242,47 +267,47 @@ router.get('/cost-analysis', async (req, res) => {
|
||||
const pool = req.app.locals.pool;
|
||||
|
||||
const [analysis] = await pool.query(`
|
||||
WITH category_costs AS (
|
||||
SELECT
|
||||
c.name as category,
|
||||
po.pid,
|
||||
po.cost_price,
|
||||
po.ordered,
|
||||
po.received,
|
||||
po.status,
|
||||
po.receiving_status
|
||||
FROM purchase_orders po
|
||||
JOIN product_categories pc ON po.pid = pc.pid
|
||||
JOIN categories c ON pc.cat_id = c.cat_id
|
||||
WHERE po.status != ${STATUS.CANCELED} -- Exclude canceled orders
|
||||
)
|
||||
SELECT
|
||||
c.name as categories,
|
||||
COUNT(DISTINCT po.product_id) as unique_products,
|
||||
ROUND(AVG(po.cost_price), 2) as avg_cost,
|
||||
MIN(po.cost_price) as min_cost,
|
||||
MAX(po.cost_price) as max_cost,
|
||||
ROUND(
|
||||
STDDEV(po.cost_price), 2
|
||||
) as cost_variance,
|
||||
SUM(po.ordered * po.cost_price) as total_spend
|
||||
FROM purchase_orders po
|
||||
JOIN products p ON po.product_id = p.product_id
|
||||
JOIN product_categories pc ON p.product_id = pc.product_id
|
||||
JOIN categories c ON pc.category_id = c.id
|
||||
GROUP BY c.name
|
||||
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
|
||||
`);
|
||||
|
||||
// Parse numeric values and add ids for React keys
|
||||
const parsedAnalysis = analysis.map(item => ({
|
||||
id: item.categories || 'Uncategorized',
|
||||
categories: item.categories || 'Uncategorized',
|
||||
unique_products: Number(item.unique_products) || 0,
|
||||
avg_cost: Number(item.avg_cost) || 0,
|
||||
min_cost: Number(item.min_cost) || 0,
|
||||
max_cost: Number(item.max_cost) || 0,
|
||||
cost_variance: Number(item.cost_variance) || 0,
|
||||
total_spend: Number(item.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)
|
||||
// Parse numeric values
|
||||
const parsedAnalysis = {
|
||||
categories: analysis.map(cat => ({
|
||||
category: cat.category,
|
||||
unique_products: Number(cat.unique_products) || 0,
|
||||
avg_cost: Number(cat.avg_cost) || 0,
|
||||
min_cost: Number(cat.min_cost) || 0,
|
||||
max_cost: Number(cat.max_cost) || 0,
|
||||
cost_variance: Number(cat.cost_variance) || 0,
|
||||
total_spend: Number(cat.total_spend) || 0
|
||||
}))
|
||||
};
|
||||
|
||||
res.json(transformedAnalysis);
|
||||
res.json(parsedAnalysis);
|
||||
} catch (error) {
|
||||
console.error('Error fetching cost analysis:', error);
|
||||
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 (
|
||||
SELECT
|
||||
po_id,
|
||||
status,
|
||||
receiving_status,
|
||||
SUM(ordered) as total_ordered,
|
||||
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
|
||||
GROUP BY po_id
|
||||
WHERE status != ${STATUS.CANCELED}
|
||||
GROUP BY po_id, status, receiving_status
|
||||
)
|
||||
SELECT
|
||||
COUNT(DISTINCT po_id) as order_count,
|
||||
@@ -311,8 +339,20 @@ router.get('/receiving-status', async (req, res) => {
|
||||
ROUND(
|
||||
SUM(total_received) / NULLIF(SUM(total_ordered), 0), 3
|
||||
) as fulfillment_rate,
|
||||
SUM(total_cost) as total_value,
|
||||
ROUND(AVG(total_cost), 2) as avg_cost
|
||||
CAST(SUM(total_cost) AS DECIMAL(15,3)) as total_value,
|
||||
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
|
||||
`);
|
||||
|
||||
@@ -323,7 +363,13 @@ router.get('/receiving-status', async (req, res) => {
|
||||
total_received: Number(status[0].total_received) || 0,
|
||||
fulfillment_rate: Number(status[0].fulfillment_rate) || 0,
|
||||
total_value: Number(status[0].total_value) || 0,
|
||||
avg_cost: Number(status[0].avg_cost) || 0
|
||||
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);
|
||||
|
||||
@@ -29,8 +29,8 @@ router.get('/', async (req, res) => {
|
||||
const [costMetrics] = await pool.query(`
|
||||
SELECT
|
||||
vendor,
|
||||
ROUND(SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2) as avg_unit_cost,
|
||||
SUM(ordered * cost_price) as total_spend
|
||||
CAST(ROUND(SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2) AS DECIMAL(15,3)) as avg_unit_cost,
|
||||
CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_spend
|
||||
FROM purchase_orders
|
||||
WHERE status = 'closed'
|
||||
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
|
||||
THEN p.vendor
|
||||
END) as activeVendors,
|
||||
ROUND(AVG(NULLIF(vm.avg_lead_time_days, 0)), 1) as avgLeadTime,
|
||||
ROUND(AVG(NULLIF(vm.order_fill_rate, 0)), 1) as avgFillRate,
|
||||
ROUND(AVG(NULLIF(vm.on_time_delivery_rate, 0)), 1) as avgOnTimeDelivery
|
||||
COALESCE(ROUND(AVG(NULLIF(vm.avg_lead_time_days, 0)), 1), 0) as avgLeadTime,
|
||||
COALESCE(ROUND(AVG(NULLIF(vm.order_fill_rate, 0)), 1), 0) as avgFillRate,
|
||||
COALESCE(ROUND(AVG(NULLIF(vm.on_time_delivery_rate, 0)), 1), 0) as avgOnTimeDelivery
|
||||
FROM products p
|
||||
LEFT JOIN vendor_metrics vm ON p.vendor = vm.vendor
|
||||
WHERE p.vendor IS NOT NULL AND p.vendor != ''
|
||||
@@ -67,8 +67,8 @@ router.get('/', async (req, res) => {
|
||||
// Get overall cost metrics
|
||||
const [overallCostMetrics] = await pool.query(`
|
||||
SELECT
|
||||
ROUND(SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2) as avg_unit_cost,
|
||||
SUM(ordered * cost_price) as total_spend
|
||||
CAST(ROUND(SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2) AS DECIMAL(15,3)) as avg_unit_cost,
|
||||
CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_spend
|
||||
FROM purchase_orders
|
||||
WHERE status = 'closed'
|
||||
AND cost_price IS NOT NULL
|
||||
@@ -78,25 +78,25 @@ router.get('/', async (req, res) => {
|
||||
|
||||
res.json({
|
||||
vendors: vendors.map(vendor => ({
|
||||
vendor_id: vendor.vendor_id || vendor.name,
|
||||
vendor_id: vendor.name,
|
||||
name: vendor.name,
|
||||
status: vendor.status,
|
||||
avg_lead_time_days: parseFloat(vendor.avg_lead_time_days || 0),
|
||||
on_time_delivery_rate: parseFloat(vendor.on_time_delivery_rate || 0),
|
||||
order_fill_rate: parseFloat(vendor.order_fill_rate || 0),
|
||||
total_orders: parseInt(vendor.total_orders || 0),
|
||||
active_products: parseInt(vendor.active_products || 0),
|
||||
avg_lead_time_days: parseFloat(vendor.avg_lead_time_days),
|
||||
on_time_delivery_rate: parseFloat(vendor.on_time_delivery_rate),
|
||||
order_fill_rate: parseFloat(vendor.order_fill_rate),
|
||||
total_orders: parseInt(vendor.total_orders),
|
||||
active_products: parseInt(vendor.active_products),
|
||||
avg_unit_cost: parseFloat(costMetricsMap[vendor.name]?.avg_unit_cost || 0),
|
||||
total_spend: parseFloat(costMetricsMap[vendor.name]?.total_spend || 0)
|
||||
})),
|
||||
stats: {
|
||||
totalVendors: parseInt(stats[0].totalVendors || 0),
|
||||
activeVendors: parseInt(stats[0].activeVendors || 0),
|
||||
avgLeadTime: parseFloat(stats[0].avgLeadTime || 0),
|
||||
avgFillRate: parseFloat(stats[0].avgFillRate || 0),
|
||||
avgOnTimeDelivery: parseFloat(stats[0].avgOnTimeDelivery || 0),
|
||||
avgUnitCost: parseFloat(overallCostMetrics[0].avg_unit_cost || 0),
|
||||
totalSpend: parseFloat(overallCostMetrics[0].total_spend || 0)
|
||||
totalVendors: parseInt(stats[0].totalVendors),
|
||||
activeVendors: parseInt(stats[0].activeVendors),
|
||||
avgLeadTime: parseFloat(stats[0].avgLeadTime),
|
||||
avgFillRate: parseFloat(stats[0].avgFillRate),
|
||||
avgOnTimeDelivery: parseFloat(stats[0].avgOnTimeDelivery),
|
||||
avgUnitCost: parseFloat(overallCostMetrics[0].avg_unit_cost),
|
||||
totalSpend: parseFloat(overallCostMetrics[0].total_spend)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
79
inventory-server/src/types/status-codes.js
Normal file
79
inventory-server/src/types/status-codes.js
Normal 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
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import config from '../../config';
|
||||
interface CategoryData {
|
||||
performance: {
|
||||
category: string;
|
||||
categoryPath: string; // Full hierarchy path
|
||||
revenue: number;
|
||||
profit: number;
|
||||
growth: number;
|
||||
@@ -13,10 +14,12 @@ interface CategoryData {
|
||||
}[];
|
||||
distribution: {
|
||||
category: string;
|
||||
categoryPath: string; // Full hierarchy path
|
||||
value: number;
|
||||
}[];
|
||||
trends: {
|
||||
category: string;
|
||||
categoryPath: string; // Full hierarchy path
|
||||
month: string;
|
||||
sales: number;
|
||||
}[];
|
||||
@@ -36,6 +39,7 @@ export function CategoryPerformance() {
|
||||
return {
|
||||
performance: rawData.performance.map((item: any) => ({
|
||||
...item,
|
||||
categoryPath: item.categoryPath || item.category,
|
||||
revenue: Number(item.revenue) || 0,
|
||||
profit: Number(item.profit) || 0,
|
||||
growth: Number(item.growth) || 0,
|
||||
@@ -43,10 +47,12 @@ export function CategoryPerformance() {
|
||||
})),
|
||||
distribution: rawData.distribution.map((item: any) => ({
|
||||
...item,
|
||||
categoryPath: item.categoryPath || item.category,
|
||||
value: Number(item.value) || 0
|
||||
})),
|
||||
trends: rawData.trends.map((item: any) => ({
|
||||
...item,
|
||||
categoryPath: item.categoryPath || item.category,
|
||||
sales: Number(item.sales) || 0
|
||||
}))
|
||||
};
|
||||
@@ -63,6 +69,8 @@ export function CategoryPerformance() {
|
||||
return <span className={color}>{value}</span>;
|
||||
};
|
||||
|
||||
const getShortCategoryName = (path: string) => path.split(' > ').pop() || path;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
@@ -76,24 +84,34 @@ export function CategoryPerformance() {
|
||||
<Pie
|
||||
data={data.distribution}
|
||||
dataKey="value"
|
||||
nameKey="category"
|
||||
nameKey="categoryPath"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={100}
|
||||
fill="#8884d8"
|
||||
label={(entry) => entry.category}
|
||||
label={({ categoryPath }) => getShortCategoryName(categoryPath)}
|
||||
>
|
||||
{data.distribution.map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.category}
|
||||
key={`${entry.category}-${entry.value}-${index}`}
|
||||
fill={COLORS[index % COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<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>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
@@ -106,10 +124,33 @@ export function CategoryPerformance() {
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<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}%`} />
|
||||
<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
|
||||
dataKey="growth"
|
||||
@@ -129,10 +170,13 @@ export function CategoryPerformance() {
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{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">
|
||||
<p className="text-sm font-medium">{category.category}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<div className="space-y-1">
|
||||
<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
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -154,7 +154,7 @@ export function PriceAnalysis() {
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{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">
|
||||
<p className="text-sm font-medium">{item.product}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -6,6 +6,7 @@ import config from '../../config';
|
||||
interface ProfitData {
|
||||
byCategory: {
|
||||
category: string;
|
||||
categoryPath: string; // Full hierarchy path
|
||||
profitMargin: number;
|
||||
revenue: number;
|
||||
cost: number;
|
||||
@@ -18,6 +19,8 @@ interface ProfitData {
|
||||
}[];
|
||||
topProducts: {
|
||||
product: string;
|
||||
category: string;
|
||||
categoryPath: string; // Full hierarchy path
|
||||
profitMargin: number;
|
||||
revenue: number;
|
||||
cost: number;
|
||||
@@ -36,6 +39,7 @@ export function ProfitAnalysis() {
|
||||
return {
|
||||
byCategory: rawData.byCategory.map((item: any) => ({
|
||||
...item,
|
||||
categoryPath: item.categoryPath || item.category,
|
||||
profitMargin: Number(item.profitMargin) || 0,
|
||||
revenue: Number(item.revenue) || 0,
|
||||
cost: Number(item.cost) || 0
|
||||
@@ -48,6 +52,7 @@ export function ProfitAnalysis() {
|
||||
})),
|
||||
topProducts: rawData.topProducts.map((item: any) => ({
|
||||
...item,
|
||||
categoryPath: item.categoryPath || item.category,
|
||||
profitMargin: Number(item.profitMargin) || 0,
|
||||
revenue: Number(item.revenue) || 0,
|
||||
cost: Number(item.cost) || 0
|
||||
@@ -60,6 +65,8 @@ export function ProfitAnalysis() {
|
||||
return <div>Loading profit analysis...</div>;
|
||||
}
|
||||
|
||||
const getShortCategoryName = (path: string) => path.split(' > ').pop() || path;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
@@ -70,10 +77,33 @@ export function ProfitAnalysis() {
|
||||
<CardContent>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<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}%`} />
|
||||
<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
|
||||
dataKey="profitMargin"
|
||||
@@ -120,10 +150,14 @@ export function ProfitAnalysis() {
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{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">
|
||||
<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()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -145,7 +145,7 @@ export function StockAnalysis() {
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{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 items-center gap-2">
|
||||
<p className="text-sm font-medium">{item.product}</p>
|
||||
|
||||
@@ -131,7 +131,7 @@ export function VendorPerformance() {
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{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">
|
||||
<p className="text-sm font-medium">{vendor.vendor}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -6,37 +6,46 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import config from "@/config"
|
||||
import { formatCurrency } from "@/lib/utils"
|
||||
|
||||
interface BestSellerProduct {
|
||||
product_id: number
|
||||
sku: string
|
||||
title: string
|
||||
units_sold: number
|
||||
revenue: number
|
||||
profit: number
|
||||
growth_rate: number
|
||||
interface Product {
|
||||
pid: number;
|
||||
sku: string;
|
||||
title: string;
|
||||
units_sold: number;
|
||||
revenue: string;
|
||||
profit: string;
|
||||
}
|
||||
|
||||
interface Category {
|
||||
cat_id: number;
|
||||
name: string;
|
||||
categoryPath: string;
|
||||
units_sold: number;
|
||||
revenue: string;
|
||||
profit: string;
|
||||
growth_rate: string;
|
||||
}
|
||||
|
||||
interface BestSellerBrand {
|
||||
brand: string
|
||||
units_sold: number
|
||||
revenue: number
|
||||
profit: number
|
||||
growth_rate: number
|
||||
revenue: string
|
||||
profit: string
|
||||
growth_rate: string
|
||||
}
|
||||
|
||||
interface BestSellerCategory {
|
||||
category_id: number
|
||||
name: string
|
||||
units_sold: number
|
||||
revenue: number
|
||||
profit: number
|
||||
growth_rate: number
|
||||
cat_id: number;
|
||||
name: string;
|
||||
units_sold: number;
|
||||
revenue: string;
|
||||
profit: string;
|
||||
growth_rate: string;
|
||||
}
|
||||
|
||||
interface BestSellersData {
|
||||
products: BestSellerProduct[]
|
||||
products: Product[]
|
||||
brands: BestSellerBrand[]
|
||||
categories: BestSellerCategory[]
|
||||
categories: Category[]
|
||||
}
|
||||
|
||||
export function BestSellers() {
|
||||
@@ -70,41 +79,29 @@ export function BestSellers() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40%]">Product</TableHead>
|
||||
<TableHead className="w-[15%] text-right">Sales</TableHead>
|
||||
<TableHead className="w-[15%] text-right">Revenue</TableHead>
|
||||
<TableHead className="w-[15%] text-right">Profit</TableHead>
|
||||
<TableHead className="w-[15%] text-right">Growth</TableHead>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead className="text-right">Units Sold</TableHead>
|
||||
<TableHead className="text-right">Revenue</TableHead>
|
||||
<TableHead className="text-right">Profit</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.products.map((product) => (
|
||||
<TableRow key={product.product_id}>
|
||||
<TableCell className="w-[40%]">
|
||||
<div>
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product/${product.product_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{product.title}
|
||||
</a>
|
||||
<p className="text-sm text-muted-foreground">{product.sku}</p>
|
||||
</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)}%
|
||||
<TableRow key={product.pid}>
|
||||
<TableCell>
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{product.title}
|
||||
</a>
|
||||
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||
</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>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -134,13 +131,13 @@ export function BestSellers() {
|
||||
{brand.units_sold.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="w-[15%] text-right">
|
||||
{formatCurrency(brand.revenue)}
|
||||
{formatCurrency(Number(brand.revenue))}
|
||||
</TableCell>
|
||||
<TableCell className="w-[15%] text-right">
|
||||
{formatCurrency(brand.profit)}
|
||||
{formatCurrency(Number(brand.profit))}
|
||||
</TableCell>
|
||||
<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>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -154,31 +151,26 @@ export function BestSellers() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40%]">Category</TableHead>
|
||||
<TableHead className="w-[15%] text-right">Sales</TableHead>
|
||||
<TableHead className="w-[15%] text-right">Revenue</TableHead>
|
||||
<TableHead className="w-[15%] text-right">Profit</TableHead>
|
||||
<TableHead className="w-[15%] text-right">Growth</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead className="text-right">Units Sold</TableHead>
|
||||
<TableHead className="text-right">Revenue</TableHead>
|
||||
<TableHead className="text-right">Profit</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.categories.map((category) => (
|
||||
<TableRow key={category.category_id}>
|
||||
<TableCell className="w-[40%]">
|
||||
<p className="font-medium">{category.name}</p>
|
||||
</TableCell>
|
||||
<TableCell className="w-[15%] text-right">
|
||||
{category.units_sold.toLocaleString()}
|
||||
</TableCell>
|
||||
<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)}%
|
||||
<TableRow key={category.cat_id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{category.name}</div>
|
||||
{category.categoryPath && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{category.categoryPath}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -11,18 +11,18 @@ import { DateRangePicker } from "@/components/ui/date-range-picker-narrow"
|
||||
|
||||
interface ForecastData {
|
||||
forecastSales: number
|
||||
forecastRevenue: number
|
||||
forecastRevenue: string
|
||||
confidenceLevel: number
|
||||
dailyForecasts: {
|
||||
date: string
|
||||
units: number
|
||||
revenue: number
|
||||
revenue: string
|
||||
confidence: number
|
||||
}[]
|
||||
categoryForecasts: {
|
||||
category: string
|
||||
units: number
|
||||
revenue: number
|
||||
revenue: string
|
||||
confidence: number
|
||||
}[]
|
||||
}
|
||||
@@ -86,7 +86,7 @@ export function ForecastMetrics() {
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Forecast Revenue</p>
|
||||
</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>
|
||||
|
||||
@@ -108,7 +108,7 @@ export function ForecastMetrics() {
|
||||
tick={false}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => [formatCurrency(value), "Revenue"]}
|
||||
formatter={(value: string) => [formatCurrency(Number(value)), "Revenue"]}
|
||||
labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')}
|
||||
/>
|
||||
<Area
|
||||
|
||||
@@ -13,11 +13,11 @@ interface InventoryMetrics {
|
||||
topVendors: {
|
||||
vendor: string;
|
||||
productCount: number;
|
||||
averageStockLevel: number;
|
||||
averageStockLevel: string;
|
||||
}[];
|
||||
stockTurnover: {
|
||||
category: string;
|
||||
rate: number;
|
||||
rate: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export function InventoryStats() {
|
||||
<BarChart data={data?.stockTurnover}>
|
||||
<XAxis dataKey="category" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Tooltip formatter={(value: string) => [Number(value).toFixed(2), "Rate"]} />
|
||||
<Bar dataKey="rate" name="Turnover Rate" fill="#60a5fa" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -93,7 +93,7 @@ export function InventoryStats() {
|
||||
</div>
|
||||
<div className="ml-4 text-right">
|
||||
<p className="text-sm font-medium">
|
||||
Avg. Stock: {vendor.averageStockLevel.toFixed(0)}
|
||||
Avg. Stock: {Number(vendor.averageStockLevel).toFixed(0)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,19 +12,20 @@ import { Badge } from "@/components/ui/badge"
|
||||
import { AlertCircle, AlertTriangle } from "lucide-react"
|
||||
import config from "@/config"
|
||||
|
||||
interface LowStockProduct {
|
||||
product_id: number
|
||||
SKU: string
|
||||
title: string
|
||||
stock_quantity: number
|
||||
reorder_qty: number
|
||||
days_of_inventory: number
|
||||
stock_status: "Critical" | "Reorder"
|
||||
daily_sales_avg: number
|
||||
interface Product {
|
||||
pid: number;
|
||||
sku: string;
|
||||
title: string;
|
||||
stock_quantity: number;
|
||||
daily_sales_avg: string;
|
||||
days_of_inventory: string;
|
||||
reorder_qty: number;
|
||||
last_purchase_date: string | null;
|
||||
lead_time_status: string;
|
||||
}
|
||||
|
||||
export function LowStockAlerts() {
|
||||
const { data: products } = useQuery<LowStockProduct[]>({
|
||||
const { data: products } = useQuery<Product[]>({
|
||||
queryKey: ["low-stock"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/low-stock/products`)
|
||||
@@ -45,35 +46,37 @@ export function LowStockAlerts() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>SKU</TableHead>
|
||||
<TableHead>Product</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>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{products?.map((product) => (
|
||||
<TableRow key={product.product_id}>
|
||||
<TableCell className="font-medium">{product.SKU}</TableCell>
|
||||
<TableCell>{product.title}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{product.stock_quantity} / {product.reorder_qty}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
product.stock_status === "Critical"
|
||||
? "border-destructive text-destructive"
|
||||
: "border-warning text-warning"
|
||||
}
|
||||
<TableRow key={product.pid}>
|
||||
<TableCell>
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{product.stock_status === "Critical" ? (
|
||||
<AlertCircle className="mr-1 h-3 w-3" />
|
||||
) : (
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{product.stock_status}
|
||||
{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">{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>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -5,13 +5,14 @@ import config from "@/config"
|
||||
import { formatCurrency } from "@/lib/utils"
|
||||
import { ClipboardList, AlertCircle, Layers, DollarSign, ShoppingCart } from "lucide-react" // Importing icons
|
||||
import { useState } from "react"
|
||||
import { PurchaseOrderStatus, ReceivingStatus } from "@/types/status-codes"
|
||||
|
||||
interface PurchaseMetricsData {
|
||||
activePurchaseOrders: number
|
||||
overduePurchaseOrders: number
|
||||
onOrderUnits: number
|
||||
onOrderCost: number
|
||||
onOrderRetail: number
|
||||
activePurchaseOrders: number // Orders that are not canceled, done, or fully received
|
||||
overduePurchaseOrders: number // Orders past their expected delivery date
|
||||
onOrderUnits: number // Total units across all active orders
|
||||
onOrderCost: number // Total cost across all active orders
|
||||
onOrderRetail: number // Total retail value across all active orders
|
||||
vendorOrders: {
|
||||
vendor: string
|
||||
orders: number
|
||||
|
||||
@@ -12,13 +12,13 @@ import { DateRangePicker } from "@/components/ui/date-range-picker-narrow"
|
||||
interface SalesData {
|
||||
totalOrders: number
|
||||
totalUnitsSold: number
|
||||
totalCogs: number
|
||||
totalRevenue: number
|
||||
totalCogs: string
|
||||
totalRevenue: string
|
||||
dailySales: {
|
||||
date: string
|
||||
units: number
|
||||
revenue: number
|
||||
cogs: number
|
||||
revenue: string
|
||||
cogs: string
|
||||
}[]
|
||||
}
|
||||
|
||||
@@ -78,14 +78,14 @@ export function SalesMetrics() {
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Cost of Goods</p>
|
||||
</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 className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Revenue</p>
|
||||
</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>
|
||||
|
||||
@@ -107,7 +107,7 @@ export function SalesMetrics() {
|
||||
tick={false}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => [formatCurrency(value), "Revenue"]}
|
||||
formatter={(value: string) => [formatCurrency(Number(value)), "Revenue"]}
|
||||
labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')}
|
||||
/>
|
||||
<Area
|
||||
|
||||
@@ -10,14 +10,14 @@ interface StockMetricsData {
|
||||
totalProducts: number
|
||||
productsInStock: number
|
||||
totalStockUnits: number
|
||||
totalStockCost: number
|
||||
totalStockRetail: number
|
||||
totalStockCost: string
|
||||
totalStockRetail: string
|
||||
brandStock: {
|
||||
brand: string
|
||||
variants: number
|
||||
units: number
|
||||
cost: number
|
||||
retail: number
|
||||
cost: string
|
||||
retail: string
|
||||
}[]
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ const renderActiveShape = (props: any) => {
|
||||
fill="#000000"
|
||||
className="text-base font-medium"
|
||||
>
|
||||
{formatCurrency(retail)}
|
||||
{formatCurrency(Number(retail))}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
@@ -154,14 +154,14 @@ export function StockMetrics() {
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Stock Cost</p>
|
||||
</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 className="flex items-baseline justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-muted-foreground">Stock Retail</p>
|
||||
</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>
|
||||
|
||||
@@ -5,18 +5,18 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
||||
import config from "@/config"
|
||||
import { formatCurrency } from "@/lib/utils"
|
||||
|
||||
interface OverstockedProduct {
|
||||
product_id: number
|
||||
SKU: string
|
||||
title: string
|
||||
stock_quantity: number
|
||||
overstocked_amt: number
|
||||
excess_cost: number
|
||||
excess_retail: number
|
||||
interface Product {
|
||||
pid: number;
|
||||
sku: string;
|
||||
title: string;
|
||||
stock_quantity: number;
|
||||
overstocked_amt: number;
|
||||
excess_cost: number;
|
||||
excess_retail: number;
|
||||
}
|
||||
|
||||
export function TopOverstockedProducts() {
|
||||
const { data } = useQuery<OverstockedProduct[]>({
|
||||
const { data } = useQuery<Product[]>({
|
||||
queryKey: ["top-overstocked-products"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/overstock/products?limit=50`)
|
||||
@@ -38,40 +38,30 @@ export function TopOverstockedProducts() {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead className="text-right">Current Stock</TableHead>
|
||||
<TableHead className="text-right">Overstock Amt</TableHead>
|
||||
<TableHead className="text-right">Overstock Cost</TableHead>
|
||||
<TableHead className="text-right">Overstock Retail</TableHead>
|
||||
<TableHead className="text-right">Stock</TableHead>
|
||||
<TableHead className="text-right">Excess</TableHead>
|
||||
<TableHead className="text-right">Cost</TableHead>
|
||||
<TableHead className="text-right">Retail</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.map((product) => (
|
||||
<TableRow key={product.product_id}>
|
||||
<TableRow key={product.pid}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product/${product.product_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{product.title}
|
||||
</a>
|
||||
<p className="text-sm text-muted-foreground">{product.SKU}</p>
|
||||
</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)}
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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.overstocked_amt}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(product.excess_cost)}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(product.excess_retail)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -3,20 +3,19 @@ import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import config from "@/config"
|
||||
import { formatCurrency } from "@/lib/utils"
|
||||
|
||||
interface ReplenishProduct {
|
||||
product_id: number
|
||||
SKU: string
|
||||
title: string
|
||||
current_stock: number
|
||||
replenish_qty: number
|
||||
replenish_cost: number
|
||||
replenish_retail: number
|
||||
interface Product {
|
||||
pid: number;
|
||||
sku: string;
|
||||
title: string;
|
||||
stock_quantity: number;
|
||||
daily_sales_avg: string;
|
||||
reorder_qty: number;
|
||||
last_purchase_date: string | null;
|
||||
}
|
||||
|
||||
export function TopReplenishProducts() {
|
||||
const { data } = useQuery<ReplenishProduct[]>({
|
||||
const { data } = useQuery<Product[]>({
|
||||
queryKey: ["top-replenish-products"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/dashboard/replenish/products?limit=50`)
|
||||
@@ -39,39 +38,29 @@ export function TopReplenishProducts() {
|
||||
<TableRow>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead className="text-right">Stock</TableHead>
|
||||
<TableHead className="text-right">Replenish</TableHead>
|
||||
<TableHead className="text-right">Cost</TableHead>
|
||||
<TableHead className="text-right">Retail</TableHead>
|
||||
<TableHead className="text-right">Daily Sales</TableHead>
|
||||
<TableHead className="text-right">Reorder Qty</TableHead>
|
||||
<TableHead>Last Purchase</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.map((product) => (
|
||||
<TableRow key={product.product_id}>
|
||||
<TableRow key={product.pid}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product/${product.product_id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{product.title}
|
||||
</a>
|
||||
<p className="text-sm text-muted-foreground">{product.SKU}</p>
|
||||
</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)}
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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">{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>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -11,18 +11,18 @@ import {
|
||||
import { TrendingUp, TrendingDown } from "lucide-react"
|
||||
import config from "@/config"
|
||||
|
||||
interface TrendingProduct {
|
||||
product_id: number
|
||||
sku: string
|
||||
title: string
|
||||
daily_sales_avg: string
|
||||
weekly_sales_avg: string
|
||||
growth_rate: string
|
||||
total_revenue: string
|
||||
interface Product {
|
||||
pid: number;
|
||||
sku: string;
|
||||
title: string;
|
||||
daily_sales_avg: string;
|
||||
weekly_sales_avg: string;
|
||||
growth_rate: string;
|
||||
total_revenue: string;
|
||||
}
|
||||
|
||||
export function TrendingProducts() {
|
||||
const { data: products } = useQuery<TrendingProduct[]>({
|
||||
const { data: products } = useQuery<Product[]>({
|
||||
queryKey: ["trending-products"],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/products/trending`)
|
||||
@@ -33,7 +33,6 @@ export function TrendingProducts() {
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
const formatPercent = (value: number) =>
|
||||
new Intl.NumberFormat("en-US", {
|
||||
style: "percent",
|
||||
@@ -42,6 +41,14 @@ export function TrendingProducts() {
|
||||
signDisplay: "exceptZero",
|
||||
}).format(value / 100)
|
||||
|
||||
const formatCurrency = (value: number) =>
|
||||
new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(value)
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
@@ -59,7 +66,7 @@ export function TrendingProducts() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{products?.map((product) => (
|
||||
<TableRow key={product.product_id}>
|
||||
<TableRow key={product.pid}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{product.title}</span>
|
||||
@@ -68,20 +75,20 @@ export function TrendingProducts() {
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{parseFloat(product.daily_sales_avg).toFixed(1)}</TableCell>
|
||||
<TableCell>{Number(product.daily_sales_avg).toFixed(1)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<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" />
|
||||
) : (
|
||||
<TrendingDown className="h-4 w-4 text-destructive" />
|
||||
)}
|
||||
<span
|
||||
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>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -3,24 +3,27 @@ import { ArrowUpDown, ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
interface ProductDetail {
|
||||
product_id: string;
|
||||
name: string;
|
||||
|
||||
interface Product {
|
||||
pid: string;
|
||||
sku: string;
|
||||
title: string;
|
||||
stock_quantity: number;
|
||||
total_sold: number;
|
||||
avg_price: number;
|
||||
first_received_date: string;
|
||||
daily_sales_avg: number;
|
||||
forecast_units: number;
|
||||
forecast_revenue: number;
|
||||
confidence_level: number;
|
||||
}
|
||||
|
||||
export interface ForecastItem {
|
||||
category: string;
|
||||
categoryPath: string;
|
||||
avgDailySales: number;
|
||||
totalSold: number;
|
||||
numProducts: number;
|
||||
avgPrice: number;
|
||||
avgTotalSold: number;
|
||||
products?: ProductDetail[];
|
||||
products?: Product[];
|
||||
}
|
||||
|
||||
export const columns: ColumnDef<ForecastItem>[] = [
|
||||
@@ -42,6 +45,16 @@ export const columns: ColumnDef<ForecastItem>[] = [
|
||||
{
|
||||
accessorKey: "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",
|
||||
@@ -147,23 +160,33 @@ export const renderSubComponent = ({ row }: { row: any }) => {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Product Name</TableHead>
|
||||
<TableHead>SKU</TableHead>
|
||||
<TableHead>First Received</TableHead>
|
||||
<TableHead>Stock Quantity</TableHead>
|
||||
<TableHead>Total Sold</TableHead>
|
||||
<TableHead>Average Price</TableHead>
|
||||
<TableHead>Product</TableHead>
|
||||
<TableHead className="text-right">Stock</TableHead>
|
||||
<TableHead className="text-right">Daily Sales</TableHead>
|
||||
<TableHead className="text-right">Forecast Units</TableHead>
|
||||
<TableHead className="text-right">Forecast Revenue</TableHead>
|
||||
<TableHead className="text-right">Confidence</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{products.map((product: ProductDetail) => (
|
||||
<TableRow key={product.product_id}>
|
||||
<TableCell>{product.name}</TableCell>
|
||||
<TableCell>{product.sku}</TableCell>
|
||||
<TableCell>{product.first_received_date}</TableCell>
|
||||
<TableCell>{product.stock_quantity.toLocaleString()}</TableCell>
|
||||
<TableCell>{product.total_sold.toLocaleString()}</TableCell>
|
||||
<TableCell>${product.avg_price.toFixed(2)}</TableCell>
|
||||
{products.map((product) => (
|
||||
<TableRow key={product.pid}>
|
||||
<TableCell>
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
|
||||
import config from "@/config";
|
||||
|
||||
interface Product {
|
||||
product_id: number;
|
||||
pid: number;
|
||||
title: string;
|
||||
SKU: string;
|
||||
barcode: string;
|
||||
@@ -38,7 +38,7 @@ interface Product {
|
||||
// Vendor info
|
||||
vendor: string;
|
||||
vendor_reference: string;
|
||||
brand: string;
|
||||
brand: string | 'Unbranded';
|
||||
|
||||
// URLs
|
||||
permalink: string;
|
||||
@@ -123,6 +123,8 @@ interface Product {
|
||||
notes: string;
|
||||
lead_time_days: number | null;
|
||||
}>;
|
||||
|
||||
category_paths?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ProductDetailProps {
|
||||
@@ -205,8 +207,8 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{product?.title || 'Loading...'}</h2>
|
||||
<p className="text-sm text-muted-foreground">{product?.SKU || ''}</p>
|
||||
<VaulDrawer.Title className="text-xl font-semibold">{product?.title || 'Loading...'}</VaulDrawer.Title>
|
||||
<VaulDrawer.Description className="text-sm text-muted-foreground">{product?.SKU || ''}</VaulDrawer.Description>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
@@ -255,22 +257,28 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">Categories</dt>
|
||||
<dd className="flex flex-wrap gap-2">
|
||||
{product?.categories?.map(category => (
|
||||
<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">
|
||||
{category}
|
||||
</span>
|
||||
)) || "N/A"}
|
||||
<dd className="flex flex-col gap-2">
|
||||
{product?.category_paths ?
|
||||
Object.entries(product.category_paths).map(([key, fullPath], index) => {
|
||||
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 className="text-xs text-muted-foreground ml-2 mt-1">
|
||||
{fullPath}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: "N/A"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">Tags</dt>
|
||||
<dd className="flex flex-wrap gap-2">
|
||||
{product?.tags?.map(tag => (
|
||||
<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"}
|
||||
N/A
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@@ -307,11 +315,11 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm text-muted-foreground">Status</dt>
|
||||
<dd>{product?.metrics?.stock_status}</dd>
|
||||
<dd>{product?.stock_status || "N/A"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
</dl>
|
||||
</Card>
|
||||
@@ -321,15 +329,15 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
<dl className="space-y-2">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</dl>
|
||||
</Card>
|
||||
@@ -356,19 +364,19 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
<dl className="space-y-2">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</dl>
|
||||
</Card>
|
||||
@@ -378,15 +386,15 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
<dl className="space-y-2">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</dl>
|
||||
</Card>
|
||||
@@ -408,11 +416,11 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
</dl>
|
||||
</Card>
|
||||
@@ -422,15 +430,15 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
<dl className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</dl>
|
||||
</Card>
|
||||
@@ -551,15 +559,15 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
<dl className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
</dl>
|
||||
</Card>
|
||||
@@ -569,7 +577,7 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
<dl className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<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>
|
||||
<dt className="text-sm text-muted-foreground">Landing Cost</dt>
|
||||
|
||||
@@ -24,7 +24,7 @@ type FilterValue = string | number | boolean;
|
||||
type ComparisonOperator = "=" | ">" | ">=" | "<" | "<=" | "between";
|
||||
|
||||
interface FilterValueWithOperator {
|
||||
value: FilterValue | [number, number];
|
||||
value: FilterValue | [string, string];
|
||||
operator: ComparisonOperator;
|
||||
}
|
||||
|
||||
@@ -317,18 +317,32 @@ export function ProductFilters({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleApplyFilter = (value: FilterValue | [number, number]) => {
|
||||
const handleApplyFilter = (value: FilterValue | [string, string]) => {
|
||||
if (!selectedFilter) return;
|
||||
|
||||
const newFilters = {
|
||||
...activeFilters,
|
||||
[selectedFilter.id]: {
|
||||
value,
|
||||
operator: selectedOperator,
|
||||
},
|
||||
};
|
||||
let filterValue: ActiveFilterValue;
|
||||
|
||||
if (selectedFilter.type === "number") {
|
||||
if (selectedOperator === "between" && Array.isArray(value)) {
|
||||
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();
|
||||
};
|
||||
|
||||
@@ -394,38 +408,14 @@ export function ProductFilters({
|
||||
|
||||
|
||||
const getFilterDisplayValue = (filter: ActiveFilter) => {
|
||||
const filterValue = activeFilters[filter.id];
|
||||
const filterOption = filterOptions.find((opt) => opt.id === filter.id);
|
||||
|
||||
// For between ranges
|
||||
if (Array.isArray(filterValue)) {
|
||||
return `${filter.label} between ${filterValue[0]} and ${filterValue[1]}`;
|
||||
if (typeof filter.value === "object" && "operator" in filter.value) {
|
||||
const { operator, value } = filter.value;
|
||||
if (Array.isArray(value)) {
|
||||
return `${operator} ${value[0]} and ${value[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}`;
|
||||
}
|
||||
|
||||
// For numeric filters with operators
|
||||
const operator = filterValue.operator;
|
||||
const value = filterValue.value;
|
||||
const operatorDisplay = {
|
||||
"=": "=",
|
||||
">": ">",
|
||||
">=": "≥",
|
||||
"<": "<",
|
||||
"<=": "≤",
|
||||
between: "between",
|
||||
}[operator];
|
||||
|
||||
return `${filter.label} ${operatorDisplay} ${value}`;
|
||||
return filter.value.toString();
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -230,7 +230,7 @@ export function ProductTable({
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{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>
|
||||
);
|
||||
@@ -261,6 +261,11 @@ export function ProductTable({
|
||||
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 value ?? '-';
|
||||
@@ -297,12 +302,12 @@ export function ProductTable({
|
||||
<TableBody>
|
||||
{products.map((product) => (
|
||||
<TableRow
|
||||
key={product.product_id}
|
||||
key={product.pid}
|
||||
onClick={() => onRowClick?.(product)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{orderedColumns.map((column) => (
|
||||
<TableCell key={`${product.product_id}-${column}`}>
|
||||
<TableCell key={`${product.pid}-${column}`}>
|
||||
{formatColumnValue(product, column)}
|
||||
</TableCell>
|
||||
))}
|
||||
|
||||
@@ -8,7 +8,7 @@ import config from '../../config';
|
||||
|
||||
interface SalesVelocityConfig {
|
||||
id: number;
|
||||
category_id: number | null;
|
||||
cat_id: number | null;
|
||||
vendor: string | null;
|
||||
daily_window_days: number;
|
||||
weekly_window_days: number;
|
||||
@@ -18,7 +18,7 @@ interface SalesVelocityConfig {
|
||||
export function CalculationSettings() {
|
||||
const [salesVelocityConfig, setSalesVelocityConfig] = useState<SalesVelocityConfig>({
|
||||
id: 1,
|
||||
category_id: null,
|
||||
cat_id: null,
|
||||
vendor: null,
|
||||
daily_window_days: 30,
|
||||
weekly_window_days: 7,
|
||||
|
||||
@@ -6,10 +6,11 @@ import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { toast } from "sonner";
|
||||
import config from '../../config';
|
||||
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/components/ui/table";
|
||||
|
||||
interface StockThreshold {
|
||||
id: number;
|
||||
category_id: number | null;
|
||||
cat_id: number | null;
|
||||
vendor: string | null;
|
||||
critical_days: number;
|
||||
reorder_days: number;
|
||||
@@ -22,7 +23,7 @@ interface StockThreshold {
|
||||
|
||||
interface LeadTimeThreshold {
|
||||
id: number;
|
||||
category_id: number | null;
|
||||
cat_id: number | null;
|
||||
vendor: string | null;
|
||||
target_days: number;
|
||||
warning_days: number;
|
||||
@@ -31,7 +32,7 @@ interface LeadTimeThreshold {
|
||||
|
||||
interface SalesVelocityConfig {
|
||||
id: number;
|
||||
category_id: number | null;
|
||||
cat_id: number | null;
|
||||
vendor: string | null;
|
||||
daily_window_days: number;
|
||||
weekly_window_days: number;
|
||||
@@ -47,7 +48,7 @@ interface ABCClassificationConfig {
|
||||
|
||||
interface SafetyStockConfig {
|
||||
id: number;
|
||||
category_id: number | null;
|
||||
cat_id: number | null;
|
||||
vendor: string | null;
|
||||
coverage_days: number;
|
||||
service_level: number;
|
||||
@@ -55,7 +56,7 @@ interface SafetyStockConfig {
|
||||
|
||||
interface TurnoverConfig {
|
||||
id: number;
|
||||
category_id: number | null;
|
||||
cat_id: number | null;
|
||||
vendor: string | null;
|
||||
calculation_period_days: number;
|
||||
target_rate: number;
|
||||
@@ -64,7 +65,7 @@ interface TurnoverConfig {
|
||||
export function Configuration() {
|
||||
const [stockThresholds, setStockThresholds] = useState<StockThreshold>({
|
||||
id: 1,
|
||||
category_id: null,
|
||||
cat_id: null,
|
||||
vendor: null,
|
||||
critical_days: 7,
|
||||
reorder_days: 14,
|
||||
@@ -75,7 +76,7 @@ export function Configuration() {
|
||||
|
||||
const [leadTimeThresholds, setLeadTimeThresholds] = useState<LeadTimeThreshold>({
|
||||
id: 1,
|
||||
category_id: null,
|
||||
cat_id: null,
|
||||
vendor: null,
|
||||
target_days: 14,
|
||||
warning_days: 21,
|
||||
@@ -84,7 +85,7 @@ export function Configuration() {
|
||||
|
||||
const [salesVelocityConfig, setSalesVelocityConfig] = useState<SalesVelocityConfig>({
|
||||
id: 1,
|
||||
category_id: null,
|
||||
cat_id: null,
|
||||
vendor: null,
|
||||
daily_window_days: 30,
|
||||
weekly_window_days: 7,
|
||||
@@ -100,7 +101,7 @@ export function Configuration() {
|
||||
|
||||
const [safetyStockConfig, setSafetyStockConfig] = useState<SafetyStockConfig>({
|
||||
id: 1,
|
||||
category_id: null,
|
||||
cat_id: null,
|
||||
vendor: null,
|
||||
coverage_days: 14,
|
||||
service_level: 95.0
|
||||
@@ -108,7 +109,7 @@ export function Configuration() {
|
||||
|
||||
const [turnoverConfig, setTurnoverConfig] = useState<TurnoverConfig>({
|
||||
id: 1,
|
||||
category_id: null,
|
||||
cat_id: null,
|
||||
vendor: null,
|
||||
calculation_period_days: 30,
|
||||
target_rate: 1.0
|
||||
|
||||
@@ -5,10 +5,11 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import config from '../../config';
|
||||
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/components/ui/table";
|
||||
|
||||
interface LeadTimeThreshold {
|
||||
id: number;
|
||||
category_id: number | null;
|
||||
cat_id: number | null;
|
||||
vendor: string | null;
|
||||
target_days: number;
|
||||
warning_days: number;
|
||||
@@ -17,6 +18,8 @@ interface LeadTimeThreshold {
|
||||
|
||||
interface ABCClassificationConfig {
|
||||
id: number;
|
||||
cat_id: number | null;
|
||||
vendor: string | null;
|
||||
a_threshold: number;
|
||||
b_threshold: number;
|
||||
classification_period_days: number;
|
||||
@@ -24,7 +27,7 @@ interface ABCClassificationConfig {
|
||||
|
||||
interface TurnoverConfig {
|
||||
id: number;
|
||||
category_id: number | null;
|
||||
cat_id: number | null;
|
||||
vendor: string | null;
|
||||
calculation_period_days: number;
|
||||
target_rate: number;
|
||||
@@ -33,27 +36,16 @@ interface TurnoverConfig {
|
||||
export function PerformanceMetrics() {
|
||||
const [leadTimeThresholds, setLeadTimeThresholds] = useState<LeadTimeThreshold>({
|
||||
id: 1,
|
||||
category_id: null,
|
||||
cat_id: null,
|
||||
vendor: null,
|
||||
target_days: 14,
|
||||
warning_days: 21,
|
||||
critical_days: 30
|
||||
});
|
||||
|
||||
const [abcConfig, setAbcConfig] = useState<ABCClassificationConfig>({
|
||||
id: 1,
|
||||
a_threshold: 20.0,
|
||||
b_threshold: 50.0,
|
||||
classification_period_days: 90
|
||||
});
|
||||
const [abcConfigs, setAbcConfigs] = useState<ABCClassificationConfig[]>([]);
|
||||
|
||||
const [turnoverConfig, setTurnoverConfig] = useState<TurnoverConfig>({
|
||||
id: 1,
|
||||
category_id: null,
|
||||
vendor: null,
|
||||
calculation_period_days: 30,
|
||||
target_rate: 1.0
|
||||
});
|
||||
const [turnoverConfigs, setTurnoverConfigs] = useState<TurnoverConfig[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
@@ -66,8 +58,8 @@ export function PerformanceMetrics() {
|
||||
}
|
||||
const data = await response.json();
|
||||
setLeadTimeThresholds(data.leadTimeThresholds);
|
||||
setAbcConfig(data.abcConfig);
|
||||
setTurnoverConfig(data.turnoverConfig);
|
||||
setAbcConfigs(data.abcConfigs);
|
||||
setTurnoverConfigs(data.turnoverConfigs);
|
||||
} catch (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'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(abcConfig)
|
||||
body: JSON.stringify(abcConfigs)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -127,7 +119,7 @@ export function PerformanceMetrics() {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(turnoverConfig)
|
||||
body: JSON.stringify(turnoverConfigs)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -210,54 +202,28 @@ export function PerformanceMetrics() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="a-threshold">A Threshold (%)</Label>
|
||||
<Input
|
||||
id="a-threshold"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
step="0.1"
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
value={abcConfig.a_threshold}
|
||||
onChange={(e) => setAbcConfig(prev => ({
|
||||
...prev,
|
||||
a_threshold: parseFloat(e.target.value) || 0
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="b-threshold">B Threshold (%)</Label>
|
||||
<Input
|
||||
id="b-threshold"
|
||||
type="number"
|
||||
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>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Vendor</TableHead>
|
||||
<TableHead className="text-right">A Threshold</TableHead>
|
||||
<TableHead className="text-right">B Threshold</TableHead>
|
||||
<TableHead className="text-right">Period Days</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{abcConfigs.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.a_threshold}%</TableCell>
|
||||
<TableCell className="text-right">{config.b_threshold}%</TableCell>
|
||||
<TableCell className="text-right">{config.classification_period_days}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Button onClick={handleUpdateABCConfig}>
|
||||
Update ABC Classification
|
||||
</Button>
|
||||
@@ -273,37 +239,26 @@ export function PerformanceMetrics() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="calculation-period">Calculation Period (days)</Label>
|
||||
<Input
|
||||
id="calculation-period"
|
||||
type="number"
|
||||
min="1"
|
||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
value={turnoverConfig.calculation_period_days}
|
||||
onChange={(e) => setTurnoverConfig(prev => ({
|
||||
...prev,
|
||||
calculation_period_days: parseInt(e.target.value) || 1
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="target-rate">Target Rate</Label>
|
||||
<Input
|
||||
id="target-rate"
|
||||
type="number"
|
||||
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>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Vendor</TableHead>
|
||||
<TableHead className="text-right">Period Days</TableHead>
|
||||
<TableHead className="text-right">Target Rate</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{turnoverConfigs.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.calculation_period_days}</TableCell>
|
||||
<TableCell className="text-right">{config.target_rate.toFixed(2)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Button onClick={handleUpdateTurnoverConfig}>
|
||||
Update Turnover Configuration
|
||||
</Button>
|
||||
|
||||
@@ -5,10 +5,11 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import config from '../../config';
|
||||
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/components/ui/table";
|
||||
|
||||
interface StockThreshold {
|
||||
id: number;
|
||||
category_id: number | null;
|
||||
cat_id: number | null;
|
||||
vendor: string | null;
|
||||
critical_days: number;
|
||||
reorder_days: number;
|
||||
@@ -19,7 +20,7 @@ interface StockThreshold {
|
||||
|
||||
interface SafetyStockConfig {
|
||||
id: number;
|
||||
category_id: number | null;
|
||||
cat_id: number | null;
|
||||
vendor: string | null;
|
||||
coverage_days: number;
|
||||
service_level: number;
|
||||
@@ -28,7 +29,7 @@ interface SafetyStockConfig {
|
||||
export function StockManagement() {
|
||||
const [stockThresholds, setStockThresholds] = useState<StockThreshold>({
|
||||
id: 1,
|
||||
category_id: null,
|
||||
cat_id: null,
|
||||
vendor: null,
|
||||
critical_days: 7,
|
||||
reorder_days: 14,
|
||||
@@ -39,7 +40,7 @@ export function StockManagement() {
|
||||
|
||||
const [safetyStockConfig, setSafetyStockConfig] = useState<SafetyStockConfig>({
|
||||
id: 1,
|
||||
category_id: null,
|
||||
cat_id: null,
|
||||
vendor: null,
|
||||
coverage_days: 14,
|
||||
service_level: 95.0
|
||||
@@ -243,6 +244,54 @@ export function StockManagement() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -10,37 +10,66 @@ import { motion } from "motion/react";
|
||||
import config from "../config";
|
||||
|
||||
interface Category {
|
||||
category_id: number;
|
||||
cat_id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
parent_category?: string;
|
||||
product_count: number;
|
||||
total_value: number;
|
||||
avg_margin: number;
|
||||
turnover_rate: number;
|
||||
growth_rate: number;
|
||||
type: number;
|
||||
parent_id: number | null;
|
||||
parent_name: string | null;
|
||||
parent_type: number | null;
|
||||
description: string | null;
|
||||
status: string;
|
||||
metrics?: {
|
||||
product_count: number;
|
||||
active_products: number;
|
||||
total_value: number;
|
||||
avg_margin: number;
|
||||
turnover_rate: number;
|
||||
growth_rate: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface TypeCount {
|
||||
type: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface CategoryFilters {
|
||||
search: string;
|
||||
parent: string;
|
||||
type: 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() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [sortColumn, setSortColumn] = useState<keyof Category>("name");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
const [sortColumn] = useState<keyof Category>("name");
|
||||
const [sortDirection] = useState<"asc" | "desc">("asc");
|
||||
const [filters, setFilters] = useState<CategoryFilters>({
|
||||
search: "",
|
||||
parent: "all",
|
||||
type: "all",
|
||||
performance: "all",
|
||||
});
|
||||
const [] = useState({
|
||||
column: 'name',
|
||||
direction: 'asc'
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["categories"],
|
||||
@@ -68,19 +97,15 @@ export function Categories() {
|
||||
);
|
||||
}
|
||||
|
||||
// Apply parent filter
|
||||
if (filters.parent !== 'all') {
|
||||
if (filters.parent === 'none') {
|
||||
filtered = filtered.filter(category => !category.parent_category);
|
||||
} else {
|
||||
filtered = filtered.filter(category => category.parent_category === filters.parent);
|
||||
}
|
||||
// Apply type filter
|
||||
if (filters.type !== 'all') {
|
||||
filtered = filtered.filter(category => category.type === parseInt(filters.type));
|
||||
}
|
||||
|
||||
// Apply performance filter
|
||||
if (filters.performance !== 'all') {
|
||||
filtered = filtered.filter(category => {
|
||||
const growth = category.growth_rate ?? 0;
|
||||
const growth = category.metrics?.growth_rate ?? 0;
|
||||
switch (filters.performance) {
|
||||
case 'high_growth': return growth >= 20;
|
||||
case 'growing': return growth >= 5 && growth < 20;
|
||||
@@ -93,6 +118,19 @@ export function Categories() {
|
||||
|
||||
// Apply sorting
|
||||
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 bVal = b[sortColumn];
|
||||
|
||||
@@ -123,9 +161,9 @@ export function Categories() {
|
||||
if (!filteredData.length) return data?.stats;
|
||||
|
||||
const activeCategories = filteredData.filter(c => c.status === 'active').length;
|
||||
const totalValue = filteredData.reduce((sum, c) => sum + (c.total_value || 0), 0);
|
||||
const margins = filteredData.map(c => c.avg_margin || 0).filter(m => m !== 0);
|
||||
const growthRates = filteredData.map(c => c.growth_rate || 0).filter(g => g !== 0);
|
||||
const totalValue = filteredData.reduce((sum, c) => sum + (c.metrics?.total_value || 0), 0);
|
||||
const margins = filteredData.map(c => c.metrics?.avg_margin || 0).filter(m => m !== 0);
|
||||
const growthRates = filteredData.map(c => c.metrics?.growth_rate || 0).filter(g => g !== 0);
|
||||
|
||||
return {
|
||||
totalCategories: filteredData.length,
|
||||
@@ -136,20 +174,7 @@ export function Categories() {
|
||||
};
|
||||
}, [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) => {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
@@ -245,17 +270,18 @@ export function Categories() {
|
||||
className="h-8 w-[150px] lg:w-[250px]"
|
||||
/>
|
||||
<Select
|
||||
value={filters.parent}
|
||||
onValueChange={(value) => setFilters(prev => ({ ...prev, parent: value }))}
|
||||
value={filters.type}
|
||||
onValueChange={(value) => setFilters(prev => ({ ...prev, type: value }))}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[180px]">
|
||||
<SelectValue placeholder="Parent Category" />
|
||||
<SelectValue placeholder="Category Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Categories</SelectItem>
|
||||
<SelectItem value="none">Top Level Only</SelectItem>
|
||||
{data?.parentCategories?.map((parent: string) => (
|
||||
<SelectItem key={parent} value={parent}>{parent}</SelectItem>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
{data?.typeCounts?.map((tc: TypeCount) => (
|
||||
<SelectItem key={tc.type} value={tc.type.toString()}>
|
||||
{TYPE_LABELS[tc.type]} ({tc.count})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -281,48 +307,66 @@ export function Categories() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead onClick={() => handleSort("name")} className="cursor-pointer">Name</TableHead>
|
||||
<TableHead onClick={() => handleSort("parent_category")} className="cursor-pointer">Parent</TableHead>
|
||||
<TableHead onClick={() => handleSort("product_count")} className="cursor-pointer">Products</TableHead>
|
||||
<TableHead onClick={() => handleSort("total_value")} className="cursor-pointer">Value</TableHead>
|
||||
<TableHead onClick={() => handleSort("avg_margin")} className="cursor-pointer">Margin</TableHead>
|
||||
<TableHead onClick={() => handleSort("turnover_rate")} className="cursor-pointer">Turnover</TableHead>
|
||||
<TableHead onClick={() => handleSort("growth_rate")} className="cursor-pointer">Growth</TableHead>
|
||||
<TableHead onClick={() => handleSort("status")} className="cursor-pointer">Status</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Parent</TableHead>
|
||||
<TableHead className="text-right">Products</TableHead>
|
||||
<TableHead className="text-right">Active</TableHead>
|
||||
<TableHead className="text-right">Value</TableHead>
|
||||
<TableHead className="text-right">Margin</TableHead>
|
||||
<TableHead className="text-right">Turnover</TableHead>
|
||||
<TableHead className="text-right">Growth</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-8">
|
||||
<TableCell colSpan={10} className="text-center py-8">
|
||||
Loading categories...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : paginatedData.map((category: Category) => (
|
||||
<TableRow key={category.category_id}>
|
||||
<TableRow key={category.cat_id}>
|
||||
<TableCell>
|
||||
<div className="font-medium">{category.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{category.description}</div>
|
||||
<Badge variant="outline">
|
||||
{TYPE_LABELS[category.type]}
|
||||
</Badge>
|
||||
</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>
|
||||
<div className="flex items-center gap-2" style={{ minWidth: '120px' }}>
|
||||
<div style={{ width: '50px', textAlign: 'right' }}>
|
||||
{typeof category.growth_rate === 'number' ? category.growth_rate.toFixed(1) : "0.0"}%
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{category.name}</span>
|
||||
|
||||
</div>
|
||||
{getPerformanceBadge(category.growth_rate ?? 0)}
|
||||
{category.description && (
|
||||
<div className="text-xs text-muted-foreground">{category.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
))}
|
||||
{!isLoading && !paginatedData.length && (
|
||||
<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
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@@ -60,19 +60,23 @@ export default function Forecasting() {
|
||||
const data = await response.json();
|
||||
return data.map((item: any) => ({
|
||||
category: item.category_name,
|
||||
categoryPath: item.path,
|
||||
avgDailySales: Number(item.avg_daily_sales) || 0,
|
||||
totalSold: Number(item.total_sold) || 0,
|
||||
numProducts: Number(item.num_products) || 0,
|
||||
avgPrice: Number(item.avg_price) || 0,
|
||||
avgTotalSold: Number(item.avgTotalSold) || 0,
|
||||
products: item.products?.map((p: any) => ({
|
||||
product_id: p.product_id,
|
||||
name: p.title,
|
||||
pid: p.pid,
|
||||
title: p.title,
|
||||
sku: p.sku,
|
||||
stock_quantity: Number(p.stock_quantity) || 0,
|
||||
total_sold: Number(p.total_sold) || 0,
|
||||
avg_price: Number(p.avg_price) || 0,
|
||||
first_received_date: p.first_received_date,
|
||||
daily_sales_avg: Number(p.daily_sales_avg) || 0,
|
||||
forecast_units: Number(p.forecast_units) || 0,
|
||||
forecast_revenue: Number(p.forecast_revenue) || 0,
|
||||
confidence_level: Number(p.confidence_level) || 0,
|
||||
categoryPath: item.path
|
||||
}))
|
||||
}));
|
||||
},
|
||||
|
||||
@@ -503,7 +503,7 @@ export function Products() {
|
||||
columnDefs={AVAILABLE_COLUMNS}
|
||||
columnOrder={columnOrder}
|
||||
onColumnOrderChange={handleColumnOrderChange}
|
||||
onRowClick={(product) => setSelectedProductId(product.product_id)}
|
||||
onRowClick={(product) => setSelectedProductId(product.pid)}
|
||||
/>
|
||||
|
||||
{totalPages > 1 && (
|
||||
|
||||
@@ -20,12 +20,21 @@ import {
|
||||
PaginationPrevious,
|
||||
} from '../components/ui/pagination';
|
||||
import { motion } from 'motion/react';
|
||||
import {
|
||||
PurchaseOrderStatus,
|
||||
ReceivingStatus as ReceivingStatusCode,
|
||||
getPurchaseOrderStatusLabel,
|
||||
getReceivingStatusLabel,
|
||||
getPurchaseOrderStatusVariant,
|
||||
getReceivingStatusVariant
|
||||
} from '../types/status-codes';
|
||||
|
||||
interface PurchaseOrder {
|
||||
id: number;
|
||||
vendor_name: string;
|
||||
order_date: string;
|
||||
status: string;
|
||||
status: number;
|
||||
receiving_status: number;
|
||||
total_items: number;
|
||||
total_quantity: number;
|
||||
total_cost: number;
|
||||
@@ -113,6 +122,16 @@ export default function PurchaseOrders() {
|
||||
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 () => {
|
||||
try {
|
||||
const searchParams = new URLSearchParams({
|
||||
@@ -171,16 +190,25 @@ export default function PurchaseOrders() {
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const variants: Record<string, { variant: "default" | "secondary" | "destructive" | "outline"; label: string }> = {
|
||||
pending: { variant: "outline", label: "Pending" },
|
||||
received: { variant: "default", label: "Received" },
|
||||
partial: { variant: "secondary", label: "Partial" },
|
||||
cancelled: { variant: "destructive", label: "Cancelled" },
|
||||
};
|
||||
|
||||
const statusConfig = variants[status.toLowerCase()] || variants.pending;
|
||||
return <Badge variant={statusConfig.variant}>{statusConfig.label}</Badge>;
|
||||
const getStatusBadge = (status: number, receivingStatus: number) => {
|
||||
// If the PO is canceled, show that status
|
||||
if (status === PurchaseOrderStatus.Canceled) {
|
||||
return <Badge variant={getPurchaseOrderStatusVariant(status)}>
|
||||
{getPurchaseOrderStatusLabel(status)}
|
||||
</Badge>;
|
||||
}
|
||||
|
||||
// If receiving has started, show receiving status
|
||||
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) => {
|
||||
@@ -252,45 +280,44 @@ export default function PurchaseOrders() {
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-center">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Input
|
||||
placeholder="Search orders..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
|
||||
className="h-8 w-[300px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={filters.status}
|
||||
onValueChange={(value) => setFilters(prev => ({ ...prev, status: value }))}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[180px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
{filterOptions.statuses.map(status => (
|
||||
<SelectItem key={status} value={status}>{status}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={filters.vendor}
|
||||
onValueChange={(value) => setFilters(prev => ({ ...prev, vendor: value }))}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[180px]">
|
||||
<SelectValue placeholder="Vendor" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Vendors</SelectItem>
|
||||
{filterOptions.vendors.map(vendor => (
|
||||
<SelectItem key={vendor} value={vendor}>{vendor}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="mb-4 flex items-center gap-4">
|
||||
<Input
|
||||
placeholder="Search orders..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
<Select
|
||||
value={filters.status}
|
||||
onValueChange={(value) => setFilters(prev => ({ ...prev, status: value }))}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_FILTER_OPTIONS.map(option => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={filters.vendor}
|
||||
onValueChange={(value) => setFilters(prev => ({ ...prev, vendor: value }))}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Select vendor" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Vendors</SelectItem>
|
||||
{filterOptions.vendors.map(vendor => (
|
||||
<SelectItem key={vendor} value={vendor}>
|
||||
{vendor}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Purchase Orders Table */}
|
||||
@@ -343,7 +370,7 @@ export default function PurchaseOrders() {
|
||||
<TableCell>{po.id}</TableCell>
|
||||
<TableCell>{po.vendor_name}</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_quantity.toLocaleString()}</TableCell>
|
||||
<TableCell>${formatNumber(po.total_cost)}</TableCell>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export interface Product {
|
||||
product_id: number;
|
||||
pid: number;
|
||||
title: string;
|
||||
SKU: string;
|
||||
stock_quantity: number;
|
||||
price: number;
|
||||
regular_price: number;
|
||||
cost_price: number;
|
||||
landing_cost_price: number | null;
|
||||
price: string; // DECIMAL(15,3)
|
||||
regular_price: string; // DECIMAL(15,3)
|
||||
cost_price: string; // DECIMAL(15,3)
|
||||
landing_cost_price: string | null; // DECIMAL(15,3)
|
||||
barcode: string;
|
||||
vendor: string;
|
||||
vendor_reference: string;
|
||||
brand: string;
|
||||
brand: string | 'Unbranded';
|
||||
categories: string[];
|
||||
tags: string[];
|
||||
options: Record<string, any>;
|
||||
@@ -24,32 +24,32 @@ export interface Product {
|
||||
updated_at: string;
|
||||
|
||||
// Metrics
|
||||
daily_sales_avg?: number;
|
||||
weekly_sales_avg?: number;
|
||||
monthly_sales_avg?: number;
|
||||
avg_quantity_per_order?: number;
|
||||
daily_sales_avg?: string; // DECIMAL(15,3)
|
||||
weekly_sales_avg?: string; // DECIMAL(15,3)
|
||||
monthly_sales_avg?: string; // DECIMAL(15,3)
|
||||
avg_quantity_per_order?: string; // DECIMAL(15,3)
|
||||
number_of_orders?: number;
|
||||
first_sale_date?: string;
|
||||
last_sale_date?: string;
|
||||
last_purchase_date?: string;
|
||||
days_of_inventory?: number;
|
||||
weeks_of_inventory?: number;
|
||||
reorder_point?: number;
|
||||
safety_stock?: number;
|
||||
avg_margin_percent?: number;
|
||||
total_revenue?: number;
|
||||
inventory_value?: number;
|
||||
cost_of_goods_sold?: number;
|
||||
gross_profit?: number;
|
||||
gmroi?: number;
|
||||
avg_lead_time_days?: number;
|
||||
days_of_inventory?: string; // DECIMAL(15,3)
|
||||
weeks_of_inventory?: string; // DECIMAL(15,3)
|
||||
reorder_point?: string; // DECIMAL(15,3)
|
||||
safety_stock?: string; // DECIMAL(15,3)
|
||||
avg_margin_percent?: string; // DECIMAL(15,3)
|
||||
total_revenue?: string; // DECIMAL(15,3)
|
||||
inventory_value?: string; // DECIMAL(15,3)
|
||||
cost_of_goods_sold?: string; // DECIMAL(15,3)
|
||||
gross_profit?: string; // DECIMAL(15,3)
|
||||
gmroi?: string; // DECIMAL(15,3)
|
||||
avg_lead_time_days?: string; // DECIMAL(15,3)
|
||||
last_received_date?: string;
|
||||
abc_class?: string;
|
||||
stock_status?: string;
|
||||
turnover_rate?: number;
|
||||
current_lead_time?: number;
|
||||
target_lead_time?: number;
|
||||
turnover_rate?: string; // DECIMAL(15,3)
|
||||
current_lead_time?: string; // DECIMAL(15,3)
|
||||
target_lead_time?: string; // DECIMAL(15,3)
|
||||
lead_time_status?: string;
|
||||
reorder_qty?: number;
|
||||
overstocked_amt?: number;
|
||||
overstocked_amt?: string; // DECIMAL(15,3)
|
||||
}
|
||||
|
||||
81
inventory/src/types/status-codes.ts
Normal file
81
inventory/src/types/status-codes.ts
Normal 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';
|
||||
}
|
||||
Reference in New Issue
Block a user