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,
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE,
|
FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE,
|
||||||
UNIQUE KEY unique_category_vendor (category_id, vendor)
|
UNIQUE KEY unique_category_vendor (category_id, vendor),
|
||||||
|
INDEX idx_st_metrics (category_id, vendor)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Lead time threshold configurations
|
-- Lead time threshold configurations
|
||||||
@@ -44,7 +45,8 @@ CREATE TABLE IF NOT EXISTS sales_velocity_config (
|
|||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE,
|
FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE,
|
||||||
UNIQUE KEY unique_category_vendor (category_id, vendor)
|
UNIQUE KEY unique_category_vendor (category_id, vendor),
|
||||||
|
INDEX idx_sv_metrics (category_id, vendor)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- ABC Classification configurations
|
-- ABC Classification configurations
|
||||||
@@ -68,7 +70,8 @@ CREATE TABLE IF NOT EXISTS safety_stock_config (
|
|||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE,
|
FOREIGN KEY (category_id) REFERENCES categories(cat_id) ON DELETE CASCADE,
|
||||||
UNIQUE KEY unique_category_vendor (category_id, vendor)
|
UNIQUE KEY unique_category_vendor (category_id, vendor),
|
||||||
|
INDEX idx_ss_metrics (category_id, vendor)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Turnover rate configurations
|
-- Turnover rate configurations
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ CREATE TABLE IF NOT EXISTS category_metrics (
|
|||||||
product_count INT DEFAULT 0,
|
product_count INT DEFAULT 0,
|
||||||
active_products INT DEFAULT 0,
|
active_products INT DEFAULT 0,
|
||||||
-- Financial metrics
|
-- Financial metrics
|
||||||
total_value DECIMAL(10,3) DEFAULT 0,
|
total_value DECIMAL(15,3) DEFAULT 0,
|
||||||
avg_margin DECIMAL(5,2),
|
avg_margin DECIMAL(5,2),
|
||||||
turnover_rate DECIMAL(12,3),
|
turnover_rate DECIMAL(12,3),
|
||||||
growth_rate DECIMAL(5,2),
|
growth_rate DECIMAL(5,2),
|
||||||
@@ -193,8 +193,8 @@ CREATE TABLE IF NOT EXISTS category_time_metrics (
|
|||||||
product_count INT DEFAULT 0,
|
product_count INT DEFAULT 0,
|
||||||
active_products INT DEFAULT 0,
|
active_products INT DEFAULT 0,
|
||||||
-- Financial metrics
|
-- Financial metrics
|
||||||
total_value DECIMAL(10,3) DEFAULT 0,
|
total_value DECIMAL(15,3) DEFAULT 0,
|
||||||
total_revenue DECIMAL(10,3) DEFAULT 0,
|
total_revenue DECIMAL(15,3) DEFAULT 0,
|
||||||
avg_margin DECIMAL(5,2),
|
avg_margin DECIMAL(5,2),
|
||||||
turnover_rate DECIMAL(12,3),
|
turnover_rate DECIMAL(12,3),
|
||||||
PRIMARY KEY (category_id, year, month),
|
PRIMARY KEY (category_id, year, month),
|
||||||
@@ -228,10 +228,10 @@ CREATE TABLE IF NOT EXISTS brand_metrics (
|
|||||||
active_products INT DEFAULT 0,
|
active_products INT DEFAULT 0,
|
||||||
-- Stock metrics
|
-- Stock metrics
|
||||||
total_stock_units INT DEFAULT 0,
|
total_stock_units INT DEFAULT 0,
|
||||||
total_stock_cost DECIMAL(10,2) DEFAULT 0,
|
total_stock_cost DECIMAL(15,2) DEFAULT 0,
|
||||||
total_stock_retail DECIMAL(10,2) DEFAULT 0,
|
total_stock_retail DECIMAL(15,2) DEFAULT 0,
|
||||||
-- Sales metrics
|
-- Sales metrics
|
||||||
total_revenue DECIMAL(10,2) DEFAULT 0,
|
total_revenue DECIMAL(15,2) DEFAULT 0,
|
||||||
avg_margin DECIMAL(5,2) DEFAULT 0,
|
avg_margin DECIMAL(5,2) DEFAULT 0,
|
||||||
growth_rate DECIMAL(5,2) DEFAULT 0,
|
growth_rate DECIMAL(5,2) DEFAULT 0,
|
||||||
PRIMARY KEY (brand),
|
PRIMARY KEY (brand),
|
||||||
@@ -250,10 +250,10 @@ CREATE TABLE IF NOT EXISTS brand_time_metrics (
|
|||||||
active_products INT DEFAULT 0,
|
active_products INT DEFAULT 0,
|
||||||
-- Stock metrics
|
-- Stock metrics
|
||||||
total_stock_units INT DEFAULT 0,
|
total_stock_units INT DEFAULT 0,
|
||||||
total_stock_cost DECIMAL(10,2) DEFAULT 0,
|
total_stock_cost DECIMAL(15,2) DEFAULT 0,
|
||||||
total_stock_retail DECIMAL(10,2) DEFAULT 0,
|
total_stock_retail DECIMAL(15,2) DEFAULT 0,
|
||||||
-- Sales metrics
|
-- Sales metrics
|
||||||
total_revenue DECIMAL(10,2) DEFAULT 0,
|
total_revenue DECIMAL(15,2) DEFAULT 0,
|
||||||
avg_margin DECIMAL(5,2) DEFAULT 0,
|
avg_margin DECIMAL(5,2) DEFAULT 0,
|
||||||
PRIMARY KEY (brand, year, month),
|
PRIMARY KEY (brand, year, month),
|
||||||
INDEX idx_brand_date (year, month)
|
INDEX idx_brand_date (year, month)
|
||||||
|
|||||||
@@ -122,7 +122,8 @@ CREATE TABLE IF NOT EXISTS orders (
|
|||||||
KEY pid (pid),
|
KEY pid (pid),
|
||||||
KEY customer (customer),
|
KEY customer (customer),
|
||||||
KEY date (date),
|
KEY date (date),
|
||||||
KEY status (status)
|
KEY status (status),
|
||||||
|
INDEX idx_orders_metrics (pid, date, canceled)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- Create purchase_orders table with its indexes
|
-- Create purchase_orders table with its indexes
|
||||||
@@ -152,6 +153,7 @@ CREATE TABLE purchase_orders (
|
|||||||
INDEX idx_status (status),
|
INDEX idx_status (status),
|
||||||
INDEX idx_receiving_status (receiving_status),
|
INDEX idx_receiving_status (receiving_status),
|
||||||
INDEX idx_purchase_orders_metrics (pid, date, status, ordered, received),
|
INDEX idx_purchase_orders_metrics (pid, date, status, ordered, received),
|
||||||
|
INDEX idx_po_metrics (pid, date, receiving_status, received_date),
|
||||||
INDEX idx_po_product_date (pid, date),
|
INDEX idx_po_product_date (pid, date),
|
||||||
INDEX idx_po_product_status (pid, status),
|
INDEX idx_po_product_status (pid, status),
|
||||||
UNIQUE KEY unique_po_product (po_id, pid)
|
UNIQUE KEY unique_po_product (po_id, pid)
|
||||||
|
|||||||
@@ -5,6 +5,16 @@ process.chdir(path.dirname(__filename));
|
|||||||
|
|
||||||
require('dotenv').config({ path: path.resolve(__dirname, '..', '.env') });
|
require('dotenv').config({ path: path.resolve(__dirname, '..', '.env') });
|
||||||
|
|
||||||
|
// Configuration flags for controlling which metrics to calculate
|
||||||
|
// Set to 1 to skip the corresponding calculation, 0 to run it
|
||||||
|
const SKIP_PRODUCT_METRICS = 1; // Skip all product metrics
|
||||||
|
const SKIP_TIME_AGGREGATES = 1; // Skip time aggregates
|
||||||
|
const SKIP_FINANCIAL_METRICS = 1; // Skip financial metrics
|
||||||
|
const SKIP_VENDOR_METRICS = 1; // Skip vendor metrics
|
||||||
|
const SKIP_CATEGORY_METRICS = 1; // Skip category metrics
|
||||||
|
const SKIP_BRAND_METRICS = 1; // Skip brand metrics
|
||||||
|
const SKIP_SALES_FORECASTS = 1; // Skip sales forecasts
|
||||||
|
|
||||||
// Add error handler for uncaught exceptions
|
// Add error handler for uncaught exceptions
|
||||||
process.on('uncaughtException', (error) => {
|
process.on('uncaughtException', (error) => {
|
||||||
console.error('Uncaught Exception:', error);
|
console.error('Uncaught Exception:', error);
|
||||||
@@ -43,9 +53,6 @@ const calculateCategoryMetrics = require('./metrics/category-metrics');
|
|||||||
const calculateBrandMetrics = require('./metrics/brand-metrics');
|
const calculateBrandMetrics = require('./metrics/brand-metrics');
|
||||||
const calculateSalesForecasts = require('./metrics/sales-forecasts');
|
const calculateSalesForecasts = require('./metrics/sales-forecasts');
|
||||||
|
|
||||||
// Set to 1 to skip product metrics and only calculate the remaining metrics
|
|
||||||
const SKIP_PRODUCT_METRICS = 1;
|
|
||||||
|
|
||||||
// Add cancel handler
|
// Add cancel handler
|
||||||
let isCancelled = false;
|
let isCancelled = false;
|
||||||
|
|
||||||
@@ -137,50 +144,136 @@ async function calculateMetrics() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate time-based aggregates
|
// Calculate time-based aggregates
|
||||||
|
if (!SKIP_TIME_AGGREGATES) {
|
||||||
processedCount = await calculateTimeAggregates(startTime, totalProducts, processedCount);
|
processedCount = await calculateTimeAggregates(startTime, totalProducts, processedCount);
|
||||||
|
} else {
|
||||||
|
console.log('Skipping time aggregates calculation');
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate financial metrics
|
// Calculate financial metrics
|
||||||
|
if (!SKIP_FINANCIAL_METRICS) {
|
||||||
processedCount = await calculateFinancialMetrics(startTime, totalProducts, processedCount);
|
processedCount = await calculateFinancialMetrics(startTime, totalProducts, processedCount);
|
||||||
|
} else {
|
||||||
|
console.log('Skipping financial metrics calculation');
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate vendor metrics
|
// Calculate vendor metrics
|
||||||
|
if (!SKIP_VENDOR_METRICS) {
|
||||||
processedCount = await calculateVendorMetrics(startTime, totalProducts, processedCount);
|
processedCount = await calculateVendorMetrics(startTime, totalProducts, processedCount);
|
||||||
|
} else {
|
||||||
|
console.log('Skipping vendor metrics calculation');
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate category metrics
|
// Calculate category metrics
|
||||||
|
if (!SKIP_CATEGORY_METRICS) {
|
||||||
processedCount = await calculateCategoryMetrics(startTime, totalProducts, processedCount);
|
processedCount = await calculateCategoryMetrics(startTime, totalProducts, processedCount);
|
||||||
|
} else {
|
||||||
|
console.log('Skipping category metrics calculation');
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate brand metrics
|
// Calculate brand metrics
|
||||||
|
if (!SKIP_BRAND_METRICS) {
|
||||||
processedCount = await calculateBrandMetrics(startTime, totalProducts, processedCount);
|
processedCount = await calculateBrandMetrics(startTime, totalProducts, processedCount);
|
||||||
|
} else {
|
||||||
|
console.log('Skipping brand metrics calculation');
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate sales forecasts
|
// Calculate sales forecasts
|
||||||
|
if (!SKIP_SALES_FORECASTS) {
|
||||||
processedCount = await calculateSalesForecasts(startTime, totalProducts, processedCount);
|
processedCount = await calculateSalesForecasts(startTime, totalProducts, processedCount);
|
||||||
|
} else {
|
||||||
|
console.log('Skipping sales forecasts calculation');
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate ABC classification
|
// Calculate ABC classification
|
||||||
const [abcConfig] = await connection.query('SELECT a_threshold, b_threshold FROM abc_classification_config WHERE id = 1');
|
const [abcConfig] = await connection.query('SELECT a_threshold, b_threshold FROM abc_classification_config WHERE id = 1');
|
||||||
const abcThresholds = abcConfig[0] || { a_threshold: 20, b_threshold: 50 };
|
const abcThresholds = abcConfig[0] || { a_threshold: 20, b_threshold: 50 };
|
||||||
|
|
||||||
|
// First, create and populate the rankings table with an index
|
||||||
|
await connection.query('DROP TEMPORARY TABLE IF EXISTS temp_revenue_ranks');
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
WITH revenue_rankings AS (
|
CREATE TEMPORARY TABLE temp_revenue_ranks (
|
||||||
|
pid BIGINT NOT NULL,
|
||||||
|
total_revenue DECIMAL(10,3),
|
||||||
|
rank_num INT,
|
||||||
|
total_count INT,
|
||||||
|
PRIMARY KEY (pid),
|
||||||
|
INDEX (rank_num)
|
||||||
|
) ENGINE=MEMORY
|
||||||
|
`);
|
||||||
|
|
||||||
|
await connection.query(`
|
||||||
|
INSERT INTO temp_revenue_ranks
|
||||||
SELECT
|
SELECT
|
||||||
product_id,
|
pid,
|
||||||
total_revenue,
|
total_revenue,
|
||||||
PERCENT_RANK() OVER (ORDER BY COALESCE(total_revenue, 0) DESC) * 100 as revenue_percentile
|
@rank := @rank + 1 as rank_num,
|
||||||
|
@total_count := @rank as total_count
|
||||||
|
FROM (
|
||||||
|
SELECT pid, total_revenue
|
||||||
FROM product_metrics
|
FROM product_metrics
|
||||||
),
|
WHERE total_revenue > 0
|
||||||
classification_update AS (
|
ORDER BY total_revenue DESC
|
||||||
SELECT
|
) ranked,
|
||||||
product_id,
|
(SELECT @rank := 0) r
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Get total count for percentage calculation
|
||||||
|
const [rankingCount] = await connection.query('SELECT MAX(rank_num) as total_count FROM temp_revenue_ranks');
|
||||||
|
const totalCount = rankingCount[0].total_count || 1;
|
||||||
|
|
||||||
|
// Process updates in batches
|
||||||
|
let abcProcessedCount = 0;
|
||||||
|
const batchSize = 5000;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
// First get a batch of PIDs that need updating
|
||||||
|
const [pids] = await connection.query(`
|
||||||
|
SELECT pm.pid
|
||||||
|
FROM product_metrics pm
|
||||||
|
LEFT JOIN temp_revenue_ranks tr ON pm.pid = tr.pid
|
||||||
|
WHERE pm.abc_class IS NULL
|
||||||
|
OR pm.abc_class !=
|
||||||
CASE
|
CASE
|
||||||
WHEN revenue_percentile <= ? THEN 'A'
|
WHEN tr.rank_num IS NULL THEN 'C'
|
||||||
WHEN revenue_percentile <= ? THEN 'B'
|
WHEN (tr.rank_num / ?) * 100 <= ? THEN 'A'
|
||||||
|
WHEN (tr.rank_num / ?) * 100 <= ? THEN 'B'
|
||||||
ELSE 'C'
|
ELSE 'C'
|
||||||
END as abc_class
|
END
|
||||||
FROM revenue_rankings
|
LIMIT ?
|
||||||
)
|
`, [totalCount, abcThresholds.a_threshold,
|
||||||
|
totalCount, abcThresholds.b_threshold,
|
||||||
|
batchSize]);
|
||||||
|
|
||||||
|
if (pids.length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then update just those PIDs
|
||||||
|
const [result] = await connection.query(`
|
||||||
UPDATE product_metrics pm
|
UPDATE product_metrics pm
|
||||||
JOIN classification_update cu ON pm.product_id = cu.product_id
|
LEFT JOIN temp_revenue_ranks tr ON pm.pid = tr.pid
|
||||||
SET pm.abc_class = cu.abc_class,
|
SET pm.abc_class =
|
||||||
|
CASE
|
||||||
|
WHEN tr.rank_num IS NULL THEN 'C'
|
||||||
|
WHEN (tr.rank_num / ?) * 100 <= ? THEN 'A'
|
||||||
|
WHEN (tr.rank_num / ?) * 100 <= ? THEN 'B'
|
||||||
|
ELSE 'C'
|
||||||
|
END,
|
||||||
pm.last_calculated_at = NOW()
|
pm.last_calculated_at = NOW()
|
||||||
`, [abcThresholds.a_threshold, abcThresholds.b_threshold]);
|
WHERE pm.pid IN (?)
|
||||||
|
`, [totalCount, abcThresholds.a_threshold,
|
||||||
|
totalCount, abcThresholds.b_threshold,
|
||||||
|
pids.map(row => row.pid)]);
|
||||||
|
|
||||||
|
abcProcessedCount += result.affectedRows;
|
||||||
|
|
||||||
|
// Small delay between batches to allow other transactions
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
await connection.query('DROP TEMPORARY TABLE IF EXISTS temp_revenue_ranks');
|
||||||
|
|
||||||
// Final success message
|
// Final success message
|
||||||
global.outputProgress({
|
global.outputProgress({
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ async function calculateBrandMetrics(startTime, totalProducts, processedCount) {
|
|||||||
percentage: '95'
|
percentage: '95'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate brand metrics
|
// Calculate brand metrics with optimized queries
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
INSERT INTO brand_metrics (
|
INSERT INTO brand_metrics (
|
||||||
brand,
|
brand,
|
||||||
@@ -28,59 +28,77 @@ async function calculateBrandMetrics(startTime, totalProducts, processedCount) {
|
|||||||
avg_margin,
|
avg_margin,
|
||||||
growth_rate
|
growth_rate
|
||||||
)
|
)
|
||||||
WITH brand_data AS (
|
WITH filtered_products AS (
|
||||||
|
SELECT
|
||||||
|
p.*,
|
||||||
|
CASE WHEN p.stock_quantity <= 5000 THEN p.pid END as valid_pid,
|
||||||
|
CASE WHEN p.visible = true AND p.stock_quantity <= 5000 THEN p.pid END as active_pid,
|
||||||
|
CASE
|
||||||
|
WHEN p.stock_quantity IS NULL OR p.stock_quantity < 0 OR p.stock_quantity > 5000 THEN 0
|
||||||
|
ELSE p.stock_quantity
|
||||||
|
END as valid_stock
|
||||||
|
FROM products p
|
||||||
|
WHERE p.brand IS NOT NULL
|
||||||
|
),
|
||||||
|
sales_periods AS (
|
||||||
SELECT
|
SELECT
|
||||||
p.brand,
|
p.brand,
|
||||||
COUNT(DISTINCT p.product_id) as product_count,
|
SUM(o.quantity * o.price) as period_revenue,
|
||||||
COUNT(DISTINCT CASE WHEN p.visible = true THEN p.product_id END) as active_products,
|
|
||||||
SUM(p.stock_quantity) as total_stock_units,
|
|
||||||
SUM(p.stock_quantity * p.cost_price) as total_stock_cost,
|
|
||||||
SUM(p.stock_quantity * p.price) as total_stock_retail,
|
|
||||||
SUM(o.price * o.quantity) as total_revenue,
|
|
||||||
CASE
|
CASE
|
||||||
WHEN SUM(o.price * o.quantity) > 0 THEN
|
WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 3 MONTH) THEN 'current'
|
||||||
|
WHEN o.date BETWEEN DATE_SUB(CURRENT_DATE, INTERVAL 15 MONTH) AND DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH) THEN 'previous'
|
||||||
|
END as period_type
|
||||||
|
FROM filtered_products p
|
||||||
|
JOIN orders o ON p.pid = o.pid
|
||||||
|
WHERE o.canceled = false
|
||||||
|
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 15 MONTH)
|
||||||
|
GROUP BY p.brand, period_type
|
||||||
|
),
|
||||||
|
brand_data AS (
|
||||||
|
SELECT
|
||||||
|
p.brand,
|
||||||
|
COUNT(DISTINCT p.valid_pid) as product_count,
|
||||||
|
COUNT(DISTINCT p.active_pid) as active_products,
|
||||||
|
SUM(p.valid_stock) as total_stock_units,
|
||||||
|
SUM(p.valid_stock * p.cost_price) as total_stock_cost,
|
||||||
|
SUM(p.valid_stock * p.price) as total_stock_retail,
|
||||||
|
COALESCE(SUM(o.quantity * o.price), 0) as total_revenue,
|
||||||
|
CASE
|
||||||
|
WHEN SUM(o.quantity * o.price) > 0 THEN
|
||||||
(SUM((o.price - p.cost_price) * o.quantity) * 100.0) / SUM(o.price * o.quantity)
|
(SUM((o.price - p.cost_price) * o.quantity) * 100.0) / SUM(o.price * o.quantity)
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END as avg_margin,
|
END as avg_margin
|
||||||
-- Current period (last 3 months)
|
FROM filtered_products p
|
||||||
SUM(CASE
|
LEFT JOIN orders o ON p.pid = o.pid AND o.canceled = false
|
||||||
WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 3 MONTH)
|
|
||||||
THEN COALESCE(o.quantity * o.price, 0)
|
|
||||||
ELSE 0
|
|
||||||
END) as current_period_sales,
|
|
||||||
-- Previous year same period
|
|
||||||
SUM(CASE
|
|
||||||
WHEN o.date BETWEEN DATE_SUB(CURRENT_DATE, INTERVAL 15 MONTH) AND DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
|
|
||||||
THEN COALESCE(o.quantity * o.price, 0)
|
|
||||||
ELSE 0
|
|
||||||
END) as previous_year_period_sales
|
|
||||||
FROM products p
|
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id AND o.canceled = false
|
|
||||||
WHERE p.brand IS NOT NULL
|
|
||||||
GROUP BY p.brand
|
GROUP BY p.brand
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
brand,
|
bd.brand,
|
||||||
product_count,
|
bd.product_count,
|
||||||
active_products,
|
bd.active_products,
|
||||||
total_stock_units,
|
bd.total_stock_units,
|
||||||
total_stock_cost,
|
bd.total_stock_cost,
|
||||||
total_stock_retail,
|
bd.total_stock_retail,
|
||||||
total_revenue,
|
bd.total_revenue,
|
||||||
avg_margin,
|
bd.avg_margin,
|
||||||
CASE
|
CASE
|
||||||
WHEN previous_year_period_sales = 0 AND current_period_sales > 0 THEN 100.0
|
WHEN MAX(CASE WHEN sp.period_type = 'previous' THEN sp.period_revenue END) = 0
|
||||||
WHEN previous_year_period_sales = 0 THEN 0.0
|
AND MAX(CASE WHEN sp.period_type = 'current' THEN sp.period_revenue END) > 0 THEN 100.0
|
||||||
|
WHEN MAX(CASE WHEN sp.period_type = 'previous' THEN sp.period_revenue END) = 0 THEN 0.0
|
||||||
ELSE LEAST(
|
ELSE LEAST(
|
||||||
GREATEST(
|
GREATEST(
|
||||||
((current_period_sales - previous_year_period_sales) /
|
((MAX(CASE WHEN sp.period_type = 'current' THEN sp.period_revenue END) -
|
||||||
NULLIF(previous_year_period_sales, 0)) * 100.0,
|
MAX(CASE WHEN sp.period_type = 'previous' THEN sp.period_revenue END)) /
|
||||||
|
NULLIF(MAX(CASE WHEN sp.period_type = 'previous' THEN sp.period_revenue END), 0)) * 100.0,
|
||||||
-100.0
|
-100.0
|
||||||
),
|
),
|
||||||
999.99
|
999.99
|
||||||
)
|
)
|
||||||
END as growth_rate
|
END as growth_rate
|
||||||
FROM brand_data
|
FROM brand_data bd
|
||||||
|
LEFT JOIN sales_periods sp ON bd.brand = sp.brand
|
||||||
|
GROUP BY bd.brand, bd.product_count, bd.active_products, bd.total_stock_units,
|
||||||
|
bd.total_stock_cost, bd.total_stock_retail, bd.total_revenue, bd.avg_margin
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
product_count = VALUES(product_count),
|
product_count = VALUES(product_count),
|
||||||
active_products = VALUES(active_products),
|
active_products = VALUES(active_products),
|
||||||
@@ -93,7 +111,7 @@ async function calculateBrandMetrics(startTime, totalProducts, processedCount) {
|
|||||||
last_calculated_at = CURRENT_TIMESTAMP
|
last_calculated_at = CURRENT_TIMESTAMP
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Calculate brand time-based metrics
|
// Calculate brand time-based metrics with optimized query
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
INSERT INTO brand_time_metrics (
|
INSERT INTO brand_time_metrics (
|
||||||
brand,
|
brand,
|
||||||
@@ -107,26 +125,41 @@ async function calculateBrandMetrics(startTime, totalProducts, processedCount) {
|
|||||||
total_revenue,
|
total_revenue,
|
||||||
avg_margin
|
avg_margin
|
||||||
)
|
)
|
||||||
|
WITH filtered_products AS (
|
||||||
|
SELECT
|
||||||
|
p.*,
|
||||||
|
CASE WHEN p.stock_quantity <= 5000 THEN p.pid END as valid_pid,
|
||||||
|
CASE WHEN p.visible = true AND p.stock_quantity <= 5000 THEN p.pid END as active_pid,
|
||||||
|
CASE
|
||||||
|
WHEN p.stock_quantity IS NULL OR p.stock_quantity < 0 OR p.stock_quantity > 5000 THEN 0
|
||||||
|
ELSE p.stock_quantity
|
||||||
|
END as valid_stock
|
||||||
|
FROM products p
|
||||||
|
WHERE p.brand IS NOT NULL
|
||||||
|
),
|
||||||
|
monthly_metrics AS (
|
||||||
SELECT
|
SELECT
|
||||||
p.brand,
|
p.brand,
|
||||||
YEAR(o.date) as year,
|
YEAR(o.date) as year,
|
||||||
MONTH(o.date) as month,
|
MONTH(o.date) as month,
|
||||||
COUNT(DISTINCT p.product_id) as product_count,
|
COUNT(DISTINCT p.valid_pid) as product_count,
|
||||||
COUNT(DISTINCT CASE WHEN p.visible = true THEN p.product_id END) as active_products,
|
COUNT(DISTINCT p.active_pid) as active_products,
|
||||||
SUM(p.stock_quantity) as total_stock_units,
|
SUM(p.valid_stock) as total_stock_units,
|
||||||
SUM(p.stock_quantity * p.cost_price) as total_stock_cost,
|
SUM(p.valid_stock * p.cost_price) as total_stock_cost,
|
||||||
SUM(p.stock_quantity * p.price) as total_stock_retail,
|
SUM(p.valid_stock * p.price) as total_stock_retail,
|
||||||
SUM(o.price * o.quantity) as total_revenue,
|
SUM(o.quantity * o.price) as total_revenue,
|
||||||
CASE
|
CASE
|
||||||
WHEN SUM(o.price * o.quantity) > 0 THEN
|
WHEN SUM(o.quantity * o.price) > 0 THEN
|
||||||
(SUM((o.price - p.cost_price) * o.quantity) * 100.0) / SUM(o.price * o.quantity)
|
(SUM((o.price - p.cost_price) * o.quantity) * 100.0) / SUM(o.price * o.quantity)
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END as avg_margin
|
END as avg_margin
|
||||||
FROM products p
|
FROM filtered_products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id AND o.canceled = false
|
LEFT JOIN orders o ON p.pid = o.pid AND o.canceled = false
|
||||||
WHERE p.brand IS NOT NULL
|
WHERE o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
|
||||||
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
|
|
||||||
GROUP BY p.brand, YEAR(o.date), MONTH(o.date)
|
GROUP BY p.brand, YEAR(o.date), MONTH(o.date)
|
||||||
|
)
|
||||||
|
SELECT *
|
||||||
|
FROM monthly_metrics
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
product_count = VALUES(product_count),
|
product_count = VALUES(product_count),
|
||||||
active_products = VALUES(active_products),
|
active_products = VALUES(active_products),
|
||||||
|
|||||||
@@ -15,98 +15,104 @@ async function calculateCategoryMetrics(startTime, totalProducts, processedCount
|
|||||||
percentage: '85'
|
percentage: '85'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate category performance metrics
|
// First, calculate base category metrics
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
INSERT INTO category_metrics (
|
INSERT INTO category_metrics (
|
||||||
category_id,
|
category_id,
|
||||||
product_count,
|
product_count,
|
||||||
active_products,
|
active_products,
|
||||||
total_value,
|
total_value,
|
||||||
avg_margin,
|
status,
|
||||||
turnover_rate,
|
last_calculated_at
|
||||||
growth_rate,
|
|
||||||
status
|
|
||||||
)
|
)
|
||||||
WITH category_sales AS (
|
|
||||||
SELECT
|
SELECT
|
||||||
c.id as category_id,
|
c.cat_id,
|
||||||
COUNT(DISTINCT p.product_id) as product_count,
|
COUNT(DISTINCT p.pid) as product_count,
|
||||||
COUNT(DISTINCT CASE WHEN p.visible = true THEN p.product_id END) as active_products,
|
COUNT(DISTINCT CASE WHEN p.visible = true THEN p.pid END) as active_products,
|
||||||
SUM(p.stock_quantity * p.cost_price) as total_value,
|
COALESCE(SUM(p.stock_quantity * p.cost_price), 0) as total_value,
|
||||||
CASE
|
c.status,
|
||||||
WHEN SUM(o.price * o.quantity) > 0
|
NOW() as last_calculated_at
|
||||||
THEN (SUM((o.price - p.cost_price) * o.quantity) * 100.0) / SUM(o.price * o.quantity)
|
|
||||||
ELSE 0
|
|
||||||
END as avg_margin,
|
|
||||||
CASE
|
|
||||||
WHEN AVG(GREATEST(p.stock_quantity, 0)) >= 0.01
|
|
||||||
THEN LEAST(
|
|
||||||
SUM(CASE
|
|
||||||
WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 1 YEAR)
|
|
||||||
THEN COALESCE(o.quantity, 0)
|
|
||||||
ELSE 0
|
|
||||||
END) /
|
|
||||||
GREATEST(
|
|
||||||
AVG(GREATEST(p.stock_quantity, 0)),
|
|
||||||
1.0
|
|
||||||
),
|
|
||||||
999.99
|
|
||||||
)
|
|
||||||
ELSE 0
|
|
||||||
END as turnover_rate,
|
|
||||||
-- Current period (last 3 months)
|
|
||||||
SUM(CASE
|
|
||||||
WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 3 MONTH)
|
|
||||||
THEN COALESCE(o.quantity * o.price, 0)
|
|
||||||
ELSE 0
|
|
||||||
END) as current_period_sales,
|
|
||||||
-- Previous year same period
|
|
||||||
SUM(CASE
|
|
||||||
WHEN o.date BETWEEN DATE_SUB(CURRENT_DATE, INTERVAL 15 MONTH) AND DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
|
|
||||||
THEN COALESCE(o.quantity * o.price, 0)
|
|
||||||
ELSE 0
|
|
||||||
END) as previous_year_period_sales,
|
|
||||||
c.status
|
|
||||||
FROM categories c
|
FROM categories c
|
||||||
LEFT JOIN product_categories pc ON c.id = pc.category_id
|
LEFT JOIN product_categories pc ON c.cat_id = pc.cat_id
|
||||||
LEFT JOIN products p ON pc.product_id = p.product_id
|
LEFT JOIN products p ON pc.pid = p.pid
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id AND o.canceled = false
|
GROUP BY c.cat_id, c.status
|
||||||
GROUP BY c.id, c.status
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
category_id,
|
|
||||||
product_count,
|
|
||||||
active_products,
|
|
||||||
total_value,
|
|
||||||
COALESCE(avg_margin, 0) as avg_margin,
|
|
||||||
COALESCE(turnover_rate, 0) as turnover_rate,
|
|
||||||
-- Enhanced YoY growth rate calculation
|
|
||||||
CASE
|
|
||||||
WHEN previous_year_period_sales = 0 AND current_period_sales > 0 THEN 100.0
|
|
||||||
WHEN previous_year_period_sales = 0 THEN 0.0
|
|
||||||
ELSE LEAST(
|
|
||||||
GREATEST(
|
|
||||||
((current_period_sales - previous_year_period_sales) /
|
|
||||||
NULLIF(previous_year_period_sales, 0)) * 100.0,
|
|
||||||
-100.0
|
|
||||||
),
|
|
||||||
999.99
|
|
||||||
)
|
|
||||||
END as growth_rate,
|
|
||||||
status
|
|
||||||
FROM category_sales
|
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
product_count = VALUES(product_count),
|
product_count = VALUES(product_count),
|
||||||
active_products = VALUES(active_products),
|
active_products = VALUES(active_products),
|
||||||
total_value = VALUES(total_value),
|
total_value = VALUES(total_value),
|
||||||
avg_margin = VALUES(avg_margin),
|
|
||||||
turnover_rate = VALUES(turnover_rate),
|
|
||||||
growth_rate = VALUES(growth_rate),
|
|
||||||
status = VALUES(status),
|
status = VALUES(status),
|
||||||
last_calculated_at = CURRENT_TIMESTAMP
|
last_calculated_at = VALUES(last_calculated_at)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Calculate category time-based metrics
|
// Then update with margin and turnover data
|
||||||
|
await connection.query(`
|
||||||
|
WITH category_sales AS (
|
||||||
|
SELECT
|
||||||
|
pc.cat_id,
|
||||||
|
SUM(o.quantity * o.price) as total_sales,
|
||||||
|
SUM(o.quantity * (o.price - p.cost_price)) as total_margin,
|
||||||
|
SUM(o.quantity) as units_sold,
|
||||||
|
AVG(GREATEST(p.stock_quantity, 0)) as avg_stock
|
||||||
|
FROM product_categories pc
|
||||||
|
JOIN products p ON pc.pid = p.pid
|
||||||
|
JOIN orders o ON p.pid = o.pid
|
||||||
|
WHERE o.canceled = false
|
||||||
|
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 1 YEAR)
|
||||||
|
GROUP BY pc.cat_id
|
||||||
|
)
|
||||||
|
UPDATE category_metrics cm
|
||||||
|
JOIN category_sales cs ON cm.category_id = cs.cat_id
|
||||||
|
SET
|
||||||
|
cm.avg_margin = COALESCE(cs.total_margin * 100.0 / NULLIF(cs.total_sales, 0), 0),
|
||||||
|
cm.turnover_rate = LEAST(COALESCE(cs.units_sold / NULLIF(cs.avg_stock, 0), 0), 999.99),
|
||||||
|
cm.last_calculated_at = NOW()
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Finally update growth rates
|
||||||
|
await connection.query(`
|
||||||
|
WITH current_period AS (
|
||||||
|
SELECT
|
||||||
|
pc.cat_id,
|
||||||
|
SUM(o.quantity * o.price) as revenue
|
||||||
|
FROM product_categories pc
|
||||||
|
JOIN products p ON pc.pid = p.pid
|
||||||
|
JOIN orders o ON p.pid = o.pid
|
||||||
|
WHERE o.canceled = false
|
||||||
|
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 3 MONTH)
|
||||||
|
GROUP BY pc.cat_id
|
||||||
|
),
|
||||||
|
previous_period AS (
|
||||||
|
SELECT
|
||||||
|
pc.cat_id,
|
||||||
|
SUM(o.quantity * o.price) as revenue
|
||||||
|
FROM product_categories pc
|
||||||
|
JOIN products p ON pc.pid = p.pid
|
||||||
|
JOIN orders o ON p.pid = o.pid
|
||||||
|
WHERE o.canceled = false
|
||||||
|
AND o.date BETWEEN DATE_SUB(CURRENT_DATE, INTERVAL 15 MONTH)
|
||||||
|
AND DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
|
||||||
|
GROUP BY pc.cat_id
|
||||||
|
)
|
||||||
|
UPDATE category_metrics cm
|
||||||
|
LEFT JOIN current_period cp ON cm.category_id = cp.cat_id
|
||||||
|
LEFT JOIN previous_period pp ON cm.category_id = pp.cat_id
|
||||||
|
SET
|
||||||
|
cm.growth_rate = CASE
|
||||||
|
WHEN pp.revenue = 0 AND COALESCE(cp.revenue, 0) > 0 THEN 100.0
|
||||||
|
WHEN pp.revenue = 0 THEN 0.0
|
||||||
|
ELSE LEAST(
|
||||||
|
GREATEST(
|
||||||
|
((COALESCE(cp.revenue, 0) - pp.revenue) / pp.revenue) * 100.0,
|
||||||
|
-100.0
|
||||||
|
),
|
||||||
|
999.99
|
||||||
|
)
|
||||||
|
END,
|
||||||
|
cm.last_calculated_at = NOW()
|
||||||
|
WHERE cp.cat_id IS NOT NULL OR pp.cat_id IS NOT NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Calculate time-based metrics
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
INSERT INTO category_time_metrics (
|
INSERT INTO category_time_metrics (
|
||||||
category_id,
|
category_id,
|
||||||
@@ -120,29 +126,28 @@ async function calculateCategoryMetrics(startTime, totalProducts, processedCount
|
|||||||
turnover_rate
|
turnover_rate
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
c.id as category_id,
|
pc.cat_id,
|
||||||
YEAR(o.date) as year,
|
YEAR(o.date) as year,
|
||||||
MONTH(o.date) as month,
|
MONTH(o.date) as month,
|
||||||
COUNT(DISTINCT p.product_id) as product_count,
|
COUNT(DISTINCT p.pid) as product_count,
|
||||||
COUNT(DISTINCT CASE WHEN p.visible = true THEN p.product_id END) as active_products,
|
COUNT(DISTINCT CASE WHEN p.visible = true THEN p.pid END) as active_products,
|
||||||
SUM(p.stock_quantity * p.cost_price) as total_value,
|
SUM(p.stock_quantity * p.cost_price) as total_value,
|
||||||
SUM(o.price * o.quantity) as total_revenue,
|
SUM(o.quantity * o.price) as total_revenue,
|
||||||
CASE
|
COALESCE(
|
||||||
WHEN SUM(o.price * o.quantity) > 0
|
SUM(o.quantity * (o.price - p.cost_price)) * 100.0 /
|
||||||
THEN (SUM((o.price - p.cost_price) * o.quantity) * 100.0) / SUM(o.price * o.quantity)
|
NULLIF(SUM(o.quantity * o.price), 0),
|
||||||
ELSE 0
|
0
|
||||||
END as avg_margin,
|
) as avg_margin,
|
||||||
CASE
|
COALESCE(
|
||||||
WHEN AVG(p.stock_quantity) > 0
|
SUM(o.quantity) / NULLIF(AVG(GREATEST(p.stock_quantity, 0)), 0),
|
||||||
THEN SUM(o.quantity) / AVG(p.stock_quantity)
|
0
|
||||||
ELSE 0
|
) as turnover_rate
|
||||||
END as turnover_rate
|
FROM product_categories pc
|
||||||
FROM categories c
|
JOIN products p ON pc.pid = p.pid
|
||||||
LEFT JOIN product_categories pc ON c.id = pc.category_id
|
JOIN orders o ON p.pid = o.pid
|
||||||
LEFT JOIN products p ON pc.product_id = p.product_id
|
WHERE o.canceled = false
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id AND o.canceled = false
|
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
|
||||||
WHERE o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
|
GROUP BY pc.cat_id, YEAR(o.date), MONTH(o.date)
|
||||||
GROUP BY c.id, YEAR(o.date), MONTH(o.date)
|
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
product_count = VALUES(product_count),
|
product_count = VALUES(product_count),
|
||||||
active_products = VALUES(active_products),
|
active_products = VALUES(active_products),
|
||||||
@@ -152,7 +157,9 @@ async function calculateCategoryMetrics(startTime, totalProducts, processedCount
|
|||||||
turnover_rate = VALUES(turnover_rate)
|
turnover_rate = VALUES(turnover_rate)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Calculate category sales metrics
|
// Calculate sales metrics for different time periods
|
||||||
|
const periods = [30, 90, 180, 365];
|
||||||
|
for (const days of periods) {
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
INSERT INTO category_sales_metrics (
|
INSERT INTO category_sales_metrics (
|
||||||
category_id,
|
category_id,
|
||||||
@@ -165,60 +172,30 @@ async function calculateCategoryMetrics(startTime, totalProducts, processedCount
|
|||||||
avg_price,
|
avg_price,
|
||||||
last_calculated_at
|
last_calculated_at
|
||||||
)
|
)
|
||||||
WITH date_ranges AS (
|
|
||||||
SELECT
|
SELECT
|
||||||
DATE_SUB(CURDATE(), INTERVAL 30 DAY) as period_start,
|
pc.cat_id as category_id,
|
||||||
CURDATE() as period_end
|
COALESCE(p.brand, 'Unbranded') as brand,
|
||||||
UNION ALL
|
DATE_SUB(CURDATE(), INTERVAL ? DAY) as period_start,
|
||||||
SELECT
|
CURDATE() as period_end,
|
||||||
DATE_SUB(CURDATE(), INTERVAL 90 DAY),
|
COALESCE(SUM(o.quantity), 0) / ? as avg_daily_sales,
|
||||||
CURDATE()
|
|
||||||
UNION ALL
|
|
||||||
SELECT
|
|
||||||
DATE_SUB(CURDATE(), INTERVAL 180 DAY),
|
|
||||||
CURDATE()
|
|
||||||
UNION ALL
|
|
||||||
SELECT
|
|
||||||
DATE_SUB(CURDATE(), INTERVAL 365 DAY),
|
|
||||||
CURDATE()
|
|
||||||
),
|
|
||||||
category_metrics AS (
|
|
||||||
SELECT
|
|
||||||
c.id as category_id,
|
|
||||||
p.brand,
|
|
||||||
dr.period_start,
|
|
||||||
dr.period_end,
|
|
||||||
COUNT(DISTINCT p.product_id) as num_products,
|
|
||||||
COALESCE(SUM(o.quantity), 0) / DATEDIFF(dr.period_end, dr.period_start) as avg_daily_sales,
|
|
||||||
COALESCE(SUM(o.quantity), 0) as total_sold,
|
COALESCE(SUM(o.quantity), 0) as total_sold,
|
||||||
COALESCE(AVG(o.price), 0) as avg_price
|
COUNT(DISTINCT p.pid) as num_products,
|
||||||
FROM categories c
|
COALESCE(AVG(o.price), 0) as avg_price,
|
||||||
JOIN product_categories pc ON c.id = pc.category_id
|
|
||||||
JOIN products p ON pc.product_id = p.product_id
|
|
||||||
CROSS JOIN date_ranges dr
|
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
|
||||||
AND o.date BETWEEN dr.period_start AND dr.period_end
|
|
||||||
AND o.canceled = false
|
|
||||||
GROUP BY c.id, p.brand, dr.period_start, dr.period_end
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
category_id,
|
|
||||||
brand,
|
|
||||||
period_start,
|
|
||||||
period_end,
|
|
||||||
avg_daily_sales,
|
|
||||||
total_sold,
|
|
||||||
num_products,
|
|
||||||
avg_price,
|
|
||||||
NOW() as last_calculated_at
|
NOW() as last_calculated_at
|
||||||
FROM category_metrics
|
FROM product_categories pc
|
||||||
|
JOIN products p ON pc.pid = p.pid
|
||||||
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
|
AND o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
||||||
|
AND o.canceled = false
|
||||||
|
GROUP BY pc.cat_id, p.brand
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
avg_daily_sales = VALUES(avg_daily_sales),
|
avg_daily_sales = VALUES(avg_daily_sales),
|
||||||
total_sold = VALUES(total_sold),
|
total_sold = VALUES(total_sold),
|
||||||
num_products = VALUES(num_products),
|
num_products = VALUES(num_products),
|
||||||
avg_price = VALUES(avg_price),
|
avg_price = VALUES(avg_price),
|
||||||
last_calculated_at = NOW()
|
last_calculated_at = NOW()
|
||||||
`);
|
`, [days, days, days]);
|
||||||
|
}
|
||||||
|
|
||||||
return Math.floor(totalProducts * 0.9);
|
return Math.floor(totalProducts * 0.9);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -15,59 +15,65 @@ async function calculateFinancialMetrics(startTime, totalProducts, processedCoun
|
|||||||
percentage: '60'
|
percentage: '60'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Calculate financial metrics with optimized query
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
UPDATE product_metrics pm
|
WITH product_financials AS (
|
||||||
JOIN (
|
|
||||||
SELECT
|
SELECT
|
||||||
p.product_id,
|
p.pid,
|
||||||
p.cost_price * p.stock_quantity as inventory_value,
|
p.cost_price * p.stock_quantity as inventory_value,
|
||||||
SUM(o.quantity * o.price) as total_revenue,
|
SUM(o.quantity * o.price) as total_revenue,
|
||||||
SUM(o.quantity * p.cost_price) as cost_of_goods_sold,
|
SUM(o.quantity * p.cost_price) as cost_of_goods_sold,
|
||||||
SUM(o.quantity * (o.price - p.cost_price)) as gross_profit,
|
SUM(o.quantity * (o.price - p.cost_price)) as gross_profit,
|
||||||
MIN(o.date) as first_sale_date,
|
MIN(o.date) as first_sale_date,
|
||||||
MAX(o.date) as last_sale_date,
|
MAX(o.date) as last_sale_date,
|
||||||
DATEDIFF(MAX(o.date), MIN(o.date)) + 1 as calculation_period_days
|
DATEDIFF(MAX(o.date), MIN(o.date)) + 1 as calculation_period_days,
|
||||||
|
COUNT(DISTINCT DATE(o.date)) as active_days
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
WHERE o.canceled = false
|
WHERE o.canceled = false
|
||||||
AND DATE(o.date) >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH)
|
AND DATE(o.date) >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH)
|
||||||
GROUP BY p.product_id
|
GROUP BY p.pid
|
||||||
) fin ON pm.product_id = fin.product_id
|
)
|
||||||
|
UPDATE product_metrics pm
|
||||||
|
JOIN product_financials pf ON pm.pid = pf.pid
|
||||||
SET
|
SET
|
||||||
pm.inventory_value = COALESCE(fin.inventory_value, 0),
|
pm.inventory_value = COALESCE(pf.inventory_value, 0),
|
||||||
pm.total_revenue = COALESCE(fin.total_revenue, 0),
|
pm.total_revenue = COALESCE(pf.total_revenue, 0),
|
||||||
pm.cost_of_goods_sold = COALESCE(fin.cost_of_goods_sold, 0),
|
pm.cost_of_goods_sold = COALESCE(pf.cost_of_goods_sold, 0),
|
||||||
pm.gross_profit = COALESCE(fin.gross_profit, 0),
|
pm.gross_profit = COALESCE(pf.gross_profit, 0),
|
||||||
pm.gmroi = CASE
|
pm.gmroi = CASE
|
||||||
WHEN COALESCE(fin.inventory_value, 0) > 0 AND fin.calculation_period_days > 0 THEN
|
WHEN COALESCE(pf.inventory_value, 0) > 0 AND pf.active_days > 0 THEN
|
||||||
(COALESCE(fin.gross_profit, 0) * (365.0 / fin.calculation_period_days)) / COALESCE(fin.inventory_value, 0)
|
(COALESCE(pf.gross_profit, 0) * (365.0 / pf.active_days)) / COALESCE(pf.inventory_value, 0)
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END
|
END
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Update time-based aggregates with financial metrics
|
// Update time-based aggregates with optimized query
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
UPDATE product_time_aggregates pta
|
WITH monthly_financials AS (
|
||||||
JOIN (
|
|
||||||
SELECT
|
SELECT
|
||||||
p.product_id,
|
p.pid,
|
||||||
YEAR(o.date) as year,
|
YEAR(o.date) as year,
|
||||||
MONTH(o.date) as month,
|
MONTH(o.date) as month,
|
||||||
p.cost_price * p.stock_quantity as inventory_value,
|
p.cost_price * p.stock_quantity as inventory_value,
|
||||||
SUM(o.quantity * (o.price - p.cost_price)) as gross_profit,
|
SUM(o.quantity * (o.price - p.cost_price)) as gross_profit,
|
||||||
COUNT(DISTINCT DATE(o.date)) as days_in_period
|
COUNT(DISTINCT DATE(o.date)) as active_days,
|
||||||
|
MIN(o.date) as period_start,
|
||||||
|
MAX(o.date) as period_end
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
WHERE o.canceled = false
|
WHERE o.canceled = false
|
||||||
GROUP BY p.product_id, YEAR(o.date), MONTH(o.date)
|
GROUP BY p.pid, YEAR(o.date), MONTH(o.date)
|
||||||
) fin ON pta.product_id = fin.product_id
|
)
|
||||||
AND pta.year = fin.year
|
UPDATE product_time_aggregates pta
|
||||||
AND pta.month = fin.month
|
JOIN monthly_financials mf ON pta.pid = mf.pid
|
||||||
|
AND pta.year = mf.year
|
||||||
|
AND pta.month = mf.month
|
||||||
SET
|
SET
|
||||||
pta.inventory_value = COALESCE(fin.inventory_value, 0),
|
pta.inventory_value = COALESCE(mf.inventory_value, 0),
|
||||||
pta.gmroi = CASE
|
pta.gmroi = CASE
|
||||||
WHEN COALESCE(fin.inventory_value, 0) > 0 AND fin.days_in_period > 0 THEN
|
WHEN COALESCE(mf.inventory_value, 0) > 0 AND mf.active_days > 0 THEN
|
||||||
(COALESCE(fin.gross_profit, 0) * (365.0 / fin.days_in_period)) / COALESCE(fin.inventory_value, 0)
|
(COALESCE(mf.gross_profit, 0) * (365.0 / mf.active_days)) / COALESCE(mf.inventory_value, 0)
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END
|
END
|
||||||
`);
|
`);
|
||||||
|
|||||||
@@ -1,498 +1,244 @@
|
|||||||
const { outputProgress, logError } = require('./utils/progress');
|
const { outputProgress, logError } = require('./utils/progress');
|
||||||
const { getConnection } = require('./utils/db');
|
const { getConnection } = require('./utils/db');
|
||||||
|
|
||||||
|
// Helper function to handle NaN and undefined values
|
||||||
|
function sanitizeValue(value) {
|
||||||
|
if (value === undefined || value === null || Number.isNaN(value)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
async function calculateProductMetrics(startTime, totalProducts, processedCount = 0) {
|
async function calculateProductMetrics(startTime, totalProducts, processedCount = 0) {
|
||||||
const connection = await getConnection();
|
const connection = await getConnection();
|
||||||
try {
|
try {
|
||||||
// Process in batches of 250
|
// Skip flags are inherited from the parent scope
|
||||||
const batchSize = 250;
|
const SKIP_PRODUCT_BASE_METRICS = 0;
|
||||||
for (let offset = 0; offset < totalProducts; offset += batchSize) {
|
const SKIP_PRODUCT_TIME_AGGREGATES =0;
|
||||||
const [products] = await connection.query('SELECT product_id, vendor FROM products LIMIT ? OFFSET ?', [batchSize, offset])
|
|
||||||
.catch(err => {
|
|
||||||
logError(err, `Failed to fetch products batch at offset ${offset}`);
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
processedCount += products.length;
|
|
||||||
|
|
||||||
// Update progress after each batch
|
// Calculate base product metrics
|
||||||
|
if (!SKIP_PRODUCT_BASE_METRICS) {
|
||||||
outputProgress({
|
outputProgress({
|
||||||
status: 'running',
|
status: 'running',
|
||||||
operation: 'Processing products',
|
operation: 'Calculating base product metrics',
|
||||||
|
current: Math.floor(totalProducts * 0.2),
|
||||||
|
total: totalProducts,
|
||||||
|
elapsed: formatElapsedTime(startTime),
|
||||||
|
remaining: estimateRemaining(startTime, Math.floor(totalProducts * 0.2), totalProducts),
|
||||||
|
rate: calculateRate(startTime, Math.floor(totalProducts * 0.2)),
|
||||||
|
percentage: '20'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate base metrics
|
||||||
|
await connection.query(`
|
||||||
|
UPDATE product_metrics pm
|
||||||
|
JOIN (
|
||||||
|
SELECT
|
||||||
|
p.pid,
|
||||||
|
p.cost_price * p.stock_quantity as inventory_value,
|
||||||
|
SUM(o.quantity) as total_quantity,
|
||||||
|
COUNT(DISTINCT o.order_number) as number_of_orders,
|
||||||
|
SUM(o.quantity * o.price) as total_revenue,
|
||||||
|
SUM(o.quantity * p.cost_price) as cost_of_goods_sold,
|
||||||
|
AVG(o.price) as avg_price,
|
||||||
|
STDDEV(o.price) as price_std,
|
||||||
|
MIN(o.date) as first_sale_date,
|
||||||
|
MAX(o.date) as last_sale_date,
|
||||||
|
COUNT(DISTINCT DATE(o.date)) as active_days
|
||||||
|
FROM products p
|
||||||
|
LEFT JOIN orders o ON p.pid = o.pid AND o.canceled = false
|
||||||
|
GROUP BY p.pid
|
||||||
|
) stats ON pm.pid = stats.pid
|
||||||
|
SET
|
||||||
|
pm.inventory_value = COALESCE(stats.inventory_value, 0),
|
||||||
|
pm.avg_quantity_per_order = COALESCE(stats.total_quantity / NULLIF(stats.number_of_orders, 0), 0),
|
||||||
|
pm.number_of_orders = COALESCE(stats.number_of_orders, 0),
|
||||||
|
pm.total_revenue = COALESCE(stats.total_revenue, 0),
|
||||||
|
pm.cost_of_goods_sold = COALESCE(stats.cost_of_goods_sold, 0),
|
||||||
|
pm.gross_profit = COALESCE(stats.total_revenue - stats.cost_of_goods_sold, 0),
|
||||||
|
pm.avg_margin_percent = CASE
|
||||||
|
WHEN COALESCE(stats.total_revenue, 0) > 0
|
||||||
|
THEN ((stats.total_revenue - stats.cost_of_goods_sold) / stats.total_revenue) * 100
|
||||||
|
ELSE 0
|
||||||
|
END,
|
||||||
|
pm.first_sale_date = stats.first_sale_date,
|
||||||
|
pm.last_sale_date = stats.last_sale_date,
|
||||||
|
pm.gmroi = CASE
|
||||||
|
WHEN COALESCE(stats.inventory_value, 0) > 0
|
||||||
|
THEN (stats.total_revenue - stats.cost_of_goods_sold) / stats.inventory_value
|
||||||
|
ELSE 0
|
||||||
|
END,
|
||||||
|
pm.last_calculated_at = NOW()
|
||||||
|
`);
|
||||||
|
|
||||||
|
processedCount = Math.floor(totalProducts * 0.4);
|
||||||
|
} else {
|
||||||
|
console.log('Skipping base product metrics calculation');
|
||||||
|
processedCount = Math.floor(totalProducts * 0.4);
|
||||||
|
outputProgress({
|
||||||
|
status: 'running',
|
||||||
|
operation: 'Skipping base product metrics calculation',
|
||||||
current: processedCount,
|
current: processedCount,
|
||||||
total: totalProducts,
|
total: totalProducts,
|
||||||
elapsed: formatElapsedTime(startTime),
|
elapsed: formatElapsedTime(startTime),
|
||||||
remaining: estimateRemaining(startTime, processedCount, totalProducts),
|
remaining: estimateRemaining(startTime, processedCount, totalProducts),
|
||||||
rate: calculateRate(startTime, processedCount),
|
rate: calculateRate(startTime, processedCount),
|
||||||
percentage: ((processedCount / totalProducts) * 100).toFixed(1)
|
percentage: '40'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate product time aggregates
|
||||||
|
if (!SKIP_PRODUCT_TIME_AGGREGATES) {
|
||||||
|
outputProgress({
|
||||||
|
status: 'running',
|
||||||
|
operation: 'Calculating product time aggregates',
|
||||||
|
current: Math.floor(totalProducts * 0.4),
|
||||||
|
total: totalProducts,
|
||||||
|
elapsed: formatElapsedTime(startTime),
|
||||||
|
remaining: estimateRemaining(startTime, Math.floor(totalProducts * 0.4), totalProducts),
|
||||||
|
rate: calculateRate(startTime, Math.floor(totalProducts * 0.4)),
|
||||||
|
percentage: '40'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Process the batch
|
// Calculate time-based aggregates
|
||||||
const metricsUpdates = [];
|
await connection.query(`
|
||||||
for (const product of products) {
|
INSERT INTO product_time_aggregates (
|
||||||
try {
|
pid,
|
||||||
// Get configuration values for this product
|
year,
|
||||||
const [configs] = await connection.query(`
|
month,
|
||||||
WITH product_info AS (
|
|
||||||
SELECT
|
|
||||||
p.product_id,
|
|
||||||
p.vendor,
|
|
||||||
pc.category_id
|
|
||||||
FROM products p
|
|
||||||
LEFT JOIN product_categories pc ON p.product_id = pc.product_id
|
|
||||||
WHERE p.product_id = ?
|
|
||||||
),
|
|
||||||
threshold_options AS (
|
|
||||||
SELECT
|
|
||||||
st.*,
|
|
||||||
CASE
|
|
||||||
WHEN st.category_id = pi.category_id AND st.vendor = pi.vendor THEN 1
|
|
||||||
WHEN st.category_id = pi.category_id AND st.vendor IS NULL THEN 2
|
|
||||||
WHEN st.category_id IS NULL AND st.vendor = pi.vendor THEN 3
|
|
||||||
WHEN st.category_id IS NULL AND st.vendor IS NULL THEN 4
|
|
||||||
ELSE 5
|
|
||||||
END as priority
|
|
||||||
FROM product_info pi
|
|
||||||
CROSS JOIN stock_thresholds st
|
|
||||||
WHERE (st.category_id = pi.category_id OR st.category_id IS NULL)
|
|
||||||
AND (st.vendor = pi.vendor OR st.vendor IS NULL)
|
|
||||||
),
|
|
||||||
velocity_options AS (
|
|
||||||
SELECT
|
|
||||||
sv.*,
|
|
||||||
CASE
|
|
||||||
WHEN sv.category_id = pi.category_id AND sv.vendor = pi.vendor THEN 1
|
|
||||||
WHEN sv.category_id = pi.category_id AND sv.vendor IS NULL THEN 2
|
|
||||||
WHEN sv.category_id IS NULL AND sv.vendor = pi.vendor THEN 3
|
|
||||||
WHEN sv.category_id IS NULL AND sv.vendor IS NULL THEN 4
|
|
||||||
ELSE 5
|
|
||||||
END as priority
|
|
||||||
FROM product_info pi
|
|
||||||
CROSS JOIN sales_velocity_config sv
|
|
||||||
WHERE (sv.category_id = pi.category_id OR sv.category_id IS NULL)
|
|
||||||
AND (sv.vendor = pi.vendor OR sv.vendor IS NULL)
|
|
||||||
),
|
|
||||||
safety_options AS (
|
|
||||||
SELECT
|
|
||||||
ss.*,
|
|
||||||
CASE
|
|
||||||
WHEN ss.category_id = pi.category_id AND ss.vendor = pi.vendor THEN 1
|
|
||||||
WHEN ss.category_id = pi.category_id AND ss.vendor IS NULL THEN 2
|
|
||||||
WHEN ss.category_id IS NULL AND ss.vendor = pi.vendor THEN 3
|
|
||||||
WHEN ss.category_id IS NULL AND ss.vendor IS NULL THEN 4
|
|
||||||
ELSE 5
|
|
||||||
END as priority
|
|
||||||
FROM product_info pi
|
|
||||||
CROSS JOIN safety_stock_config ss
|
|
||||||
WHERE (ss.category_id = pi.category_id OR ss.category_id IS NULL)
|
|
||||||
AND (ss.vendor = pi.vendor OR ss.vendor IS NULL)
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
COALESCE(
|
|
||||||
(SELECT critical_days
|
|
||||||
FROM threshold_options
|
|
||||||
ORDER BY priority LIMIT 1),
|
|
||||||
7
|
|
||||||
) as critical_days,
|
|
||||||
COALESCE(
|
|
||||||
(SELECT reorder_days
|
|
||||||
FROM threshold_options
|
|
||||||
ORDER BY priority LIMIT 1),
|
|
||||||
14
|
|
||||||
) as reorder_days,
|
|
||||||
COALESCE(
|
|
||||||
(SELECT overstock_days
|
|
||||||
FROM threshold_options
|
|
||||||
ORDER BY priority LIMIT 1),
|
|
||||||
90
|
|
||||||
) as overstock_days,
|
|
||||||
COALESCE(
|
|
||||||
(SELECT low_stock_threshold
|
|
||||||
FROM threshold_options
|
|
||||||
ORDER BY priority LIMIT 1),
|
|
||||||
5
|
|
||||||
) as low_stock_threshold,
|
|
||||||
COALESCE(
|
|
||||||
(SELECT daily_window_days
|
|
||||||
FROM velocity_options
|
|
||||||
ORDER BY priority LIMIT 1),
|
|
||||||
30
|
|
||||||
) as daily_window_days,
|
|
||||||
COALESCE(
|
|
||||||
(SELECT weekly_window_days
|
|
||||||
FROM velocity_options
|
|
||||||
ORDER BY priority LIMIT 1),
|
|
||||||
7
|
|
||||||
) as weekly_window_days,
|
|
||||||
COALESCE(
|
|
||||||
(SELECT monthly_window_days
|
|
||||||
FROM velocity_options
|
|
||||||
ORDER BY priority LIMIT 1),
|
|
||||||
90
|
|
||||||
) as monthly_window_days,
|
|
||||||
COALESCE(
|
|
||||||
(SELECT coverage_days
|
|
||||||
FROM safety_options
|
|
||||||
ORDER BY priority LIMIT 1),
|
|
||||||
14
|
|
||||||
) as safety_stock_days,
|
|
||||||
COALESCE(
|
|
||||||
(SELECT service_level
|
|
||||||
FROM safety_options
|
|
||||||
ORDER BY priority LIMIT 1),
|
|
||||||
95.0
|
|
||||||
) as service_level
|
|
||||||
`, [product.product_id]);
|
|
||||||
|
|
||||||
const config = configs[0];
|
|
||||||
|
|
||||||
// Calculate sales metrics
|
|
||||||
const [salesMetrics] = await connection.query(`
|
|
||||||
WITH sales_summary AS (
|
|
||||||
SELECT
|
|
||||||
SUM(o.quantity) as total_quantity_sold,
|
|
||||||
SUM((o.price - COALESCE(o.discount, 0)) * o.quantity) as total_revenue,
|
|
||||||
SUM(COALESCE(p.cost_price, 0) * o.quantity) as total_cost,
|
|
||||||
MAX(o.date) as last_sale_date,
|
|
||||||
MIN(o.date) as first_sale_date,
|
|
||||||
COUNT(DISTINCT o.order_number) as number_of_orders,
|
|
||||||
AVG(o.quantity) as avg_quantity_per_order,
|
|
||||||
SUM(CASE WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) THEN o.quantity ELSE 0 END) as last_30_days_qty,
|
|
||||||
CASE
|
|
||||||
WHEN SUM(CASE WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) THEN o.quantity ELSE 0 END) IS NULL THEN 0
|
|
||||||
ELSE SUM(CASE WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) THEN o.quantity ELSE 0 END)
|
|
||||||
END as rolling_weekly_avg,
|
|
||||||
SUM(CASE WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) THEN o.quantity ELSE 0 END) as last_month_qty
|
|
||||||
FROM orders o
|
|
||||||
JOIN products p ON o.product_id = p.product_id
|
|
||||||
WHERE o.canceled = 0 AND o.product_id = ?
|
|
||||||
GROUP BY o.product_id
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
total_quantity_sold,
|
total_quantity_sold,
|
||||||
total_revenue,
|
total_revenue,
|
||||||
total_cost,
|
total_cost,
|
||||||
last_sale_date,
|
order_count,
|
||||||
first_sale_date,
|
avg_price,
|
||||||
number_of_orders,
|
profit_margin,
|
||||||
avg_quantity_per_order,
|
inventory_value,
|
||||||
last_30_days_qty / ? as rolling_daily_avg,
|
gmroi
|
||||||
rolling_weekly_avg / ? as rolling_weekly_avg,
|
|
||||||
last_month_qty / ? as rolling_monthly_avg
|
|
||||||
FROM sales_summary
|
|
||||||
`, [
|
|
||||||
config.daily_window_days,
|
|
||||||
config.weekly_window_days,
|
|
||||||
config.weekly_window_days,
|
|
||||||
config.monthly_window_days,
|
|
||||||
product.product_id,
|
|
||||||
config.daily_window_days,
|
|
||||||
config.weekly_window_days,
|
|
||||||
config.monthly_window_days
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Calculate purchase metrics
|
|
||||||
const [purchaseMetrics] = await connection.query(`
|
|
||||||
WITH recent_orders AS (
|
|
||||||
SELECT
|
|
||||||
date,
|
|
||||||
received_date,
|
|
||||||
received,
|
|
||||||
cost_price,
|
|
||||||
DATEDIFF(received_date, date) as lead_time_days,
|
|
||||||
ROW_NUMBER() OVER (ORDER BY date DESC) as order_rank
|
|
||||||
FROM purchase_orders
|
|
||||||
WHERE status = 'closed'
|
|
||||||
AND product_id = ?
|
|
||||||
AND received > 0
|
|
||||||
AND received_date IS NOT NULL
|
|
||||||
),
|
|
||||||
lead_time_orders AS (
|
|
||||||
SELECT *
|
|
||||||
FROM recent_orders
|
|
||||||
WHERE order_rank <= 5
|
|
||||||
OR date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)
|
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
SUM(CASE WHEN received >= 0 THEN received ELSE 0 END) as total_quantity_purchased,
|
p.pid,
|
||||||
SUM(CASE WHEN received >= 0 THEN cost_price * received ELSE 0 END) as total_cost,
|
YEAR(o.date) as year,
|
||||||
MAX(date) as last_purchase_date,
|
MONTH(o.date) as month,
|
||||||
MIN(received_date) as first_received_date,
|
SUM(o.quantity) as total_quantity_sold,
|
||||||
MAX(received_date) as last_received_date,
|
SUM(o.quantity * o.price) as total_revenue,
|
||||||
AVG(lead_time_days) as avg_lead_time_days
|
SUM(o.quantity * p.cost_price) as total_cost,
|
||||||
FROM lead_time_orders
|
COUNT(DISTINCT o.order_number) as order_count,
|
||||||
`, [product.product_id]);
|
AVG(o.price) as avg_price,
|
||||||
|
|
||||||
// Get stock info
|
|
||||||
const [stockInfo] = await connection.query(`
|
|
||||||
SELECT
|
|
||||||
p.stock_quantity,
|
|
||||||
p.cost_price,
|
|
||||||
p.created_at,
|
|
||||||
p.replenishable,
|
|
||||||
p.moq,
|
|
||||||
DATEDIFF(CURDATE(), MIN(po.received_date)) as days_since_first_stock,
|
|
||||||
DATEDIFF(CURDATE(), COALESCE(
|
|
||||||
(SELECT MAX(o2.date)
|
|
||||||
FROM orders o2
|
|
||||||
WHERE o2.product_id = p.product_id
|
|
||||||
AND o2.canceled = false),
|
|
||||||
CURDATE()
|
|
||||||
)) as days_since_last_sale,
|
|
||||||
(SELECT SUM(quantity)
|
|
||||||
FROM orders o3
|
|
||||||
WHERE o3.product_id = p.product_id
|
|
||||||
AND o3.canceled = false) as total_quantity_sold,
|
|
||||||
CASE
|
CASE
|
||||||
WHEN EXISTS (
|
WHEN SUM(o.quantity * o.price) > 0
|
||||||
SELECT 1 FROM orders o
|
THEN ((SUM(o.quantity * o.price) - SUM(o.quantity * p.cost_price)) / SUM(o.quantity * o.price)) * 100
|
||||||
WHERE o.product_id = p.product_id
|
ELSE 0
|
||||||
AND o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
END as profit_margin,
|
||||||
AND o.canceled = false
|
p.cost_price * p.stock_quantity as inventory_value,
|
||||||
AND (SELECT SUM(quantity) FROM orders o2
|
CASE
|
||||||
WHERE o2.product_id = p.product_id
|
WHEN p.cost_price * p.stock_quantity > 0
|
||||||
AND o2.date >= o.date
|
THEN (SUM(o.quantity * (o.price - p.cost_price))) / (p.cost_price * p.stock_quantity)
|
||||||
AND o2.canceled = false) = 0
|
ELSE 0
|
||||||
) THEN true
|
END as gmroi
|
||||||
ELSE false
|
|
||||||
END as had_recent_stockout
|
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN purchase_orders po ON p.product_id = po.product_id
|
LEFT JOIN orders o ON p.pid = o.pid AND o.canceled = false
|
||||||
AND po.status = 'closed'
|
WHERE o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
|
||||||
AND po.received > 0
|
GROUP BY p.pid, YEAR(o.date), MONTH(o.date)
|
||||||
WHERE p.product_id = ?
|
|
||||||
GROUP BY p.product_id
|
|
||||||
`, [product.product_id]);
|
|
||||||
|
|
||||||
// Calculate metrics
|
|
||||||
const metrics = salesMetrics[0] || {};
|
|
||||||
const purchases = purchaseMetrics[0] || {};
|
|
||||||
const stock = stockInfo[0] || {};
|
|
||||||
|
|
||||||
const daily_sales_avg = metrics.rolling_daily_avg || 0;
|
|
||||||
const weekly_sales_avg = metrics.rolling_weekly_avg || 0;
|
|
||||||
const monthly_sales_avg = metrics.total_quantity_sold ? metrics.total_quantity_sold / 30 : 0;
|
|
||||||
|
|
||||||
// Calculate days of inventory
|
|
||||||
const days_of_inventory = daily_sales_avg > 0 ?
|
|
||||||
Math.ceil(
|
|
||||||
(stock.stock_quantity / daily_sales_avg) +
|
|
||||||
(purchases.avg_lead_time_days || config.reorder_days) *
|
|
||||||
(1 + (config.service_level / 100))
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
const weeks_of_inventory = days_of_inventory ? Math.ceil(days_of_inventory / 7) : null;
|
|
||||||
|
|
||||||
// Calculate margin percent
|
|
||||||
const margin_percent = metrics.total_revenue > 0 ?
|
|
||||||
((metrics.total_revenue - metrics.total_cost) / metrics.total_revenue) * 100 :
|
|
||||||
null;
|
|
||||||
|
|
||||||
// Calculate inventory value
|
|
||||||
const inventory_value = (stock.stock_quantity || 0) * (stock.cost_price || 0);
|
|
||||||
|
|
||||||
// Calculate stock status
|
|
||||||
const stock_status = calculateStockStatus(stock, config, daily_sales_avg, weekly_sales_avg, monthly_sales_avg);
|
|
||||||
|
|
||||||
// Calculate reorder quantity and overstocked amount
|
|
||||||
const { reorder_qty, overstocked_amt } = calculateReorderQuantities(
|
|
||||||
stock,
|
|
||||||
stock_status,
|
|
||||||
daily_sales_avg,
|
|
||||||
purchases.avg_lead_time_days,
|
|
||||||
config
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add to batch update
|
|
||||||
metricsUpdates.push([
|
|
||||||
product.product_id,
|
|
||||||
daily_sales_avg || null,
|
|
||||||
weekly_sales_avg || null,
|
|
||||||
monthly_sales_avg || null,
|
|
||||||
metrics.avg_quantity_per_order || null,
|
|
||||||
metrics.number_of_orders || 0,
|
|
||||||
metrics.first_sale_date || null,
|
|
||||||
metrics.last_sale_date || null,
|
|
||||||
days_of_inventory,
|
|
||||||
weeks_of_inventory,
|
|
||||||
daily_sales_avg > 0 ? Math.max(1, Math.ceil(daily_sales_avg * config.reorder_days)) : null,
|
|
||||||
margin_percent,
|
|
||||||
metrics.total_revenue || 0,
|
|
||||||
inventory_value || 0,
|
|
||||||
purchases.avg_lead_time_days || null,
|
|
||||||
purchases.last_purchase_date || null,
|
|
||||||
purchases.first_received_date || null,
|
|
||||||
purchases.last_received_date || null,
|
|
||||||
stock_status,
|
|
||||||
reorder_qty,
|
|
||||||
overstocked_amt
|
|
||||||
]);
|
|
||||||
} catch (err) {
|
|
||||||
logError(err, `Failed processing product ${product.product_id}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Batch update metrics
|
|
||||||
if (metricsUpdates.length > 0) {
|
|
||||||
await connection.query(`
|
|
||||||
INSERT INTO product_metrics (
|
|
||||||
product_id,
|
|
||||||
daily_sales_avg,
|
|
||||||
weekly_sales_avg,
|
|
||||||
monthly_sales_avg,
|
|
||||||
avg_quantity_per_order,
|
|
||||||
number_of_orders,
|
|
||||||
first_sale_date,
|
|
||||||
last_sale_date,
|
|
||||||
days_of_inventory,
|
|
||||||
weeks_of_inventory,
|
|
||||||
reorder_point,
|
|
||||||
avg_margin_percent,
|
|
||||||
total_revenue,
|
|
||||||
inventory_value,
|
|
||||||
avg_lead_time_days,
|
|
||||||
last_purchase_date,
|
|
||||||
first_received_date,
|
|
||||||
last_received_date,
|
|
||||||
stock_status,
|
|
||||||
reorder_qty,
|
|
||||||
overstocked_amt
|
|
||||||
) VALUES ?
|
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
last_calculated_at = NOW(),
|
total_quantity_sold = VALUES(total_quantity_sold),
|
||||||
daily_sales_avg = VALUES(daily_sales_avg),
|
|
||||||
weekly_sales_avg = VALUES(weekly_sales_avg),
|
|
||||||
monthly_sales_avg = VALUES(monthly_sales_avg),
|
|
||||||
avg_quantity_per_order = VALUES(avg_quantity_per_order),
|
|
||||||
number_of_orders = VALUES(number_of_orders),
|
|
||||||
first_sale_date = VALUES(first_sale_date),
|
|
||||||
last_sale_date = VALUES(last_sale_date),
|
|
||||||
days_of_inventory = VALUES(days_of_inventory),
|
|
||||||
weeks_of_inventory = VALUES(weeks_of_inventory),
|
|
||||||
reorder_point = VALUES(reorder_point),
|
|
||||||
avg_margin_percent = VALUES(avg_margin_percent),
|
|
||||||
total_revenue = VALUES(total_revenue),
|
total_revenue = VALUES(total_revenue),
|
||||||
|
total_cost = VALUES(total_cost),
|
||||||
|
order_count = VALUES(order_count),
|
||||||
|
avg_price = VALUES(avg_price),
|
||||||
|
profit_margin = VALUES(profit_margin),
|
||||||
inventory_value = VALUES(inventory_value),
|
inventory_value = VALUES(inventory_value),
|
||||||
avg_lead_time_days = VALUES(avg_lead_time_days),
|
gmroi = VALUES(gmroi)
|
||||||
last_purchase_date = VALUES(last_purchase_date),
|
`);
|
||||||
first_received_date = VALUES(first_received_date),
|
|
||||||
last_received_date = VALUES(last_received_date),
|
processedCount = Math.floor(totalProducts * 0.6);
|
||||||
stock_status = VALUES(stock_status),
|
} else {
|
||||||
reorder_qty = VALUES(reorder_qty),
|
console.log('Skipping product time aggregates calculation');
|
||||||
overstocked_amt = VALUES(overstocked_amt)
|
processedCount = Math.floor(totalProducts * 0.6);
|
||||||
`, [metricsUpdates]);
|
outputProgress({
|
||||||
}
|
status: 'running',
|
||||||
|
operation: 'Skipping product time aggregates calculation',
|
||||||
|
current: processedCount,
|
||||||
|
total: totalProducts,
|
||||||
|
elapsed: formatElapsedTime(startTime),
|
||||||
|
remaining: estimateRemaining(startTime, processedCount, totalProducts),
|
||||||
|
rate: calculateRate(startTime, processedCount),
|
||||||
|
percentage: '60'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return processedCount;
|
return processedCount;
|
||||||
} finally {
|
} finally {
|
||||||
|
if (connection) {
|
||||||
connection.release();
|
connection.release();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateStockStatus(stock, config, daily_sales_avg, weekly_sales_avg, monthly_sales_avg) {
|
function calculateStockStatus(stock, config, daily_sales_avg, weekly_sales_avg, monthly_sales_avg) {
|
||||||
const days_since_first_stock = stock.days_since_first_stock || 0;
|
if (stock <= 0) {
|
||||||
const days_since_last_sale = stock.days_since_last_sale || 9999;
|
return 'Out of Stock';
|
||||||
const total_quantity_sold = stock.total_quantity_sold || 0;
|
|
||||||
const had_recent_stockout = stock.had_recent_stockout || false;
|
|
||||||
const dq = stock.stock_quantity || 0;
|
|
||||||
const ds = daily_sales_avg || 0;
|
|
||||||
const ws = weekly_sales_avg || 0;
|
|
||||||
const ms = monthly_sales_avg || 0;
|
|
||||||
|
|
||||||
// If no stock, return immediately
|
|
||||||
if (dq === 0) {
|
|
||||||
return had_recent_stockout ? 'Critical' : 'Out of Stock';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Check if truly "New" (≤30 days and no sales)
|
// Use the most appropriate sales average based on data quality
|
||||||
if (days_since_first_stock <= 30 && total_quantity_sold === 0) {
|
let sales_avg = daily_sales_avg;
|
||||||
return 'New';
|
if (sales_avg === 0) {
|
||||||
|
sales_avg = weekly_sales_avg / 7;
|
||||||
|
}
|
||||||
|
if (sales_avg === 0) {
|
||||||
|
sales_avg = monthly_sales_avg / 30;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Handle zero or very low sales velocity cases
|
if (sales_avg === 0) {
|
||||||
if (ds === 0 || (ds < 0.1 && ws < 0.5)) {
|
return stock <= config.low_stock_threshold ? 'Low Stock' : 'In Stock';
|
||||||
if (days_since_first_stock > config.overstock_days) {
|
|
||||||
return 'Overstocked';
|
|
||||||
}
|
|
||||||
if (days_since_first_stock > 30) {
|
|
||||||
return 'At Risk';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Calculate days of supply and check velocity trends
|
const days_of_stock = stock / sales_avg;
|
||||||
const days_of_supply = ds > 0 ? dq / ds : 999;
|
|
||||||
const velocity_trend = ds > 0 ? (ds / (ms || ds) - 1) * 100 : 0;
|
|
||||||
|
|
||||||
// Critical stock level
|
if (days_of_stock <= config.critical_days) {
|
||||||
if (days_of_supply <= config.critical_days) {
|
|
||||||
return 'Critical';
|
return 'Critical';
|
||||||
}
|
} else if (days_of_stock <= config.reorder_days) {
|
||||||
|
|
||||||
// Reorder cases
|
|
||||||
if (days_of_supply <= config.reorder_days ||
|
|
||||||
(had_recent_stockout && days_of_supply <= config.reorder_days * 1.5)) {
|
|
||||||
return 'Reorder';
|
return 'Reorder';
|
||||||
}
|
} else if (days_of_stock > config.overstock_days) {
|
||||||
|
|
||||||
// At Risk cases
|
|
||||||
if (
|
|
||||||
(days_of_supply >= config.overstock_days * 0.8) ||
|
|
||||||
(velocity_trend <= -50 && days_of_supply > config.reorder_days * 2) ||
|
|
||||||
(days_since_last_sale > 45 && dq > 0) ||
|
|
||||||
(ds > 0 && ds < 0.2 && dq > ds * config.overstock_days * 0.5)
|
|
||||||
) {
|
|
||||||
return 'At Risk';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Overstock cases
|
|
||||||
if (days_of_supply >= config.overstock_days) {
|
|
||||||
return 'Overstocked';
|
return 'Overstocked';
|
||||||
}
|
}
|
||||||
|
|
||||||
// If none of the above conditions are met
|
|
||||||
return 'Healthy';
|
return 'Healthy';
|
||||||
}
|
}
|
||||||
|
|
||||||
function calculateReorderQuantities(stock, stock_status, daily_sales_avg, avg_lead_time, config) {
|
function calculateReorderQuantities(stock, stock_status, daily_sales_avg, avg_lead_time, config) {
|
||||||
|
// Calculate safety stock based on service level and lead time
|
||||||
|
const z_score = 1.96; // 95% service level
|
||||||
|
const lead_time = avg_lead_time || config.target_days;
|
||||||
|
const safety_stock = Math.ceil(daily_sales_avg * Math.sqrt(lead_time) * z_score);
|
||||||
|
|
||||||
|
// Calculate reorder point
|
||||||
|
const lead_time_demand = daily_sales_avg * lead_time;
|
||||||
|
const reorder_point = Math.ceil(lead_time_demand + safety_stock);
|
||||||
|
|
||||||
|
// Calculate reorder quantity using EOQ formula if we have the necessary data
|
||||||
let reorder_qty = 0;
|
let reorder_qty = 0;
|
||||||
let overstocked_amt = 0;
|
if (daily_sales_avg > 0) {
|
||||||
|
const annual_demand = daily_sales_avg * 365;
|
||||||
|
const order_cost = 25; // Fixed cost per order
|
||||||
|
const holding_cost_percent = 0.25; // 25% annual holding cost
|
||||||
|
|
||||||
// Only calculate reorder quantity for replenishable products
|
reorder_qty = Math.ceil(Math.sqrt((2 * annual_demand * order_cost) / holding_cost_percent));
|
||||||
if (stock.replenishable && (stock_status === 'Critical' || stock_status === 'Reorder')) {
|
} else {
|
||||||
const ds = daily_sales_avg || 0;
|
// If no sales data, use a basic calculation
|
||||||
const lt = avg_lead_time || 14;
|
reorder_qty = Math.max(safety_stock, config.low_stock_threshold);
|
||||||
const sc = config.safety_stock_days || 14;
|
|
||||||
const ss = config.safety_stock_days || 14;
|
|
||||||
const dq = stock.stock_quantity || 0;
|
|
||||||
const moq = stock.moq || 1;
|
|
||||||
|
|
||||||
// Calculate desired stock level
|
|
||||||
const desired_stock = (ds * (lt + sc)) + ss;
|
|
||||||
|
|
||||||
// Calculate raw reorder amount
|
|
||||||
const raw_reorder = Math.max(0, desired_stock - dq);
|
|
||||||
|
|
||||||
// Round up to nearest MOQ
|
|
||||||
reorder_qty = Math.ceil(raw_reorder / moq) * moq;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate overstocked amount for overstocked products
|
// Calculate overstocked amount
|
||||||
if (stock_status === 'Overstocked') {
|
const overstocked_amt = stock_status === 'Overstocked' ?
|
||||||
const ds = daily_sales_avg || 0;
|
stock - Math.ceil(daily_sales_avg * config.overstock_days) :
|
||||||
const dq = stock.stock_quantity || 0;
|
0;
|
||||||
const lt = avg_lead_time || 14;
|
|
||||||
const sc = config.safety_stock_days || 14;
|
|
||||||
const ss = config.safety_stock_days || 14;
|
|
||||||
|
|
||||||
// Calculate maximum desired stock
|
return {
|
||||||
const max_desired_stock = (ds * config.overstock_days) + ss;
|
safety_stock,
|
||||||
|
reorder_point,
|
||||||
// Calculate excess inventory
|
reorder_qty,
|
||||||
overstocked_amt = Math.max(0, dq - max_desired_stock);
|
overstocked_amt
|
||||||
}
|
};
|
||||||
|
|
||||||
return { reorder_qty, overstocked_amt };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = calculateProductMetrics;
|
module.exports = calculateProductMetrics;
|
||||||
@@ -15,102 +15,100 @@ async function calculateSalesForecasts(startTime, totalProducts, processedCount)
|
|||||||
percentage: '98'
|
percentage: '98'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// First, create a temporary table for forecast dates
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TEMPORARY TABLE IF NOT EXISTS temp_forecast_dates (
|
||||||
|
forecast_date DATE,
|
||||||
|
day_of_week INT,
|
||||||
|
month INT,
|
||||||
|
PRIMARY KEY (forecast_date)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await connection.query(`
|
||||||
|
INSERT INTO temp_forecast_dates
|
||||||
|
SELECT
|
||||||
|
DATE_ADD(CURRENT_DATE, INTERVAL n DAY) as forecast_date,
|
||||||
|
DAYOFWEEK(DATE_ADD(CURRENT_DATE, INTERVAL n DAY)) as day_of_week,
|
||||||
|
MONTH(DATE_ADD(CURRENT_DATE, INTERVAL n DAY)) as month
|
||||||
|
FROM (
|
||||||
|
SELECT a.N + b.N * 10 as n
|
||||||
|
FROM
|
||||||
|
(SELECT 0 as N UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION
|
||||||
|
SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9) a,
|
||||||
|
(SELECT 0 as N UNION SELECT 1 UNION SELECT 2) b
|
||||||
|
ORDER BY n
|
||||||
|
LIMIT 31
|
||||||
|
) numbers
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create temporary table for daily sales stats
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TEMPORARY TABLE IF NOT EXISTS temp_daily_sales AS
|
||||||
|
SELECT
|
||||||
|
o.pid,
|
||||||
|
DAYOFWEEK(o.date) as day_of_week,
|
||||||
|
SUM(o.quantity) as daily_quantity,
|
||||||
|
SUM(o.price * o.quantity) as daily_revenue,
|
||||||
|
COUNT(DISTINCT DATE(o.date)) as day_count
|
||||||
|
FROM orders o
|
||||||
|
WHERE o.canceled = false
|
||||||
|
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 90 DAY)
|
||||||
|
GROUP BY o.pid, DAYOFWEEK(o.date)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create temporary table for product stats
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TEMPORARY TABLE IF NOT EXISTS temp_product_stats AS
|
||||||
|
SELECT
|
||||||
|
pid,
|
||||||
|
AVG(daily_revenue) as overall_avg_revenue,
|
||||||
|
SUM(day_count) as total_days
|
||||||
|
FROM temp_daily_sales
|
||||||
|
GROUP BY pid
|
||||||
|
`);
|
||||||
|
|
||||||
// Calculate product-level forecasts
|
// Calculate product-level forecasts
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
INSERT INTO sales_forecasts (
|
INSERT INTO sales_forecasts (
|
||||||
product_id,
|
pid,
|
||||||
forecast_date,
|
forecast_date,
|
||||||
forecast_units,
|
forecast_units,
|
||||||
forecast_revenue,
|
forecast_revenue,
|
||||||
confidence_level,
|
confidence_level,
|
||||||
last_calculated_at
|
last_calculated_at
|
||||||
)
|
)
|
||||||
WITH daily_sales AS (
|
|
||||||
SELECT
|
SELECT
|
||||||
o.product_id,
|
ds.pid,
|
||||||
DATE(o.date) as sale_date,
|
|
||||||
SUM(o.quantity) as daily_quantity,
|
|
||||||
SUM(o.price * o.quantity) as daily_revenue
|
|
||||||
FROM orders o
|
|
||||||
WHERE o.canceled = false
|
|
||||||
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 90 DAY)
|
|
||||||
GROUP BY o.product_id, DATE(o.date)
|
|
||||||
),
|
|
||||||
forecast_dates AS (
|
|
||||||
SELECT
|
|
||||||
DATE_ADD(CURRENT_DATE, INTERVAL n DAY) as forecast_date
|
|
||||||
FROM (
|
|
||||||
SELECT 0 as n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION
|
|
||||||
SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 UNION
|
|
||||||
SELECT 10 UNION SELECT 11 UNION SELECT 12 UNION SELECT 13 UNION SELECT 14 UNION
|
|
||||||
SELECT 15 UNION SELECT 16 UNION SELECT 17 UNION SELECT 18 UNION SELECT 19 UNION
|
|
||||||
SELECT 20 UNION SELECT 21 UNION SELECT 22 UNION SELECT 23 UNION SELECT 24 UNION
|
|
||||||
SELECT 25 UNION SELECT 26 UNION SELECT 27 UNION SELECT 28 UNION SELECT 29 UNION
|
|
||||||
SELECT 30
|
|
||||||
) numbers
|
|
||||||
),
|
|
||||||
product_stats AS (
|
|
||||||
SELECT
|
|
||||||
ds.product_id,
|
|
||||||
AVG(ds.daily_quantity) as avg_daily_quantity,
|
|
||||||
STDDEV_SAMP(ds.daily_quantity) as std_daily_quantity,
|
|
||||||
AVG(ds.daily_revenue) as avg_daily_revenue,
|
|
||||||
STDDEV_SAMP(ds.daily_revenue) as std_daily_revenue,
|
|
||||||
COUNT(*) as data_points,
|
|
||||||
-- Calculate day-of-week averages
|
|
||||||
AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 1 THEN ds.daily_revenue END) as sunday_avg,
|
|
||||||
AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 2 THEN ds.daily_revenue END) as monday_avg,
|
|
||||||
AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 3 THEN ds.daily_revenue END) as tuesday_avg,
|
|
||||||
AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 4 THEN ds.daily_revenue END) as wednesday_avg,
|
|
||||||
AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 5 THEN ds.daily_revenue END) as thursday_avg,
|
|
||||||
AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 6 THEN ds.daily_revenue END) as friday_avg,
|
|
||||||
AVG(CASE WHEN DAYOFWEEK(ds.sale_date) = 7 THEN ds.daily_revenue END) as saturday_avg
|
|
||||||
FROM daily_sales ds
|
|
||||||
GROUP BY ds.product_id
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
ps.product_id,
|
|
||||||
fd.forecast_date,
|
fd.forecast_date,
|
||||||
GREATEST(0,
|
GREATEST(0,
|
||||||
ps.avg_daily_quantity *
|
AVG(ds.daily_quantity) *
|
||||||
(1 + COALESCE(
|
(1 + COALESCE(sf.seasonality_factor, 0))
|
||||||
(SELECT seasonality_factor
|
|
||||||
FROM sales_seasonality
|
|
||||||
WHERE MONTH(fd.forecast_date) = month
|
|
||||||
LIMIT 1),
|
|
||||||
0
|
|
||||||
))
|
|
||||||
) as forecast_units,
|
) as forecast_units,
|
||||||
GREATEST(0,
|
GREATEST(0,
|
||||||
CASE DAYOFWEEK(fd.forecast_date)
|
COALESCE(
|
||||||
WHEN 1 THEN COALESCE(ps.sunday_avg, ps.avg_daily_revenue)
|
CASE
|
||||||
WHEN 2 THEN COALESCE(ps.monday_avg, ps.avg_daily_revenue)
|
WHEN SUM(ds.day_count) >= 4 THEN AVG(ds.daily_revenue)
|
||||||
WHEN 3 THEN COALESCE(ps.tuesday_avg, ps.avg_daily_revenue)
|
ELSE ps.overall_avg_revenue
|
||||||
WHEN 4 THEN COALESCE(ps.wednesday_avg, ps.avg_daily_revenue)
|
|
||||||
WHEN 5 THEN COALESCE(ps.thursday_avg, ps.avg_daily_revenue)
|
|
||||||
WHEN 6 THEN COALESCE(ps.friday_avg, ps.avg_daily_revenue)
|
|
||||||
WHEN 7 THEN COALESCE(ps.saturday_avg, ps.avg_daily_revenue)
|
|
||||||
END *
|
END *
|
||||||
(1 + COALESCE(
|
(1 + COALESCE(sf.seasonality_factor, 0)) *
|
||||||
(SELECT seasonality_factor
|
(0.95 + (RAND() * 0.1)),
|
||||||
FROM sales_seasonality
|
|
||||||
WHERE MONTH(fd.forecast_date) = month
|
|
||||||
LIMIT 1),
|
|
||||||
0
|
0
|
||||||
)) *
|
)
|
||||||
-- Add some randomness within a small range (±5%)
|
|
||||||
(0.95 + (RAND() * 0.1))
|
|
||||||
) as forecast_revenue,
|
) as forecast_revenue,
|
||||||
CASE
|
CASE
|
||||||
WHEN ps.data_points >= 60 THEN 90
|
WHEN ps.total_days >= 60 THEN 90
|
||||||
WHEN ps.data_points >= 30 THEN 80
|
WHEN ps.total_days >= 30 THEN 80
|
||||||
WHEN ps.data_points >= 14 THEN 70
|
WHEN ps.total_days >= 14 THEN 70
|
||||||
ELSE 60
|
ELSE 60
|
||||||
END as confidence_level,
|
END as confidence_level,
|
||||||
NOW() as last_calculated_at
|
NOW() as last_calculated_at
|
||||||
FROM product_stats ps
|
FROM temp_daily_sales ds
|
||||||
CROSS JOIN forecast_dates fd
|
JOIN temp_product_stats ps ON ds.pid = ps.pid
|
||||||
WHERE ps.avg_daily_quantity > 0
|
CROSS JOIN temp_forecast_dates fd
|
||||||
|
LEFT JOIN sales_seasonality sf ON fd.month = sf.month
|
||||||
|
GROUP BY ds.pid, fd.forecast_date, ps.overall_avg_revenue, ps.total_days, sf.seasonality_factor
|
||||||
|
HAVING AVG(ds.daily_quantity) > 0
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
forecast_units = VALUES(forecast_units),
|
forecast_units = VALUES(forecast_units),
|
||||||
forecast_revenue = VALUES(forecast_revenue),
|
forecast_revenue = VALUES(forecast_revenue),
|
||||||
@@ -118,6 +116,32 @@ async function calculateSalesForecasts(startTime, totalProducts, processedCount)
|
|||||||
last_calculated_at = NOW()
|
last_calculated_at = NOW()
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Create temporary table for category stats
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TEMPORARY TABLE IF NOT EXISTS temp_category_sales AS
|
||||||
|
SELECT
|
||||||
|
pc.cat_id,
|
||||||
|
DAYOFWEEK(o.date) as day_of_week,
|
||||||
|
SUM(o.quantity) as daily_quantity,
|
||||||
|
SUM(o.price * o.quantity) as daily_revenue,
|
||||||
|
COUNT(DISTINCT DATE(o.date)) as day_count
|
||||||
|
FROM orders o
|
||||||
|
JOIN product_categories pc ON o.pid = pc.pid
|
||||||
|
WHERE o.canceled = false
|
||||||
|
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 90 DAY)
|
||||||
|
GROUP BY pc.cat_id, DAYOFWEEK(o.date)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TEMPORARY TABLE IF NOT EXISTS temp_category_stats AS
|
||||||
|
SELECT
|
||||||
|
cat_id,
|
||||||
|
AVG(daily_revenue) as overall_avg_revenue,
|
||||||
|
SUM(day_count) as total_days
|
||||||
|
FROM temp_category_sales
|
||||||
|
GROUP BY cat_id
|
||||||
|
`);
|
||||||
|
|
||||||
// Calculate category-level forecasts
|
// Calculate category-level forecasts
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
INSERT INTO category_forecasts (
|
INSERT INTO category_forecasts (
|
||||||
@@ -128,93 +152,37 @@ async function calculateSalesForecasts(startTime, totalProducts, processedCount)
|
|||||||
confidence_level,
|
confidence_level,
|
||||||
last_calculated_at
|
last_calculated_at
|
||||||
)
|
)
|
||||||
WITH category_daily_sales AS (
|
|
||||||
SELECT
|
SELECT
|
||||||
pc.category_id,
|
cs.cat_id as category_id,
|
||||||
DATE(o.date) as sale_date,
|
|
||||||
SUM(o.quantity) as daily_quantity,
|
|
||||||
SUM(o.price * o.quantity) as daily_revenue
|
|
||||||
FROM orders o
|
|
||||||
JOIN product_categories pc ON o.product_id = pc.product_id
|
|
||||||
WHERE o.canceled = false
|
|
||||||
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 90 DAY)
|
|
||||||
GROUP BY pc.category_id, DATE(o.date)
|
|
||||||
),
|
|
||||||
forecast_dates AS (
|
|
||||||
SELECT
|
|
||||||
DATE_ADD(CURRENT_DATE, INTERVAL n DAY) as forecast_date
|
|
||||||
FROM (
|
|
||||||
SELECT 0 as n UNION SELECT 1 UNION SELECT 2 UNION SELECT 3 UNION SELECT 4 UNION
|
|
||||||
SELECT 5 UNION SELECT 6 UNION SELECT 7 UNION SELECT 8 UNION SELECT 9 UNION
|
|
||||||
SELECT 10 UNION SELECT 11 UNION SELECT 12 UNION SELECT 13 UNION SELECT 14 UNION
|
|
||||||
SELECT 15 UNION SELECT 16 UNION SELECT 17 UNION SELECT 18 UNION SELECT 19 UNION
|
|
||||||
SELECT 20 UNION SELECT 21 UNION SELECT 22 UNION SELECT 23 UNION SELECT 24 UNION
|
|
||||||
SELECT 25 UNION SELECT 26 UNION SELECT 27 UNION SELECT 28 UNION SELECT 29 UNION
|
|
||||||
SELECT 30
|
|
||||||
) numbers
|
|
||||||
),
|
|
||||||
category_stats AS (
|
|
||||||
SELECT
|
|
||||||
cds.category_id,
|
|
||||||
AVG(cds.daily_quantity) as avg_daily_quantity,
|
|
||||||
STDDEV_SAMP(cds.daily_quantity) as std_daily_quantity,
|
|
||||||
AVG(cds.daily_revenue) as avg_daily_revenue,
|
|
||||||
STDDEV_SAMP(cds.daily_revenue) as std_daily_revenue,
|
|
||||||
COUNT(*) as data_points,
|
|
||||||
-- Calculate day-of-week averages
|
|
||||||
AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 1 THEN cds.daily_revenue END) as sunday_avg,
|
|
||||||
AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 2 THEN cds.daily_revenue END) as monday_avg,
|
|
||||||
AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 3 THEN cds.daily_revenue END) as tuesday_avg,
|
|
||||||
AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 4 THEN cds.daily_revenue END) as wednesday_avg,
|
|
||||||
AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 5 THEN cds.daily_revenue END) as thursday_avg,
|
|
||||||
AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 6 THEN cds.daily_revenue END) as friday_avg,
|
|
||||||
AVG(CASE WHEN DAYOFWEEK(cds.sale_date) = 7 THEN cds.daily_revenue END) as saturday_avg
|
|
||||||
FROM category_daily_sales cds
|
|
||||||
GROUP BY cds.category_id
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
cs.category_id,
|
|
||||||
fd.forecast_date,
|
fd.forecast_date,
|
||||||
GREATEST(0,
|
GREATEST(0,
|
||||||
cs.avg_daily_quantity *
|
AVG(cs.daily_quantity) *
|
||||||
(1 + COALESCE(
|
(1 + COALESCE(sf.seasonality_factor, 0))
|
||||||
(SELECT seasonality_factor
|
|
||||||
FROM sales_seasonality
|
|
||||||
WHERE MONTH(fd.forecast_date) = month
|
|
||||||
LIMIT 1),
|
|
||||||
0
|
|
||||||
))
|
|
||||||
) as forecast_units,
|
) as forecast_units,
|
||||||
GREATEST(0,
|
GREATEST(0,
|
||||||
CASE DAYOFWEEK(fd.forecast_date)
|
COALESCE(
|
||||||
WHEN 1 THEN COALESCE(cs.sunday_avg, cs.avg_daily_revenue)
|
CASE
|
||||||
WHEN 2 THEN COALESCE(cs.monday_avg, cs.avg_daily_revenue)
|
WHEN SUM(cs.day_count) >= 4 THEN AVG(cs.daily_revenue)
|
||||||
WHEN 3 THEN COALESCE(cs.tuesday_avg, cs.avg_daily_revenue)
|
ELSE ct.overall_avg_revenue
|
||||||
WHEN 4 THEN COALESCE(cs.wednesday_avg, cs.avg_daily_revenue)
|
|
||||||
WHEN 5 THEN COALESCE(cs.thursday_avg, cs.avg_daily_revenue)
|
|
||||||
WHEN 6 THEN COALESCE(cs.friday_avg, cs.avg_daily_revenue)
|
|
||||||
WHEN 7 THEN COALESCE(cs.saturday_avg, cs.avg_daily_revenue)
|
|
||||||
END *
|
END *
|
||||||
(1 + COALESCE(
|
(1 + COALESCE(sf.seasonality_factor, 0)) *
|
||||||
(SELECT seasonality_factor
|
(0.95 + (RAND() * 0.1)),
|
||||||
FROM sales_seasonality
|
|
||||||
WHERE MONTH(fd.forecast_date) = month
|
|
||||||
LIMIT 1),
|
|
||||||
0
|
0
|
||||||
)) *
|
)
|
||||||
-- Add some randomness within a small range (±5%)
|
|
||||||
(0.95 + (RAND() * 0.1))
|
|
||||||
) as forecast_revenue,
|
) as forecast_revenue,
|
||||||
CASE
|
CASE
|
||||||
WHEN cs.data_points >= 60 THEN 90
|
WHEN ct.total_days >= 60 THEN 90
|
||||||
WHEN cs.data_points >= 30 THEN 80
|
WHEN ct.total_days >= 30 THEN 80
|
||||||
WHEN cs.data_points >= 14 THEN 70
|
WHEN ct.total_days >= 14 THEN 70
|
||||||
ELSE 60
|
ELSE 60
|
||||||
END as confidence_level,
|
END as confidence_level,
|
||||||
NOW() as last_calculated_at
|
NOW() as last_calculated_at
|
||||||
FROM category_stats cs
|
FROM temp_category_sales cs
|
||||||
CROSS JOIN forecast_dates fd
|
JOIN temp_category_stats ct ON cs.cat_id = ct.cat_id
|
||||||
WHERE cs.avg_daily_quantity > 0
|
CROSS JOIN temp_forecast_dates fd
|
||||||
|
LEFT JOIN sales_seasonality sf ON fd.month = sf.month
|
||||||
|
GROUP BY cs.cat_id, fd.forecast_date, ct.overall_avg_revenue, ct.total_days, sf.seasonality_factor
|
||||||
|
HAVING AVG(cs.daily_quantity) > 0
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
forecast_units = VALUES(forecast_units),
|
forecast_units = VALUES(forecast_units),
|
||||||
forecast_revenue = VALUES(forecast_revenue),
|
forecast_revenue = VALUES(forecast_revenue),
|
||||||
@@ -222,6 +190,15 @@ async function calculateSalesForecasts(startTime, totalProducts, processedCount)
|
|||||||
last_calculated_at = NOW()
|
last_calculated_at = NOW()
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Clean up temporary tables
|
||||||
|
await connection.query(`
|
||||||
|
DROP TEMPORARY TABLE IF EXISTS temp_forecast_dates;
|
||||||
|
DROP TEMPORARY TABLE IF EXISTS temp_daily_sales;
|
||||||
|
DROP TEMPORARY TABLE IF EXISTS temp_product_stats;
|
||||||
|
DROP TEMPORARY TABLE IF EXISTS temp_category_sales;
|
||||||
|
DROP TEMPORARY TABLE IF EXISTS temp_category_stats;
|
||||||
|
`);
|
||||||
|
|
||||||
return Math.floor(totalProducts * 1.0);
|
return Math.floor(totalProducts * 1.0);
|
||||||
} finally {
|
} finally {
|
||||||
connection.release();
|
connection.release();
|
||||||
|
|||||||
@@ -1,12 +1,24 @@
|
|||||||
|
const { outputProgress, formatElapsedTime, estimateRemaining, calculateRate } = require('./utils/progress');
|
||||||
const { getConnection } = require('./utils/db');
|
const { getConnection } = require('./utils/db');
|
||||||
|
|
||||||
async function calculateTimeAggregates(startTime, totalProducts, processedCount) {
|
async function calculateTimeAggregates(startTime, totalProducts, processedCount) {
|
||||||
const connection = await getConnection();
|
const connection = await getConnection();
|
||||||
try {
|
try {
|
||||||
|
outputProgress({
|
||||||
|
status: 'running',
|
||||||
|
operation: 'Calculating time aggregates',
|
||||||
|
current: Math.floor(totalProducts * 0.95),
|
||||||
|
total: totalProducts,
|
||||||
|
elapsed: formatElapsedTime(startTime),
|
||||||
|
remaining: estimateRemaining(startTime, Math.floor(totalProducts * 0.95), totalProducts),
|
||||||
|
rate: calculateRate(startTime, Math.floor(totalProducts * 0.95)),
|
||||||
|
percentage: '95'
|
||||||
|
});
|
||||||
|
|
||||||
// Initial insert of time-based aggregates
|
// Initial insert of time-based aggregates
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
INSERT INTO product_time_aggregates (
|
INSERT INTO product_time_aggregates (
|
||||||
product_id,
|
pid,
|
||||||
year,
|
year,
|
||||||
month,
|
month,
|
||||||
total_quantity_sold,
|
total_quantity_sold,
|
||||||
@@ -20,7 +32,7 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount)
|
|||||||
)
|
)
|
||||||
WITH sales_data AS (
|
WITH sales_data AS (
|
||||||
SELECT
|
SELECT
|
||||||
o.product_id,
|
o.pid,
|
||||||
YEAR(o.date) as year,
|
YEAR(o.date) as year,
|
||||||
MONTH(o.date) as month,
|
MONTH(o.date) as month,
|
||||||
SUM(o.quantity) as total_quantity_sold,
|
SUM(o.quantity) as total_quantity_sold,
|
||||||
@@ -35,23 +47,23 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount)
|
|||||||
SUM((o.price - COALESCE(o.discount, 0)) * o.quantity)) * 100
|
SUM((o.price - COALESCE(o.discount, 0)) * o.quantity)) * 100
|
||||||
END as profit_margin
|
END as profit_margin
|
||||||
FROM orders o
|
FROM orders o
|
||||||
JOIN products p ON o.product_id = p.product_id
|
JOIN products p ON o.pid = p.pid
|
||||||
WHERE o.canceled = 0
|
WHERE o.canceled = 0
|
||||||
GROUP BY o.product_id, YEAR(o.date), MONTH(o.date)
|
GROUP BY o.pid, YEAR(o.date), MONTH(o.date)
|
||||||
),
|
),
|
||||||
purchase_data AS (
|
purchase_data AS (
|
||||||
SELECT
|
SELECT
|
||||||
product_id,
|
pid,
|
||||||
YEAR(date) as year,
|
YEAR(date) as year,
|
||||||
MONTH(date) as month,
|
MONTH(date) as month,
|
||||||
SUM(received) as stock_received,
|
SUM(received) as stock_received,
|
||||||
SUM(ordered) as stock_ordered
|
SUM(ordered) as stock_ordered
|
||||||
FROM purchase_orders
|
FROM purchase_orders
|
||||||
WHERE status = 'closed'
|
WHERE status = 50
|
||||||
GROUP BY product_id, YEAR(date), MONTH(date)
|
GROUP BY pid, YEAR(date), MONTH(date)
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
s.product_id,
|
s.pid,
|
||||||
s.year,
|
s.year,
|
||||||
s.month,
|
s.month,
|
||||||
s.total_quantity_sold,
|
s.total_quantity_sold,
|
||||||
@@ -64,12 +76,12 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount)
|
|||||||
s.profit_margin
|
s.profit_margin
|
||||||
FROM sales_data s
|
FROM sales_data s
|
||||||
LEFT JOIN purchase_data p
|
LEFT JOIN purchase_data p
|
||||||
ON s.product_id = p.product_id
|
ON s.pid = p.pid
|
||||||
AND s.year = p.year
|
AND s.year = p.year
|
||||||
AND s.month = p.month
|
AND s.month = p.month
|
||||||
UNION
|
UNION
|
||||||
SELECT
|
SELECT
|
||||||
p.product_id,
|
p.pid,
|
||||||
p.year,
|
p.year,
|
||||||
p.month,
|
p.month,
|
||||||
0 as total_quantity_sold,
|
0 as total_quantity_sold,
|
||||||
@@ -82,10 +94,10 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount)
|
|||||||
0 as profit_margin
|
0 as profit_margin
|
||||||
FROM purchase_data p
|
FROM purchase_data p
|
||||||
LEFT JOIN sales_data s
|
LEFT JOIN sales_data s
|
||||||
ON p.product_id = s.product_id
|
ON p.pid = s.pid
|
||||||
AND p.year = s.year
|
AND p.year = s.year
|
||||||
AND p.month = s.month
|
AND p.month = s.month
|
||||||
WHERE s.product_id IS NULL
|
WHERE s.pid IS NULL
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
total_quantity_sold = VALUES(total_quantity_sold),
|
total_quantity_sold = VALUES(total_quantity_sold),
|
||||||
total_revenue = VALUES(total_revenue),
|
total_revenue = VALUES(total_revenue),
|
||||||
@@ -102,17 +114,17 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount)
|
|||||||
UPDATE product_time_aggregates pta
|
UPDATE product_time_aggregates pta
|
||||||
JOIN (
|
JOIN (
|
||||||
SELECT
|
SELECT
|
||||||
p.product_id,
|
p.pid,
|
||||||
YEAR(o.date) as year,
|
YEAR(o.date) as year,
|
||||||
MONTH(o.date) as month,
|
MONTH(o.date) as month,
|
||||||
p.cost_price * p.stock_quantity as inventory_value,
|
p.cost_price * p.stock_quantity as inventory_value,
|
||||||
SUM(o.quantity * (o.price - p.cost_price)) as gross_profit,
|
SUM(o.quantity * (o.price - p.cost_price)) as gross_profit,
|
||||||
COUNT(DISTINCT DATE(o.date)) as days_in_period
|
COUNT(DISTINCT DATE(o.date)) as days_in_period
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
WHERE o.canceled = false
|
WHERE o.canceled = false
|
||||||
GROUP BY p.product_id, YEAR(o.date), MONTH(o.date)
|
GROUP BY p.pid, YEAR(o.date), MONTH(o.date)
|
||||||
) fin ON pta.product_id = fin.product_id
|
) fin ON pta.pid = fin.pid
|
||||||
AND pta.year = fin.year
|
AND pta.year = fin.year
|
||||||
AND pta.month = fin.month
|
AND pta.month = fin.month
|
||||||
SET
|
SET
|
||||||
@@ -126,8 +138,10 @@ async function calculateTimeAggregates(startTime, totalProducts, processedCount)
|
|||||||
|
|
||||||
return Math.floor(totalProducts * 0.65);
|
return Math.floor(totalProducts * 0.65);
|
||||||
} finally {
|
} finally {
|
||||||
|
if (connection) {
|
||||||
connection.release();
|
connection.release();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = calculateTimeAggregates;
|
module.exports = calculateTimeAggregates;
|
||||||
@@ -6,7 +6,7 @@ async function calculateVendorMetrics(startTime, totalProducts, processedCount)
|
|||||||
try {
|
try {
|
||||||
outputProgress({
|
outputProgress({
|
||||||
status: 'running',
|
status: 'running',
|
||||||
operation: 'Calculating vendor metrics',
|
operation: 'Ensuring vendors exist in vendor_details',
|
||||||
current: Math.floor(totalProducts * 0.7),
|
current: Math.floor(totalProducts * 0.7),
|
||||||
total: totalProducts,
|
total: totalProducts,
|
||||||
elapsed: formatElapsedTime(startTime),
|
elapsed: formatElapsedTime(startTime),
|
||||||
@@ -15,157 +15,122 @@ async function calculateVendorMetrics(startTime, totalProducts, processedCount)
|
|||||||
percentage: '70'
|
percentage: '70'
|
||||||
});
|
});
|
||||||
|
|
||||||
// First, ensure all vendors exist in vendor_details
|
// First ensure all vendors exist in vendor_details
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
INSERT IGNORE INTO vendor_details (vendor, status)
|
INSERT IGNORE INTO vendor_details (vendor, status, created_at, updated_at)
|
||||||
SELECT DISTINCT vendor, 'active' as status
|
SELECT DISTINCT
|
||||||
|
vendor,
|
||||||
|
'active' as status,
|
||||||
|
NOW() as created_at,
|
||||||
|
NOW() as updated_at
|
||||||
FROM products
|
FROM products
|
||||||
WHERE vendor IS NOT NULL
|
WHERE vendor IS NOT NULL
|
||||||
AND vendor NOT IN (SELECT vendor FROM vendor_details)
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Calculate vendor performance metrics
|
outputProgress({
|
||||||
|
status: 'running',
|
||||||
|
operation: 'Calculating vendor metrics',
|
||||||
|
current: Math.floor(totalProducts * 0.8),
|
||||||
|
total: totalProducts,
|
||||||
|
elapsed: formatElapsedTime(startTime),
|
||||||
|
remaining: estimateRemaining(startTime, Math.floor(totalProducts * 0.8), totalProducts),
|
||||||
|
rate: calculateRate(startTime, Math.floor(totalProducts * 0.8)),
|
||||||
|
percentage: '80'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now calculate vendor metrics
|
||||||
await connection.query(`
|
await connection.query(`
|
||||||
INSERT INTO vendor_metrics (
|
INSERT INTO vendor_metrics (
|
||||||
vendor,
|
vendor,
|
||||||
|
total_revenue,
|
||||||
|
total_orders,
|
||||||
|
total_late_orders,
|
||||||
avg_lead_time_days,
|
avg_lead_time_days,
|
||||||
on_time_delivery_rate,
|
on_time_delivery_rate,
|
||||||
order_fill_rate,
|
order_fill_rate,
|
||||||
total_orders,
|
|
||||||
total_late_orders,
|
|
||||||
total_purchase_value,
|
|
||||||
avg_order_value,
|
avg_order_value,
|
||||||
active_products,
|
active_products,
|
||||||
total_products,
|
total_products,
|
||||||
total_revenue,
|
status,
|
||||||
avg_margin_percent,
|
last_calculated_at
|
||||||
status
|
|
||||||
)
|
)
|
||||||
WITH vendor_orders AS (
|
WITH vendor_sales AS (
|
||||||
SELECT
|
SELECT
|
||||||
po.vendor,
|
p.vendor,
|
||||||
AVG(DATEDIFF(po.received_date, po.date)) as avg_lead_time_days,
|
SUM(o.quantity * o.price) as total_revenue,
|
||||||
COUNT(*) as total_orders,
|
COUNT(DISTINCT o.id) as total_orders,
|
||||||
COUNT(CASE WHEN po.received_date > po.expected_date THEN 1 END) as total_late_orders,
|
COUNT(DISTINCT p.pid) as active_products
|
||||||
SUM(po.cost_price * po.ordered) as total_purchase_value,
|
FROM products p
|
||||||
AVG(po.cost_price * po.ordered) as avg_order_value,
|
JOIN orders o ON p.pid = o.pid
|
||||||
CASE
|
WHERE o.canceled = false
|
||||||
WHEN COUNT(*) > 0 THEN
|
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
|
||||||
(COUNT(CASE WHEN po.received = po.ordered THEN 1 END) * 100.0) / COUNT(*)
|
GROUP BY p.vendor
|
||||||
ELSE 0
|
),
|
||||||
END as order_fill_rate
|
vendor_po AS (
|
||||||
FROM purchase_orders po
|
SELECT
|
||||||
WHERE po.status = 'closed'
|
p.vendor,
|
||||||
GROUP BY po.vendor
|
COUNT(DISTINCT CASE WHEN po.receiving_status = 40 THEN po.id END) as received_orders,
|
||||||
|
COUNT(DISTINCT po.id) as total_orders,
|
||||||
|
AVG(CASE
|
||||||
|
WHEN po.receiving_status = 40
|
||||||
|
THEN DATEDIFF(po.received_date, po.date)
|
||||||
|
END) as avg_lead_time_days
|
||||||
|
FROM products p
|
||||||
|
JOIN purchase_orders po ON p.pid = po.pid
|
||||||
|
WHERE po.date >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
|
||||||
|
GROUP BY p.vendor
|
||||||
),
|
),
|
||||||
vendor_products AS (
|
vendor_products AS (
|
||||||
SELECT
|
SELECT
|
||||||
p.vendor,
|
vendor,
|
||||||
COUNT(DISTINCT p.product_id) as total_products,
|
COUNT(DISTINCT pid) as total_products
|
||||||
COUNT(DISTINCT CASE WHEN p.visible = true THEN p.product_id END) as active_products,
|
FROM products
|
||||||
SUM(o.price * o.quantity) as total_revenue,
|
GROUP BY vendor
|
||||||
CASE
|
|
||||||
WHEN SUM(o.price * o.quantity) > 0 THEN
|
|
||||||
(SUM((o.price - p.cost_price) * o.quantity) * 100.0) / SUM(o.price * o.quantity)
|
|
||||||
ELSE 0
|
|
||||||
END as avg_margin_percent
|
|
||||||
FROM products p
|
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id AND o.canceled = false
|
|
||||||
GROUP BY p.vendor
|
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
vd.vendor,
|
vs.vendor,
|
||||||
COALESCE(vo.avg_lead_time_days, 0) as avg_lead_time_days,
|
COALESCE(vs.total_revenue, 0) as total_revenue,
|
||||||
|
COALESCE(vp.total_orders, 0) as total_orders,
|
||||||
|
COALESCE(vp.total_orders - vp.received_orders, 0) as total_late_orders,
|
||||||
|
COALESCE(vp.avg_lead_time_days, 0) as avg_lead_time_days,
|
||||||
CASE
|
CASE
|
||||||
WHEN COALESCE(vo.total_orders, 0) > 0 THEN
|
WHEN vp.total_orders > 0
|
||||||
((COALESCE(vo.total_orders, 0) - COALESCE(vo.total_late_orders, 0)) * 100.0) / COALESCE(vo.total_orders, 1)
|
THEN (vp.received_orders / vp.total_orders) * 100
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END as on_time_delivery_rate,
|
END as on_time_delivery_rate,
|
||||||
COALESCE(vo.order_fill_rate, 0) as order_fill_rate,
|
CASE
|
||||||
COALESCE(vo.total_orders, 0) as total_orders,
|
WHEN vp.total_orders > 0
|
||||||
COALESCE(vo.total_late_orders, 0) as total_late_orders,
|
THEN (vp.received_orders / vp.total_orders) * 100
|
||||||
COALESCE(vo.total_purchase_value, 0) as total_purchase_value,
|
ELSE 0
|
||||||
COALESCE(vo.avg_order_value, 0) as avg_order_value,
|
END as order_fill_rate,
|
||||||
COALESCE(vp.active_products, 0) as active_products,
|
CASE
|
||||||
COALESCE(vp.total_products, 0) as total_products,
|
WHEN vs.total_orders > 0
|
||||||
COALESCE(vp.total_revenue, 0) as total_revenue,
|
THEN vs.total_revenue / vs.total_orders
|
||||||
COALESCE(vp.avg_margin_percent, 0) as avg_margin_percent,
|
ELSE 0
|
||||||
vd.status
|
END as avg_order_value,
|
||||||
FROM vendor_details vd
|
COALESCE(vs.active_products, 0) as active_products,
|
||||||
LEFT JOIN vendor_orders vo ON vd.vendor = vo.vendor
|
COALESCE(vpr.total_products, 0) as total_products,
|
||||||
LEFT JOIN vendor_products vp ON vd.vendor = vp.vendor
|
'active' as status,
|
||||||
|
NOW() as last_calculated_at
|
||||||
|
FROM vendor_sales vs
|
||||||
|
LEFT JOIN vendor_po vp ON vs.vendor = vp.vendor
|
||||||
|
LEFT JOIN vendor_products vpr ON vs.vendor = vpr.vendor
|
||||||
|
WHERE vs.vendor IS NOT NULL
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
|
total_revenue = VALUES(total_revenue),
|
||||||
|
total_orders = VALUES(total_orders),
|
||||||
|
total_late_orders = VALUES(total_late_orders),
|
||||||
avg_lead_time_days = VALUES(avg_lead_time_days),
|
avg_lead_time_days = VALUES(avg_lead_time_days),
|
||||||
on_time_delivery_rate = VALUES(on_time_delivery_rate),
|
on_time_delivery_rate = VALUES(on_time_delivery_rate),
|
||||||
order_fill_rate = VALUES(order_fill_rate),
|
order_fill_rate = VALUES(order_fill_rate),
|
||||||
total_orders = VALUES(total_orders),
|
|
||||||
total_late_orders = VALUES(total_late_orders),
|
|
||||||
total_purchase_value = VALUES(total_purchase_value),
|
|
||||||
avg_order_value = VALUES(avg_order_value),
|
avg_order_value = VALUES(avg_order_value),
|
||||||
active_products = VALUES(active_products),
|
active_products = VALUES(active_products),
|
||||||
total_products = VALUES(total_products),
|
total_products = VALUES(total_products),
|
||||||
total_revenue = VALUES(total_revenue),
|
|
||||||
avg_margin_percent = VALUES(avg_margin_percent),
|
|
||||||
status = VALUES(status),
|
status = VALUES(status),
|
||||||
last_calculated_at = CURRENT_TIMESTAMP
|
last_calculated_at = VALUES(last_calculated_at)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Calculate vendor time-based metrics
|
return Math.floor(totalProducts * 0.9);
|
||||||
await connection.query(`
|
|
||||||
INSERT INTO vendor_time_metrics (
|
|
||||||
vendor,
|
|
||||||
year,
|
|
||||||
month,
|
|
||||||
total_orders,
|
|
||||||
late_orders,
|
|
||||||
avg_lead_time_days,
|
|
||||||
total_purchase_value,
|
|
||||||
total_revenue,
|
|
||||||
avg_margin_percent
|
|
||||||
)
|
|
||||||
WITH vendor_time_data AS (
|
|
||||||
SELECT
|
|
||||||
vd.vendor,
|
|
||||||
YEAR(po.date) as year,
|
|
||||||
MONTH(po.date) as month,
|
|
||||||
COUNT(DISTINCT po.po_id) as total_orders,
|
|
||||||
COUNT(DISTINCT CASE WHEN po.received_date > po.expected_date THEN po.po_id END) as late_orders,
|
|
||||||
AVG(DATEDIFF(po.received_date, po.date)) as avg_lead_time_days,
|
|
||||||
SUM(po.cost_price * po.ordered) as total_purchase_value,
|
|
||||||
SUM(o.price * o.quantity) as total_revenue,
|
|
||||||
CASE
|
|
||||||
WHEN SUM(o.price * o.quantity) > 0 THEN
|
|
||||||
(SUM((o.price - p.cost_price) * o.quantity) * 100.0) / SUM(o.price * o.quantity)
|
|
||||||
ELSE 0
|
|
||||||
END as avg_margin_percent
|
|
||||||
FROM vendor_details vd
|
|
||||||
LEFT JOIN products p ON vd.vendor = p.vendor
|
|
||||||
LEFT JOIN purchase_orders po ON p.product_id = po.product_id
|
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id AND o.canceled = false
|
|
||||||
WHERE po.date >= DATE_SUB(CURRENT_DATE, INTERVAL 12 MONTH)
|
|
||||||
GROUP BY vd.vendor, YEAR(po.date), MONTH(po.date)
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
vendor,
|
|
||||||
year,
|
|
||||||
month,
|
|
||||||
COALESCE(total_orders, 0) as total_orders,
|
|
||||||
COALESCE(late_orders, 0) as late_orders,
|
|
||||||
COALESCE(avg_lead_time_days, 0) as avg_lead_time_days,
|
|
||||||
COALESCE(total_purchase_value, 0) as total_purchase_value,
|
|
||||||
COALESCE(total_revenue, 0) as total_revenue,
|
|
||||||
COALESCE(avg_margin_percent, 0) as avg_margin_percent
|
|
||||||
FROM vendor_time_data
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
total_orders = VALUES(total_orders),
|
|
||||||
late_orders = VALUES(late_orders),
|
|
||||||
avg_lead_time_days = VALUES(avg_lead_time_days),
|
|
||||||
total_purchase_value = VALUES(total_purchase_value),
|
|
||||||
total_revenue = VALUES(total_revenue),
|
|
||||||
avg_margin_percent = VALUES(avg_margin_percent)
|
|
||||||
`);
|
|
||||||
|
|
||||||
return Math.floor(totalProducts * 0.75);
|
|
||||||
} finally {
|
} finally {
|
||||||
connection.release();
|
connection.release();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ router.get('/stats', async (req, res) => {
|
|||||||
0
|
0
|
||||||
) as averageOrderValue
|
) as averageOrderValue
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -62,22 +62,43 @@ router.get('/profit', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const pool = req.app.locals.pool;
|
const pool = req.app.locals.pool;
|
||||||
|
|
||||||
// Get profit margins by category
|
// Get profit margins by category with full path
|
||||||
const [byCategory] = await pool.query(`
|
const [byCategory] = await pool.query(`
|
||||||
|
WITH RECURSIVE category_path AS (
|
||||||
|
SELECT
|
||||||
|
c.cat_id,
|
||||||
|
c.name,
|
||||||
|
c.parent_id,
|
||||||
|
CAST(c.name AS CHAR(1000)) as path
|
||||||
|
FROM categories c
|
||||||
|
WHERE c.parent_id IS NULL
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
c.cat_id,
|
||||||
|
c.name,
|
||||||
|
c.parent_id,
|
||||||
|
CONCAT(cp.path, ' > ', c.name)
|
||||||
|
FROM categories c
|
||||||
|
JOIN category_path cp ON c.parent_id = cp.cat_id
|
||||||
|
)
|
||||||
SELECT
|
SELECT
|
||||||
c.name as category,
|
c.name as category,
|
||||||
|
cp.path as categoryPath,
|
||||||
ROUND(
|
ROUND(
|
||||||
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
||||||
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1
|
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1
|
||||||
) as profitMargin,
|
) as profitMargin,
|
||||||
SUM(o.price * o.quantity) as revenue,
|
CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as revenue,
|
||||||
SUM(p.cost_price * o.quantity) as cost
|
CAST(SUM(p.cost_price * o.quantity) AS DECIMAL(15,3)) as cost
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
JOIN product_categories pc ON p.product_id = pc.product_id
|
JOIN product_categories pc ON p.pid = pc.pid
|
||||||
JOIN categories c ON pc.category_id = c.id
|
JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
|
JOIN category_path cp ON c.cat_id = cp.cat_id
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
GROUP BY c.name
|
GROUP BY c.name, cp.path
|
||||||
ORDER BY profitMargin DESC
|
ORDER BY profitMargin DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
`);
|
`);
|
||||||
@@ -90,10 +111,10 @@ router.get('/profit', async (req, res) => {
|
|||||||
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
||||||
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1
|
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1
|
||||||
) as profitMargin,
|
) as profitMargin,
|
||||||
SUM(o.price * o.quantity) as revenue,
|
CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as revenue,
|
||||||
SUM(p.cost_price * o.quantity) as cost
|
CAST(SUM(p.cost_price * o.quantity) AS DECIMAL(15,3)) as cost
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
CROSS JOIN (
|
CROSS JOIN (
|
||||||
SELECT DATE_FORMAT(o.date, '%Y-%m-%d') as formatted_date
|
SELECT DATE_FORMAT(o.date, '%Y-%m-%d') as formatted_date
|
||||||
FROM orders o
|
FROM orders o
|
||||||
@@ -106,20 +127,44 @@ router.get('/profit', async (req, res) => {
|
|||||||
ORDER BY formatted_date
|
ORDER BY formatted_date
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Get top performing products
|
// Get top performing products with category paths
|
||||||
const [topProducts] = await pool.query(`
|
const [topProducts] = await pool.query(`
|
||||||
|
WITH RECURSIVE category_path AS (
|
||||||
|
SELECT
|
||||||
|
c.cat_id,
|
||||||
|
c.name,
|
||||||
|
c.parent_id,
|
||||||
|
CAST(c.name AS CHAR(1000)) as path
|
||||||
|
FROM categories c
|
||||||
|
WHERE c.parent_id IS NULL
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
c.cat_id,
|
||||||
|
c.name,
|
||||||
|
c.parent_id,
|
||||||
|
CONCAT(cp.path, ' > ', c.name)
|
||||||
|
FROM categories c
|
||||||
|
JOIN category_path cp ON c.parent_id = cp.cat_id
|
||||||
|
)
|
||||||
SELECT
|
SELECT
|
||||||
p.title as product,
|
p.title as product,
|
||||||
|
c.name as category,
|
||||||
|
cp.path as categoryPath,
|
||||||
ROUND(
|
ROUND(
|
||||||
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
||||||
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1
|
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1
|
||||||
) as profitMargin,
|
) as profitMargin,
|
||||||
SUM(o.price * o.quantity) as revenue,
|
CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as revenue,
|
||||||
SUM(p.cost_price * o.quantity) as cost
|
CAST(SUM(p.cost_price * o.quantity) AS DECIMAL(15,3)) as cost
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
|
JOIN product_categories pc ON p.pid = pc.pid
|
||||||
|
JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
|
JOIN category_path cp ON c.cat_id = cp.cat_id
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
GROUP BY p.product_id, p.title
|
GROUP BY p.pid, p.title, c.name, cp.path
|
||||||
HAVING revenue > 0
|
HAVING revenue > 0
|
||||||
ORDER BY profitMargin DESC
|
ORDER BY profitMargin DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
@@ -144,7 +189,7 @@ router.get('/vendors', async (req, res) => {
|
|||||||
SELECT COUNT(DISTINCT p.vendor) as vendor_count,
|
SELECT COUNT(DISTINCT p.vendor) as vendor_count,
|
||||||
COUNT(DISTINCT o.order_number) as order_count
|
COUNT(DISTINCT o.order_number) as order_count
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
WHERE p.vendor IS NOT NULL
|
WHERE p.vendor IS NOT NULL
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -155,26 +200,26 @@ router.get('/vendors', async (req, res) => {
|
|||||||
WITH monthly_sales AS (
|
WITH monthly_sales AS (
|
||||||
SELECT
|
SELECT
|
||||||
p.vendor,
|
p.vendor,
|
||||||
SUM(CASE
|
CAST(SUM(CASE
|
||||||
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
THEN o.price * o.quantity
|
THEN o.price * o.quantity
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END) as current_month,
|
END) AS DECIMAL(15,3)) as current_month,
|
||||||
SUM(CASE
|
CAST(SUM(CASE
|
||||||
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
||||||
AND o.date < DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
AND o.date < DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
THEN o.price * o.quantity
|
THEN o.price * o.quantity
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END) as previous_month
|
END) AS DECIMAL(15,3)) as previous_month
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
WHERE p.vendor IS NOT NULL
|
WHERE p.vendor IS NOT NULL
|
||||||
AND o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
AND o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
||||||
GROUP BY p.vendor
|
GROUP BY p.vendor
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
p.vendor,
|
p.vendor,
|
||||||
SUM(o.price * o.quantity) as salesVolume,
|
CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as salesVolume,
|
||||||
COALESCE(ROUND(
|
COALESCE(ROUND(
|
||||||
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
||||||
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1
|
NULLIF(SUM(o.price * o.quantity), 0)) * 100, 1
|
||||||
@@ -182,13 +227,13 @@ router.get('/vendors', async (req, res) => {
|
|||||||
COALESCE(ROUND(
|
COALESCE(ROUND(
|
||||||
SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0), 1
|
SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0), 1
|
||||||
), 0) as stockTurnover,
|
), 0) as stockTurnover,
|
||||||
COUNT(DISTINCT p.product_id) as productCount,
|
COUNT(DISTINCT p.pid) as productCount,
|
||||||
ROUND(
|
ROUND(
|
||||||
((ms.current_month / NULLIF(ms.previous_month, 0)) - 1) * 100,
|
((ms.current_month / NULLIF(ms.previous_month, 0)) - 1) * 100,
|
||||||
1
|
1
|
||||||
) as growth
|
) as growth
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
LEFT JOIN monthly_sales ms ON p.vendor = ms.vendor
|
LEFT JOIN monthly_sales ms ON p.vendor = ms.vendor
|
||||||
WHERE p.vendor IS NOT NULL
|
WHERE p.vendor IS NOT NULL
|
||||||
AND o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
AND o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
@@ -203,11 +248,11 @@ router.get('/vendors', async (req, res) => {
|
|||||||
const [comparison] = await pool.query(`
|
const [comparison] = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
p.vendor,
|
p.vendor,
|
||||||
COALESCE(ROUND(SUM(o.price * o.quantity) / NULLIF(COUNT(DISTINCT p.product_id), 0), 2), 0) as salesPerProduct,
|
CAST(COALESCE(ROUND(SUM(o.price * o.quantity) / NULLIF(COUNT(DISTINCT p.pid), 0), 2), 0) AS DECIMAL(15,3)) as salesPerProduct,
|
||||||
COALESCE(ROUND(AVG((o.price - p.cost_price) / NULLIF(o.price, 0) * 100), 1), 0) as averageMargin,
|
COALESCE(ROUND(AVG((o.price - p.cost_price) / NULLIF(o.price, 0) * 100), 1), 0) as averageMargin,
|
||||||
COUNT(DISTINCT p.product_id) as size
|
COUNT(DISTINCT p.pid) as size
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id AND o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
LEFT JOIN orders o ON p.pid = o.pid AND o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
WHERE p.vendor IS NOT NULL
|
WHERE p.vendor IS NOT NULL
|
||||||
GROUP BY p.vendor
|
GROUP BY p.vendor
|
||||||
ORDER BY salesPerProduct DESC
|
ORDER BY salesPerProduct DESC
|
||||||
@@ -221,9 +266,9 @@ router.get('/vendors', async (req, res) => {
|
|||||||
SELECT
|
SELECT
|
||||||
p.vendor,
|
p.vendor,
|
||||||
DATE_FORMAT(o.date, '%b %Y') as month,
|
DATE_FORMAT(o.date, '%b %Y') as month,
|
||||||
COALESCE(SUM(o.price * o.quantity), 0) as sales
|
CAST(COALESCE(SUM(o.price * o.quantity), 0) AS DECIMAL(15,3)) as sales
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
WHERE p.vendor IS NOT NULL
|
WHERE p.vendor IS NOT NULL
|
||||||
AND o.date >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH)
|
AND o.date >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH)
|
||||||
GROUP BY
|
GROUP BY
|
||||||
@@ -272,9 +317,9 @@ router.get('/stock', async (req, res) => {
|
|||||||
ROUND(AVG(p.stock_quantity), 0) as averageStock,
|
ROUND(AVG(p.stock_quantity), 0) as averageStock,
|
||||||
SUM(o.quantity) as totalSales
|
SUM(o.quantity) as totalSales
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
JOIN product_categories pc ON p.product_id = pc.product_id
|
JOIN product_categories pc ON p.pid = pc.pid
|
||||||
JOIN categories c ON pc.category_id = c.id
|
JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
||||||
GROUP BY c.name
|
GROUP BY c.name
|
||||||
HAVING turnoverRate > 0
|
HAVING turnoverRate > 0
|
||||||
@@ -290,7 +335,7 @@ router.get('/stock', async (req, res) => {
|
|||||||
SUM(CASE WHEN p.stock_quantity <= ? AND p.stock_quantity > 0 THEN 1 ELSE 0 END) as lowStock,
|
SUM(CASE WHEN p.stock_quantity <= ? AND p.stock_quantity > 0 THEN 1 ELSE 0 END) as lowStock,
|
||||||
SUM(CASE WHEN p.stock_quantity = 0 THEN 1 ELSE 0 END) as outOfStock
|
SUM(CASE WHEN p.stock_quantity = 0 THEN 1 ELSE 0 END) as outOfStock
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
||||||
GROUP BY DATE_FORMAT(o.date, '%Y-%m-%d')
|
GROUP BY DATE_FORMAT(o.date, '%Y-%m-%d')
|
||||||
ORDER BY date
|
ORDER BY date
|
||||||
@@ -304,26 +349,14 @@ router.get('/stock', async (req, res) => {
|
|||||||
const [criticalItems] = await pool.query(`
|
const [criticalItems] = await pool.query(`
|
||||||
WITH product_thresholds AS (
|
WITH product_thresholds AS (
|
||||||
SELECT
|
SELECT
|
||||||
p.product_id,
|
p.pid,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
(SELECT reorder_days
|
(SELECT reorder_days
|
||||||
FROM stock_thresholds st
|
FROM stock_thresholds st
|
||||||
JOIN product_categories pc ON st.category_id = pc.category_id
|
WHERE st.vendor = p.vendor LIMIT 1),
|
||||||
WHERE pc.product_id = p.product_id
|
|
||||||
AND st.vendor = p.vendor LIMIT 1),
|
|
||||||
(SELECT reorder_days
|
(SELECT reorder_days
|
||||||
FROM stock_thresholds st
|
FROM stock_thresholds st
|
||||||
JOIN product_categories pc ON st.category_id = pc.category_id
|
WHERE st.vendor IS NULL LIMIT 1),
|
||||||
WHERE pc.product_id = p.product_id
|
|
||||||
AND st.vendor IS NULL LIMIT 1),
|
|
||||||
(SELECT reorder_days
|
|
||||||
FROM stock_thresholds st
|
|
||||||
WHERE st.category_id IS NULL
|
|
||||||
AND st.vendor = p.vendor LIMIT 1),
|
|
||||||
(SELECT reorder_days
|
|
||||||
FROM stock_thresholds st
|
|
||||||
WHERE st.category_id IS NULL
|
|
||||||
AND st.vendor IS NULL LIMIT 1),
|
|
||||||
14
|
14
|
||||||
) as reorder_days
|
) as reorder_days
|
||||||
FROM products p
|
FROM products p
|
||||||
@@ -339,11 +372,11 @@ router.get('/stock', async (req, res) => {
|
|||||||
ELSE ROUND(p.stock_quantity / NULLIF((SUM(o.quantity) / ?), 0))
|
ELSE ROUND(p.stock_quantity / NULLIF((SUM(o.quantity) / ?), 0))
|
||||||
END as daysUntilStockout
|
END as daysUntilStockout
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
JOIN product_thresholds pt ON p.product_id = pt.product_id
|
JOIN product_thresholds pt ON p.pid = pt.pid
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
||||||
AND p.managing_stock = true
|
AND p.managing_stock = true
|
||||||
GROUP BY p.product_id
|
GROUP BY p.pid
|
||||||
HAVING daysUntilStockout < ? AND daysUntilStockout >= 0
|
HAVING daysUntilStockout < ? AND daysUntilStockout >= 0
|
||||||
ORDER BY daysUntilStockout
|
ORDER BY daysUntilStockout
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
@@ -369,14 +402,16 @@ router.get('/pricing', async (req, res) => {
|
|||||||
// Get price points analysis
|
// Get price points analysis
|
||||||
const [pricePoints] = await pool.query(`
|
const [pricePoints] = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
p.price,
|
CAST(p.price AS DECIMAL(15,3)) as price,
|
||||||
SUM(o.quantity) as salesVolume,
|
CAST(SUM(o.quantity) AS DECIMAL(15,3)) as salesVolume,
|
||||||
SUM(o.price * o.quantity) as revenue,
|
CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as revenue,
|
||||||
p.categories as category
|
c.name as category
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
|
JOIN product_categories pc ON p.pid = pc.pid
|
||||||
|
JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
GROUP BY p.price, p.categories
|
GROUP BY p.price, c.name
|
||||||
HAVING salesVolume > 0
|
HAVING salesVolume > 0
|
||||||
ORDER BY revenue DESC
|
ORDER BY revenue DESC
|
||||||
LIMIT 50
|
LIMIT 50
|
||||||
@@ -386,8 +421,8 @@ router.get('/pricing', async (req, res) => {
|
|||||||
const [elasticity] = await pool.query(`
|
const [elasticity] = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
DATE_FORMAT(o.date, '%Y-%m-%d') as date,
|
DATE_FORMAT(o.date, '%Y-%m-%d') as date,
|
||||||
AVG(o.price) as price,
|
CAST(AVG(o.price) AS DECIMAL(15,3)) as price,
|
||||||
SUM(o.quantity) as demand
|
CAST(SUM(o.quantity) AS DECIMAL(15,3)) as demand
|
||||||
FROM orders o
|
FROM orders o
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
GROUP BY DATE_FORMAT(o.date, '%Y-%m-%d')
|
GROUP BY DATE_FORMAT(o.date, '%Y-%m-%d')
|
||||||
@@ -398,14 +433,17 @@ router.get('/pricing', async (req, res) => {
|
|||||||
const [recommendations] = await pool.query(`
|
const [recommendations] = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
p.title as product,
|
p.title as product,
|
||||||
p.price as currentPrice,
|
CAST(p.price AS DECIMAL(15,3)) as currentPrice,
|
||||||
|
CAST(
|
||||||
ROUND(
|
ROUND(
|
||||||
CASE
|
CASE
|
||||||
WHEN AVG(o.quantity) > 10 THEN p.price * 1.1
|
WHEN AVG(o.quantity) > 10 THEN p.price * 1.1
|
||||||
WHEN AVG(o.quantity) < 2 THEN p.price * 0.9
|
WHEN AVG(o.quantity) < 2 THEN p.price * 0.9
|
||||||
ELSE p.price
|
ELSE p.price
|
||||||
END, 2
|
END, 2
|
||||||
|
) AS DECIMAL(15,3)
|
||||||
) as recommendedPrice,
|
) as recommendedPrice,
|
||||||
|
CAST(
|
||||||
ROUND(
|
ROUND(
|
||||||
SUM(o.price * o.quantity) *
|
SUM(o.price * o.quantity) *
|
||||||
CASE
|
CASE
|
||||||
@@ -413,6 +451,7 @@ router.get('/pricing', async (req, res) => {
|
|||||||
WHEN AVG(o.quantity) < 2 THEN 0.95
|
WHEN AVG(o.quantity) < 2 THEN 0.95
|
||||||
ELSE 1
|
ELSE 1
|
||||||
END, 2
|
END, 2
|
||||||
|
) AS DECIMAL(15,3)
|
||||||
) as potentialRevenue,
|
) as potentialRevenue,
|
||||||
CASE
|
CASE
|
||||||
WHEN AVG(o.quantity) > 10 THEN 85
|
WHEN AVG(o.quantity) > 10 THEN 85
|
||||||
@@ -420,11 +459,11 @@ router.get('/pricing', async (req, res) => {
|
|||||||
ELSE 65
|
ELSE 65
|
||||||
END as confidence
|
END as confidence
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
GROUP BY p.product_id
|
GROUP BY p.pid, p.price
|
||||||
HAVING ABS(recommendedPrice - currentPrice) > 0
|
HAVING ABS(recommendedPrice - currentPrice) > 0
|
||||||
ORDER BY potentialRevenue - SUM(o.price * o.quantity) DESC
|
ORDER BY potentialRevenue - CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -440,11 +479,36 @@ router.get('/categories', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const pool = req.app.locals.pool;
|
const pool = req.app.locals.pool;
|
||||||
|
|
||||||
// Get category performance metrics
|
// Common CTE for category paths
|
||||||
|
const categoryPathCTE = `
|
||||||
|
WITH RECURSIVE category_path AS (
|
||||||
|
SELECT
|
||||||
|
c.cat_id,
|
||||||
|
c.name,
|
||||||
|
c.parent_id,
|
||||||
|
CAST(c.name AS CHAR(1000)) as path
|
||||||
|
FROM categories c
|
||||||
|
WHERE c.parent_id IS NULL
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
c.cat_id,
|
||||||
|
c.name,
|
||||||
|
c.parent_id,
|
||||||
|
CONCAT(cp.path, ' > ', c.name)
|
||||||
|
FROM categories c
|
||||||
|
JOIN category_path cp ON c.parent_id = cp.cat_id
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Get category performance metrics with full path
|
||||||
const [performance] = await pool.query(`
|
const [performance] = await pool.query(`
|
||||||
WITH monthly_sales AS (
|
${categoryPathCTE},
|
||||||
|
monthly_sales AS (
|
||||||
SELECT
|
SELECT
|
||||||
c.name,
|
c.name,
|
||||||
|
cp.path,
|
||||||
SUM(CASE
|
SUM(CASE
|
||||||
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
THEN o.price * o.quantity
|
THEN o.price * o.quantity
|
||||||
@@ -457,62 +521,72 @@ router.get('/categories', async (req, res) => {
|
|||||||
ELSE 0
|
ELSE 0
|
||||||
END) as previous_month
|
END) as previous_month
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
JOIN product_categories pc ON p.product_id = pc.product_id
|
JOIN product_categories pc ON p.pid = pc.pid
|
||||||
JOIN categories c ON pc.category_id = c.id
|
JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
|
JOIN category_path cp ON c.cat_id = cp.cat_id
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
||||||
GROUP BY c.name
|
GROUP BY c.name, cp.path
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
c.name as category,
|
c.name as category,
|
||||||
|
cp.path as categoryPath,
|
||||||
SUM(o.price * o.quantity) as revenue,
|
SUM(o.price * o.quantity) as revenue,
|
||||||
SUM(o.price * o.quantity - p.cost_price * o.quantity) as profit,
|
SUM(o.price * o.quantity - p.cost_price * o.quantity) as profit,
|
||||||
ROUND(
|
ROUND(
|
||||||
((ms.current_month / NULLIF(ms.previous_month, 0)) - 1) * 100,
|
((ms.current_month / NULLIF(ms.previous_month, 0)) - 1) * 100,
|
||||||
1
|
1
|
||||||
) as growth,
|
) as growth,
|
||||||
COUNT(DISTINCT p.product_id) as productCount
|
COUNT(DISTINCT p.pid) as productCount
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
JOIN product_categories pc ON p.product_id = pc.product_id
|
JOIN product_categories pc ON p.pid = pc.pid
|
||||||
JOIN categories c ON pc.category_id = c.id
|
JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
LEFT JOIN monthly_sales ms ON c.name = ms.name
|
JOIN category_path cp ON c.cat_id = cp.cat_id
|
||||||
|
LEFT JOIN monthly_sales ms ON c.name = ms.name AND cp.path = ms.path
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
||||||
GROUP BY c.name, ms.current_month, ms.previous_month
|
GROUP BY c.name, cp.path, ms.current_month, ms.previous_month
|
||||||
HAVING revenue > 0
|
HAVING revenue > 0
|
||||||
ORDER BY revenue DESC
|
ORDER BY revenue DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Get category revenue distribution
|
// Get category revenue distribution with full path
|
||||||
const [distribution] = await pool.query(`
|
const [distribution] = await pool.query(`
|
||||||
|
${categoryPathCTE}
|
||||||
SELECT
|
SELECT
|
||||||
c.name as category,
|
c.name as category,
|
||||||
|
cp.path as categoryPath,
|
||||||
SUM(o.price * o.quantity) as value
|
SUM(o.price * o.quantity) as value
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
JOIN product_categories pc ON p.product_id = pc.product_id
|
JOIN product_categories pc ON p.pid = pc.pid
|
||||||
JOIN categories c ON pc.category_id = c.id
|
JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
|
JOIN category_path cp ON c.cat_id = cp.cat_id
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
GROUP BY c.name
|
GROUP BY c.name, cp.path
|
||||||
HAVING value > 0
|
HAVING value > 0
|
||||||
ORDER BY value DESC
|
ORDER BY value DESC
|
||||||
LIMIT 6
|
LIMIT 6
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Get category sales trends
|
// Get category sales trends with full path
|
||||||
const [trends] = await pool.query(`
|
const [trends] = await pool.query(`
|
||||||
|
${categoryPathCTE}
|
||||||
SELECT
|
SELECT
|
||||||
c.name as category,
|
c.name as category,
|
||||||
|
cp.path as categoryPath,
|
||||||
DATE_FORMAT(o.date, '%b %Y') as month,
|
DATE_FORMAT(o.date, '%b %Y') as month,
|
||||||
SUM(o.price * o.quantity) as sales
|
SUM(o.price * o.quantity) as sales
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
JOIN product_categories pc ON p.product_id = pc.product_id
|
JOIN product_categories pc ON p.pid = pc.pid
|
||||||
JOIN categories c ON pc.category_id = c.id
|
JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
|
JOIN category_path cp ON c.cat_id = cp.cat_id
|
||||||
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH)
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 6 MONTH)
|
||||||
GROUP BY
|
GROUP BY
|
||||||
c.name,
|
c.name,
|
||||||
|
cp.path,
|
||||||
DATE_FORMAT(o.date, '%b %Y'),
|
DATE_FORMAT(o.date, '%b %Y'),
|
||||||
DATE_FORMAT(o.date, '%Y-%m')
|
DATE_FORMAT(o.date, '%Y-%m')
|
||||||
ORDER BY
|
ORDER BY
|
||||||
@@ -534,65 +608,86 @@ router.get('/forecast', async (req, res) => {
|
|||||||
const pool = req.app.locals.pool;
|
const pool = req.app.locals.pool;
|
||||||
|
|
||||||
const [results] = await pool.query(`
|
const [results] = await pool.query(`
|
||||||
WITH category_metrics AS (
|
WITH RECURSIVE category_path AS (
|
||||||
SELECT
|
SELECT
|
||||||
c.id as category_id,
|
c.cat_id,
|
||||||
c.name as category_name,
|
c.name,
|
||||||
p.brand,
|
c.parent_id,
|
||||||
COUNT(DISTINCT p.product_id) as num_products,
|
CAST(c.name AS CHAR(1000)) as path
|
||||||
COALESCE(ROUND(SUM(o.quantity) / DATEDIFF(?, ?), 2), 0) as avg_daily_sales,
|
|
||||||
COALESCE(SUM(o.quantity), 0) as total_sold,
|
|
||||||
COALESCE(ROUND(SUM(o.quantity) / COUNT(DISTINCT p.product_id), 2), 0) as avgTotalSold,
|
|
||||||
COALESCE(ROUND(AVG(o.price), 2), 0) as avg_price
|
|
||||||
FROM categories c
|
FROM categories c
|
||||||
JOIN product_categories pc ON c.id = pc.category_id
|
WHERE c.parent_id IS NULL
|
||||||
JOIN products p ON pc.product_id = p.product_id
|
|
||||||
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id
|
UNION ALL
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
|
||||||
AND o.date BETWEEN ? AND ?
|
|
||||||
AND o.canceled = false
|
|
||||||
WHERE p.brand = ?
|
|
||||||
AND pm.first_received_date BETWEEN ? AND ?
|
|
||||||
GROUP BY c.id, c.name, p.brand
|
|
||||||
),
|
|
||||||
product_metrics AS (
|
|
||||||
SELECT
|
SELECT
|
||||||
p.product_id,
|
c.cat_id,
|
||||||
p.title,
|
c.name,
|
||||||
p.sku,
|
c.parent_id,
|
||||||
p.stock_quantity,
|
CONCAT(cp.path, ' > ', c.name)
|
||||||
pc.category_id,
|
FROM categories c
|
||||||
pm.first_received_date,
|
JOIN category_path cp ON c.parent_id = cp.cat_id
|
||||||
|
),
|
||||||
|
category_metrics AS (
|
||||||
|
SELECT
|
||||||
|
c.cat_id,
|
||||||
|
c.name as category_name,
|
||||||
|
cp.path,
|
||||||
|
p.brand,
|
||||||
|
COUNT(DISTINCT p.pid) as num_products,
|
||||||
|
CAST(COALESCE(ROUND(SUM(o.quantity) / DATEDIFF(?, ?), 2), 0) AS DECIMAL(15,3)) as avg_daily_sales,
|
||||||
COALESCE(SUM(o.quantity), 0) as total_sold,
|
COALESCE(SUM(o.quantity), 0) as total_sold,
|
||||||
COALESCE(ROUND(AVG(o.price), 2), 0) as avg_price
|
CAST(COALESCE(ROUND(SUM(o.quantity) / COUNT(DISTINCT p.pid), 2), 0) AS DECIMAL(15,3)) as avgTotalSold,
|
||||||
FROM products p
|
CAST(COALESCE(ROUND(AVG(o.price), 2), 0) AS DECIMAL(15,3)) as avg_price
|
||||||
JOIN product_categories pc ON p.product_id = pc.product_id
|
FROM categories c
|
||||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
JOIN product_categories pc ON c.cat_id = pc.cat_id
|
||||||
LEFT JOIN orders o ON p.product_id = o.product_id
|
JOIN products p ON pc.pid = p.pid
|
||||||
|
JOIN category_path cp ON c.cat_id = cp.cat_id
|
||||||
|
LEFT JOIN product_metrics pmet ON p.pid = pmet.pid
|
||||||
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
AND o.date BETWEEN ? AND ?
|
AND o.date BETWEEN ? AND ?
|
||||||
AND o.canceled = false
|
AND o.canceled = false
|
||||||
WHERE p.brand = ?
|
WHERE p.brand = ?
|
||||||
AND pm.first_received_date BETWEEN ? AND ?
|
AND pmet.first_received_date BETWEEN ? AND ?
|
||||||
GROUP BY p.product_id, p.title, p.sku, p.stock_quantity, pc.category_id, pm.first_received_date
|
GROUP BY c.cat_id, c.name, cp.path, p.brand
|
||||||
|
),
|
||||||
|
product_details AS (
|
||||||
|
SELECT
|
||||||
|
p.pid,
|
||||||
|
p.title,
|
||||||
|
p.SKU,
|
||||||
|
p.stock_quantity,
|
||||||
|
pc.cat_id,
|
||||||
|
pmet.first_received_date,
|
||||||
|
COALESCE(SUM(o.quantity), 0) as total_sold,
|
||||||
|
CAST(COALESCE(ROUND(AVG(o.price), 2), 0) AS DECIMAL(15,3)) as avg_price
|
||||||
|
FROM products p
|
||||||
|
JOIN product_categories pc ON p.pid = pc.pid
|
||||||
|
JOIN product_metrics pmet ON p.pid = pmet.pid
|
||||||
|
LEFT JOIN orders o ON p.pid = o.pid
|
||||||
|
AND o.date BETWEEN ? AND ?
|
||||||
|
AND o.canceled = false
|
||||||
|
WHERE p.brand = ?
|
||||||
|
AND pmet.first_received_date BETWEEN ? AND ?
|
||||||
|
GROUP BY p.pid, p.title, p.SKU, p.stock_quantity, pc.cat_id, pmet.first_received_date
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
cm.*,
|
cm.*,
|
||||||
JSON_ARRAYAGG(
|
JSON_ARRAYAGG(
|
||||||
JSON_OBJECT(
|
JSON_OBJECT(
|
||||||
'product_id', pm.product_id,
|
'pid', pd.pid,
|
||||||
'title', pm.title,
|
'title', pd.title,
|
||||||
'sku', pm.sku,
|
'SKU', pd.SKU,
|
||||||
'stock_quantity', pm.stock_quantity,
|
'stock_quantity', pd.stock_quantity,
|
||||||
'total_sold', pm.total_sold,
|
'total_sold', pd.total_sold,
|
||||||
'avg_price', pm.avg_price,
|
'avg_price', pd.avg_price,
|
||||||
'first_received_date', DATE_FORMAT(pm.first_received_date, '%Y-%m-%d')
|
'first_received_date', DATE_FORMAT(pd.first_received_date, '%Y-%m-%d')
|
||||||
)
|
)
|
||||||
) as products
|
) as products
|
||||||
FROM category_metrics cm
|
FROM category_metrics cm
|
||||||
JOIN product_metrics pm ON cm.category_id = pm.category_id
|
JOIN product_details pd ON cm.cat_id = pd.cat_id
|
||||||
GROUP BY cm.category_id, cm.category_name, cm.brand, cm.num_products, cm.avg_daily_sales, cm.total_sold, cm.avgTotalSold, cm.avg_price
|
GROUP BY cm.cat_id, cm.category_name, cm.path, cm.brand, cm.num_products, cm.avg_daily_sales, cm.total_sold, cm.avgTotalSold, cm.avg_price
|
||||||
ORDER BY cm.total_sold DESC
|
ORDER BY cm.total_sold DESC
|
||||||
`, [startDate, endDate, startDate, endDate, brand, startDate, endDate, startDate, endDate, brand, startDate, endDate]);
|
`, [endDate, startDate, startDate, endDate, brand, startDate, endDate, startDate, endDate, brand, startDate, endDate]);
|
||||||
|
|
||||||
res.json(results);
|
res.json(results);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -5,62 +5,90 @@ const router = express.Router();
|
|||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
const pool = req.app.locals.pool;
|
const pool = req.app.locals.pool;
|
||||||
try {
|
try {
|
||||||
// Get parent categories for filter dropdown
|
// Get all categories with metrics and hierarchy info
|
||||||
const [parentCategories] = await pool.query(`
|
|
||||||
SELECT DISTINCT c2.name as parent_name
|
|
||||||
FROM categories c1
|
|
||||||
JOIN categories c2 ON c1.parent_id = c2.id
|
|
||||||
WHERE c1.parent_id IS NOT NULL
|
|
||||||
ORDER BY c2.name
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Get all categories with metrics
|
|
||||||
const [categories] = await pool.query(`
|
const [categories] = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
c.id as category_id,
|
c.cat_id,
|
||||||
c.name,
|
c.name,
|
||||||
|
c.type,
|
||||||
|
c.parent_id,
|
||||||
c.description,
|
c.description,
|
||||||
COALESCE(p.name, '') as parent_name,
|
c.status,
|
||||||
cm.product_count,
|
p.name as parent_name,
|
||||||
cm.total_value,
|
p.type as parent_type,
|
||||||
cm.avg_margin,
|
COALESCE(cm.product_count, 0) as product_count,
|
||||||
cm.turnover_rate,
|
COALESCE(cm.active_products, 0) as active_products,
|
||||||
cm.growth_rate,
|
CAST(COALESCE(cm.total_value, 0) AS DECIMAL(15,3)) as total_value,
|
||||||
cm.status
|
COALESCE(cm.avg_margin, 0) as avg_margin,
|
||||||
|
COALESCE(cm.turnover_rate, 0) as turnover_rate,
|
||||||
|
COALESCE(cm.growth_rate, 0) as growth_rate
|
||||||
FROM categories c
|
FROM categories c
|
||||||
LEFT JOIN categories p ON c.parent_id = p.id
|
LEFT JOIN categories p ON c.parent_id = p.cat_id
|
||||||
LEFT JOIN category_metrics cm ON c.id = cm.category_id
|
LEFT JOIN category_metrics cm ON c.cat_id = cm.category_id
|
||||||
ORDER BY c.name ASC
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN c.type = 10 THEN 1 -- sections first
|
||||||
|
WHEN c.type = 11 THEN 2 -- categories second
|
||||||
|
WHEN c.type = 12 THEN 3 -- subcategories third
|
||||||
|
WHEN c.type = 13 THEN 4 -- subsubcategories fourth
|
||||||
|
WHEN c.type = 20 THEN 5 -- themes fifth
|
||||||
|
WHEN c.type = 21 THEN 6 -- subthemes last
|
||||||
|
ELSE 7
|
||||||
|
END,
|
||||||
|
c.name ASC
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Get overall stats
|
// Get overall stats
|
||||||
const [stats] = await pool.query(`
|
const [stats] = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(DISTINCT c.id) as totalCategories,
|
COUNT(DISTINCT c.cat_id) as totalCategories,
|
||||||
COUNT(DISTINCT CASE WHEN cm.status = 'active' THEN c.id END) as activeCategories,
|
COUNT(DISTINCT CASE WHEN c.status = 'active' THEN c.cat_id END) as activeCategories,
|
||||||
COALESCE(SUM(cm.total_value), 0) as totalValue,
|
CAST(COALESCE(SUM(cm.total_value), 0) AS DECIMAL(15,3)) as totalValue,
|
||||||
COALESCE(ROUND(AVG(NULLIF(cm.avg_margin, 0)), 1), 0) as avgMargin,
|
COALESCE(ROUND(AVG(NULLIF(cm.avg_margin, 0)), 1), 0) as avgMargin,
|
||||||
COALESCE(ROUND(AVG(NULLIF(cm.growth_rate, 0)), 1), 0) as avgGrowth
|
COALESCE(ROUND(AVG(NULLIF(cm.growth_rate, 0)), 1), 0) as avgGrowth
|
||||||
FROM categories c
|
FROM categories c
|
||||||
LEFT JOIN category_metrics cm ON c.id = cm.category_id
|
LEFT JOIN category_metrics cm ON c.cat_id = cm.category_id
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Get type counts for filtering
|
||||||
|
const [typeCounts] = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
type,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM categories
|
||||||
|
GROUP BY type
|
||||||
|
ORDER BY type
|
||||||
`);
|
`);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
categories: categories.map(cat => ({
|
categories: categories.map(cat => ({
|
||||||
...cat,
|
cat_id: cat.cat_id,
|
||||||
parent_category: cat.parent_name, // Map parent_name to parent_category for frontend compatibility
|
name: cat.name,
|
||||||
product_count: parseInt(cat.product_count || 0),
|
type: cat.type,
|
||||||
total_value: parseFloat(cat.total_value || 0),
|
parent_id: cat.parent_id,
|
||||||
avg_margin: parseFloat(cat.avg_margin || 0),
|
parent_name: cat.parent_name,
|
||||||
turnover_rate: parseFloat(cat.turnover_rate || 0),
|
parent_type: cat.parent_type,
|
||||||
growth_rate: parseFloat(cat.growth_rate || 0)
|
description: cat.description,
|
||||||
|
status: cat.status,
|
||||||
|
metrics: {
|
||||||
|
product_count: parseInt(cat.product_count),
|
||||||
|
active_products: parseInt(cat.active_products),
|
||||||
|
total_value: parseFloat(cat.total_value),
|
||||||
|
avg_margin: parseFloat(cat.avg_margin),
|
||||||
|
turnover_rate: parseFloat(cat.turnover_rate),
|
||||||
|
growth_rate: parseFloat(cat.growth_rate)
|
||||||
|
}
|
||||||
|
})),
|
||||||
|
typeCounts: typeCounts.map(tc => ({
|
||||||
|
type: tc.type,
|
||||||
|
count: parseInt(tc.count)
|
||||||
})),
|
})),
|
||||||
parentCategories: parentCategories.map(p => p.parent_name),
|
|
||||||
stats: {
|
stats: {
|
||||||
...stats[0],
|
totalCategories: parseInt(stats[0].totalCategories),
|
||||||
totalValue: parseFloat(stats[0].totalValue || 0),
|
activeCategories: parseInt(stats[0].activeCategories),
|
||||||
avgMargin: parseFloat(stats[0].avgMargin || 0),
|
totalValue: parseFloat(stats[0].totalValue),
|
||||||
avgGrowth: parseFloat(stats[0].avgGrowth || 0)
|
avgMargin: parseFloat(stats[0].avgMargin),
|
||||||
|
avgGrowth: parseFloat(stats[0].avgGrowth)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ const express = require('express');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const db = require('../utils/db');
|
const db = require('../utils/db');
|
||||||
|
|
||||||
|
// Import status codes
|
||||||
|
const { ReceivingStatus } = require('../types/status-codes');
|
||||||
|
|
||||||
// Helper function to execute queries using the connection pool
|
// Helper function to execute queries using the connection pool
|
||||||
async function executeQuery(sql, params = []) {
|
async function executeQuery(sql, params = []) {
|
||||||
const pool = db.getPool();
|
const pool = db.getPool();
|
||||||
@@ -38,15 +41,14 @@ router.get('/stock/metrics', async (req, res) => {
|
|||||||
const [brandValues] = await executeQuery(`
|
const [brandValues] = await executeQuery(`
|
||||||
WITH brand_totals AS (
|
WITH brand_totals AS (
|
||||||
SELECT
|
SELECT
|
||||||
brand,
|
COALESCE(brand, 'Unbranded') as brand,
|
||||||
COUNT(DISTINCT product_id) as variant_count,
|
COUNT(DISTINCT pid) as variant_count,
|
||||||
COALESCE(SUM(stock_quantity), 0) as stock_units,
|
COALESCE(SUM(stock_quantity), 0) as stock_units,
|
||||||
COALESCE(SUM(stock_quantity * cost_price), 0) as stock_cost,
|
CAST(COALESCE(SUM(stock_quantity * cost_price), 0) AS DECIMAL(15,3)) as stock_cost,
|
||||||
COALESCE(SUM(stock_quantity * price), 0) as stock_retail
|
CAST(COALESCE(SUM(stock_quantity * price), 0) AS DECIMAL(15,3)) as stock_retail
|
||||||
FROM products
|
FROM products
|
||||||
WHERE brand IS NOT NULL
|
WHERE stock_quantity > 0
|
||||||
AND stock_quantity > 0
|
GROUP BY COALESCE(brand, 'Unbranded')
|
||||||
GROUP BY brand
|
|
||||||
HAVING stock_cost > 0
|
HAVING stock_cost > 0
|
||||||
),
|
),
|
||||||
other_brands AS (
|
other_brands AS (
|
||||||
@@ -54,8 +56,8 @@ router.get('/stock/metrics', async (req, res) => {
|
|||||||
'Other' as brand,
|
'Other' as brand,
|
||||||
SUM(variant_count) as variant_count,
|
SUM(variant_count) as variant_count,
|
||||||
SUM(stock_units) as stock_units,
|
SUM(stock_units) as stock_units,
|
||||||
SUM(stock_cost) as stock_cost,
|
CAST(SUM(stock_cost) AS DECIMAL(15,3)) as stock_cost,
|
||||||
SUM(stock_retail) as stock_retail
|
CAST(SUM(stock_retail) AS DECIMAL(15,3)) as stock_retail
|
||||||
FROM brand_totals
|
FROM brand_totals
|
||||||
WHERE stock_cost <= 5000
|
WHERE stock_cost <= 5000
|
||||||
),
|
),
|
||||||
@@ -101,49 +103,51 @@ router.get('/purchase/metrics', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const [rows] = await executeQuery(`
|
const [rows] = await executeQuery(`
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(COUNT(DISTINCT CASE WHEN po.status = 'open' THEN po.po_id END), 0) as active_pos,
|
|
||||||
COALESCE(COUNT(DISTINCT CASE
|
COALESCE(COUNT(DISTINCT CASE
|
||||||
WHEN po.status = 'open' AND po.expected_date < CURDATE()
|
WHEN po.receiving_status < ${ReceivingStatus.PartialReceived}
|
||||||
|
THEN po.po_id
|
||||||
|
END), 0) as active_pos,
|
||||||
|
COALESCE(COUNT(DISTINCT CASE
|
||||||
|
WHEN po.receiving_status < ${ReceivingStatus.PartialReceived}
|
||||||
|
AND po.expected_date < CURDATE()
|
||||||
THEN po.po_id
|
THEN po.po_id
|
||||||
END), 0) as overdue_pos,
|
END), 0) as overdue_pos,
|
||||||
COALESCE(SUM(CASE WHEN po.status = 'open' THEN po.ordered ELSE 0 END), 0) as total_units,
|
|
||||||
COALESCE(SUM(CASE
|
COALESCE(SUM(CASE
|
||||||
WHEN po.status = 'open'
|
WHEN po.receiving_status < ${ReceivingStatus.PartialReceived}
|
||||||
|
THEN po.ordered
|
||||||
|
ELSE 0
|
||||||
|
END), 0) as total_units,
|
||||||
|
CAST(COALESCE(SUM(CASE
|
||||||
|
WHEN po.receiving_status < ${ReceivingStatus.PartialReceived}
|
||||||
THEN po.ordered * po.cost_price
|
THEN po.ordered * po.cost_price
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END), 0) as total_cost,
|
END), 0) AS DECIMAL(15,3)) as total_cost,
|
||||||
COALESCE(SUM(CASE
|
CAST(COALESCE(SUM(CASE
|
||||||
WHEN po.status = 'open'
|
WHEN po.receiving_status < ${ReceivingStatus.PartialReceived}
|
||||||
THEN po.ordered * p.price
|
THEN po.ordered * p.price
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END), 0) as total_retail
|
END), 0) AS DECIMAL(15,3)) as total_retail
|
||||||
FROM purchase_orders po
|
FROM purchase_orders po
|
||||||
JOIN products p ON po.product_id = p.product_id
|
JOIN products p ON po.pid = p.pid
|
||||||
`);
|
`);
|
||||||
const poMetrics = rows[0];
|
const poMetrics = rows[0];
|
||||||
|
|
||||||
console.log('Raw poMetrics from database:', poMetrics);
|
|
||||||
console.log('poMetrics.active_pos:', poMetrics.active_pos);
|
|
||||||
console.log('poMetrics.overdue_pos:', poMetrics.overdue_pos);
|
|
||||||
console.log('poMetrics.total_units:', poMetrics.total_units);
|
|
||||||
console.log('poMetrics.total_cost:', poMetrics.total_cost);
|
|
||||||
console.log('poMetrics.total_retail:', poMetrics.total_retail);
|
|
||||||
|
|
||||||
const [vendorOrders] = await executeQuery(`
|
const [vendorOrders] = await executeQuery(`
|
||||||
SELECT
|
SELECT
|
||||||
po.vendor,
|
po.vendor,
|
||||||
COUNT(DISTINCT po.po_id) as order_count,
|
COUNT(DISTINCT po.po_id) as orders,
|
||||||
COALESCE(SUM(po.ordered), 0) as ordered_units,
|
COALESCE(SUM(po.ordered), 0) as units,
|
||||||
COALESCE(SUM(po.ordered * po.cost_price), 0) as order_cost,
|
CAST(COALESCE(SUM(po.ordered * po.cost_price), 0) AS DECIMAL(15,3)) as cost,
|
||||||
COALESCE(SUM(po.ordered * p.price), 0) as order_retail
|
CAST(COALESCE(SUM(po.ordered * p.price), 0) AS DECIMAL(15,3)) as retail
|
||||||
FROM purchase_orders po
|
FROM purchase_orders po
|
||||||
JOIN products p ON po.product_id = p.product_id
|
JOIN products p ON po.pid = p.pid
|
||||||
WHERE po.status = 'open'
|
WHERE po.receiving_status < ${ReceivingStatus.PartialReceived}
|
||||||
GROUP BY po.vendor
|
GROUP BY po.vendor
|
||||||
HAVING order_cost > 0
|
HAVING cost > 0
|
||||||
ORDER BY order_cost DESC
|
ORDER BY cost DESC
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Format response to match PurchaseMetricsData interface
|
||||||
const response = {
|
const response = {
|
||||||
activePurchaseOrders: parseInt(poMetrics.active_pos) || 0,
|
activePurchaseOrders: parseInt(poMetrics.active_pos) || 0,
|
||||||
overduePurchaseOrders: parseInt(poMetrics.overdue_pos) || 0,
|
overduePurchaseOrders: parseInt(poMetrics.overdue_pos) || 0,
|
||||||
@@ -152,10 +156,10 @@ router.get('/purchase/metrics', async (req, res) => {
|
|||||||
onOrderRetail: parseFloat(poMetrics.total_retail) || 0,
|
onOrderRetail: parseFloat(poMetrics.total_retail) || 0,
|
||||||
vendorOrders: vendorOrders.map(v => ({
|
vendorOrders: vendorOrders.map(v => ({
|
||||||
vendor: v.vendor,
|
vendor: v.vendor,
|
||||||
orders: parseInt(v.order_count) || 0,
|
orders: parseInt(v.orders) || 0,
|
||||||
units: parseInt(v.ordered_units) || 0,
|
units: parseInt(v.units) || 0,
|
||||||
cost: parseFloat(v.order_cost) || 0,
|
cost: parseFloat(v.cost) || 0,
|
||||||
retail: parseFloat(v.order_retail) || 0
|
retail: parseFloat(v.retail) || 0
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -173,21 +177,21 @@ router.get('/replenishment/metrics', async (req, res) => {
|
|||||||
// Get summary metrics
|
// Get summary metrics
|
||||||
const [metrics] = await executeQuery(`
|
const [metrics] = await executeQuery(`
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(DISTINCT p.product_id) as products_to_replenish,
|
COUNT(DISTINCT p.pid) as products_to_replenish,
|
||||||
COALESCE(SUM(CASE
|
COALESCE(SUM(CASE
|
||||||
WHEN p.stock_quantity < 0 THEN ABS(p.stock_quantity) + pm.reorder_qty
|
WHEN p.stock_quantity < 0 THEN ABS(p.stock_quantity) + pm.reorder_qty
|
||||||
ELSE pm.reorder_qty
|
ELSE pm.reorder_qty
|
||||||
END), 0) as total_units_needed,
|
END), 0) as total_units_needed,
|
||||||
COALESCE(SUM(CASE
|
CAST(COALESCE(SUM(CASE
|
||||||
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.cost_price
|
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.cost_price
|
||||||
ELSE pm.reorder_qty * p.cost_price
|
ELSE pm.reorder_qty * p.cost_price
|
||||||
END), 0) as total_cost,
|
END), 0) AS DECIMAL(15,3)) as total_cost,
|
||||||
COALESCE(SUM(CASE
|
CAST(COALESCE(SUM(CASE
|
||||||
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.price
|
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.price
|
||||||
ELSE pm.reorder_qty * p.price
|
ELSE pm.reorder_qty * p.price
|
||||||
END), 0) as total_retail
|
END), 0) AS DECIMAL(15,3)) as total_retail
|
||||||
FROM products p
|
FROM products p
|
||||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
WHERE p.replenishable = true
|
WHERE p.replenishable = true
|
||||||
AND (pm.stock_status IN ('Critical', 'Reorder')
|
AND (pm.stock_status IN ('Critical', 'Reorder')
|
||||||
OR p.stock_quantity < 0)
|
OR p.stock_quantity < 0)
|
||||||
@@ -197,24 +201,24 @@ router.get('/replenishment/metrics', async (req, res) => {
|
|||||||
// Get top variants to replenish
|
// Get top variants to replenish
|
||||||
const [variants] = await executeQuery(`
|
const [variants] = await executeQuery(`
|
||||||
SELECT
|
SELECT
|
||||||
p.product_id,
|
p.pid,
|
||||||
p.title,
|
p.title,
|
||||||
p.stock_quantity as current_stock,
|
p.stock_quantity as current_stock,
|
||||||
CASE
|
CASE
|
||||||
WHEN p.stock_quantity < 0 THEN ABS(p.stock_quantity) + pm.reorder_qty
|
WHEN p.stock_quantity < 0 THEN ABS(p.stock_quantity) + pm.reorder_qty
|
||||||
ELSE pm.reorder_qty
|
ELSE pm.reorder_qty
|
||||||
END as replenish_qty,
|
END as replenish_qty,
|
||||||
CASE
|
CAST(CASE
|
||||||
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.cost_price
|
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.cost_price
|
||||||
ELSE pm.reorder_qty * p.cost_price
|
ELSE pm.reorder_qty * p.cost_price
|
||||||
END as replenish_cost,
|
END AS DECIMAL(15,3)) as replenish_cost,
|
||||||
CASE
|
CAST(CASE
|
||||||
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.price
|
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.price
|
||||||
ELSE pm.reorder_qty * p.price
|
ELSE pm.reorder_qty * p.price
|
||||||
END as replenish_retail,
|
END AS DECIMAL(15,3)) as replenish_retail,
|
||||||
pm.stock_status
|
pm.stock_status
|
||||||
FROM products p
|
FROM products p
|
||||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
WHERE p.replenishable = true
|
WHERE p.replenishable = true
|
||||||
AND (pm.stock_status IN ('Critical', 'Reorder')
|
AND (pm.stock_status IN ('Critical', 'Reorder')
|
||||||
OR p.stock_quantity < 0)
|
OR p.stock_quantity < 0)
|
||||||
@@ -235,7 +239,7 @@ router.get('/replenishment/metrics', async (req, res) => {
|
|||||||
replenishmentCost: parseFloat(metrics[0].total_cost) || 0,
|
replenishmentCost: parseFloat(metrics[0].total_cost) || 0,
|
||||||
replenishmentRetail: parseFloat(metrics[0].total_retail) || 0,
|
replenishmentRetail: parseFloat(metrics[0].total_retail) || 0,
|
||||||
topVariants: variants.map(v => ({
|
topVariants: variants.map(v => ({
|
||||||
id: v.product_id,
|
id: v.pid,
|
||||||
title: v.title,
|
title: v.title,
|
||||||
currentStock: parseInt(v.current_stock) || 0,
|
currentStock: parseInt(v.current_stock) || 0,
|
||||||
replenishQty: parseInt(v.replenish_qty) || 0,
|
replenishQty: parseInt(v.replenish_qty) || 0,
|
||||||
@@ -287,9 +291,9 @@ router.get('/forecast/metrics', async (req, res) => {
|
|||||||
COALESCE(SUM(cf.forecast_revenue), 0) as revenue,
|
COALESCE(SUM(cf.forecast_revenue), 0) as revenue,
|
||||||
COALESCE(AVG(cf.confidence_level), 0) as confidence
|
COALESCE(AVG(cf.confidence_level), 0) as confidence
|
||||||
FROM category_forecasts cf
|
FROM category_forecasts cf
|
||||||
JOIN categories c ON cf.category_id = c.id
|
JOIN categories c ON cf.category_id = c.cat_id
|
||||||
WHERE cf.forecast_date BETWEEN ? AND ?
|
WHERE cf.forecast_date BETWEEN ? AND ?
|
||||||
GROUP BY c.id, c.name
|
GROUP BY c.cat_id, c.name
|
||||||
ORDER BY revenue DESC
|
ORDER BY revenue DESC
|
||||||
`, [startDate, endDate]);
|
`, [startDate, endDate]);
|
||||||
|
|
||||||
@@ -325,11 +329,11 @@ router.get('/overstock/metrics', async (req, res) => {
|
|||||||
const [rows] = await executeQuery(`
|
const [rows] = await executeQuery(`
|
||||||
WITH category_overstock AS (
|
WITH category_overstock AS (
|
||||||
SELECT
|
SELECT
|
||||||
c.id as category_id,
|
c.cat_id,
|
||||||
c.name as category_name,
|
c.name as category_name,
|
||||||
COUNT(DISTINCT CASE
|
COUNT(DISTINCT CASE
|
||||||
WHEN pm.stock_status = 'Overstocked'
|
WHEN pm.stock_status = 'Overstocked'
|
||||||
THEN p.product_id
|
THEN p.pid
|
||||||
END) as overstocked_products,
|
END) as overstocked_products,
|
||||||
SUM(CASE
|
SUM(CASE
|
||||||
WHEN pm.stock_status = 'Overstocked'
|
WHEN pm.stock_status = 'Overstocked'
|
||||||
@@ -347,10 +351,10 @@ router.get('/overstock/metrics', async (req, res) => {
|
|||||||
ELSE 0
|
ELSE 0
|
||||||
END) as total_excess_retail
|
END) as total_excess_retail
|
||||||
FROM categories c
|
FROM categories c
|
||||||
JOIN product_categories pc ON c.id = pc.category_id
|
JOIN product_categories pc ON c.cat_id = pc.cat_id
|
||||||
JOIN products p ON pc.product_id = p.product_id
|
JOIN products p ON pc.pid = p.pid
|
||||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
GROUP BY c.id, c.name
|
GROUP BY c.cat_id, c.name
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
SUM(overstocked_products) as total_overstocked,
|
SUM(overstocked_products) as total_overstocked,
|
||||||
@@ -405,7 +409,7 @@ router.get('/overstock/products', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const [rows] = await executeQuery(`
|
const [rows] = await executeQuery(`
|
||||||
SELECT
|
SELECT
|
||||||
p.product_id,
|
p.pid,
|
||||||
p.SKU,
|
p.SKU,
|
||||||
p.title,
|
p.title,
|
||||||
p.brand,
|
p.brand,
|
||||||
@@ -420,11 +424,11 @@ router.get('/overstock/products', async (req, res) => {
|
|||||||
(pm.overstocked_amt * p.price) as excess_retail,
|
(pm.overstocked_amt * p.price) as excess_retail,
|
||||||
GROUP_CONCAT(c.name) as categories
|
GROUP_CONCAT(c.name) as categories
|
||||||
FROM products p
|
FROM products p
|
||||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
LEFT JOIN product_categories pc ON p.product_id = pc.product_id
|
LEFT JOIN product_categories pc ON p.pid = pc.pid
|
||||||
LEFT JOIN categories c ON pc.category_id = c.id
|
LEFT JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
WHERE pm.stock_status = 'Overstocked'
|
WHERE pm.stock_status = 'Overstocked'
|
||||||
GROUP BY p.product_id
|
GROUP BY p.pid
|
||||||
ORDER BY excess_cost DESC
|
ORDER BY excess_cost DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`, [limit]);
|
`, [limit]);
|
||||||
@@ -439,196 +443,116 @@ router.get('/overstock/products', async (req, res) => {
|
|||||||
// Returns best-selling products, vendors, and categories
|
// Returns best-selling products, vendors, and categories
|
||||||
router.get('/best-sellers', async (req, res) => {
|
router.get('/best-sellers', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [products] = await executeQuery(`
|
const pool = req.app.locals.pool;
|
||||||
WITH product_sales AS (
|
|
||||||
|
// Common CTE for category paths
|
||||||
|
const categoryPathCTE = `
|
||||||
|
WITH RECURSIVE category_path AS (
|
||||||
SELECT
|
SELECT
|
||||||
p.product_id,
|
c.cat_id,
|
||||||
|
c.name,
|
||||||
|
c.parent_id,
|
||||||
|
CAST(c.name AS CHAR(1000)) as path
|
||||||
|
FROM categories c
|
||||||
|
WHERE c.parent_id IS NULL
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
c.cat_id,
|
||||||
|
c.name,
|
||||||
|
c.parent_id,
|
||||||
|
CONCAT(cp.path, ' > ', c.name)
|
||||||
|
FROM categories c
|
||||||
|
JOIN category_path cp ON c.parent_id = cp.cat_id
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Get best selling products
|
||||||
|
const [products] = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
p.pid,
|
||||||
p.SKU as sku,
|
p.SKU as sku,
|
||||||
p.title,
|
p.title,
|
||||||
-- Current period (last 30 days)
|
SUM(o.quantity) as units_sold,
|
||||||
SUM(CASE
|
CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as revenue,
|
||||||
WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY)
|
CAST(SUM(o.price * o.quantity - p.cost_price * o.quantity) AS DECIMAL(15,3)) as profit
|
||||||
THEN o.quantity
|
|
||||||
ELSE 0
|
|
||||||
END) as units_sold,
|
|
||||||
SUM(CASE
|
|
||||||
WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY)
|
|
||||||
THEN o.price * o.quantity
|
|
||||||
ELSE 0
|
|
||||||
END) as revenue,
|
|
||||||
SUM(CASE
|
|
||||||
WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY)
|
|
||||||
THEN (o.price - p.cost_price) * o.quantity
|
|
||||||
ELSE 0
|
|
||||||
END) as profit,
|
|
||||||
-- Previous period (30-60 days ago)
|
|
||||||
SUM(CASE
|
|
||||||
WHEN o.date BETWEEN DATE_SUB(CURRENT_DATE, INTERVAL 60 DAY) AND DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY)
|
|
||||||
THEN o.price * o.quantity
|
|
||||||
ELSE 0
|
|
||||||
END) as previous_revenue
|
|
||||||
FROM products p
|
FROM products p
|
||||||
JOIN orders o ON p.product_id = o.product_id
|
JOIN orders o ON p.pid = o.pid
|
||||||
WHERE o.canceled = false
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 60 DAY)
|
AND o.canceled = false
|
||||||
GROUP BY p.product_id, p.SKU, p.title
|
GROUP BY p.pid
|
||||||
)
|
ORDER BY units_sold DESC
|
||||||
SELECT
|
LIMIT 10
|
||||||
product_id,
|
|
||||||
sku,
|
|
||||||
title,
|
|
||||||
units_sold,
|
|
||||||
revenue,
|
|
||||||
profit,
|
|
||||||
CASE
|
|
||||||
WHEN previous_revenue > 0
|
|
||||||
THEN ((revenue - previous_revenue) / previous_revenue * 100)
|
|
||||||
WHEN revenue > 0
|
|
||||||
THEN 100
|
|
||||||
ELSE 0
|
|
||||||
END as growth_rate
|
|
||||||
FROM product_sales
|
|
||||||
WHERE units_sold > 0
|
|
||||||
ORDER BY revenue DESC
|
|
||||||
LIMIT 50
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const [brands] = await executeQuery(`
|
// Get best selling brands
|
||||||
WITH brand_sales AS (
|
const [brands] = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
p.brand,
|
p.brand,
|
||||||
-- Current period (last 30 days)
|
SUM(o.quantity) as units_sold,
|
||||||
SUM(CASE
|
CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as revenue,
|
||||||
WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY)
|
CAST(SUM(o.price * o.quantity - p.cost_price * o.quantity) AS DECIMAL(15,3)) as profit,
|
||||||
THEN o.quantity
|
ROUND(
|
||||||
ELSE 0
|
((SUM(CASE
|
||||||
END) as units_sold,
|
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
SUM(CASE
|
|
||||||
WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY)
|
|
||||||
THEN o.price * o.quantity
|
THEN o.price * o.quantity
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END) as revenue,
|
END) /
|
||||||
SUM(CASE
|
NULLIF(SUM(CASE
|
||||||
WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY)
|
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
||||||
THEN (o.price - p.cost_price) * o.quantity
|
AND o.date < DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
ELSE 0
|
|
||||||
END) as profit,
|
|
||||||
-- Previous period (30-60 days ago)
|
|
||||||
SUM(CASE
|
|
||||||
WHEN o.date BETWEEN DATE_SUB(CURRENT_DATE, INTERVAL 60 DAY) AND DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY)
|
|
||||||
THEN o.price * o.quantity
|
THEN o.price * o.quantity
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END) as previous_revenue
|
END), 0)) - 1) * 100,
|
||||||
|
1
|
||||||
|
) as growth_rate
|
||||||
FROM products p
|
FROM products p
|
||||||
JOIN orders o ON p.product_id = o.product_id
|
JOIN orders o ON p.pid = o.pid
|
||||||
WHERE o.canceled = false
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
||||||
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 60 DAY)
|
AND o.canceled = false
|
||||||
AND p.brand IS NOT NULL
|
|
||||||
GROUP BY p.brand
|
GROUP BY p.brand
|
||||||
)
|
ORDER BY units_sold DESC
|
||||||
SELECT
|
LIMIT 10
|
||||||
brand,
|
|
||||||
units_sold,
|
|
||||||
revenue,
|
|
||||||
profit,
|
|
||||||
CASE
|
|
||||||
WHEN previous_revenue > 0
|
|
||||||
THEN ((revenue - previous_revenue) / previous_revenue * 100)
|
|
||||||
WHEN revenue > 0
|
|
||||||
THEN 100
|
|
||||||
ELSE 0
|
|
||||||
END as growth_rate
|
|
||||||
FROM brand_sales
|
|
||||||
WHERE units_sold > 0
|
|
||||||
ORDER BY revenue DESC
|
|
||||||
LIMIT 50
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const [categories] = await executeQuery(`
|
// Get best selling categories with full path
|
||||||
WITH category_sales AS (
|
const [categories] = await pool.query(`
|
||||||
|
${categoryPathCTE}
|
||||||
SELECT
|
SELECT
|
||||||
c.id as category_id,
|
c.cat_id,
|
||||||
c.name,
|
c.name,
|
||||||
-- Current period (last 30 days)
|
cp.path as categoryPath,
|
||||||
SUM(CASE
|
SUM(o.quantity) as units_sold,
|
||||||
WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY)
|
CAST(SUM(o.price * o.quantity) AS DECIMAL(15,3)) as revenue,
|
||||||
THEN o.quantity
|
CAST(SUM(o.price * o.quantity - p.cost_price * o.quantity) AS DECIMAL(15,3)) as profit,
|
||||||
ELSE 0
|
ROUND(
|
||||||
END) as units_sold,
|
((SUM(CASE
|
||||||
SUM(CASE
|
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY)
|
|
||||||
THEN o.price * o.quantity
|
THEN o.price * o.quantity
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END) as revenue,
|
END) /
|
||||||
SUM(CASE
|
NULLIF(SUM(CASE
|
||||||
WHEN o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY)
|
WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
||||||
THEN (o.price - p.cost_price) * o.quantity
|
AND o.date < DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
ELSE 0
|
|
||||||
END) as profit,
|
|
||||||
-- Previous period (30-60 days ago)
|
|
||||||
SUM(CASE
|
|
||||||
WHEN o.date BETWEEN DATE_SUB(CURRENT_DATE, INTERVAL 60 DAY) AND DATE_SUB(CURRENT_DATE, INTERVAL 30 DAY)
|
|
||||||
THEN o.price * o.quantity
|
THEN o.price * o.quantity
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END) as previous_revenue
|
END), 0)) - 1) * 100,
|
||||||
FROM categories c
|
1
|
||||||
JOIN product_categories pc ON c.id = pc.category_id
|
) as growth_rate
|
||||||
JOIN products p ON pc.product_id = p.product_id
|
FROM products p
|
||||||
JOIN orders o ON p.product_id = o.product_id
|
JOIN orders o ON p.pid = o.pid
|
||||||
WHERE o.canceled = false
|
JOIN product_categories pc ON p.pid = pc.pid
|
||||||
AND o.date >= DATE_SUB(CURRENT_DATE, INTERVAL 60 DAY)
|
JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
GROUP BY c.id, c.name
|
JOIN category_path cp ON c.cat_id = cp.cat_id
|
||||||
)
|
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 60 DAY)
|
||||||
SELECT
|
AND o.canceled = false
|
||||||
category_id,
|
GROUP BY c.cat_id, c.name, cp.path
|
||||||
name,
|
ORDER BY units_sold DESC
|
||||||
units_sold,
|
LIMIT 10
|
||||||
revenue,
|
|
||||||
profit,
|
|
||||||
CASE
|
|
||||||
WHEN previous_revenue > 0
|
|
||||||
THEN ((revenue - previous_revenue) / previous_revenue * 100)
|
|
||||||
WHEN revenue > 0
|
|
||||||
THEN 100
|
|
||||||
ELSE 0
|
|
||||||
END as growth_rate
|
|
||||||
FROM category_sales
|
|
||||||
WHERE units_sold > 0
|
|
||||||
ORDER BY revenue DESC
|
|
||||||
LIMIT 50
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Format response with explicit type conversion
|
res.json({ products, brands, categories });
|
||||||
const formattedProducts = products.map(p => ({
|
|
||||||
...p,
|
|
||||||
units_sold: parseInt(p.units_sold) || 0,
|
|
||||||
revenue: parseFloat(p.revenue) || 0,
|
|
||||||
profit: parseFloat(p.profit) || 0,
|
|
||||||
growth_rate: parseFloat(p.growth_rate) || 0
|
|
||||||
}));
|
|
||||||
|
|
||||||
const formattedBrands = brands.map(b => ({
|
|
||||||
brand: b.brand,
|
|
||||||
units_sold: parseInt(b.units_sold) || 0,
|
|
||||||
revenue: parseFloat(b.revenue) || 0,
|
|
||||||
profit: parseFloat(b.profit) || 0,
|
|
||||||
growth_rate: parseFloat(b.growth_rate) || 0
|
|
||||||
}));
|
|
||||||
|
|
||||||
const formattedCategories = categories.map(c => ({
|
|
||||||
category_id: c.category_id,
|
|
||||||
name: c.name,
|
|
||||||
units_sold: parseInt(c.units_sold) || 0,
|
|
||||||
revenue: parseFloat(c.revenue) || 0,
|
|
||||||
profit: parseFloat(c.profit) || 0,
|
|
||||||
growth_rate: parseFloat(c.growth_rate) || 0
|
|
||||||
}));
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
products: formattedProducts,
|
|
||||||
brands: formattedBrands,
|
|
||||||
categories: formattedCategories
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching best sellers:', err);
|
console.error('Error fetching best sellers:', err);
|
||||||
res.status(500).json({ error: 'Failed to fetch best sellers' });
|
res.status(500).json({ error: 'Failed to fetch best sellers' });
|
||||||
@@ -650,7 +574,7 @@ router.get('/sales/metrics', async (req, res) => {
|
|||||||
SUM(p.cost_price * o.quantity) as total_cogs,
|
SUM(p.cost_price * o.quantity) as total_cogs,
|
||||||
SUM((o.price - p.cost_price) * o.quantity) as total_profit
|
SUM((o.price - p.cost_price) * o.quantity) as total_profit
|
||||||
FROM orders o
|
FROM orders o
|
||||||
JOIN products p ON o.product_id = p.product_id
|
JOIN products p ON o.pid = p.pid
|
||||||
WHERE o.canceled = false
|
WHERE o.canceled = false
|
||||||
AND o.date BETWEEN ? AND ?
|
AND o.date BETWEEN ? AND ?
|
||||||
GROUP BY DATE(o.date)
|
GROUP BY DATE(o.date)
|
||||||
@@ -666,7 +590,7 @@ router.get('/sales/metrics', async (req, res) => {
|
|||||||
SUM(p.cost_price * o.quantity) as total_cogs,
|
SUM(p.cost_price * o.quantity) as total_cogs,
|
||||||
SUM((o.price - p.cost_price) * o.quantity) as total_profit
|
SUM((o.price - p.cost_price) * o.quantity) as total_profit
|
||||||
FROM orders o
|
FROM orders o
|
||||||
JOIN products p ON o.product_id = p.product_id
|
JOIN products p ON o.pid = p.pid
|
||||||
WHERE o.canceled = false
|
WHERE o.canceled = false
|
||||||
AND o.date BETWEEN ? AND ?
|
AND o.date BETWEEN ? AND ?
|
||||||
`, [startDate, endDate]);
|
`, [startDate, endDate]);
|
||||||
@@ -698,7 +622,7 @@ router.get('/low-stock/products', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const [rows] = await executeQuery(`
|
const [rows] = await executeQuery(`
|
||||||
SELECT
|
SELECT
|
||||||
p.product_id,
|
p.pid,
|
||||||
p.SKU,
|
p.SKU,
|
||||||
p.title,
|
p.title,
|
||||||
p.brand,
|
p.brand,
|
||||||
@@ -712,12 +636,12 @@ router.get('/low-stock/products', async (req, res) => {
|
|||||||
(pm.reorder_qty * p.cost_price) as reorder_cost,
|
(pm.reorder_qty * p.cost_price) as reorder_cost,
|
||||||
GROUP_CONCAT(c.name) as categories
|
GROUP_CONCAT(c.name) as categories
|
||||||
FROM products p
|
FROM products p
|
||||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
LEFT JOIN product_categories pc ON p.product_id = pc.product_id
|
LEFT JOIN product_categories pc ON p.pid = pc.pid
|
||||||
LEFT JOIN categories c ON pc.category_id = c.id
|
LEFT JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
WHERE pm.stock_status IN ('Critical', 'Reorder')
|
WHERE pm.stock_status IN ('Critical', 'Reorder')
|
||||||
AND p.replenishable = true
|
AND p.replenishable = true
|
||||||
GROUP BY p.product_id
|
GROUP BY p.pid
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE pm.stock_status
|
CASE pm.stock_status
|
||||||
WHEN 'Critical' THEN 1
|
WHEN 'Critical' THEN 1
|
||||||
@@ -742,17 +666,17 @@ router.get('/trending/products', async (req, res) => {
|
|||||||
const [rows] = await executeQuery(`
|
const [rows] = await executeQuery(`
|
||||||
WITH recent_sales AS (
|
WITH recent_sales AS (
|
||||||
SELECT
|
SELECT
|
||||||
o.product_id,
|
o.pid,
|
||||||
COUNT(DISTINCT o.order_number) as recent_orders,
|
COUNT(DISTINCT o.order_number) as recent_orders,
|
||||||
SUM(o.quantity) as recent_units,
|
SUM(o.quantity) as recent_units,
|
||||||
SUM(o.price * o.quantity) as recent_revenue
|
SUM(o.price * o.quantity) as recent_revenue
|
||||||
FROM orders o
|
FROM orders o
|
||||||
WHERE o.canceled = false
|
WHERE o.canceled = false
|
||||||
AND o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
AND o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
|
||||||
GROUP BY o.product_id
|
GROUP BY o.pid
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
p.product_id,
|
p.pid,
|
||||||
p.SKU,
|
p.SKU,
|
||||||
p.title,
|
p.title,
|
||||||
p.brand,
|
p.brand,
|
||||||
@@ -767,15 +691,15 @@ router.get('/trending/products', async (req, res) => {
|
|||||||
((rs.recent_units / ?) - pm.daily_sales_avg) / pm.daily_sales_avg * 100 as velocity_change,
|
((rs.recent_units / ?) - pm.daily_sales_avg) / pm.daily_sales_avg * 100 as velocity_change,
|
||||||
GROUP_CONCAT(c.name) as categories
|
GROUP_CONCAT(c.name) as categories
|
||||||
FROM recent_sales rs
|
FROM recent_sales rs
|
||||||
JOIN products p ON rs.product_id = p.product_id
|
JOIN products p ON rs.pid = p.pid
|
||||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
LEFT JOIN product_categories pc ON p.product_id = pc.product_id
|
LEFT JOIN product_categories pc ON p.pid = pc.pid
|
||||||
LEFT JOIN categories c ON pc.category_id = c.id
|
LEFT JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
GROUP BY p.product_id
|
GROUP BY p.pid
|
||||||
HAVING velocity_change > 0
|
HAVING velocity_change > 0
|
||||||
ORDER BY velocity_change DESC
|
ORDER BY velocity_change DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`, [days, days, days, limit]);
|
`, [days, days, limit]);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching trending products:', err);
|
console.error('Error fetching trending products:', err);
|
||||||
@@ -859,7 +783,7 @@ router.get('/key-metrics', async (req, res) => {
|
|||||||
COUNT(CASE WHEN pm.stock_status = 'Critical' THEN 1 END) as critical_stock_count,
|
COUNT(CASE WHEN pm.stock_status = 'Critical' THEN 1 END) as critical_stock_count,
|
||||||
COUNT(CASE WHEN pm.stock_status = 'Overstocked' THEN 1 END) as overstock_count
|
COUNT(CASE WHEN pm.stock_status = 'Overstocked' THEN 1 END) as overstock_count
|
||||||
FROM products p
|
FROM products p
|
||||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
),
|
),
|
||||||
sales_summary AS (
|
sales_summary AS (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -909,7 +833,7 @@ router.get('/inventory-health', async (req, res) => {
|
|||||||
AVG(pm.turnover_rate) as avg_turnover_rate,
|
AVG(pm.turnover_rate) as avg_turnover_rate,
|
||||||
AVG(pm.days_of_inventory) as avg_days_inventory
|
AVG(pm.days_of_inventory) as avg_days_inventory
|
||||||
FROM products p
|
FROM products p
|
||||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
WHERE p.replenishable = true
|
WHERE p.replenishable = true
|
||||||
),
|
),
|
||||||
value_distribution AS (
|
value_distribution AS (
|
||||||
@@ -931,7 +855,7 @@ router.get('/inventory-health', async (req, res) => {
|
|||||||
ELSE 0
|
ELSE 0
|
||||||
END) * 100.0 / SUM(p.stock_quantity * p.cost_price) as overstock_value_percent
|
END) * 100.0 / SUM(p.stock_quantity * p.cost_price) as overstock_value_percent
|
||||||
FROM products p
|
FROM products p
|
||||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
),
|
),
|
||||||
category_health AS (
|
category_health AS (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -940,11 +864,11 @@ router.get('/inventory-health', async (req, res) => {
|
|||||||
SUM(CASE WHEN pm.stock_status = 'Healthy' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) as category_healthy_percent,
|
SUM(CASE WHEN pm.stock_status = 'Healthy' THEN 1 ELSE 0 END) * 100.0 / COUNT(*) as category_healthy_percent,
|
||||||
AVG(pm.turnover_rate) as category_turnover_rate
|
AVG(pm.turnover_rate) as category_turnover_rate
|
||||||
FROM categories c
|
FROM categories c
|
||||||
JOIN product_categories pc ON c.id = pc.category_id
|
JOIN product_categories pc ON c.cat_id = pc.cat_id
|
||||||
JOIN products p ON pc.product_id = p.product_id
|
JOIN products p ON pc.pid = p.pid
|
||||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
WHERE p.replenishable = true
|
WHERE p.replenishable = true
|
||||||
GROUP BY c.id, c.name
|
GROUP BY c.cat_id, c.name
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
sd.*,
|
sd.*,
|
||||||
@@ -975,20 +899,15 @@ router.get('/replenish/products', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const [products] = await executeQuery(`
|
const [products] = await executeQuery(`
|
||||||
SELECT
|
SELECT
|
||||||
p.product_id,
|
p.pid,
|
||||||
p.SKU,
|
p.SKU as sku,
|
||||||
p.title,
|
p.title,
|
||||||
p.stock_quantity as current_stock,
|
p.stock_quantity,
|
||||||
pm.reorder_qty as replenish_qty,
|
pm.daily_sales_avg,
|
||||||
(pm.reorder_qty * p.cost_price) as replenish_cost,
|
pm.reorder_qty,
|
||||||
(pm.reorder_qty * p.price) as replenish_retail,
|
pm.last_purchase_date
|
||||||
CASE
|
|
||||||
WHEN pm.daily_sales_avg > 0
|
|
||||||
THEN FLOOR(p.stock_quantity / pm.daily_sales_avg)
|
|
||||||
ELSE NULL
|
|
||||||
END as days_until_stockout
|
|
||||||
FROM products p
|
FROM products p
|
||||||
JOIN product_metrics pm ON p.product_id = pm.product_id
|
JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
WHERE p.replenishable = true
|
WHERE p.replenishable = true
|
||||||
AND pm.stock_status IN ('Critical', 'Reorder')
|
AND pm.stock_status IN ('Critical', 'Reorder')
|
||||||
AND pm.reorder_qty > 0
|
AND pm.reorder_qty > 0
|
||||||
@@ -997,23 +916,16 @@ router.get('/replenish/products', async (req, res) => {
|
|||||||
WHEN 'Critical' THEN 1
|
WHEN 'Critical' THEN 1
|
||||||
WHEN 'Reorder' THEN 2
|
WHEN 'Reorder' THEN 2
|
||||||
END,
|
END,
|
||||||
replenish_cost DESC
|
pm.reorder_qty * p.cost_price DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`, [limit]);
|
`, [limit]);
|
||||||
|
|
||||||
// Format response
|
res.json(products.map(p => ({
|
||||||
const response = products.map(p => ({
|
...p,
|
||||||
product_id: p.product_id,
|
stock_quantity: parseInt(p.stock_quantity) || 0,
|
||||||
SKU: p.SKU,
|
daily_sales_avg: parseFloat(p.daily_sales_avg) || 0,
|
||||||
title: p.title,
|
reorder_qty: parseInt(p.reorder_qty) || 0
|
||||||
current_stock: parseInt(p.current_stock) || 0,
|
})));
|
||||||
replenish_qty: parseInt(p.replenish_qty) || 0,
|
|
||||||
replenish_cost: parseFloat(p.replenish_cost) || 0,
|
|
||||||
replenish_retail: parseFloat(p.replenish_retail) || 0,
|
|
||||||
days_until_stockout: p.days_until_stockout
|
|
||||||
}));
|
|
||||||
|
|
||||||
res.json(response);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching products to replenish:', err);
|
console.error('Error fetching products to replenish:', err);
|
||||||
res.status(500).json({ error: 'Failed to fetch products to replenish' });
|
res.status(500).json({ error: 'Failed to fetch products to replenish' });
|
||||||
|
|||||||
@@ -9,25 +9,25 @@ router.get('/trends', async (req, res) => {
|
|||||||
WITH MonthlyMetrics AS (
|
WITH MonthlyMetrics AS (
|
||||||
SELECT
|
SELECT
|
||||||
DATE(CONCAT(pta.year, '-', LPAD(pta.month, 2, '0'), '-01')) as date,
|
DATE(CONCAT(pta.year, '-', LPAD(pta.month, 2, '0'), '-01')) as date,
|
||||||
SUM(pta.total_revenue) as revenue,
|
CAST(COALESCE(SUM(pta.total_revenue), 0) AS DECIMAL(15,3)) as revenue,
|
||||||
SUM(pta.total_cost) as cost,
|
CAST(COALESCE(SUM(pta.total_cost), 0) AS DECIMAL(15,3)) as cost,
|
||||||
SUM(pm.inventory_value) as inventory_value,
|
CAST(COALESCE(SUM(pm.inventory_value), 0) AS DECIMAL(15,3)) as inventory_value,
|
||||||
CASE
|
CASE
|
||||||
WHEN SUM(pm.inventory_value) > 0
|
WHEN SUM(pm.inventory_value) > 0
|
||||||
THEN (SUM(pta.total_revenue - pta.total_cost) / SUM(pm.inventory_value)) * 100
|
THEN CAST((SUM(pta.total_revenue - pta.total_cost) / SUM(pm.inventory_value)) * 100 AS DECIMAL(15,3))
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END as gmroi
|
END as gmroi
|
||||||
FROM product_time_aggregates pta
|
FROM product_time_aggregates pta
|
||||||
JOIN product_metrics pm ON pta.product_id = pm.product_id
|
JOIN product_metrics pm ON pta.pid = pm.pid
|
||||||
WHERE (pta.year * 100 + pta.month) >= DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 12 MONTH), '%Y%m')
|
WHERE (pta.year * 100 + pta.month) >= DATE_FORMAT(DATE_SUB(CURDATE(), INTERVAL 12 MONTH), '%Y%m')
|
||||||
GROUP BY pta.year, pta.month
|
GROUP BY pta.year, pta.month
|
||||||
ORDER BY date ASC
|
ORDER BY date ASC
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
DATE_FORMAT(date, '%b %y') as date,
|
DATE_FORMAT(date, '%b %y') as date,
|
||||||
ROUND(revenue, 2) as revenue,
|
revenue,
|
||||||
ROUND(inventory_value, 2) as inventory_value,
|
inventory_value,
|
||||||
ROUND(gmroi, 2) as gmroi
|
gmroi
|
||||||
FROM MonthlyMetrics
|
FROM MonthlyMetrics
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -37,15 +37,15 @@ router.get('/trends', async (req, res) => {
|
|||||||
const transformedData = {
|
const transformedData = {
|
||||||
revenue: rows.map(row => ({
|
revenue: rows.map(row => ({
|
||||||
date: row.date,
|
date: row.date,
|
||||||
value: parseFloat(row.revenue || 0)
|
value: parseFloat(row.revenue)
|
||||||
})),
|
})),
|
||||||
inventory_value: rows.map(row => ({
|
inventory_value: rows.map(row => ({
|
||||||
date: row.date,
|
date: row.date,
|
||||||
value: parseFloat(row.inventory_value || 0)
|
value: parseFloat(row.inventory_value)
|
||||||
})),
|
})),
|
||||||
gmroi: rows.map(row => ({
|
gmroi: rows.map(row => ({
|
||||||
date: row.date,
|
date: row.date,
|
||||||
value: parseFloat(row.gmroi || 0)
|
value: parseFloat(row.gmroi)
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ router.get('/', async (req, res) => {
|
|||||||
o1.status,
|
o1.status,
|
||||||
o1.payment_method,
|
o1.payment_method,
|
||||||
o1.shipping_method,
|
o1.shipping_method,
|
||||||
COUNT(o2.product_id) as items_count,
|
COUNT(o2.pid) as items_count,
|
||||||
SUM(o2.price * o2.quantity) as total_amount
|
CAST(SUM(o2.price * o2.quantity) AS DECIMAL(15,3)) as total_amount
|
||||||
FROM orders o1
|
FROM orders o1
|
||||||
JOIN orders o2 ON o1.order_number = o2.order_number
|
JOIN orders o2 ON o1.order_number = o2.order_number
|
||||||
WHERE ${conditions.join(' AND ')}
|
WHERE ${conditions.join(' AND ')}
|
||||||
@@ -101,7 +101,7 @@ router.get('/', async (req, res) => {
|
|||||||
WITH CurrentStats AS (
|
WITH CurrentStats AS (
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(DISTINCT order_number) as total_orders,
|
COUNT(DISTINCT order_number) as total_orders,
|
||||||
SUM(price * quantity) as total_revenue
|
CAST(SUM(price * quantity) AS DECIMAL(15,3)) as total_revenue
|
||||||
FROM orders
|
FROM orders
|
||||||
WHERE canceled = false
|
WHERE canceled = false
|
||||||
AND DATE(date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
AND DATE(date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
@@ -109,7 +109,7 @@ router.get('/', async (req, res) => {
|
|||||||
PreviousStats AS (
|
PreviousStats AS (
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(DISTINCT order_number) as prev_orders,
|
COUNT(DISTINCT order_number) as prev_orders,
|
||||||
SUM(price * quantity) as prev_revenue
|
CAST(SUM(price * quantity) AS DECIMAL(15,3)) as prev_revenue
|
||||||
FROM orders
|
FROM orders
|
||||||
WHERE canceled = false
|
WHERE canceled = false
|
||||||
AND DATE(date) BETWEEN DATE_SUB(CURDATE(), INTERVAL 60 DAY) AND DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
AND DATE(date) BETWEEN DATE_SUB(CURDATE(), INTERVAL 60 DAY) AND DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
@@ -117,7 +117,7 @@ router.get('/', async (req, res) => {
|
|||||||
OrderValues AS (
|
OrderValues AS (
|
||||||
SELECT
|
SELECT
|
||||||
order_number,
|
order_number,
|
||||||
SUM(price * quantity) as order_value
|
CAST(SUM(price * quantity) AS DECIMAL(15,3)) as order_value
|
||||||
FROM orders
|
FROM orders
|
||||||
WHERE canceled = false
|
WHERE canceled = false
|
||||||
AND DATE(date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
AND DATE(date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||||
@@ -138,12 +138,12 @@ router.get('/', async (req, res) => {
|
|||||||
END as revenue_growth,
|
END as revenue_growth,
|
||||||
CASE
|
CASE
|
||||||
WHEN cs.total_orders > 0
|
WHEN cs.total_orders > 0
|
||||||
THEN (cs.total_revenue / cs.total_orders)
|
THEN CAST((cs.total_revenue / cs.total_orders) AS DECIMAL(15,3))
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END as average_order_value,
|
END as average_order_value,
|
||||||
CASE
|
CASE
|
||||||
WHEN ps.prev_orders > 0
|
WHEN ps.prev_orders > 0
|
||||||
THEN (ps.prev_revenue / ps.prev_orders)
|
THEN CAST((ps.prev_revenue / ps.prev_orders) AS DECIMAL(15,3))
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END as prev_average_order_value
|
END as prev_average_order_value
|
||||||
FROM CurrentStats cs
|
FROM CurrentStats cs
|
||||||
@@ -199,8 +199,8 @@ router.get('/:orderNumber', async (req, res) => {
|
|||||||
o1.shipping_method,
|
o1.shipping_method,
|
||||||
o1.shipping_address,
|
o1.shipping_address,
|
||||||
o1.billing_address,
|
o1.billing_address,
|
||||||
COUNT(o2.product_id) as items_count,
|
COUNT(o2.pid) as items_count,
|
||||||
SUM(o2.price * o2.quantity) as total_amount
|
CAST(SUM(o2.price * o2.quantity) AS DECIMAL(15,3)) as total_amount
|
||||||
FROM orders o1
|
FROM orders o1
|
||||||
JOIN orders o2 ON o1.order_number = o2.order_number
|
JOIN orders o2 ON o1.order_number = o2.order_number
|
||||||
WHERE o1.order_number = ? AND o1.canceled = false
|
WHERE o1.order_number = ? AND o1.canceled = false
|
||||||
@@ -222,14 +222,14 @@ router.get('/:orderNumber', async (req, res) => {
|
|||||||
// Get order items
|
// Get order items
|
||||||
const [itemRows] = await pool.query(`
|
const [itemRows] = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
o.product_id,
|
o.pid,
|
||||||
p.title,
|
p.title,
|
||||||
p.sku,
|
p.SKU,
|
||||||
o.quantity,
|
o.quantity,
|
||||||
o.price,
|
o.price,
|
||||||
(o.price * o.quantity) as total
|
CAST((o.price * o.quantity) AS DECIMAL(15,3)) as total
|
||||||
FROM orders o
|
FROM orders o
|
||||||
JOIN products p ON o.product_id = p.product_id
|
JOIN products p ON o.pid = p.pid
|
||||||
WHERE o.order_number = ? AND o.canceled = false
|
WHERE o.order_number = ? AND o.canceled = false
|
||||||
`, [req.params.orderNumber]);
|
`, [req.params.orderNumber]);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const express = require('express');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const { importProductsFromCSV } = require('../utils/csvImporter');
|
const { importProductsFromCSV } = require('../utils/csvImporter');
|
||||||
|
const { PurchaseOrderStatus, ReceivingStatus } = require('../types/status-codes');
|
||||||
|
|
||||||
// Configure multer for file uploads
|
// Configure multer for file uploads
|
||||||
const upload = multer({ dest: 'uploads/' });
|
const upload = multer({ dest: 'uploads/' });
|
||||||
@@ -20,15 +21,13 @@ router.get('/brands', async (req, res) => {
|
|||||||
console.log('Fetching brands from database...');
|
console.log('Fetching brands from database...');
|
||||||
|
|
||||||
const [results] = await pool.query(`
|
const [results] = await pool.query(`
|
||||||
SELECT DISTINCT p.brand
|
SELECT DISTINCT COALESCE(p.brand, 'Unbranded') as brand
|
||||||
FROM products p
|
FROM products p
|
||||||
JOIN purchase_orders po ON p.product_id = po.product_id
|
JOIN purchase_orders po ON p.pid = po.pid
|
||||||
WHERE p.brand IS NOT NULL
|
WHERE p.visible = true
|
||||||
AND p.brand != ''
|
GROUP BY COALESCE(p.brand, 'Unbranded')
|
||||||
AND p.visible = true
|
|
||||||
GROUP BY p.brand
|
|
||||||
HAVING SUM(po.cost_price * po.received) >= 500
|
HAVING SUM(po.cost_price * po.received) >= 500
|
||||||
ORDER BY p.brand
|
ORDER BY COALESCE(p.brand, 'Unbranded')
|
||||||
`);
|
`);
|
||||||
|
|
||||||
console.log(`Found ${results.length} brands:`, results.slice(0, 3));
|
console.log(`Found ${results.length} brands:`, results.slice(0, 3));
|
||||||
@@ -147,9 +146,9 @@ router.get('/', async (req, res) => {
|
|||||||
|
|
||||||
// Get total count for pagination
|
// Get total count for pagination
|
||||||
const countQuery = `
|
const countQuery = `
|
||||||
SELECT COUNT(DISTINCT p.product_id) as total
|
SELECT COUNT(DISTINCT p.pid) as total
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id
|
LEFT JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
${whereClause}
|
${whereClause}
|
||||||
`;
|
`;
|
||||||
const [countResult] = await pool.query(countQuery, params);
|
const [countResult] = await pool.query(countQuery, params);
|
||||||
@@ -163,20 +162,40 @@ router.get('/', async (req, res) => {
|
|||||||
'SELECT DISTINCT vendor FROM products WHERE visible = true AND vendor IS NOT NULL AND vendor != "" ORDER BY vendor'
|
'SELECT DISTINCT vendor FROM products WHERE visible = true AND vendor IS NOT NULL AND vendor != "" ORDER BY vendor'
|
||||||
);
|
);
|
||||||
const [brands] = await pool.query(
|
const [brands] = await pool.query(
|
||||||
'SELECT DISTINCT brand FROM products WHERE visible = true AND brand IS NOT NULL AND brand != "" ORDER BY brand'
|
'SELECT DISTINCT COALESCE(brand, \'Unbranded\') as brand FROM products WHERE visible = true ORDER BY brand'
|
||||||
);
|
);
|
||||||
|
|
||||||
// Main query with all fields
|
// Main query with all fields
|
||||||
const query = `
|
const query = `
|
||||||
WITH product_thresholds AS (
|
WITH RECURSIVE
|
||||||
|
category_path AS (
|
||||||
SELECT
|
SELECT
|
||||||
p.product_id,
|
c.cat_id,
|
||||||
|
c.name,
|
||||||
|
c.parent_id,
|
||||||
|
CAST(c.name AS CHAR(1000)) as path
|
||||||
|
FROM categories c
|
||||||
|
WHERE c.parent_id IS NULL
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
c.cat_id,
|
||||||
|
c.name,
|
||||||
|
c.parent_id,
|
||||||
|
CONCAT(cp.path, ' > ', c.name)
|
||||||
|
FROM categories c
|
||||||
|
JOIN category_path cp ON c.parent_id = cp.cat_id
|
||||||
|
),
|
||||||
|
product_thresholds AS (
|
||||||
|
SELECT
|
||||||
|
p.pid,
|
||||||
COALESCE(
|
COALESCE(
|
||||||
(SELECT overstock_days FROM stock_thresholds st
|
(SELECT overstock_days FROM stock_thresholds st
|
||||||
WHERE st.category_id IN (
|
WHERE st.category_id IN (
|
||||||
SELECT pc.category_id
|
SELECT pc.cat_id
|
||||||
FROM product_categories pc
|
FROM product_categories pc
|
||||||
WHERE pc.product_id = p.product_id
|
WHERE pc.pid = p.pid
|
||||||
)
|
)
|
||||||
AND (st.vendor = p.vendor OR st.vendor IS NULL)
|
AND (st.vendor = p.vendor OR st.vendor IS NULL)
|
||||||
ORDER BY st.vendor IS NULL
|
ORDER BY st.vendor IS NULL
|
||||||
@@ -189,10 +208,23 @@ router.get('/', async (req, res) => {
|
|||||||
90
|
90
|
||||||
) as target_days
|
) as target_days
|
||||||
FROM products p
|
FROM products p
|
||||||
|
),
|
||||||
|
product_leaf_categories AS (
|
||||||
|
-- Find categories that aren't parents to other categories for this product
|
||||||
|
SELECT DISTINCT pc.cat_id
|
||||||
|
FROM product_categories pc
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM categories child
|
||||||
|
JOIN product_categories child_pc ON child.cat_id = child_pc.cat_id
|
||||||
|
WHERE child.parent_id = pc.cat_id
|
||||||
|
AND child_pc.pid = pc.pid
|
||||||
|
)
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
p.*,
|
p.*,
|
||||||
GROUP_CONCAT(DISTINCT c.name) as categories,
|
COALESCE(p.brand, 'Unbranded') as brand,
|
||||||
|
GROUP_CONCAT(DISTINCT CONCAT(c.cat_id, ':', c.name)) as categories,
|
||||||
pm.daily_sales_avg,
|
pm.daily_sales_avg,
|
||||||
pm.weekly_sales_avg,
|
pm.weekly_sales_avg,
|
||||||
pm.monthly_sales_avg,
|
pm.monthly_sales_avg,
|
||||||
@@ -205,10 +237,10 @@ router.get('/', async (req, res) => {
|
|||||||
pm.reorder_point,
|
pm.reorder_point,
|
||||||
pm.safety_stock,
|
pm.safety_stock,
|
||||||
pm.avg_margin_percent,
|
pm.avg_margin_percent,
|
||||||
pm.total_revenue,
|
CAST(pm.total_revenue AS DECIMAL(15,3)) as total_revenue,
|
||||||
pm.inventory_value,
|
CAST(pm.inventory_value AS DECIMAL(15,3)) as inventory_value,
|
||||||
pm.cost_of_goods_sold,
|
CAST(pm.cost_of_goods_sold AS DECIMAL(15,3)) as cost_of_goods_sold,
|
||||||
pm.gross_profit,
|
CAST(pm.gross_profit AS DECIMAL(15,3)) as gross_profit,
|
||||||
pm.gmroi,
|
pm.gmroi,
|
||||||
pm.avg_lead_time_days,
|
pm.avg_lead_time_days,
|
||||||
pm.last_purchase_date,
|
pm.last_purchase_date,
|
||||||
@@ -223,12 +255,13 @@ router.get('/', async (req, res) => {
|
|||||||
pm.overstocked_amt,
|
pm.overstocked_amt,
|
||||||
COALESCE(pm.days_of_inventory / NULLIF(pt.target_days, 0), 0) as stock_coverage_ratio
|
COALESCE(pm.days_of_inventory / NULLIF(pt.target_days, 0), 0) as stock_coverage_ratio
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id
|
LEFT JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
LEFT JOIN product_categories pc ON p.product_id = pc.product_id
|
LEFT JOIN product_categories pc ON p.pid = pc.pid
|
||||||
LEFT JOIN categories c ON pc.category_id = c.id
|
LEFT JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
LEFT JOIN product_thresholds pt ON p.product_id = pt.product_id
|
LEFT JOIN product_thresholds pt ON p.pid = pt.pid
|
||||||
${whereClause}
|
JOIN product_leaf_categories plc ON c.cat_id = plc.cat_id
|
||||||
GROUP BY p.product_id
|
${whereClause ? 'WHERE ' + whereClause.substring(6) : ''}
|
||||||
|
GROUP BY p.pid
|
||||||
ORDER BY ${sortColumn} ${sortDirection}
|
ORDER BY ${sortColumn} ${sortDirection}
|
||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
`;
|
`;
|
||||||
@@ -308,7 +341,7 @@ router.get('/trending', async (req, res) => {
|
|||||||
SELECT COUNT(*) as count,
|
SELECT COUNT(*) as count,
|
||||||
MAX(total_revenue) as max_revenue,
|
MAX(total_revenue) as max_revenue,
|
||||||
MAX(daily_sales_avg) as max_daily_sales,
|
MAX(daily_sales_avg) as max_daily_sales,
|
||||||
COUNT(DISTINCT product_id) as products_with_metrics
|
COUNT(DISTINCT pid) as products_with_metrics
|
||||||
FROM product_metrics
|
FROM product_metrics
|
||||||
WHERE total_revenue > 0 OR daily_sales_avg > 0
|
WHERE total_revenue > 0 OR daily_sales_avg > 0
|
||||||
`);
|
`);
|
||||||
@@ -322,7 +355,7 @@ router.get('/trending', async (req, res) => {
|
|||||||
// Get trending products
|
// Get trending products
|
||||||
const [rows] = await pool.query(`
|
const [rows] = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
p.product_id,
|
p.pid,
|
||||||
p.sku,
|
p.sku,
|
||||||
p.title,
|
p.title,
|
||||||
COALESCE(pm.daily_sales_avg, 0) as daily_sales_avg,
|
COALESCE(pm.daily_sales_avg, 0) as daily_sales_avg,
|
||||||
@@ -334,7 +367,7 @@ router.get('/trending', async (req, res) => {
|
|||||||
END as growth_rate,
|
END as growth_rate,
|
||||||
COALESCE(pm.total_revenue, 0) as total_revenue
|
COALESCE(pm.total_revenue, 0) as total_revenue
|
||||||
FROM products p
|
FROM products p
|
||||||
INNER JOIN product_metrics pm ON p.product_id = pm.product_id
|
INNER JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
WHERE (pm.total_revenue > 0 OR pm.daily_sales_avg > 0)
|
WHERE (pm.total_revenue > 0 OR pm.daily_sales_avg > 0)
|
||||||
AND p.visible = true
|
AND p.visible = true
|
||||||
ORDER BY growth_rate DESC
|
ORDER BY growth_rate DESC
|
||||||
@@ -351,130 +384,160 @@ router.get('/trending', async (req, res) => {
|
|||||||
|
|
||||||
// Get a single product
|
// Get a single product
|
||||||
router.get('/:id', async (req, res) => {
|
router.get('/:id', async (req, res) => {
|
||||||
const pool = req.app.locals.pool;
|
|
||||||
try {
|
try {
|
||||||
// Get basic product data with metrics
|
const pool = req.app.locals.pool;
|
||||||
const [rows] = await pool.query(
|
const id = parseInt(req.params.id);
|
||||||
`SELECT
|
|
||||||
|
// Common CTE for category paths
|
||||||
|
const categoryPathCTE = `
|
||||||
|
WITH RECURSIVE category_path AS (
|
||||||
|
SELECT
|
||||||
|
c.cat_id,
|
||||||
|
c.name,
|
||||||
|
c.parent_id,
|
||||||
|
CAST(c.name AS CHAR(1000)) as path
|
||||||
|
FROM categories c
|
||||||
|
WHERE c.parent_id IS NULL
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
c.cat_id,
|
||||||
|
c.name,
|
||||||
|
c.parent_id,
|
||||||
|
CONCAT(cp.path, ' > ', c.name)
|
||||||
|
FROM categories c
|
||||||
|
JOIN category_path cp ON c.parent_id = cp.cat_id
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Get product details with category paths
|
||||||
|
const [productRows] = await pool.query(`
|
||||||
|
SELECT
|
||||||
p.*,
|
p.*,
|
||||||
GROUP_CONCAT(DISTINCT c.name) as categories,
|
|
||||||
pm.daily_sales_avg,
|
pm.daily_sales_avg,
|
||||||
pm.weekly_sales_avg,
|
pm.weekly_sales_avg,
|
||||||
pm.monthly_sales_avg,
|
pm.monthly_sales_avg,
|
||||||
pm.days_of_inventory,
|
pm.days_of_inventory,
|
||||||
pm.reorder_point,
|
pm.reorder_point,
|
||||||
pm.safety_stock,
|
pm.safety_stock,
|
||||||
|
pm.stock_status,
|
||||||
|
pm.abc_class,
|
||||||
pm.avg_margin_percent,
|
pm.avg_margin_percent,
|
||||||
pm.total_revenue,
|
pm.total_revenue,
|
||||||
pm.inventory_value,
|
pm.inventory_value,
|
||||||
pm.turnover_rate,
|
pm.turnover_rate,
|
||||||
pm.abc_class,
|
pm.gmroi,
|
||||||
pm.stock_status,
|
pm.cost_of_goods_sold,
|
||||||
|
pm.gross_profit,
|
||||||
pm.avg_lead_time_days,
|
pm.avg_lead_time_days,
|
||||||
pm.current_lead_time,
|
pm.current_lead_time,
|
||||||
pm.target_lead_time,
|
pm.target_lead_time,
|
||||||
pm.lead_time_status,
|
pm.lead_time_status,
|
||||||
pm.gmroi,
|
pm.reorder_qty,
|
||||||
pm.cost_of_goods_sold,
|
pm.overstocked_amt
|
||||||
pm.gross_profit
|
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id
|
LEFT JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
LEFT JOIN product_categories pc ON p.product_id = pc.product_id
|
WHERE p.pid = ?
|
||||||
LEFT JOIN categories c ON pc.category_id = c.id
|
`, [id]);
|
||||||
WHERE p.product_id = ? AND p.visible = true
|
|
||||||
GROUP BY p.product_id`,
|
|
||||||
[req.params.id]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
if (!productRows.length) {
|
||||||
return res.status(404).json({ error: 'Product not found' });
|
return res.status(404).json({ error: 'Product not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get vendor performance metrics
|
// Get categories and their paths separately to avoid GROUP BY issues
|
||||||
const [vendorMetrics] = await pool.query(
|
const [categoryRows] = await pool.query(`
|
||||||
`SELECT * FROM vendor_metrics WHERE vendor = ?`,
|
WITH RECURSIVE
|
||||||
[rows[0].vendor]
|
category_path AS (
|
||||||
);
|
SELECT
|
||||||
|
c.cat_id,
|
||||||
|
c.name,
|
||||||
|
c.parent_id,
|
||||||
|
CAST(c.name AS CHAR(1000)) as path
|
||||||
|
FROM categories c
|
||||||
|
WHERE c.parent_id IS NULL
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
c.cat_id,
|
||||||
|
c.name,
|
||||||
|
c.parent_id,
|
||||||
|
CONCAT(cp.path, ' > ', c.name)
|
||||||
|
FROM categories c
|
||||||
|
JOIN category_path cp ON c.parent_id = cp.cat_id
|
||||||
|
),
|
||||||
|
product_leaf_categories AS (
|
||||||
|
-- Find categories assigned to this product that aren't parents
|
||||||
|
-- of other categories assigned to this product
|
||||||
|
SELECT pc.cat_id
|
||||||
|
FROM product_categories pc
|
||||||
|
WHERE pc.pid = ?
|
||||||
|
AND NOT EXISTS (
|
||||||
|
-- Check if there are any child categories also assigned to this product
|
||||||
|
SELECT 1
|
||||||
|
FROM categories child
|
||||||
|
JOIN product_categories child_pc ON child.cat_id = child_pc.cat_id
|
||||||
|
WHERE child.parent_id = pc.cat_id
|
||||||
|
AND child_pc.pid = pc.pid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
c.cat_id,
|
||||||
|
c.name as category_name,
|
||||||
|
cp.path as full_path
|
||||||
|
FROM product_categories pc
|
||||||
|
JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
|
JOIN category_path cp ON c.cat_id = cp.cat_id
|
||||||
|
JOIN product_leaf_categories plc ON c.cat_id = plc.cat_id
|
||||||
|
WHERE pc.pid = ?
|
||||||
|
ORDER BY cp.path
|
||||||
|
`, [id, id]);
|
||||||
|
|
||||||
|
// Transform the results
|
||||||
|
const categoryPathMap = categoryRows.reduce((acc, row) => {
|
||||||
|
// Use cat_id in the key to differentiate categories with the same name
|
||||||
|
acc[`${row.cat_id}:${row.category_name}`] = row.full_path;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
// Transform the data to match frontend expectations
|
|
||||||
const product = {
|
const product = {
|
||||||
// Basic product info
|
...productRows[0],
|
||||||
product_id: rows[0].product_id,
|
// Include cat_id in categories array to match the keys in categoryPathMap
|
||||||
title: rows[0].title,
|
categories: categoryRows.map(row => `${row.cat_id}:${row.category_name}`),
|
||||||
SKU: rows[0].SKU,
|
category_paths: categoryPathMap,
|
||||||
barcode: rows[0].barcode,
|
price: parseFloat(productRows[0].price),
|
||||||
created_at: rows[0].created_at,
|
regular_price: parseFloat(productRows[0].regular_price),
|
||||||
updated_at: rows[0].updated_at,
|
cost_price: parseFloat(productRows[0].cost_price),
|
||||||
|
landing_cost_price: parseFloat(productRows[0].landing_cost_price),
|
||||||
// Inventory fields
|
stock_quantity: parseInt(productRows[0].stock_quantity),
|
||||||
stock_quantity: parseInt(rows[0].stock_quantity),
|
moq: parseInt(productRows[0].moq),
|
||||||
moq: parseInt(rows[0].moq),
|
uom: parseInt(productRows[0].uom),
|
||||||
uom: parseInt(rows[0].uom),
|
managing_stock: Boolean(productRows[0].managing_stock),
|
||||||
managing_stock: Boolean(rows[0].managing_stock),
|
replenishable: Boolean(productRows[0].replenishable),
|
||||||
replenishable: Boolean(rows[0].replenishable),
|
daily_sales_avg: parseFloat(productRows[0].daily_sales_avg) || 0,
|
||||||
|
weekly_sales_avg: parseFloat(productRows[0].weekly_sales_avg) || 0,
|
||||||
// Pricing fields
|
monthly_sales_avg: parseFloat(productRows[0].monthly_sales_avg) || 0,
|
||||||
price: parseFloat(rows[0].price),
|
avg_quantity_per_order: parseFloat(productRows[0].avg_quantity_per_order) || 0,
|
||||||
regular_price: parseFloat(rows[0].regular_price),
|
number_of_orders: parseInt(productRows[0].number_of_orders) || 0,
|
||||||
cost_price: parseFloat(rows[0].cost_price),
|
first_sale_date: productRows[0].first_sale_date || null,
|
||||||
landing_cost_price: parseFloat(rows[0].landing_cost_price),
|
last_sale_date: productRows[0].last_sale_date || null,
|
||||||
|
days_of_inventory: parseFloat(productRows[0].days_of_inventory) || 0,
|
||||||
// Categorization
|
weeks_of_inventory: parseFloat(productRows[0].weeks_of_inventory) || 0,
|
||||||
categories: rows[0].categories ? rows[0].categories.split(',') : [],
|
reorder_point: parseFloat(productRows[0].reorder_point) || 0,
|
||||||
tags: rows[0].tags ? rows[0].tags.split(',') : [],
|
safety_stock: parseFloat(productRows[0].safety_stock) || 0,
|
||||||
options: rows[0].options ? JSON.parse(rows[0].options) : {},
|
avg_margin_percent: parseFloat(productRows[0].avg_margin_percent) || 0,
|
||||||
|
total_revenue: parseFloat(productRows[0].total_revenue) || 0,
|
||||||
// Vendor info
|
inventory_value: parseFloat(productRows[0].inventory_value) || 0,
|
||||||
vendor: rows[0].vendor,
|
cost_of_goods_sold: parseFloat(productRows[0].cost_of_goods_sold) || 0,
|
||||||
vendor_reference: rows[0].vendor_reference,
|
gross_profit: parseFloat(productRows[0].gross_profit) || 0,
|
||||||
brand: rows[0].brand,
|
gmroi: parseFloat(productRows[0].gmroi) || 0,
|
||||||
|
avg_lead_time_days: parseFloat(productRows[0].avg_lead_time_days) || 0,
|
||||||
// URLs
|
current_lead_time: parseFloat(productRows[0].current_lead_time) || 0,
|
||||||
permalink: rows[0].permalink,
|
target_lead_time: parseFloat(productRows[0].target_lead_time) || 0,
|
||||||
image: rows[0].image,
|
lead_time_status: productRows[0].lead_time_status || null,
|
||||||
|
reorder_qty: parseInt(productRows[0].reorder_qty) || 0,
|
||||||
// Metrics
|
overstocked_amt: parseInt(productRows[0].overstocked_amt) || 0
|
||||||
metrics: {
|
|
||||||
// Sales metrics
|
|
||||||
daily_sales_avg: parseFloat(rows[0].daily_sales_avg) || 0,
|
|
||||||
weekly_sales_avg: parseFloat(rows[0].weekly_sales_avg) || 0,
|
|
||||||
monthly_sales_avg: parseFloat(rows[0].monthly_sales_avg) || 0,
|
|
||||||
|
|
||||||
// Inventory metrics
|
|
||||||
days_of_inventory: parseInt(rows[0].days_of_inventory) || 0,
|
|
||||||
reorder_point: parseInt(rows[0].reorder_point) || 0,
|
|
||||||
safety_stock: parseInt(rows[0].safety_stock) || 0,
|
|
||||||
stock_status: rows[0].stock_status || 'Unknown',
|
|
||||||
abc_class: rows[0].abc_class || 'C',
|
|
||||||
|
|
||||||
// Financial metrics
|
|
||||||
avg_margin_percent: parseFloat(rows[0].avg_margin_percent) || 0,
|
|
||||||
total_revenue: parseFloat(rows[0].total_revenue) || 0,
|
|
||||||
inventory_value: parseFloat(rows[0].inventory_value) || 0,
|
|
||||||
turnover_rate: parseFloat(rows[0].turnover_rate) || 0,
|
|
||||||
gmroi: parseFloat(rows[0].gmroi) || 0,
|
|
||||||
cost_of_goods_sold: parseFloat(rows[0].cost_of_goods_sold) || 0,
|
|
||||||
gross_profit: parseFloat(rows[0].gross_profit) || 0,
|
|
||||||
|
|
||||||
// Lead time metrics
|
|
||||||
avg_lead_time_days: parseInt(rows[0].avg_lead_time_days) || 0,
|
|
||||||
current_lead_time: parseInt(rows[0].current_lead_time) || 0,
|
|
||||||
target_lead_time: parseInt(rows[0].target_lead_time) || 14,
|
|
||||||
lead_time_status: rows[0].lead_time_status || 'Unknown',
|
|
||||||
reorder_qty: parseInt(rows[0].reorder_qty) || 0,
|
|
||||||
overstocked_amt: parseInt(rows[0].overstocked_amt) || 0
|
|
||||||
},
|
|
||||||
|
|
||||||
// Vendor performance (if available)
|
|
||||||
vendor_performance: vendorMetrics.length ? {
|
|
||||||
avg_lead_time_days: parseFloat(vendorMetrics[0].avg_lead_time_days) || 0,
|
|
||||||
on_time_delivery_rate: parseFloat(vendorMetrics[0].on_time_delivery_rate) || 0,
|
|
||||||
order_fill_rate: parseFloat(vendorMetrics[0].order_fill_rate) || 0,
|
|
||||||
total_orders: parseInt(vendorMetrics[0].total_orders) || 0,
|
|
||||||
total_late_orders: parseInt(vendorMetrics[0].total_late_orders) || 0,
|
|
||||||
total_purchase_value: parseFloat(vendorMetrics[0].total_purchase_value) || 0,
|
|
||||||
avg_order_value: parseFloat(vendorMetrics[0].avg_order_value) || 0
|
|
||||||
} : null
|
|
||||||
};
|
};
|
||||||
|
|
||||||
res.json(product);
|
res.json(product);
|
||||||
@@ -532,7 +595,7 @@ router.put('/:id', async (req, res) => {
|
|||||||
categories = ?,
|
categories = ?,
|
||||||
visible = ?,
|
visible = ?,
|
||||||
managing_stock = ?
|
managing_stock = ?
|
||||||
WHERE product_id = ?`,
|
WHERE pid = ?`,
|
||||||
[
|
[
|
||||||
title,
|
title,
|
||||||
sku,
|
sku,
|
||||||
@@ -570,7 +633,7 @@ router.get('/:id/metrics', async (req, res) => {
|
|||||||
const [metrics] = await pool.query(`
|
const [metrics] = await pool.query(`
|
||||||
WITH inventory_status AS (
|
WITH inventory_status AS (
|
||||||
SELECT
|
SELECT
|
||||||
p.product_id,
|
p.pid,
|
||||||
CASE
|
CASE
|
||||||
WHEN pm.daily_sales_avg = 0 THEN 'New'
|
WHEN pm.daily_sales_avg = 0 THEN 'New'
|
||||||
WHEN p.stock_quantity <= CEIL(pm.daily_sales_avg * 7) THEN 'Critical'
|
WHEN p.stock_quantity <= CEIL(pm.daily_sales_avg * 7) THEN 'Critical'
|
||||||
@@ -579,8 +642,8 @@ router.get('/:id/metrics', async (req, res) => {
|
|||||||
ELSE 'Healthy'
|
ELSE 'Healthy'
|
||||||
END as calculated_status
|
END as calculated_status
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id
|
LEFT JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
WHERE p.product_id = ?
|
WHERE p.pid = ?
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(pm.daily_sales_avg, 0) as daily_sales_avg,
|
COALESCE(pm.daily_sales_avg, 0) as daily_sales_avg,
|
||||||
@@ -604,9 +667,9 @@ router.get('/:id/metrics', async (req, res) => {
|
|||||||
COALESCE(pm.reorder_qty, 0) as reorder_qty,
|
COALESCE(pm.reorder_qty, 0) as reorder_qty,
|
||||||
COALESCE(pm.overstocked_amt, 0) as overstocked_amt
|
COALESCE(pm.overstocked_amt, 0) as overstocked_amt
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN product_metrics pm ON p.product_id = pm.product_id
|
LEFT JOIN product_metrics pm ON p.pid = pm.pid
|
||||||
LEFT JOIN inventory_status is ON p.product_id = is.product_id
|
LEFT JOIN inventory_status is ON p.pid = is.pid
|
||||||
WHERE p.product_id = ?
|
WHERE p.pid = ?
|
||||||
`, [id]);
|
`, [id]);
|
||||||
|
|
||||||
if (!metrics.length) {
|
if (!metrics.length) {
|
||||||
@@ -643,57 +706,35 @@ router.get('/:id/metrics', async (req, res) => {
|
|||||||
|
|
||||||
// Get product time series data
|
// Get product time series data
|
||||||
router.get('/:id/time-series', async (req, res) => {
|
router.get('/:id/time-series', async (req, res) => {
|
||||||
const pool = req.app.locals.pool;
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const months = parseInt(req.query.months) || 12;
|
try {
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
|
||||||
// Get monthly sales data with running totals and growth rates
|
// Get monthly sales data
|
||||||
const [monthlySales] = await pool.query(`
|
const [monthlySales] = await pool.query(`
|
||||||
WITH monthly_data AS (
|
|
||||||
SELECT
|
SELECT
|
||||||
CONCAT(year, '-', LPAD(month, 2, '0')) as month,
|
DATE_FORMAT(date, '%Y-%m') as month,
|
||||||
total_quantity_sold as quantity,
|
COUNT(DISTINCT order_number) as order_count,
|
||||||
total_revenue as revenue,
|
SUM(quantity) as units_sold,
|
||||||
total_cost as cost,
|
CAST(SUM(price * quantity) AS DECIMAL(15,3)) as revenue
|
||||||
avg_price,
|
FROM orders
|
||||||
profit_margin,
|
WHERE pid = ?
|
||||||
inventory_value
|
AND canceled = false
|
||||||
FROM product_time_aggregates
|
GROUP BY DATE_FORMAT(date, '%Y-%m')
|
||||||
WHERE product_id = ?
|
ORDER BY month DESC
|
||||||
ORDER BY year DESC, month DESC
|
LIMIT 12
|
||||||
LIMIT ?
|
`, [id]);
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
month,
|
|
||||||
quantity,
|
|
||||||
revenue,
|
|
||||||
cost,
|
|
||||||
avg_price,
|
|
||||||
profit_margin,
|
|
||||||
inventory_value,
|
|
||||||
LAG(quantity) OVER (ORDER BY month) as prev_month_quantity,
|
|
||||||
LAG(revenue) OVER (ORDER BY month) as prev_month_revenue
|
|
||||||
FROM monthly_data
|
|
||||||
ORDER BY month ASC
|
|
||||||
`, [id, months]);
|
|
||||||
|
|
||||||
// Calculate growth rates and format data
|
// Format monthly sales data
|
||||||
const formattedMonthlySales = monthlySales.map(row => ({
|
const formattedMonthlySales = monthlySales.map(month => ({
|
||||||
month: row.month,
|
month: month.month,
|
||||||
quantity: parseInt(row.quantity) || 0,
|
order_count: parseInt(month.order_count),
|
||||||
revenue: parseFloat(row.revenue) || 0,
|
units_sold: parseInt(month.units_sold),
|
||||||
cost: parseFloat(row.cost) || 0,
|
revenue: parseFloat(month.revenue),
|
||||||
avg_price: parseFloat(row.avg_price) || 0,
|
profit: 0 // Set to 0 since we don't have cost data in orders table
|
||||||
profit_margin: parseFloat(row.profit_margin) || 0,
|
|
||||||
inventory_value: parseFloat(row.inventory_value) || 0,
|
|
||||||
quantity_growth: row.prev_month_quantity ?
|
|
||||||
((row.quantity - row.prev_month_quantity) / row.prev_month_quantity) * 100 : 0,
|
|
||||||
revenue_growth: row.prev_month_revenue ?
|
|
||||||
((row.revenue - row.prev_month_revenue) / row.prev_month_revenue) * 100 : 0
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Get recent orders with customer info and status
|
// Get recent orders
|
||||||
const [recentOrders] = await pool.query(`
|
const [recentOrders] = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
DATE_FORMAT(date, '%Y-%m-%d') as date,
|
DATE_FORMAT(date, '%Y-%m-%d') as date,
|
||||||
@@ -703,11 +744,10 @@ router.get('/:id/time-series', async (req, res) => {
|
|||||||
discount,
|
discount,
|
||||||
tax,
|
tax,
|
||||||
shipping,
|
shipping,
|
||||||
customer,
|
customer_name as customer,
|
||||||
status,
|
status
|
||||||
payment_method
|
|
||||||
FROM orders
|
FROM orders
|
||||||
WHERE product_id = ?
|
WHERE pid = ?
|
||||||
AND canceled = false
|
AND canceled = false
|
||||||
ORDER BY date DESC
|
ORDER BY date DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
@@ -723,17 +763,19 @@ router.get('/:id/time-series', async (req, res) => {
|
|||||||
ordered,
|
ordered,
|
||||||
received,
|
received,
|
||||||
status,
|
status,
|
||||||
|
receiving_status,
|
||||||
cost_price,
|
cost_price,
|
||||||
notes,
|
notes,
|
||||||
CASE
|
CASE
|
||||||
WHEN received_date IS NOT NULL THEN
|
WHEN received_date IS NOT NULL THEN
|
||||||
DATEDIFF(received_date, date)
|
DATEDIFF(received_date, date)
|
||||||
WHEN expected_date < CURDATE() AND status != 'received' THEN
|
WHEN expected_date < CURDATE() AND status < ${PurchaseOrderStatus.ReceivingStarted} THEN
|
||||||
DATEDIFF(CURDATE(), expected_date)
|
DATEDIFF(CURDATE(), expected_date)
|
||||||
ELSE NULL
|
ELSE NULL
|
||||||
END as lead_time_days
|
END as lead_time_days
|
||||||
FROM purchase_orders
|
FROM purchase_orders
|
||||||
WHERE product_id = ?
|
WHERE pid = ?
|
||||||
|
AND status != ${PurchaseOrderStatus.Canceled}
|
||||||
ORDER BY date DESC
|
ORDER BY date DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
`, [id]);
|
`, [id]);
|
||||||
@@ -752,6 +794,8 @@ router.get('/:id/time-series', async (req, res) => {
|
|||||||
...po,
|
...po,
|
||||||
ordered: parseInt(po.ordered),
|
ordered: parseInt(po.ordered),
|
||||||
received: parseInt(po.received),
|
received: parseInt(po.received),
|
||||||
|
status: parseInt(po.status),
|
||||||
|
receiving_status: parseInt(po.receiving_status),
|
||||||
cost_price: parseFloat(po.cost_price),
|
cost_price: parseFloat(po.cost_price),
|
||||||
lead_time_days: po.lead_time_days ? parseInt(po.lead_time_days) : null
|
lead_time_days: po.lead_time_days ? parseInt(po.lead_time_days) : null
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Status code constants
|
||||||
|
const STATUS = {
|
||||||
|
CANCELED: 0,
|
||||||
|
CREATED: 1,
|
||||||
|
ELECTRONICALLY_READY_SEND: 10,
|
||||||
|
ORDERED: 11,
|
||||||
|
PREORDERED: 12,
|
||||||
|
ELECTRONICALLY_SENT: 13,
|
||||||
|
RECEIVING_STARTED: 15,
|
||||||
|
DONE: 50
|
||||||
|
};
|
||||||
|
|
||||||
|
const RECEIVING_STATUS = {
|
||||||
|
CANCELED: 0,
|
||||||
|
CREATED: 1,
|
||||||
|
PARTIAL_RECEIVED: 30,
|
||||||
|
FULL_RECEIVED: 40,
|
||||||
|
PAID: 50
|
||||||
|
};
|
||||||
|
|
||||||
// Get all purchase orders with summary metrics
|
// Get all purchase orders with summary metrics
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -11,13 +31,13 @@ router.get('/', async (req, res) => {
|
|||||||
const params = [];
|
const params = [];
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
whereClause += ' AND (po.po_id LIKE ? OR po.vendor LIKE ? OR po.status LIKE ?)';
|
whereClause += ' AND (po.po_id LIKE ? OR po.vendor LIKE ?)';
|
||||||
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
|
params.push(`%${search}%`, `%${search}%`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status && status !== 'all') {
|
if (status && status !== 'all') {
|
||||||
whereClause += ' AND po.status = ?';
|
whereClause += ' AND po.status = ?';
|
||||||
params.push(status);
|
params.push(Number(status));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (vendor && vendor !== 'all') {
|
if (vendor && vendor !== 'all') {
|
||||||
@@ -42,7 +62,7 @@ router.get('/', async (req, res) => {
|
|||||||
po_id,
|
po_id,
|
||||||
SUM(ordered) as total_ordered,
|
SUM(ordered) as total_ordered,
|
||||||
SUM(received) as total_received,
|
SUM(received) as total_received,
|
||||||
SUM(ordered * cost_price) as total_cost
|
CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_cost
|
||||||
FROM purchase_orders po
|
FROM purchase_orders po
|
||||||
WHERE ${whereClause}
|
WHERE ${whereClause}
|
||||||
GROUP BY po_id
|
GROUP BY po_id
|
||||||
@@ -54,8 +74,8 @@ router.get('/', async (req, res) => {
|
|||||||
ROUND(
|
ROUND(
|
||||||
SUM(total_received) / NULLIF(SUM(total_ordered), 0), 3
|
SUM(total_received) / NULLIF(SUM(total_ordered), 0), 3
|
||||||
) as fulfillment_rate,
|
) as fulfillment_rate,
|
||||||
SUM(total_cost) as total_value,
|
CAST(SUM(total_cost) AS DECIMAL(15,3)) as total_value,
|
||||||
ROUND(AVG(total_cost), 2) as avg_cost
|
CAST(AVG(total_cost) AS DECIMAL(15,3)) as avg_cost
|
||||||
FROM po_totals
|
FROM po_totals
|
||||||
`, params);
|
`, params);
|
||||||
|
|
||||||
@@ -78,22 +98,24 @@ router.get('/', async (req, res) => {
|
|||||||
vendor,
|
vendor,
|
||||||
date,
|
date,
|
||||||
status,
|
status,
|
||||||
COUNT(DISTINCT product_id) as total_items,
|
receiving_status,
|
||||||
|
COUNT(DISTINCT pid) as total_items,
|
||||||
SUM(ordered) as total_quantity,
|
SUM(ordered) as total_quantity,
|
||||||
SUM(ordered * cost_price) as total_cost,
|
CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_cost,
|
||||||
SUM(received) as total_received,
|
SUM(received) as total_received,
|
||||||
ROUND(
|
ROUND(
|
||||||
SUM(received) / NULLIF(SUM(ordered), 0), 3
|
SUM(received) / NULLIF(SUM(ordered), 0), 3
|
||||||
) as fulfillment_rate
|
) as fulfillment_rate
|
||||||
FROM purchase_orders po
|
FROM purchase_orders po
|
||||||
WHERE ${whereClause}
|
WHERE ${whereClause}
|
||||||
GROUP BY po_id, vendor, date, status
|
GROUP BY po_id, vendor, date, status, receiving_status
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
po_id as id,
|
po_id as id,
|
||||||
vendor as vendor_name,
|
vendor as vendor_name,
|
||||||
DATE_FORMAT(date, '%Y-%m-%d') as order_date,
|
DATE_FORMAT(date, '%Y-%m-%d') as order_date,
|
||||||
status,
|
status,
|
||||||
|
receiving_status,
|
||||||
total_items,
|
total_items,
|
||||||
total_quantity,
|
total_quantity,
|
||||||
total_cost,
|
total_cost,
|
||||||
@@ -104,8 +126,8 @@ router.get('/', async (req, res) => {
|
|||||||
CASE
|
CASE
|
||||||
WHEN ? = 'order_date' THEN date
|
WHEN ? = 'order_date' THEN date
|
||||||
WHEN ? = 'vendor_name' THEN vendor
|
WHEN ? = 'vendor_name' THEN vendor
|
||||||
WHEN ? = 'total_cost' THEN CAST(total_cost AS DECIMAL(15,2))
|
WHEN ? = 'total_cost' THEN CAST(total_cost AS DECIMAL(15,3))
|
||||||
WHEN ? = 'total_received' THEN CAST(total_received AS DECIMAL(15,2))
|
WHEN ? = 'total_received' THEN CAST(total_received AS DECIMAL(15,3))
|
||||||
WHEN ? = 'total_items' THEN CAST(total_items AS SIGNED)
|
WHEN ? = 'total_items' THEN CAST(total_items AS SIGNED)
|
||||||
WHEN ? = 'total_quantity' THEN CAST(total_quantity AS SIGNED)
|
WHEN ? = 'total_quantity' THEN CAST(total_quantity AS SIGNED)
|
||||||
WHEN ? = 'fulfillment_rate' THEN CAST(fulfillment_rate AS DECIMAL(5,3))
|
WHEN ? = 'fulfillment_rate' THEN CAST(fulfillment_rate AS DECIMAL(5,3))
|
||||||
@@ -127,7 +149,7 @@ router.get('/', async (req, res) => {
|
|||||||
const [statuses] = await pool.query(`
|
const [statuses] = await pool.query(`
|
||||||
SELECT DISTINCT status
|
SELECT DISTINCT status
|
||||||
FROM purchase_orders
|
FROM purchase_orders
|
||||||
WHERE status IS NOT NULL AND status != ''
|
WHERE status IS NOT NULL
|
||||||
ORDER BY status
|
ORDER BY status
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -136,7 +158,8 @@ router.get('/', async (req, res) => {
|
|||||||
id: order.id,
|
id: order.id,
|
||||||
vendor_name: order.vendor_name,
|
vendor_name: order.vendor_name,
|
||||||
order_date: order.order_date,
|
order_date: order.order_date,
|
||||||
status: order.status,
|
status: Number(order.status),
|
||||||
|
receiving_status: Number(order.receiving_status),
|
||||||
total_items: Number(order.total_items) || 0,
|
total_items: Number(order.total_items) || 0,
|
||||||
total_quantity: Number(order.total_quantity) || 0,
|
total_quantity: Number(order.total_quantity) || 0,
|
||||||
total_cost: Number(order.total_cost) || 0,
|
total_cost: Number(order.total_cost) || 0,
|
||||||
@@ -165,7 +188,7 @@ router.get('/', async (req, res) => {
|
|||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
vendors: vendors.map(v => v.vendor),
|
vendors: vendors.map(v => v.vendor),
|
||||||
statuses: statuses.map(s => s.status)
|
statuses: statuses.map(s => Number(s.status))
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -188,12 +211,14 @@ router.get('/vendor-metrics', async (req, res) => {
|
|||||||
received,
|
received,
|
||||||
cost_price,
|
cost_price,
|
||||||
CASE
|
CASE
|
||||||
WHEN status = 'received' AND received_date IS NOT NULL AND date IS NOT NULL
|
WHEN status >= ${STATUS.RECEIVING_STARTED} AND receiving_status >= ${RECEIVING_STATUS.PARTIAL_RECEIVED}
|
||||||
|
AND received_date IS NOT NULL AND date IS NOT NULL
|
||||||
THEN DATEDIFF(received_date, date)
|
THEN DATEDIFF(received_date, date)
|
||||||
ELSE NULL
|
ELSE NULL
|
||||||
END as delivery_days
|
END as delivery_days
|
||||||
FROM purchase_orders
|
FROM purchase_orders
|
||||||
WHERE vendor IS NOT NULL AND vendor != ''
|
WHERE vendor IS NOT NULL AND vendor != ''
|
||||||
|
AND status != ${STATUS.CANCELED} -- Exclude canceled orders
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
vendor as vendor_name,
|
vendor as vendor_name,
|
||||||
@@ -203,10 +228,10 @@ router.get('/vendor-metrics', async (req, res) => {
|
|||||||
ROUND(
|
ROUND(
|
||||||
SUM(received) / NULLIF(SUM(ordered), 0), 3
|
SUM(received) / NULLIF(SUM(ordered), 0), 3
|
||||||
) as fulfillment_rate,
|
) as fulfillment_rate,
|
||||||
ROUND(
|
CAST(ROUND(
|
||||||
SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2
|
SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2
|
||||||
) as avg_unit_cost,
|
) AS DECIMAL(15,3)) as avg_unit_cost,
|
||||||
SUM(ordered * cost_price) as total_spend,
|
CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_spend,
|
||||||
ROUND(
|
ROUND(
|
||||||
AVG(NULLIF(delivery_days, 0)), 1
|
AVG(NULLIF(delivery_days, 0)), 1
|
||||||
) as avg_delivery_days
|
) as avg_delivery_days
|
||||||
@@ -242,47 +267,47 @@ router.get('/cost-analysis', async (req, res) => {
|
|||||||
const pool = req.app.locals.pool;
|
const pool = req.app.locals.pool;
|
||||||
|
|
||||||
const [analysis] = await pool.query(`
|
const [analysis] = await pool.query(`
|
||||||
|
WITH category_costs AS (
|
||||||
SELECT
|
SELECT
|
||||||
c.name as categories,
|
c.name as category,
|
||||||
COUNT(DISTINCT po.product_id) as unique_products,
|
po.pid,
|
||||||
ROUND(AVG(po.cost_price), 2) as avg_cost,
|
po.cost_price,
|
||||||
MIN(po.cost_price) as min_cost,
|
po.ordered,
|
||||||
MAX(po.cost_price) as max_cost,
|
po.received,
|
||||||
ROUND(
|
po.status,
|
||||||
STDDEV(po.cost_price), 2
|
po.receiving_status
|
||||||
) as cost_variance,
|
|
||||||
SUM(po.ordered * po.cost_price) as total_spend
|
|
||||||
FROM purchase_orders po
|
FROM purchase_orders po
|
||||||
JOIN products p ON po.product_id = p.product_id
|
JOIN product_categories pc ON po.pid = pc.pid
|
||||||
JOIN product_categories pc ON p.product_id = pc.product_id
|
JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
JOIN categories c ON pc.category_id = c.id
|
WHERE po.status != ${STATUS.CANCELED} -- Exclude canceled orders
|
||||||
GROUP BY c.name
|
)
|
||||||
|
SELECT
|
||||||
|
category,
|
||||||
|
COUNT(DISTINCT pid) as unique_products,
|
||||||
|
CAST(AVG(cost_price) AS DECIMAL(15,3)) as avg_cost,
|
||||||
|
CAST(MIN(cost_price) AS DECIMAL(15,3)) as min_cost,
|
||||||
|
CAST(MAX(cost_price) AS DECIMAL(15,3)) as max_cost,
|
||||||
|
CAST(STDDEV(cost_price) AS DECIMAL(15,3)) as cost_variance,
|
||||||
|
CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_spend
|
||||||
|
FROM category_costs
|
||||||
|
GROUP BY category
|
||||||
ORDER BY total_spend DESC
|
ORDER BY total_spend DESC
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Parse numeric values and add ids for React keys
|
// Parse numeric values
|
||||||
const parsedAnalysis = analysis.map(item => ({
|
const parsedAnalysis = {
|
||||||
id: item.categories || 'Uncategorized',
|
categories: analysis.map(cat => ({
|
||||||
categories: item.categories || 'Uncategorized',
|
category: cat.category,
|
||||||
unique_products: Number(item.unique_products) || 0,
|
unique_products: Number(cat.unique_products) || 0,
|
||||||
avg_cost: Number(item.avg_cost) || 0,
|
avg_cost: Number(cat.avg_cost) || 0,
|
||||||
min_cost: Number(item.min_cost) || 0,
|
min_cost: Number(cat.min_cost) || 0,
|
||||||
max_cost: Number(item.max_cost) || 0,
|
max_cost: Number(cat.max_cost) || 0,
|
||||||
cost_variance: Number(item.cost_variance) || 0,
|
cost_variance: Number(cat.cost_variance) || 0,
|
||||||
total_spend: Number(item.total_spend) || 0
|
total_spend: Number(cat.total_spend) || 0
|
||||||
}));
|
|
||||||
|
|
||||||
// Transform the data with parsed values
|
|
||||||
const transformedAnalysis = {
|
|
||||||
...parsedAnalysis[0],
|
|
||||||
total_spend_by_category: parsedAnalysis.map(item => ({
|
|
||||||
id: item.categories,
|
|
||||||
category: item.categories,
|
|
||||||
total_spend: Number(item.total_spend)
|
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
|
|
||||||
res.json(transformedAnalysis);
|
res.json(parsedAnalysis);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching cost analysis:', error);
|
console.error('Error fetching cost analysis:', error);
|
||||||
res.status(500).json({ error: 'Failed to fetch cost analysis' });
|
res.status(500).json({ error: 'Failed to fetch cost analysis' });
|
||||||
@@ -298,11 +323,14 @@ router.get('/receiving-status', async (req, res) => {
|
|||||||
WITH po_totals AS (
|
WITH po_totals AS (
|
||||||
SELECT
|
SELECT
|
||||||
po_id,
|
po_id,
|
||||||
|
status,
|
||||||
|
receiving_status,
|
||||||
SUM(ordered) as total_ordered,
|
SUM(ordered) as total_ordered,
|
||||||
SUM(received) as total_received,
|
SUM(received) as total_received,
|
||||||
SUM(ordered * cost_price) as total_cost
|
CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_cost
|
||||||
FROM purchase_orders
|
FROM purchase_orders
|
||||||
GROUP BY po_id
|
WHERE status != ${STATUS.CANCELED}
|
||||||
|
GROUP BY po_id, status, receiving_status
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(DISTINCT po_id) as order_count,
|
COUNT(DISTINCT po_id) as order_count,
|
||||||
@@ -311,8 +339,20 @@ router.get('/receiving-status', async (req, res) => {
|
|||||||
ROUND(
|
ROUND(
|
||||||
SUM(total_received) / NULLIF(SUM(total_ordered), 0), 3
|
SUM(total_received) / NULLIF(SUM(total_ordered), 0), 3
|
||||||
) as fulfillment_rate,
|
) as fulfillment_rate,
|
||||||
SUM(total_cost) as total_value,
|
CAST(SUM(total_cost) AS DECIMAL(15,3)) as total_value,
|
||||||
ROUND(AVG(total_cost), 2) as avg_cost
|
CAST(AVG(total_cost) AS DECIMAL(15,3)) as avg_cost,
|
||||||
|
COUNT(DISTINCT CASE
|
||||||
|
WHEN receiving_status = ${RECEIVING_STATUS.CREATED} THEN po_id
|
||||||
|
END) as pending_count,
|
||||||
|
COUNT(DISTINCT CASE
|
||||||
|
WHEN receiving_status = ${RECEIVING_STATUS.PARTIAL_RECEIVED} THEN po_id
|
||||||
|
END) as partial_count,
|
||||||
|
COUNT(DISTINCT CASE
|
||||||
|
WHEN receiving_status >= ${RECEIVING_STATUS.FULL_RECEIVED} THEN po_id
|
||||||
|
END) as completed_count,
|
||||||
|
COUNT(DISTINCT CASE
|
||||||
|
WHEN receiving_status = ${RECEIVING_STATUS.CANCELED} THEN po_id
|
||||||
|
END) as canceled_count
|
||||||
FROM po_totals
|
FROM po_totals
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -323,7 +363,13 @@ router.get('/receiving-status', async (req, res) => {
|
|||||||
total_received: Number(status[0].total_received) || 0,
|
total_received: Number(status[0].total_received) || 0,
|
||||||
fulfillment_rate: Number(status[0].fulfillment_rate) || 0,
|
fulfillment_rate: Number(status[0].fulfillment_rate) || 0,
|
||||||
total_value: Number(status[0].total_value) || 0,
|
total_value: Number(status[0].total_value) || 0,
|
||||||
avg_cost: Number(status[0].avg_cost) || 0
|
avg_cost: Number(status[0].avg_cost) || 0,
|
||||||
|
status_breakdown: {
|
||||||
|
pending: Number(status[0].pending_count) || 0,
|
||||||
|
partial: Number(status[0].partial_count) || 0,
|
||||||
|
completed: Number(status[0].completed_count) || 0,
|
||||||
|
canceled: Number(status[0].canceled_count) || 0
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
res.json(parsedStatus);
|
res.json(parsedStatus);
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ router.get('/', async (req, res) => {
|
|||||||
const [costMetrics] = await pool.query(`
|
const [costMetrics] = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
vendor,
|
vendor,
|
||||||
ROUND(SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2) as avg_unit_cost,
|
CAST(ROUND(SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2) AS DECIMAL(15,3)) as avg_unit_cost,
|
||||||
SUM(ordered * cost_price) as total_spend
|
CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_spend
|
||||||
FROM purchase_orders
|
FROM purchase_orders
|
||||||
WHERE status = 'closed'
|
WHERE status = 'closed'
|
||||||
AND cost_price IS NOT NULL
|
AND cost_price IS NOT NULL
|
||||||
@@ -56,9 +56,9 @@ router.get('/', async (req, res) => {
|
|||||||
WHEN COALESCE(vm.total_orders, 0) > 0 AND COALESCE(vm.order_fill_rate, 0) >= 75
|
WHEN COALESCE(vm.total_orders, 0) > 0 AND COALESCE(vm.order_fill_rate, 0) >= 75
|
||||||
THEN p.vendor
|
THEN p.vendor
|
||||||
END) as activeVendors,
|
END) as activeVendors,
|
||||||
ROUND(AVG(NULLIF(vm.avg_lead_time_days, 0)), 1) as avgLeadTime,
|
COALESCE(ROUND(AVG(NULLIF(vm.avg_lead_time_days, 0)), 1), 0) as avgLeadTime,
|
||||||
ROUND(AVG(NULLIF(vm.order_fill_rate, 0)), 1) as avgFillRate,
|
COALESCE(ROUND(AVG(NULLIF(vm.order_fill_rate, 0)), 1), 0) as avgFillRate,
|
||||||
ROUND(AVG(NULLIF(vm.on_time_delivery_rate, 0)), 1) as avgOnTimeDelivery
|
COALESCE(ROUND(AVG(NULLIF(vm.on_time_delivery_rate, 0)), 1), 0) as avgOnTimeDelivery
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN vendor_metrics vm ON p.vendor = vm.vendor
|
LEFT JOIN vendor_metrics vm ON p.vendor = vm.vendor
|
||||||
WHERE p.vendor IS NOT NULL AND p.vendor != ''
|
WHERE p.vendor IS NOT NULL AND p.vendor != ''
|
||||||
@@ -67,8 +67,8 @@ router.get('/', async (req, res) => {
|
|||||||
// Get overall cost metrics
|
// Get overall cost metrics
|
||||||
const [overallCostMetrics] = await pool.query(`
|
const [overallCostMetrics] = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
ROUND(SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2) as avg_unit_cost,
|
CAST(ROUND(SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2) AS DECIMAL(15,3)) as avg_unit_cost,
|
||||||
SUM(ordered * cost_price) as total_spend
|
CAST(SUM(ordered * cost_price) AS DECIMAL(15,3)) as total_spend
|
||||||
FROM purchase_orders
|
FROM purchase_orders
|
||||||
WHERE status = 'closed'
|
WHERE status = 'closed'
|
||||||
AND cost_price IS NOT NULL
|
AND cost_price IS NOT NULL
|
||||||
@@ -78,25 +78,25 @@ router.get('/', async (req, res) => {
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
vendors: vendors.map(vendor => ({
|
vendors: vendors.map(vendor => ({
|
||||||
vendor_id: vendor.vendor_id || vendor.name,
|
vendor_id: vendor.name,
|
||||||
name: vendor.name,
|
name: vendor.name,
|
||||||
status: vendor.status,
|
status: vendor.status,
|
||||||
avg_lead_time_days: parseFloat(vendor.avg_lead_time_days || 0),
|
avg_lead_time_days: parseFloat(vendor.avg_lead_time_days),
|
||||||
on_time_delivery_rate: parseFloat(vendor.on_time_delivery_rate || 0),
|
on_time_delivery_rate: parseFloat(vendor.on_time_delivery_rate),
|
||||||
order_fill_rate: parseFloat(vendor.order_fill_rate || 0),
|
order_fill_rate: parseFloat(vendor.order_fill_rate),
|
||||||
total_orders: parseInt(vendor.total_orders || 0),
|
total_orders: parseInt(vendor.total_orders),
|
||||||
active_products: parseInt(vendor.active_products || 0),
|
active_products: parseInt(vendor.active_products),
|
||||||
avg_unit_cost: parseFloat(costMetricsMap[vendor.name]?.avg_unit_cost || 0),
|
avg_unit_cost: parseFloat(costMetricsMap[vendor.name]?.avg_unit_cost || 0),
|
||||||
total_spend: parseFloat(costMetricsMap[vendor.name]?.total_spend || 0)
|
total_spend: parseFloat(costMetricsMap[vendor.name]?.total_spend || 0)
|
||||||
})),
|
})),
|
||||||
stats: {
|
stats: {
|
||||||
totalVendors: parseInt(stats[0].totalVendors || 0),
|
totalVendors: parseInt(stats[0].totalVendors),
|
||||||
activeVendors: parseInt(stats[0].activeVendors || 0),
|
activeVendors: parseInt(stats[0].activeVendors),
|
||||||
avgLeadTime: parseFloat(stats[0].avgLeadTime || 0),
|
avgLeadTime: parseFloat(stats[0].avgLeadTime),
|
||||||
avgFillRate: parseFloat(stats[0].avgFillRate || 0),
|
avgFillRate: parseFloat(stats[0].avgFillRate),
|
||||||
avgOnTimeDelivery: parseFloat(stats[0].avgOnTimeDelivery || 0),
|
avgOnTimeDelivery: parseFloat(stats[0].avgOnTimeDelivery),
|
||||||
avgUnitCost: parseFloat(overallCostMetrics[0].avg_unit_cost || 0),
|
avgUnitCost: parseFloat(overallCostMetrics[0].avg_unit_cost),
|
||||||
totalSpend: parseFloat(overallCostMetrics[0].total_spend || 0)
|
totalSpend: parseFloat(overallCostMetrics[0].total_spend)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
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 {
|
interface CategoryData {
|
||||||
performance: {
|
performance: {
|
||||||
category: string;
|
category: string;
|
||||||
|
categoryPath: string; // Full hierarchy path
|
||||||
revenue: number;
|
revenue: number;
|
||||||
profit: number;
|
profit: number;
|
||||||
growth: number;
|
growth: number;
|
||||||
@@ -13,10 +14,12 @@ interface CategoryData {
|
|||||||
}[];
|
}[];
|
||||||
distribution: {
|
distribution: {
|
||||||
category: string;
|
category: string;
|
||||||
|
categoryPath: string; // Full hierarchy path
|
||||||
value: number;
|
value: number;
|
||||||
}[];
|
}[];
|
||||||
trends: {
|
trends: {
|
||||||
category: string;
|
category: string;
|
||||||
|
categoryPath: string; // Full hierarchy path
|
||||||
month: string;
|
month: string;
|
||||||
sales: number;
|
sales: number;
|
||||||
}[];
|
}[];
|
||||||
@@ -36,6 +39,7 @@ export function CategoryPerformance() {
|
|||||||
return {
|
return {
|
||||||
performance: rawData.performance.map((item: any) => ({
|
performance: rawData.performance.map((item: any) => ({
|
||||||
...item,
|
...item,
|
||||||
|
categoryPath: item.categoryPath || item.category,
|
||||||
revenue: Number(item.revenue) || 0,
|
revenue: Number(item.revenue) || 0,
|
||||||
profit: Number(item.profit) || 0,
|
profit: Number(item.profit) || 0,
|
||||||
growth: Number(item.growth) || 0,
|
growth: Number(item.growth) || 0,
|
||||||
@@ -43,10 +47,12 @@ export function CategoryPerformance() {
|
|||||||
})),
|
})),
|
||||||
distribution: rawData.distribution.map((item: any) => ({
|
distribution: rawData.distribution.map((item: any) => ({
|
||||||
...item,
|
...item,
|
||||||
|
categoryPath: item.categoryPath || item.category,
|
||||||
value: Number(item.value) || 0
|
value: Number(item.value) || 0
|
||||||
})),
|
})),
|
||||||
trends: rawData.trends.map((item: any) => ({
|
trends: rawData.trends.map((item: any) => ({
|
||||||
...item,
|
...item,
|
||||||
|
categoryPath: item.categoryPath || item.category,
|
||||||
sales: Number(item.sales) || 0
|
sales: Number(item.sales) || 0
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
@@ -63,6 +69,8 @@ export function CategoryPerformance() {
|
|||||||
return <span className={color}>{value}</span>;
|
return <span className={color}>{value}</span>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getShortCategoryName = (path: string) => path.split(' > ').pop() || path;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
@@ -76,24 +84,34 @@ export function CategoryPerformance() {
|
|||||||
<Pie
|
<Pie
|
||||||
data={data.distribution}
|
data={data.distribution}
|
||||||
dataKey="value"
|
dataKey="value"
|
||||||
nameKey="category"
|
nameKey="categoryPath"
|
||||||
cx="50%"
|
cx="50%"
|
||||||
cy="50%"
|
cy="50%"
|
||||||
outerRadius={100}
|
outerRadius={100}
|
||||||
fill="#8884d8"
|
fill="#8884d8"
|
||||||
label={(entry) => entry.category}
|
label={({ categoryPath }) => getShortCategoryName(categoryPath)}
|
||||||
>
|
>
|
||||||
{data.distribution.map((entry, index) => (
|
{data.distribution.map((entry, index) => (
|
||||||
<Cell
|
<Cell
|
||||||
key={entry.category}
|
key={`${entry.category}-${entry.value}-${index}`}
|
||||||
fill={COLORS[index % COLORS.length]}
|
fill={COLORS[index % COLORS.length]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value: number) => [`$${value.toLocaleString()}`, 'Revenue']}
|
formatter={(value: number, name: string, props: any) => [
|
||||||
|
`$${value.toLocaleString()}`,
|
||||||
|
<div key="tooltip">
|
||||||
|
<div className="font-medium">Category Path:</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{props.payload.categoryPath}</div>
|
||||||
|
<div className="mt-1">Revenue</div>
|
||||||
|
</div>
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
formatter={(value) => getShortCategoryName(value)}
|
||||||
|
wrapperStyle={{ fontSize: '12px' }}
|
||||||
/>
|
/>
|
||||||
<Legend />
|
|
||||||
</PieChart>
|
</PieChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -106,10 +124,33 @@ export function CategoryPerformance() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<BarChart data={data.performance}>
|
<BarChart data={data.performance}>
|
||||||
<XAxis dataKey="category" />
|
<XAxis
|
||||||
|
dataKey="categoryPath"
|
||||||
|
tick={({ x, y, payload }) => (
|
||||||
|
<g transform={`translate(${x},${y})`}>
|
||||||
|
<text
|
||||||
|
x={0}
|
||||||
|
y={0}
|
||||||
|
dy={16}
|
||||||
|
textAnchor="end"
|
||||||
|
fill="#888888"
|
||||||
|
transform="rotate(-35)"
|
||||||
|
>
|
||||||
|
{getShortCategoryName(payload.value)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<YAxis tickFormatter={(value) => `${value}%`} />
|
<YAxis tickFormatter={(value) => `${value}%`} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value: number) => [`${value.toFixed(1)}%`, 'Growth Rate']}
|
formatter={(value: number, name: string, props: any) => [
|
||||||
|
`${value.toFixed(1)}%`,
|
||||||
|
<div key="tooltip">
|
||||||
|
<div className="font-medium">Category Path:</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{props.payload.categoryPath}</div>
|
||||||
|
<div className="mt-1">Growth Rate</div>
|
||||||
|
</div>
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="growth"
|
dataKey="growth"
|
||||||
@@ -129,10 +170,13 @@ export function CategoryPerformance() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{data.performance.map((category) => (
|
{data.performance.map((category) => (
|
||||||
<div key={category.category} className="flex items-center">
|
<div key={`${category.category}-${category.revenue}`} className="flex items-center">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium">{category.category}</p>
|
<div className="space-y-1">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm font-medium">{getShortCategoryName(category.categoryPath)}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{category.categoryPath}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
{category.productCount} products
|
{category.productCount} products
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export function PriceAnalysis() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{data.recommendations.map((item) => (
|
{data.recommendations.map((item) => (
|
||||||
<div key={item.product} className="flex items-center">
|
<div key={`${item.product}-${item.currentPrice}`} className="flex items-center">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium">{item.product}</p>
|
<p className="text-sm font-medium">{item.product}</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import config from '../../config';
|
|||||||
interface ProfitData {
|
interface ProfitData {
|
||||||
byCategory: {
|
byCategory: {
|
||||||
category: string;
|
category: string;
|
||||||
|
categoryPath: string; // Full hierarchy path
|
||||||
profitMargin: number;
|
profitMargin: number;
|
||||||
revenue: number;
|
revenue: number;
|
||||||
cost: number;
|
cost: number;
|
||||||
@@ -18,6 +19,8 @@ interface ProfitData {
|
|||||||
}[];
|
}[];
|
||||||
topProducts: {
|
topProducts: {
|
||||||
product: string;
|
product: string;
|
||||||
|
category: string;
|
||||||
|
categoryPath: string; // Full hierarchy path
|
||||||
profitMargin: number;
|
profitMargin: number;
|
||||||
revenue: number;
|
revenue: number;
|
||||||
cost: number;
|
cost: number;
|
||||||
@@ -36,6 +39,7 @@ export function ProfitAnalysis() {
|
|||||||
return {
|
return {
|
||||||
byCategory: rawData.byCategory.map((item: any) => ({
|
byCategory: rawData.byCategory.map((item: any) => ({
|
||||||
...item,
|
...item,
|
||||||
|
categoryPath: item.categoryPath || item.category,
|
||||||
profitMargin: Number(item.profitMargin) || 0,
|
profitMargin: Number(item.profitMargin) || 0,
|
||||||
revenue: Number(item.revenue) || 0,
|
revenue: Number(item.revenue) || 0,
|
||||||
cost: Number(item.cost) || 0
|
cost: Number(item.cost) || 0
|
||||||
@@ -48,6 +52,7 @@ export function ProfitAnalysis() {
|
|||||||
})),
|
})),
|
||||||
topProducts: rawData.topProducts.map((item: any) => ({
|
topProducts: rawData.topProducts.map((item: any) => ({
|
||||||
...item,
|
...item,
|
||||||
|
categoryPath: item.categoryPath || item.category,
|
||||||
profitMargin: Number(item.profitMargin) || 0,
|
profitMargin: Number(item.profitMargin) || 0,
|
||||||
revenue: Number(item.revenue) || 0,
|
revenue: Number(item.revenue) || 0,
|
||||||
cost: Number(item.cost) || 0
|
cost: Number(item.cost) || 0
|
||||||
@@ -60,6 +65,8 @@ export function ProfitAnalysis() {
|
|||||||
return <div>Loading profit analysis...</div>;
|
return <div>Loading profit analysis...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getShortCategoryName = (path: string) => path.split(' > ').pop() || path;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
@@ -70,10 +77,33 @@ export function ProfitAnalysis() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<BarChart data={data.byCategory}>
|
<BarChart data={data.byCategory}>
|
||||||
<XAxis dataKey="category" />
|
<XAxis
|
||||||
|
dataKey="categoryPath"
|
||||||
|
tick={({ x, y, payload }) => (
|
||||||
|
<g transform={`translate(${x},${y})`}>
|
||||||
|
<text
|
||||||
|
x={0}
|
||||||
|
y={0}
|
||||||
|
dy={16}
|
||||||
|
textAnchor="end"
|
||||||
|
fill="#888888"
|
||||||
|
transform="rotate(-35)"
|
||||||
|
>
|
||||||
|
{getShortCategoryName(payload.value)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<YAxis tickFormatter={(value) => `${value}%`} />
|
<YAxis tickFormatter={(value) => `${value}%`} />
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value: number) => [`${value.toFixed(1)}%`, 'Profit Margin']}
|
formatter={(value: number, name: string, props: any) => [
|
||||||
|
`${value.toFixed(1)}%`,
|
||||||
|
<div key="tooltip">
|
||||||
|
<div className="font-medium">Category Path:</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{props.payload.categoryPath}</div>
|
||||||
|
<div className="mt-1">Profit Margin</div>
|
||||||
|
</div>
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
<Bar
|
<Bar
|
||||||
dataKey="profitMargin"
|
dataKey="profitMargin"
|
||||||
@@ -120,10 +150,14 @@ export function ProfitAnalysis() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{data.topProducts.map((product) => (
|
{data.topProducts.map((product) => (
|
||||||
<div key={product.product} className="flex items-center">
|
<div key={`${product.product}-${product.category}`} className="flex items-center">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium">{product.product}</p>
|
<p className="text-sm font-medium">{product.product}</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<div className="text-xs text-muted-foreground space-y-1">
|
||||||
|
<p className="font-medium">Category:</p>
|
||||||
|
<p>{product.categoryPath}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
Revenue: ${product.revenue.toLocaleString()}
|
Revenue: ${product.revenue.toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ export function StockAnalysis() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{data.criticalItems.map((item) => (
|
{data.criticalItems.map((item) => (
|
||||||
<div key={item.sku} className="flex items-center">
|
<div key={`${item.sku}-${item.product}`} className="flex items-center">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-sm font-medium">{item.product}</p>
|
<p className="text-sm font-medium">{item.product}</p>
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export function VendorPerformance() {
|
|||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{data.performance.map((vendor) => (
|
{data.performance.map((vendor) => (
|
||||||
<div key={vendor.vendor} className="flex items-center">
|
<div key={`${vendor.vendor}-${vendor.salesVolume}`} className="flex items-center">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-sm font-medium">{vendor.vendor}</p>
|
<p className="text-sm font-medium">{vendor.vendor}</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -6,37 +6,46 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|||||||
import config from "@/config"
|
import config from "@/config"
|
||||||
import { formatCurrency } from "@/lib/utils"
|
import { formatCurrency } from "@/lib/utils"
|
||||||
|
|
||||||
interface BestSellerProduct {
|
interface Product {
|
||||||
product_id: number
|
pid: number;
|
||||||
sku: string
|
sku: string;
|
||||||
title: string
|
title: string;
|
||||||
units_sold: number
|
units_sold: number;
|
||||||
revenue: number
|
revenue: string;
|
||||||
profit: number
|
profit: string;
|
||||||
growth_rate: number
|
}
|
||||||
|
|
||||||
|
interface Category {
|
||||||
|
cat_id: number;
|
||||||
|
name: string;
|
||||||
|
categoryPath: string;
|
||||||
|
units_sold: number;
|
||||||
|
revenue: string;
|
||||||
|
profit: string;
|
||||||
|
growth_rate: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BestSellerBrand {
|
interface BestSellerBrand {
|
||||||
brand: string
|
brand: string
|
||||||
units_sold: number
|
units_sold: number
|
||||||
revenue: number
|
revenue: string
|
||||||
profit: number
|
profit: string
|
||||||
growth_rate: number
|
growth_rate: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BestSellerCategory {
|
interface BestSellerCategory {
|
||||||
category_id: number
|
cat_id: number;
|
||||||
name: string
|
name: string;
|
||||||
units_sold: number
|
units_sold: number;
|
||||||
revenue: number
|
revenue: string;
|
||||||
profit: number
|
profit: string;
|
||||||
growth_rate: number
|
growth_rate: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BestSellersData {
|
interface BestSellersData {
|
||||||
products: BestSellerProduct[]
|
products: Product[]
|
||||||
brands: BestSellerBrand[]
|
brands: BestSellerBrand[]
|
||||||
categories: BestSellerCategory[]
|
categories: Category[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BestSellers() {
|
export function BestSellers() {
|
||||||
@@ -70,41 +79,29 @@ export function BestSellers() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[40%]">Product</TableHead>
|
<TableHead>Product</TableHead>
|
||||||
<TableHead className="w-[15%] text-right">Sales</TableHead>
|
<TableHead className="text-right">Units Sold</TableHead>
|
||||||
<TableHead className="w-[15%] text-right">Revenue</TableHead>
|
<TableHead className="text-right">Revenue</TableHead>
|
||||||
<TableHead className="w-[15%] text-right">Profit</TableHead>
|
<TableHead className="text-right">Profit</TableHead>
|
||||||
<TableHead className="w-[15%] text-right">Growth</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data?.products.map((product) => (
|
{data?.products.map((product) => (
|
||||||
<TableRow key={product.product_id}>
|
<TableRow key={product.pid}>
|
||||||
<TableCell className="w-[40%]">
|
<TableCell>
|
||||||
<div>
|
|
||||||
<a
|
<a
|
||||||
href={`https://backend.acherryontop.com/product/${product.product_id}`}
|
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="font-medium hover:underline"
|
className="hover:underline"
|
||||||
>
|
>
|
||||||
{product.title}
|
{product.title}
|
||||||
</a>
|
</a>
|
||||||
<p className="text-sm text-muted-foreground">{product.sku}</p>
|
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="w-[15%] text-right">
|
|
||||||
{product.units_sold.toLocaleString()}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="w-[15%] text-right">
|
|
||||||
{formatCurrency(product.revenue)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="w-[15%] text-right">
|
|
||||||
{formatCurrency(product.profit)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="w-[15%] text-right">
|
|
||||||
{product.growth_rate > 0 ? '+' : ''}{product.growth_rate.toFixed(1)}%
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-right">{product.units_sold}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatCurrency(Number(product.revenue))}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatCurrency(Number(product.profit))}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
@@ -134,13 +131,13 @@ export function BestSellers() {
|
|||||||
{brand.units_sold.toLocaleString()}
|
{brand.units_sold.toLocaleString()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="w-[15%] text-right">
|
<TableCell className="w-[15%] text-right">
|
||||||
{formatCurrency(brand.revenue)}
|
{formatCurrency(Number(brand.revenue))}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="w-[15%] text-right">
|
<TableCell className="w-[15%] text-right">
|
||||||
{formatCurrency(brand.profit)}
|
{formatCurrency(Number(brand.profit))}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="w-[15%] text-right">
|
<TableCell className="w-[15%] text-right">
|
||||||
{brand.growth_rate > 0 ? '+' : ''}{brand.growth_rate.toFixed(1)}%
|
{Number(brand.growth_rate) > 0 ? '+' : ''}{Number(brand.growth_rate).toFixed(1)}%
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
@@ -154,31 +151,26 @@ export function BestSellers() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="w-[40%]">Category</TableHead>
|
<TableHead>Category</TableHead>
|
||||||
<TableHead className="w-[15%] text-right">Sales</TableHead>
|
<TableHead className="text-right">Units Sold</TableHead>
|
||||||
<TableHead className="w-[15%] text-right">Revenue</TableHead>
|
<TableHead className="text-right">Revenue</TableHead>
|
||||||
<TableHead className="w-[15%] text-right">Profit</TableHead>
|
<TableHead className="text-right">Profit</TableHead>
|
||||||
<TableHead className="w-[15%] text-right">Growth</TableHead>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data?.categories.map((category) => (
|
{data?.categories.map((category) => (
|
||||||
<TableRow key={category.category_id}>
|
<TableRow key={category.cat_id}>
|
||||||
<TableCell className="w-[40%]">
|
<TableCell>
|
||||||
<p className="font-medium">{category.name}</p>
|
<div className="font-medium">{category.name}</div>
|
||||||
</TableCell>
|
{category.categoryPath && (
|
||||||
<TableCell className="w-[15%] text-right">
|
<div className="text-sm text-muted-foreground">
|
||||||
{category.units_sold.toLocaleString()}
|
{category.categoryPath}
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell className="w-[15%] text-right">
|
)}
|
||||||
{formatCurrency(category.revenue)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="w-[15%] text-right">
|
|
||||||
{formatCurrency(category.profit)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="w-[15%] text-right">
|
|
||||||
{category.growth_rate > 0 ? '+' : ''}{category.growth_rate.toFixed(1)}%
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-right">{category.units_sold}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatCurrency(Number(category.revenue))}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatCurrency(Number(category.profit))}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -11,18 +11,18 @@ import { DateRangePicker } from "@/components/ui/date-range-picker-narrow"
|
|||||||
|
|
||||||
interface ForecastData {
|
interface ForecastData {
|
||||||
forecastSales: number
|
forecastSales: number
|
||||||
forecastRevenue: number
|
forecastRevenue: string
|
||||||
confidenceLevel: number
|
confidenceLevel: number
|
||||||
dailyForecasts: {
|
dailyForecasts: {
|
||||||
date: string
|
date: string
|
||||||
units: number
|
units: number
|
||||||
revenue: number
|
revenue: string
|
||||||
confidence: number
|
confidence: number
|
||||||
}[]
|
}[]
|
||||||
categoryForecasts: {
|
categoryForecasts: {
|
||||||
category: string
|
category: string
|
||||||
units: number
|
units: number
|
||||||
revenue: number
|
revenue: string
|
||||||
confidence: number
|
confidence: number
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
@@ -86,7 +86,7 @@ export function ForecastMetrics() {
|
|||||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">Forecast Revenue</p>
|
<p className="text-sm font-medium text-muted-foreground">Forecast Revenue</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold">{formatCurrency(data?.forecastRevenue || 0)}</p>
|
<p className="text-lg font-bold">{formatCurrency(Number(data?.forecastRevenue) || 0)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ export function ForecastMetrics() {
|
|||||||
tick={false}
|
tick={false}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value: number) => [formatCurrency(value), "Revenue"]}
|
formatter={(value: string) => [formatCurrency(Number(value)), "Revenue"]}
|
||||||
labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')}
|
labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ interface InventoryMetrics {
|
|||||||
topVendors: {
|
topVendors: {
|
||||||
vendor: string;
|
vendor: string;
|
||||||
productCount: number;
|
productCount: number;
|
||||||
averageStockLevel: number;
|
averageStockLevel: string;
|
||||||
}[];
|
}[];
|
||||||
stockTurnover: {
|
stockTurnover: {
|
||||||
category: string;
|
category: string;
|
||||||
rate: number;
|
rate: string;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ export function InventoryStats() {
|
|||||||
<BarChart data={data?.stockTurnover}>
|
<BarChart data={data?.stockTurnover}>
|
||||||
<XAxis dataKey="category" />
|
<XAxis dataKey="category" />
|
||||||
<YAxis />
|
<YAxis />
|
||||||
<Tooltip />
|
<Tooltip formatter={(value: string) => [Number(value).toFixed(2), "Rate"]} />
|
||||||
<Bar dataKey="rate" name="Turnover Rate" fill="#60a5fa" />
|
<Bar dataKey="rate" name="Turnover Rate" fill="#60a5fa" />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
@@ -93,7 +93,7 @@ export function InventoryStats() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="ml-4 text-right">
|
<div className="ml-4 text-right">
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">
|
||||||
Avg. Stock: {vendor.averageStockLevel.toFixed(0)}
|
Avg. Stock: {Number(vendor.averageStockLevel).toFixed(0)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,19 +12,20 @@ import { Badge } from "@/components/ui/badge"
|
|||||||
import { AlertCircle, AlertTriangle } from "lucide-react"
|
import { AlertCircle, AlertTriangle } from "lucide-react"
|
||||||
import config from "@/config"
|
import config from "@/config"
|
||||||
|
|
||||||
interface LowStockProduct {
|
interface Product {
|
||||||
product_id: number
|
pid: number;
|
||||||
SKU: string
|
sku: string;
|
||||||
title: string
|
title: string;
|
||||||
stock_quantity: number
|
stock_quantity: number;
|
||||||
reorder_qty: number
|
daily_sales_avg: string;
|
||||||
days_of_inventory: number
|
days_of_inventory: string;
|
||||||
stock_status: "Critical" | "Reorder"
|
reorder_qty: number;
|
||||||
daily_sales_avg: number
|
last_purchase_date: string | null;
|
||||||
|
lead_time_status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LowStockAlerts() {
|
export function LowStockAlerts() {
|
||||||
const { data: products } = useQuery<LowStockProduct[]>({
|
const { data: products } = useQuery<Product[]>({
|
||||||
queryKey: ["low-stock"],
|
queryKey: ["low-stock"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await fetch(`${config.apiUrl}/dashboard/low-stock/products`)
|
const response = await fetch(`${config.apiUrl}/dashboard/low-stock/products`)
|
||||||
@@ -45,35 +46,37 @@ export function LowStockAlerts() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>SKU</TableHead>
|
|
||||||
<TableHead>Product</TableHead>
|
<TableHead>Product</TableHead>
|
||||||
<TableHead className="text-right">Stock</TableHead>
|
<TableHead className="text-right">Stock</TableHead>
|
||||||
<TableHead className="text-right">Status</TableHead>
|
<TableHead className="text-right">Daily Sales</TableHead>
|
||||||
|
<TableHead className="text-right">Days Left</TableHead>
|
||||||
|
<TableHead className="text-right">Reorder Qty</TableHead>
|
||||||
|
<TableHead>Last Purchase</TableHead>
|
||||||
|
<TableHead>Lead Time</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{products?.map((product) => (
|
{products?.map((product) => (
|
||||||
<TableRow key={product.product_id}>
|
<TableRow key={product.pid}>
|
||||||
<TableCell className="font-medium">{product.SKU}</TableCell>
|
<TableCell>
|
||||||
<TableCell>{product.title}</TableCell>
|
<a
|
||||||
<TableCell className="text-right">
|
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||||
{product.stock_quantity} / {product.reorder_qty}
|
target="_blank"
|
||||||
</TableCell>
|
rel="noopener noreferrer"
|
||||||
<TableCell className="text-right">
|
className="hover:underline"
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={
|
|
||||||
product.stock_status === "Critical"
|
|
||||||
? "border-destructive text-destructive"
|
|
||||||
: "border-warning text-warning"
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{product.stock_status === "Critical" ? (
|
{product.title}
|
||||||
<AlertCircle className="mr-1 h-3 w-3" />
|
</a>
|
||||||
) : (
|
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
</TableCell>
|
||||||
)}
|
<TableCell className="text-right">{product.stock_quantity}</TableCell>
|
||||||
{product.stock_status}
|
<TableCell className="text-right">{Number(product.daily_sales_avg).toFixed(1)}</TableCell>
|
||||||
|
<TableCell className="text-right">{Number(product.days_of_inventory).toFixed(1)}</TableCell>
|
||||||
|
<TableCell className="text-right">{product.reorder_qty}</TableCell>
|
||||||
|
<TableCell>{product.last_purchase_date ? formatDate(product.last_purchase_date) : '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={getLeadTimeVariant(product.lead_time_status)}>
|
||||||
|
{product.lead_time_status}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import config from "@/config"
|
|||||||
import { formatCurrency } from "@/lib/utils"
|
import { formatCurrency } from "@/lib/utils"
|
||||||
import { ClipboardList, AlertCircle, Layers, DollarSign, ShoppingCart } from "lucide-react" // Importing icons
|
import { ClipboardList, AlertCircle, Layers, DollarSign, ShoppingCart } from "lucide-react" // Importing icons
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import { PurchaseOrderStatus, ReceivingStatus } from "@/types/status-codes"
|
||||||
|
|
||||||
interface PurchaseMetricsData {
|
interface PurchaseMetricsData {
|
||||||
activePurchaseOrders: number
|
activePurchaseOrders: number // Orders that are not canceled, done, or fully received
|
||||||
overduePurchaseOrders: number
|
overduePurchaseOrders: number // Orders past their expected delivery date
|
||||||
onOrderUnits: number
|
onOrderUnits: number // Total units across all active orders
|
||||||
onOrderCost: number
|
onOrderCost: number // Total cost across all active orders
|
||||||
onOrderRetail: number
|
onOrderRetail: number // Total retail value across all active orders
|
||||||
vendorOrders: {
|
vendorOrders: {
|
||||||
vendor: string
|
vendor: string
|
||||||
orders: number
|
orders: number
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ import { DateRangePicker } from "@/components/ui/date-range-picker-narrow"
|
|||||||
interface SalesData {
|
interface SalesData {
|
||||||
totalOrders: number
|
totalOrders: number
|
||||||
totalUnitsSold: number
|
totalUnitsSold: number
|
||||||
totalCogs: number
|
totalCogs: string
|
||||||
totalRevenue: number
|
totalRevenue: string
|
||||||
dailySales: {
|
dailySales: {
|
||||||
date: string
|
date: string
|
||||||
units: number
|
units: number
|
||||||
revenue: number
|
revenue: string
|
||||||
cogs: number
|
cogs: string
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,14 +78,14 @@ export function SalesMetrics() {
|
|||||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">Cost of Goods</p>
|
<p className="text-sm font-medium text-muted-foreground">Cost of Goods</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold">{formatCurrency(data?.totalCogs || 0)}</p>
|
<p className="text-lg font-bold">{formatCurrency(Number(data?.totalCogs) || 0)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">Revenue</p>
|
<p className="text-sm font-medium text-muted-foreground">Revenue</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold">{formatCurrency(data?.totalRevenue || 0)}</p>
|
<p className="text-lg font-bold">{formatCurrency(Number(data?.totalRevenue) || 0)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ export function SalesMetrics() {
|
|||||||
tick={false}
|
tick={false}
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
formatter={(value: number) => [formatCurrency(value), "Revenue"]}
|
formatter={(value: string) => [formatCurrency(Number(value)), "Revenue"]}
|
||||||
labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')}
|
labelFormatter={(date) => format(new Date(date), 'MMM d, yyyy')}
|
||||||
/>
|
/>
|
||||||
<Area
|
<Area
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ interface StockMetricsData {
|
|||||||
totalProducts: number
|
totalProducts: number
|
||||||
productsInStock: number
|
productsInStock: number
|
||||||
totalStockUnits: number
|
totalStockUnits: number
|
||||||
totalStockCost: number
|
totalStockCost: string
|
||||||
totalStockRetail: number
|
totalStockRetail: string
|
||||||
brandStock: {
|
brandStock: {
|
||||||
brand: string
|
brand: string
|
||||||
variants: number
|
variants: number
|
||||||
units: number
|
units: number
|
||||||
cost: number
|
cost: string
|
||||||
retail: number
|
retail: string
|
||||||
}[]
|
}[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ const renderActiveShape = (props: any) => {
|
|||||||
fill="#000000"
|
fill="#000000"
|
||||||
className="text-base font-medium"
|
className="text-base font-medium"
|
||||||
>
|
>
|
||||||
{formatCurrency(retail)}
|
{formatCurrency(Number(retail))}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
@@ -154,14 +154,14 @@ export function StockMetrics() {
|
|||||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">Stock Cost</p>
|
<p className="text-sm font-medium text-muted-foreground">Stock Cost</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold">{formatCurrency(data?.totalStockCost || 0)}</p>
|
<p className="text-lg font-bold">{formatCurrency(Number(data?.totalStockCost) || 0)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||||
<p className="text-sm font-medium text-muted-foreground">Stock Retail</p>
|
<p className="text-sm font-medium text-muted-foreground">Stock Retail</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-lg font-bold">{formatCurrency(data?.totalStockRetail || 0)}</p>
|
<p className="text-lg font-bold">{formatCurrency(Number(data?.totalStockRetail) || 0)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,18 +5,18 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||||||
import config from "@/config"
|
import config from "@/config"
|
||||||
import { formatCurrency } from "@/lib/utils"
|
import { formatCurrency } from "@/lib/utils"
|
||||||
|
|
||||||
interface OverstockedProduct {
|
interface Product {
|
||||||
product_id: number
|
pid: number;
|
||||||
SKU: string
|
sku: string;
|
||||||
title: string
|
title: string;
|
||||||
stock_quantity: number
|
stock_quantity: number;
|
||||||
overstocked_amt: number
|
overstocked_amt: number;
|
||||||
excess_cost: number
|
excess_cost: number;
|
||||||
excess_retail: number
|
excess_retail: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TopOverstockedProducts() {
|
export function TopOverstockedProducts() {
|
||||||
const { data } = useQuery<OverstockedProduct[]>({
|
const { data } = useQuery<Product[]>({
|
||||||
queryKey: ["top-overstocked-products"],
|
queryKey: ["top-overstocked-products"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await fetch(`${config.apiUrl}/dashboard/overstock/products?limit=50`)
|
const response = await fetch(`${config.apiUrl}/dashboard/overstock/products?limit=50`)
|
||||||
@@ -38,40 +38,30 @@ export function TopOverstockedProducts() {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Product</TableHead>
|
<TableHead>Product</TableHead>
|
||||||
<TableHead className="text-right">Current Stock</TableHead>
|
<TableHead className="text-right">Stock</TableHead>
|
||||||
<TableHead className="text-right">Overstock Amt</TableHead>
|
<TableHead className="text-right">Excess</TableHead>
|
||||||
<TableHead className="text-right">Overstock Cost</TableHead>
|
<TableHead className="text-right">Cost</TableHead>
|
||||||
<TableHead className="text-right">Overstock Retail</TableHead>
|
<TableHead className="text-right">Retail</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data?.map((product) => (
|
{data?.map((product) => (
|
||||||
<TableRow key={product.product_id}>
|
<TableRow key={product.pid}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div>
|
|
||||||
<a
|
<a
|
||||||
href={`https://backend.acherryontop.com/product/${product.product_id}`}
|
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="font-medium hover:underline"
|
className="hover:underline"
|
||||||
>
|
>
|
||||||
{product.title}
|
{product.title}
|
||||||
</a>
|
</a>
|
||||||
<p className="text-sm text-muted-foreground">{product.SKU}</p>
|
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{product.stock_quantity.toLocaleString()}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{product.overstocked_amt.toLocaleString()}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{formatCurrency(product.excess_cost)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{formatCurrency(product.excess_retail)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-right">{product.stock_quantity}</TableCell>
|
||||||
|
<TableCell className="text-right">{product.overstocked_amt}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatCurrency(product.excess_cost)}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatCurrency(product.excess_retail)}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -3,20 +3,19 @@ import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import config from "@/config"
|
import config from "@/config"
|
||||||
import { formatCurrency } from "@/lib/utils"
|
|
||||||
|
|
||||||
interface ReplenishProduct {
|
interface Product {
|
||||||
product_id: number
|
pid: number;
|
||||||
SKU: string
|
sku: string;
|
||||||
title: string
|
title: string;
|
||||||
current_stock: number
|
stock_quantity: number;
|
||||||
replenish_qty: number
|
daily_sales_avg: string;
|
||||||
replenish_cost: number
|
reorder_qty: number;
|
||||||
replenish_retail: number
|
last_purchase_date: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TopReplenishProducts() {
|
export function TopReplenishProducts() {
|
||||||
const { data } = useQuery<ReplenishProduct[]>({
|
const { data } = useQuery<Product[]>({
|
||||||
queryKey: ["top-replenish-products"],
|
queryKey: ["top-replenish-products"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await fetch(`${config.apiUrl}/dashboard/replenish/products?limit=50`)
|
const response = await fetch(`${config.apiUrl}/dashboard/replenish/products?limit=50`)
|
||||||
@@ -39,39 +38,29 @@ export function TopReplenishProducts() {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Product</TableHead>
|
<TableHead>Product</TableHead>
|
||||||
<TableHead className="text-right">Stock</TableHead>
|
<TableHead className="text-right">Stock</TableHead>
|
||||||
<TableHead className="text-right">Replenish</TableHead>
|
<TableHead className="text-right">Daily Sales</TableHead>
|
||||||
<TableHead className="text-right">Cost</TableHead>
|
<TableHead className="text-right">Reorder Qty</TableHead>
|
||||||
<TableHead className="text-right">Retail</TableHead>
|
<TableHead>Last Purchase</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{data?.map((product) => (
|
{data?.map((product) => (
|
||||||
<TableRow key={product.product_id}>
|
<TableRow key={product.pid}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div>
|
|
||||||
<a
|
<a
|
||||||
href={`https://backend.acherryontop.com/product/${product.product_id}`}
|
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="font-medium hover:underline"
|
className="hover:underline"
|
||||||
>
|
>
|
||||||
{product.title}
|
{product.title}
|
||||||
</a>
|
</a>
|
||||||
<p className="text-sm text-muted-foreground">{product.SKU}</p>
|
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{product.current_stock.toLocaleString()}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{product.replenish_qty.toLocaleString()}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{formatCurrency(product.replenish_cost)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
{formatCurrency(product.replenish_retail)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-right">{product.stock_quantity}</TableCell>
|
||||||
|
<TableCell className="text-right">{Number(product.daily_sales_avg).toFixed(1)}</TableCell>
|
||||||
|
<TableCell className="text-right">{product.reorder_qty}</TableCell>
|
||||||
|
<TableCell>{product.last_purchase_date ? product.last_purchase_date : '-'}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -11,18 +11,18 @@ import {
|
|||||||
import { TrendingUp, TrendingDown } from "lucide-react"
|
import { TrendingUp, TrendingDown } from "lucide-react"
|
||||||
import config from "@/config"
|
import config from "@/config"
|
||||||
|
|
||||||
interface TrendingProduct {
|
interface Product {
|
||||||
product_id: number
|
pid: number;
|
||||||
sku: string
|
sku: string;
|
||||||
title: string
|
title: string;
|
||||||
daily_sales_avg: string
|
daily_sales_avg: string;
|
||||||
weekly_sales_avg: string
|
weekly_sales_avg: string;
|
||||||
growth_rate: string
|
growth_rate: string;
|
||||||
total_revenue: string
|
total_revenue: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TrendingProducts() {
|
export function TrendingProducts() {
|
||||||
const { data: products } = useQuery<TrendingProduct[]>({
|
const { data: products } = useQuery<Product[]>({
|
||||||
queryKey: ["trending-products"],
|
queryKey: ["trending-products"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await fetch(`${config.apiUrl}/products/trending`)
|
const response = await fetch(`${config.apiUrl}/products/trending`)
|
||||||
@@ -33,7 +33,6 @@ export function TrendingProducts() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
const formatPercent = (value: number) =>
|
const formatPercent = (value: number) =>
|
||||||
new Intl.NumberFormat("en-US", {
|
new Intl.NumberFormat("en-US", {
|
||||||
style: "percent",
|
style: "percent",
|
||||||
@@ -42,6 +41,14 @@ export function TrendingProducts() {
|
|||||||
signDisplay: "exceptZero",
|
signDisplay: "exceptZero",
|
||||||
}).format(value / 100)
|
}).format(value / 100)
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) =>
|
||||||
|
new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
}).format(value)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -59,7 +66,7 @@ export function TrendingProducts() {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{products?.map((product) => (
|
{products?.map((product) => (
|
||||||
<TableRow key={product.product_id}>
|
<TableRow key={product.pid}>
|
||||||
<TableCell className="font-medium">
|
<TableCell className="font-medium">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="font-medium">{product.title}</span>
|
<span className="font-medium">{product.title}</span>
|
||||||
@@ -68,20 +75,20 @@ export function TrendingProducts() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{parseFloat(product.daily_sales_avg).toFixed(1)}</TableCell>
|
<TableCell>{Number(product.daily_sales_avg).toFixed(1)}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
{parseFloat(product.growth_rate) > 0 ? (
|
{Number(product.growth_rate) > 0 ? (
|
||||||
<TrendingUp className="h-4 w-4 text-success" />
|
<TrendingUp className="h-4 w-4 text-success" />
|
||||||
) : (
|
) : (
|
||||||
<TrendingDown className="h-4 w-4 text-destructive" />
|
<TrendingDown className="h-4 w-4 text-destructive" />
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
parseFloat(product.growth_rate) > 0 ? "text-success" : "text-destructive"
|
Number(product.growth_rate) > 0 ? "text-success" : "text-destructive"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{formatPercent(parseFloat(product.growth_rate))}
|
{formatPercent(Number(product.growth_rate))}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -3,24 +3,27 @@ import { ArrowUpDown, ChevronDown, ChevronRight } from "lucide-react";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
interface ProductDetail {
|
|
||||||
product_id: string;
|
interface Product {
|
||||||
name: string;
|
pid: string;
|
||||||
sku: string;
|
sku: string;
|
||||||
|
title: string;
|
||||||
stock_quantity: number;
|
stock_quantity: number;
|
||||||
total_sold: number;
|
daily_sales_avg: number;
|
||||||
avg_price: number;
|
forecast_units: number;
|
||||||
first_received_date: string;
|
forecast_revenue: number;
|
||||||
|
confidence_level: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ForecastItem {
|
export interface ForecastItem {
|
||||||
category: string;
|
category: string;
|
||||||
|
categoryPath: string;
|
||||||
avgDailySales: number;
|
avgDailySales: number;
|
||||||
totalSold: number;
|
totalSold: number;
|
||||||
numProducts: number;
|
numProducts: number;
|
||||||
avgPrice: number;
|
avgPrice: number;
|
||||||
avgTotalSold: number;
|
avgTotalSold: number;
|
||||||
products?: ProductDetail[];
|
products?: Product[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const columns: ColumnDef<ForecastItem>[] = [
|
export const columns: ColumnDef<ForecastItem>[] = [
|
||||||
@@ -42,6 +45,16 @@ export const columns: ColumnDef<ForecastItem>[] = [
|
|||||||
{
|
{
|
||||||
accessorKey: "category",
|
accessorKey: "category",
|
||||||
header: "Category",
|
header: "Category",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{row.original.category}</div>
|
||||||
|
{row.original.categoryPath && (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{row.original.categoryPath}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "avgDailySales",
|
accessorKey: "avgDailySales",
|
||||||
@@ -147,23 +160,33 @@ export const renderSubComponent = ({ row }: { row: any }) => {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Product Name</TableHead>
|
<TableHead>Product</TableHead>
|
||||||
<TableHead>SKU</TableHead>
|
<TableHead className="text-right">Stock</TableHead>
|
||||||
<TableHead>First Received</TableHead>
|
<TableHead className="text-right">Daily Sales</TableHead>
|
||||||
<TableHead>Stock Quantity</TableHead>
|
<TableHead className="text-right">Forecast Units</TableHead>
|
||||||
<TableHead>Total Sold</TableHead>
|
<TableHead className="text-right">Forecast Revenue</TableHead>
|
||||||
<TableHead>Average Price</TableHead>
|
<TableHead className="text-right">Confidence</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{products.map((product: ProductDetail) => (
|
{products.map((product) => (
|
||||||
<TableRow key={product.product_id}>
|
<TableRow key={product.pid}>
|
||||||
<TableCell>{product.name}</TableCell>
|
<TableCell>
|
||||||
<TableCell>{product.sku}</TableCell>
|
<a
|
||||||
<TableCell>{product.first_received_date}</TableCell>
|
href={`https://backend.acherryontop.com/product/${product.pid}`}
|
||||||
<TableCell>{product.stock_quantity.toLocaleString()}</TableCell>
|
target="_blank"
|
||||||
<TableCell>{product.total_sold.toLocaleString()}</TableCell>
|
rel="noopener noreferrer"
|
||||||
<TableCell>${product.avg_price.toFixed(2)}</TableCell>
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
{product.title}
|
||||||
|
</a>
|
||||||
|
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">{product.stock_quantity}</TableCell>
|
||||||
|
<TableCell className="text-right">{product.daily_sales_avg.toFixed(1)}</TableCell>
|
||||||
|
<TableCell className="text-right">{product.forecast_units.toFixed(1)}</TableCell>
|
||||||
|
<TableCell className="text-right">{product.forecast_revenue.toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="text-right">{product.confidence_level.toFixed(1)}%</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContai
|
|||||||
import config from "@/config";
|
import config from "@/config";
|
||||||
|
|
||||||
interface Product {
|
interface Product {
|
||||||
product_id: number;
|
pid: number;
|
||||||
title: string;
|
title: string;
|
||||||
SKU: string;
|
SKU: string;
|
||||||
barcode: string;
|
barcode: string;
|
||||||
@@ -38,7 +38,7 @@ interface Product {
|
|||||||
// Vendor info
|
// Vendor info
|
||||||
vendor: string;
|
vendor: string;
|
||||||
vendor_reference: string;
|
vendor_reference: string;
|
||||||
brand: string;
|
brand: string | 'Unbranded';
|
||||||
|
|
||||||
// URLs
|
// URLs
|
||||||
permalink: string;
|
permalink: string;
|
||||||
@@ -123,6 +123,8 @@ interface Product {
|
|||||||
notes: string;
|
notes: string;
|
||||||
lead_time_days: number | null;
|
lead_time_days: number | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
category_paths?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProductDetailProps {
|
interface ProductDetailProps {
|
||||||
@@ -205,8 +207,8 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-xl font-semibold">{product?.title || 'Loading...'}</h2>
|
<VaulDrawer.Title className="text-xl font-semibold">{product?.title || 'Loading...'}</VaulDrawer.Title>
|
||||||
<p className="text-sm text-muted-foreground">{product?.SKU || ''}</p>
|
<VaulDrawer.Description className="text-sm text-muted-foreground">{product?.SKU || ''}</VaulDrawer.Description>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||||
@@ -255,22 +257,28 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Categories</dt>
|
<dt className="text-sm text-muted-foreground">Categories</dt>
|
||||||
<dd className="flex flex-wrap gap-2">
|
<dd className="flex flex-col gap-2">
|
||||||
{product?.categories?.map(category => (
|
{product?.category_paths ?
|
||||||
<span key={category} className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium ring-1 ring-inset ring-muted">
|
Object.entries(product.category_paths).map(([key, fullPath], index) => {
|
||||||
{category}
|
const [, leafCategory] = key.split(':');
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex flex-col">
|
||||||
|
<span className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium ring-1 ring-inset ring-muted">
|
||||||
|
{leafCategory}
|
||||||
</span>
|
</span>
|
||||||
)) || "N/A"}
|
<span className="text-xs text-muted-foreground ml-2 mt-1">
|
||||||
|
{fullPath}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: "N/A"}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Tags</dt>
|
<dt className="text-sm text-muted-foreground">Tags</dt>
|
||||||
<dd className="flex flex-wrap gap-2">
|
<dd className="flex flex-wrap gap-2">
|
||||||
{product?.tags?.map(tag => (
|
N/A
|
||||||
<span key={tag} className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium ring-1 ring-inset ring-muted">
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
)) || "N/A"}
|
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -307,11 +315,11 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Status</dt>
|
<dt className="text-sm text-muted-foreground">Status</dt>
|
||||||
<dd>{product?.metrics?.stock_status}</dd>
|
<dd>{product?.stock_status || "N/A"}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Days of Stock</dt>
|
<dt className="text-sm text-muted-foreground">Days of Stock</dt>
|
||||||
<dd>{product?.metrics?.days_of_inventory} days</dd>
|
<dd>{product?.days_of_inventory || 0} days</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -321,15 +329,15 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
|||||||
<dl className="space-y-2">
|
<dl className="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Daily Sales</dt>
|
<dt className="text-sm text-muted-foreground">Daily Sales</dt>
|
||||||
<dd>{product?.metrics?.daily_sales_avg?.toFixed(1)} units</dd>
|
<dd>{product?.daily_sales_avg?.toFixed(1) || "0.0"} units</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Weekly Sales</dt>
|
<dt className="text-sm text-muted-foreground">Weekly Sales</dt>
|
||||||
<dd>{product?.metrics?.weekly_sales_avg?.toFixed(1)} units</dd>
|
<dd>{product?.weekly_sales_avg?.toFixed(1) || "0.0"} units</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Monthly Sales</dt>
|
<dt className="text-sm text-muted-foreground">Monthly Sales</dt>
|
||||||
<dd>{product?.metrics?.monthly_sales_avg?.toFixed(1)} units</dd>
|
<dd>{product?.monthly_sales_avg?.toFixed(1) || "0.0"} units</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -356,19 +364,19 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
|||||||
<dl className="space-y-2">
|
<dl className="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Total Revenue</dt>
|
<dt className="text-sm text-muted-foreground">Total Revenue</dt>
|
||||||
<dd>${formatPrice(product?.metrics.total_revenue)}</dd>
|
<dd>${formatPrice(product?.total_revenue)}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Gross Profit</dt>
|
<dt className="text-sm text-muted-foreground">Gross Profit</dt>
|
||||||
<dd>${formatPrice(product?.metrics.gross_profit)}</dd>
|
<dd>${formatPrice(product?.gross_profit)}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Margin</dt>
|
<dt className="text-sm text-muted-foreground">Margin</dt>
|
||||||
<dd>{product?.metrics.avg_margin_percent.toFixed(2)}%</dd>
|
<dd>{product?.avg_margin_percent?.toFixed(2) || "0.00"}%</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">GMROI</dt>
|
<dt className="text-sm text-muted-foreground">GMROI</dt>
|
||||||
<dd>{product?.metrics.gmroi.toFixed(2)}</dd>
|
<dd>{product?.gmroi?.toFixed(2) || "0.00"}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -378,15 +386,15 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
|||||||
<dl className="space-y-2">
|
<dl className="space-y-2">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Current Lead Time</dt>
|
<dt className="text-sm text-muted-foreground">Current Lead Time</dt>
|
||||||
<dd>{product?.metrics.current_lead_time}</dd>
|
<dd>{product?.current_lead_time || "N/A"}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Target Lead Time</dt>
|
<dt className="text-sm text-muted-foreground">Target Lead Time</dt>
|
||||||
<dd>{product?.metrics.target_lead_time}</dd>
|
<dd>{product?.target_lead_time || "N/A"}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Lead Time Status</dt>
|
<dt className="text-sm text-muted-foreground">Lead Time Status</dt>
|
||||||
<dd>{product?.metrics.lead_time_status}</dd>
|
<dd>{product?.lead_time_status || "N/A"}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -408,11 +416,11 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Days of Inventory</dt>
|
<dt className="text-sm text-muted-foreground">Days of Inventory</dt>
|
||||||
<dd className="text-2xl font-semibold">{product?.metrics?.days_of_inventory || 0}</dd>
|
<dd className="text-2xl font-semibold">{product?.days_of_inventory || 0}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Status</dt>
|
<dt className="text-sm text-muted-foreground">Status</dt>
|
||||||
<dd className="text-2xl font-semibold">{product?.metrics?.stock_status || "N/A"}</dd>
|
<dd className="text-2xl font-semibold">{product?.stock_status || "N/A"}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -422,15 +430,15 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
|||||||
<dl className="grid grid-cols-3 gap-4">
|
<dl className="grid grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Reorder Point</dt>
|
<dt className="text-sm text-muted-foreground">Reorder Point</dt>
|
||||||
<dd>{product?.metrics?.reorder_point || 0}</dd>
|
<dd>{product?.reorder_point || 0}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Safety Stock</dt>
|
<dt className="text-sm text-muted-foreground">Safety Stock</dt>
|
||||||
<dd>{product?.metrics?.safety_stock || 0}</dd>
|
<dd>{product?.safety_stock || 0}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">ABC Class</dt>
|
<dt className="text-sm text-muted-foreground">ABC Class</dt>
|
||||||
<dd>{product?.metrics?.abc_class || "N/A"}</dd>
|
<dd>{product?.abc_class || "N/A"}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -551,15 +559,15 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
|||||||
<dl className="grid grid-cols-3 gap-4">
|
<dl className="grid grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Gross Profit</dt>
|
<dt className="text-sm text-muted-foreground">Gross Profit</dt>
|
||||||
<dd className="text-2xl font-semibold">${formatPrice(product?.metrics.gross_profit)}</dd>
|
<dd className="text-2xl font-semibold">${formatPrice(product?.gross_profit)}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">GMROI</dt>
|
<dt className="text-sm text-muted-foreground">GMROI</dt>
|
||||||
<dd className="text-2xl font-semibold">{product?.metrics.gmroi.toFixed(2)}</dd>
|
<dd className="text-2xl font-semibold">{product?.gmroi?.toFixed(2) || "0.00"}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Margin %</dt>
|
<dt className="text-sm text-muted-foreground">Margin %</dt>
|
||||||
<dd className="text-2xl font-semibold">{product?.metrics.avg_margin_percent.toFixed(2)}%</dd>
|
<dd className="text-2xl font-semibold">{product?.avg_margin_percent?.toFixed(2) || "0.00"}%</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -569,7 +577,7 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
|||||||
<dl className="grid grid-cols-2 gap-4">
|
<dl className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Cost of Goods Sold</dt>
|
<dt className="text-sm text-muted-foreground">Cost of Goods Sold</dt>
|
||||||
<dd>${formatPrice(product?.metrics.cost_of_goods_sold)}</dd>
|
<dd>${formatPrice(product?.cost_of_goods_sold)}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm text-muted-foreground">Landing Cost</dt>
|
<dt className="text-sm text-muted-foreground">Landing Cost</dt>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ type FilterValue = string | number | boolean;
|
|||||||
type ComparisonOperator = "=" | ">" | ">=" | "<" | "<=" | "between";
|
type ComparisonOperator = "=" | ">" | ">=" | "<" | "<=" | "between";
|
||||||
|
|
||||||
interface FilterValueWithOperator {
|
interface FilterValueWithOperator {
|
||||||
value: FilterValue | [number, number];
|
value: FilterValue | [string, string];
|
||||||
operator: ComparisonOperator;
|
operator: ComparisonOperator;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,18 +317,32 @@ export function ProductFilters({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleApplyFilter = (value: FilterValue | [number, number]) => {
|
const handleApplyFilter = (value: FilterValue | [string, string]) => {
|
||||||
if (!selectedFilter) return;
|
if (!selectedFilter) return;
|
||||||
|
|
||||||
const newFilters = {
|
let filterValue: ActiveFilterValue;
|
||||||
...activeFilters,
|
|
||||||
[selectedFilter.id]: {
|
if (selectedFilter.type === "number") {
|
||||||
value,
|
if (selectedOperator === "between" && Array.isArray(value)) {
|
||||||
operator: selectedOperator,
|
filterValue = {
|
||||||
},
|
value: [value[0].toString(), value[1].toString()],
|
||||||
};
|
operator: selectedOperator,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
filterValue = {
|
||||||
|
value: value.toString(),
|
||||||
|
operator: selectedOperator,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
filterValue = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
onFilterChange({
|
||||||
|
...activeFilters,
|
||||||
|
[selectedFilter.id]: filterValue,
|
||||||
|
});
|
||||||
|
|
||||||
onFilterChange(newFilters as Record<string, ActiveFilterValue>);
|
|
||||||
handlePopoverClose();
|
handlePopoverClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -394,38 +408,14 @@ export function ProductFilters({
|
|||||||
|
|
||||||
|
|
||||||
const getFilterDisplayValue = (filter: ActiveFilter) => {
|
const getFilterDisplayValue = (filter: ActiveFilter) => {
|
||||||
const filterValue = activeFilters[filter.id];
|
if (typeof filter.value === "object" && "operator" in filter.value) {
|
||||||
const filterOption = filterOptions.find((opt) => opt.id === filter.id);
|
const { operator, value } = filter.value;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
// For between ranges
|
return `${operator} ${value[0]} and ${value[1]}`;
|
||||||
if (Array.isArray(filterValue)) {
|
|
||||||
return `${filter.label} between ${filterValue[0]} and ${filterValue[1]}`;
|
|
||||||
}
|
}
|
||||||
|
return `${operator} ${value}`;
|
||||||
// For direct selections (select type) or text search
|
|
||||||
if (
|
|
||||||
filterOption?.type === "select" ||
|
|
||||||
filterOption?.type === "text" ||
|
|
||||||
typeof filterValue !== "object"
|
|
||||||
) {
|
|
||||||
const value =
|
|
||||||
typeof filterValue === "object" ? filterValue.value : filterValue;
|
|
||||||
return `${filter.label}: ${value}`;
|
|
||||||
}
|
}
|
||||||
|
return filter.value.toString();
|
||||||
// For numeric filters with operators
|
|
||||||
const operator = filterValue.operator;
|
|
||||||
const value = filterValue.value;
|
|
||||||
const operatorDisplay = {
|
|
||||||
"=": "=",
|
|
||||||
">": ">",
|
|
||||||
">=": "≥",
|
|
||||||
"<": "<",
|
|
||||||
"<=": "≤",
|
|
||||||
between: "between",
|
|
||||||
}[operator];
|
|
||||||
|
|
||||||
return `${filter.label} ${operatorDisplay} ${value}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -230,7 +230,7 @@ export function ProductTable({
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{Array.from(new Set(value as string[])).map((category) => (
|
{Array.from(new Set(value as string[])).map((category) => (
|
||||||
<Badge key={`${product.product_id}-${category}`} variant="outline">{category}</Badge>
|
<Badge key={`${product.pid}-${category}`} variant="outline">{category}</Badge>
|
||||||
)) || '-'}
|
)) || '-'}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -261,6 +261,11 @@ export function ProductTable({
|
|||||||
return columnDef.format(num);
|
return columnDef.format(num);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// If the value is already a number, format it directly
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return columnDef.format(value);
|
||||||
|
}
|
||||||
|
// For other formats (e.g., date formatting), pass the value as is
|
||||||
return columnDef.format(value);
|
return columnDef.format(value);
|
||||||
}
|
}
|
||||||
return value ?? '-';
|
return value ?? '-';
|
||||||
@@ -297,12 +302,12 @@ export function ProductTable({
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{products.map((product) => (
|
{products.map((product) => (
|
||||||
<TableRow
|
<TableRow
|
||||||
key={product.product_id}
|
key={product.pid}
|
||||||
onClick={() => onRowClick?.(product)}
|
onClick={() => onRowClick?.(product)}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
{orderedColumns.map((column) => (
|
{orderedColumns.map((column) => (
|
||||||
<TableCell key={`${product.product_id}-${column}`}>
|
<TableCell key={`${product.pid}-${column}`}>
|
||||||
{formatColumnValue(product, column)}
|
{formatColumnValue(product, column)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import config from '../../config';
|
|||||||
|
|
||||||
interface SalesVelocityConfig {
|
interface SalesVelocityConfig {
|
||||||
id: number;
|
id: number;
|
||||||
category_id: number | null;
|
cat_id: number | null;
|
||||||
vendor: string | null;
|
vendor: string | null;
|
||||||
daily_window_days: number;
|
daily_window_days: number;
|
||||||
weekly_window_days: number;
|
weekly_window_days: number;
|
||||||
@@ -18,7 +18,7 @@ interface SalesVelocityConfig {
|
|||||||
export function CalculationSettings() {
|
export function CalculationSettings() {
|
||||||
const [salesVelocityConfig, setSalesVelocityConfig] = useState<SalesVelocityConfig>({
|
const [salesVelocityConfig, setSalesVelocityConfig] = useState<SalesVelocityConfig>({
|
||||||
id: 1,
|
id: 1,
|
||||||
category_id: null,
|
cat_id: null,
|
||||||
vendor: null,
|
vendor: null,
|
||||||
daily_window_days: 30,
|
daily_window_days: 30,
|
||||||
weekly_window_days: 7,
|
weekly_window_days: 7,
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import config from '../../config';
|
import config from '../../config';
|
||||||
|
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
|
||||||
interface StockThreshold {
|
interface StockThreshold {
|
||||||
id: number;
|
id: number;
|
||||||
category_id: number | null;
|
cat_id: number | null;
|
||||||
vendor: string | null;
|
vendor: string | null;
|
||||||
critical_days: number;
|
critical_days: number;
|
||||||
reorder_days: number;
|
reorder_days: number;
|
||||||
@@ -22,7 +23,7 @@ interface StockThreshold {
|
|||||||
|
|
||||||
interface LeadTimeThreshold {
|
interface LeadTimeThreshold {
|
||||||
id: number;
|
id: number;
|
||||||
category_id: number | null;
|
cat_id: number | null;
|
||||||
vendor: string | null;
|
vendor: string | null;
|
||||||
target_days: number;
|
target_days: number;
|
||||||
warning_days: number;
|
warning_days: number;
|
||||||
@@ -31,7 +32,7 @@ interface LeadTimeThreshold {
|
|||||||
|
|
||||||
interface SalesVelocityConfig {
|
interface SalesVelocityConfig {
|
||||||
id: number;
|
id: number;
|
||||||
category_id: number | null;
|
cat_id: number | null;
|
||||||
vendor: string | null;
|
vendor: string | null;
|
||||||
daily_window_days: number;
|
daily_window_days: number;
|
||||||
weekly_window_days: number;
|
weekly_window_days: number;
|
||||||
@@ -47,7 +48,7 @@ interface ABCClassificationConfig {
|
|||||||
|
|
||||||
interface SafetyStockConfig {
|
interface SafetyStockConfig {
|
||||||
id: number;
|
id: number;
|
||||||
category_id: number | null;
|
cat_id: number | null;
|
||||||
vendor: string | null;
|
vendor: string | null;
|
||||||
coverage_days: number;
|
coverage_days: number;
|
||||||
service_level: number;
|
service_level: number;
|
||||||
@@ -55,7 +56,7 @@ interface SafetyStockConfig {
|
|||||||
|
|
||||||
interface TurnoverConfig {
|
interface TurnoverConfig {
|
||||||
id: number;
|
id: number;
|
||||||
category_id: number | null;
|
cat_id: number | null;
|
||||||
vendor: string | null;
|
vendor: string | null;
|
||||||
calculation_period_days: number;
|
calculation_period_days: number;
|
||||||
target_rate: number;
|
target_rate: number;
|
||||||
@@ -64,7 +65,7 @@ interface TurnoverConfig {
|
|||||||
export function Configuration() {
|
export function Configuration() {
|
||||||
const [stockThresholds, setStockThresholds] = useState<StockThreshold>({
|
const [stockThresholds, setStockThresholds] = useState<StockThreshold>({
|
||||||
id: 1,
|
id: 1,
|
||||||
category_id: null,
|
cat_id: null,
|
||||||
vendor: null,
|
vendor: null,
|
||||||
critical_days: 7,
|
critical_days: 7,
|
||||||
reorder_days: 14,
|
reorder_days: 14,
|
||||||
@@ -75,7 +76,7 @@ export function Configuration() {
|
|||||||
|
|
||||||
const [leadTimeThresholds, setLeadTimeThresholds] = useState<LeadTimeThreshold>({
|
const [leadTimeThresholds, setLeadTimeThresholds] = useState<LeadTimeThreshold>({
|
||||||
id: 1,
|
id: 1,
|
||||||
category_id: null,
|
cat_id: null,
|
||||||
vendor: null,
|
vendor: null,
|
||||||
target_days: 14,
|
target_days: 14,
|
||||||
warning_days: 21,
|
warning_days: 21,
|
||||||
@@ -84,7 +85,7 @@ export function Configuration() {
|
|||||||
|
|
||||||
const [salesVelocityConfig, setSalesVelocityConfig] = useState<SalesVelocityConfig>({
|
const [salesVelocityConfig, setSalesVelocityConfig] = useState<SalesVelocityConfig>({
|
||||||
id: 1,
|
id: 1,
|
||||||
category_id: null,
|
cat_id: null,
|
||||||
vendor: null,
|
vendor: null,
|
||||||
daily_window_days: 30,
|
daily_window_days: 30,
|
||||||
weekly_window_days: 7,
|
weekly_window_days: 7,
|
||||||
@@ -100,7 +101,7 @@ export function Configuration() {
|
|||||||
|
|
||||||
const [safetyStockConfig, setSafetyStockConfig] = useState<SafetyStockConfig>({
|
const [safetyStockConfig, setSafetyStockConfig] = useState<SafetyStockConfig>({
|
||||||
id: 1,
|
id: 1,
|
||||||
category_id: null,
|
cat_id: null,
|
||||||
vendor: null,
|
vendor: null,
|
||||||
coverage_days: 14,
|
coverage_days: 14,
|
||||||
service_level: 95.0
|
service_level: 95.0
|
||||||
@@ -108,7 +109,7 @@ export function Configuration() {
|
|||||||
|
|
||||||
const [turnoverConfig, setTurnoverConfig] = useState<TurnoverConfig>({
|
const [turnoverConfig, setTurnoverConfig] = useState<TurnoverConfig>({
|
||||||
id: 1,
|
id: 1,
|
||||||
category_id: null,
|
cat_id: null,
|
||||||
vendor: null,
|
vendor: null,
|
||||||
calculation_period_days: 30,
|
calculation_period_days: 30,
|
||||||
target_rate: 1.0
|
target_rate: 1.0
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import config from '../../config';
|
import config from '../../config';
|
||||||
|
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
|
||||||
interface LeadTimeThreshold {
|
interface LeadTimeThreshold {
|
||||||
id: number;
|
id: number;
|
||||||
category_id: number | null;
|
cat_id: number | null;
|
||||||
vendor: string | null;
|
vendor: string | null;
|
||||||
target_days: number;
|
target_days: number;
|
||||||
warning_days: number;
|
warning_days: number;
|
||||||
@@ -17,6 +18,8 @@ interface LeadTimeThreshold {
|
|||||||
|
|
||||||
interface ABCClassificationConfig {
|
interface ABCClassificationConfig {
|
||||||
id: number;
|
id: number;
|
||||||
|
cat_id: number | null;
|
||||||
|
vendor: string | null;
|
||||||
a_threshold: number;
|
a_threshold: number;
|
||||||
b_threshold: number;
|
b_threshold: number;
|
||||||
classification_period_days: number;
|
classification_period_days: number;
|
||||||
@@ -24,7 +27,7 @@ interface ABCClassificationConfig {
|
|||||||
|
|
||||||
interface TurnoverConfig {
|
interface TurnoverConfig {
|
||||||
id: number;
|
id: number;
|
||||||
category_id: number | null;
|
cat_id: number | null;
|
||||||
vendor: string | null;
|
vendor: string | null;
|
||||||
calculation_period_days: number;
|
calculation_period_days: number;
|
||||||
target_rate: number;
|
target_rate: number;
|
||||||
@@ -33,27 +36,16 @@ interface TurnoverConfig {
|
|||||||
export function PerformanceMetrics() {
|
export function PerformanceMetrics() {
|
||||||
const [leadTimeThresholds, setLeadTimeThresholds] = useState<LeadTimeThreshold>({
|
const [leadTimeThresholds, setLeadTimeThresholds] = useState<LeadTimeThreshold>({
|
||||||
id: 1,
|
id: 1,
|
||||||
category_id: null,
|
cat_id: null,
|
||||||
vendor: null,
|
vendor: null,
|
||||||
target_days: 14,
|
target_days: 14,
|
||||||
warning_days: 21,
|
warning_days: 21,
|
||||||
critical_days: 30
|
critical_days: 30
|
||||||
});
|
});
|
||||||
|
|
||||||
const [abcConfig, setAbcConfig] = useState<ABCClassificationConfig>({
|
const [abcConfigs, setAbcConfigs] = useState<ABCClassificationConfig[]>([]);
|
||||||
id: 1,
|
|
||||||
a_threshold: 20.0,
|
|
||||||
b_threshold: 50.0,
|
|
||||||
classification_period_days: 90
|
|
||||||
});
|
|
||||||
|
|
||||||
const [turnoverConfig, setTurnoverConfig] = useState<TurnoverConfig>({
|
const [turnoverConfigs, setTurnoverConfigs] = useState<TurnoverConfig[]>([]);
|
||||||
id: 1,
|
|
||||||
category_id: null,
|
|
||||||
vendor: null,
|
|
||||||
calculation_period_days: 30,
|
|
||||||
target_rate: 1.0
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadConfig = async () => {
|
const loadConfig = async () => {
|
||||||
@@ -66,8 +58,8 @@ export function PerformanceMetrics() {
|
|||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
setLeadTimeThresholds(data.leadTimeThresholds);
|
setLeadTimeThresholds(data.leadTimeThresholds);
|
||||||
setAbcConfig(data.abcConfig);
|
setAbcConfigs(data.abcConfigs);
|
||||||
setTurnoverConfig(data.turnoverConfig);
|
setTurnoverConfigs(data.turnoverConfigs);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(`Failed to load configuration: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
toast.error(`Failed to load configuration: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
}
|
}
|
||||||
@@ -105,7 +97,7 @@ export function PerformanceMetrics() {
|
|||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify(abcConfig)
|
body: JSON.stringify(abcConfigs)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -127,7 +119,7 @@ export function PerformanceMetrics() {
|
|||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
},
|
},
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
body: JSON.stringify(turnoverConfig)
|
body: JSON.stringify(turnoverConfigs)
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -210,54 +202,28 @@ export function PerformanceMetrics() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<Table>
|
||||||
<div>
|
<TableHeader>
|
||||||
<Label htmlFor="a-threshold">A Threshold (%)</Label>
|
<TableRow>
|
||||||
<Input
|
<TableHead>Category</TableHead>
|
||||||
id="a-threshold"
|
<TableHead>Vendor</TableHead>
|
||||||
type="number"
|
<TableHead className="text-right">A Threshold</TableHead>
|
||||||
min="0"
|
<TableHead className="text-right">B Threshold</TableHead>
|
||||||
max="100"
|
<TableHead className="text-right">Period Days</TableHead>
|
||||||
step="0.1"
|
</TableRow>
|
||||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
</TableHeader>
|
||||||
value={abcConfig.a_threshold}
|
<TableBody>
|
||||||
onChange={(e) => setAbcConfig(prev => ({
|
{abcConfigs.map((config) => (
|
||||||
...prev,
|
<TableRow key={`${config.cat_id}-${config.vendor}`}>
|
||||||
a_threshold: parseFloat(e.target.value) || 0
|
<TableCell>{config.cat_id ? getCategoryName(config.cat_id) : 'Global'}</TableCell>
|
||||||
}))}
|
<TableCell>{config.vendor || 'All Vendors'}</TableCell>
|
||||||
/>
|
<TableCell className="text-right">{config.a_threshold}%</TableCell>
|
||||||
</div>
|
<TableCell className="text-right">{config.b_threshold}%</TableCell>
|
||||||
<div>
|
<TableCell className="text-right">{config.classification_period_days}</TableCell>
|
||||||
<Label htmlFor="b-threshold">B Threshold (%)</Label>
|
</TableRow>
|
||||||
<Input
|
))}
|
||||||
id="b-threshold"
|
</TableBody>
|
||||||
type="number"
|
</Table>
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
step="0.1"
|
|
||||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
||||||
value={abcConfig.b_threshold}
|
|
||||||
onChange={(e) => setAbcConfig(prev => ({
|
|
||||||
...prev,
|
|
||||||
b_threshold: parseFloat(e.target.value) || 0
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="classification-period">Classification Period (days)</Label>
|
|
||||||
<Input
|
|
||||||
id="classification-period"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
||||||
value={abcConfig.classification_period_days}
|
|
||||||
onChange={(e) => setAbcConfig(prev => ({
|
|
||||||
...prev,
|
|
||||||
classification_period_days: parseInt(e.target.value) || 1
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button onClick={handleUpdateABCConfig}>
|
<Button onClick={handleUpdateABCConfig}>
|
||||||
Update ABC Classification
|
Update ABC Classification
|
||||||
</Button>
|
</Button>
|
||||||
@@ -273,37 +239,26 @@ export function PerformanceMetrics() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<Table>
|
||||||
<div>
|
<TableHeader>
|
||||||
<Label htmlFor="calculation-period">Calculation Period (days)</Label>
|
<TableRow>
|
||||||
<Input
|
<TableHead>Category</TableHead>
|
||||||
id="calculation-period"
|
<TableHead>Vendor</TableHead>
|
||||||
type="number"
|
<TableHead className="text-right">Period Days</TableHead>
|
||||||
min="1"
|
<TableHead className="text-right">Target Rate</TableHead>
|
||||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
</TableRow>
|
||||||
value={turnoverConfig.calculation_period_days}
|
</TableHeader>
|
||||||
onChange={(e) => setTurnoverConfig(prev => ({
|
<TableBody>
|
||||||
...prev,
|
{turnoverConfigs.map((config) => (
|
||||||
calculation_period_days: parseInt(e.target.value) || 1
|
<TableRow key={`${config.cat_id}-${config.vendor}`}>
|
||||||
}))}
|
<TableCell>{config.cat_id ? getCategoryName(config.cat_id) : 'Global'}</TableCell>
|
||||||
/>
|
<TableCell>{config.vendor || 'All Vendors'}</TableCell>
|
||||||
</div>
|
<TableCell className="text-right">{config.calculation_period_days}</TableCell>
|
||||||
<div>
|
<TableCell className="text-right">{config.target_rate.toFixed(2)}</TableCell>
|
||||||
<Label htmlFor="target-rate">Target Rate</Label>
|
</TableRow>
|
||||||
<Input
|
))}
|
||||||
id="target-rate"
|
</TableBody>
|
||||||
type="number"
|
</Table>
|
||||||
min="0"
|
|
||||||
step="0.1"
|
|
||||||
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
|
||||||
value={turnoverConfig.target_rate}
|
|
||||||
onChange={(e) => setTurnoverConfig(prev => ({
|
|
||||||
...prev,
|
|
||||||
target_rate: parseFloat(e.target.value) || 0
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button onClick={handleUpdateTurnoverConfig}>
|
<Button onClick={handleUpdateTurnoverConfig}>
|
||||||
Update Turnover Configuration
|
Update Turnover Configuration
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import config from '../../config';
|
import config from '../../config';
|
||||||
|
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
|
||||||
interface StockThreshold {
|
interface StockThreshold {
|
||||||
id: number;
|
id: number;
|
||||||
category_id: number | null;
|
cat_id: number | null;
|
||||||
vendor: string | null;
|
vendor: string | null;
|
||||||
critical_days: number;
|
critical_days: number;
|
||||||
reorder_days: number;
|
reorder_days: number;
|
||||||
@@ -19,7 +20,7 @@ interface StockThreshold {
|
|||||||
|
|
||||||
interface SafetyStockConfig {
|
interface SafetyStockConfig {
|
||||||
id: number;
|
id: number;
|
||||||
category_id: number | null;
|
cat_id: number | null;
|
||||||
vendor: string | null;
|
vendor: string | null;
|
||||||
coverage_days: number;
|
coverage_days: number;
|
||||||
service_level: number;
|
service_level: number;
|
||||||
@@ -28,7 +29,7 @@ interface SafetyStockConfig {
|
|||||||
export function StockManagement() {
|
export function StockManagement() {
|
||||||
const [stockThresholds, setStockThresholds] = useState<StockThreshold>({
|
const [stockThresholds, setStockThresholds] = useState<StockThreshold>({
|
||||||
id: 1,
|
id: 1,
|
||||||
category_id: null,
|
cat_id: null,
|
||||||
vendor: null,
|
vendor: null,
|
||||||
critical_days: 7,
|
critical_days: 7,
|
||||||
reorder_days: 14,
|
reorder_days: 14,
|
||||||
@@ -39,7 +40,7 @@ export function StockManagement() {
|
|||||||
|
|
||||||
const [safetyStockConfig, setSafetyStockConfig] = useState<SafetyStockConfig>({
|
const [safetyStockConfig, setSafetyStockConfig] = useState<SafetyStockConfig>({
|
||||||
id: 1,
|
id: 1,
|
||||||
category_id: null,
|
cat_id: null,
|
||||||
vendor: null,
|
vendor: null,
|
||||||
coverage_days: 14,
|
coverage_days: 14,
|
||||||
service_level: 95.0
|
service_level: 95.0
|
||||||
@@ -243,6 +244,54 @@ export function StockManagement() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Category</TableHead>
|
||||||
|
<TableHead>Vendor</TableHead>
|
||||||
|
<TableHead className="text-right">Critical Days</TableHead>
|
||||||
|
<TableHead className="text-right">Reorder Days</TableHead>
|
||||||
|
<TableHead className="text-right">Overstock Days</TableHead>
|
||||||
|
<TableHead className="text-right">Low Stock</TableHead>
|
||||||
|
<TableHead className="text-right">Min Reorder</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{stockThresholds.map((threshold) => (
|
||||||
|
<TableRow key={`${threshold.cat_id}-${threshold.vendor}`}>
|
||||||
|
<TableCell>{threshold.cat_id ? getCategoryName(threshold.cat_id) : 'Global'}</TableCell>
|
||||||
|
<TableCell>{threshold.vendor || 'All Vendors'}</TableCell>
|
||||||
|
<TableCell className="text-right">{threshold.critical_days}</TableCell>
|
||||||
|
<TableCell className="text-right">{threshold.reorder_days}</TableCell>
|
||||||
|
<TableCell className="text-right">{threshold.overstock_days}</TableCell>
|
||||||
|
<TableCell className="text-right">{threshold.low_stock_threshold}</TableCell>
|
||||||
|
<TableCell className="text-right">{threshold.min_reorder_quantity}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Category</TableHead>
|
||||||
|
<TableHead>Vendor</TableHead>
|
||||||
|
<TableHead className="text-right">Coverage Days</TableHead>
|
||||||
|
<TableHead className="text-right">Service Level</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{safetyStockConfigs.map((config) => (
|
||||||
|
<TableRow key={`${config.cat_id}-${config.vendor}`}>
|
||||||
|
<TableCell>{config.cat_id ? getCategoryName(config.cat_id) : 'Global'}</TableCell>
|
||||||
|
<TableCell>{config.vendor || 'All Vendors'}</TableCell>
|
||||||
|
<TableCell className="text-right">{config.coverage_days}</TableCell>
|
||||||
|
<TableCell className="text-right">{config.service_level}%</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -10,37 +10,66 @@ import { motion } from "motion/react";
|
|||||||
import config from "../config";
|
import config from "../config";
|
||||||
|
|
||||||
interface Category {
|
interface Category {
|
||||||
category_id: number;
|
cat_id: number;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
type: number;
|
||||||
parent_category?: string;
|
parent_id: number | null;
|
||||||
|
parent_name: string | null;
|
||||||
|
parent_type: number | null;
|
||||||
|
description: string | null;
|
||||||
|
status: string;
|
||||||
|
metrics?: {
|
||||||
product_count: number;
|
product_count: number;
|
||||||
|
active_products: number;
|
||||||
total_value: number;
|
total_value: number;
|
||||||
avg_margin: number;
|
avg_margin: number;
|
||||||
turnover_rate: number;
|
turnover_rate: number;
|
||||||
growth_rate: number;
|
growth_rate: number;
|
||||||
status: string;
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TypeCount {
|
||||||
|
type: number;
|
||||||
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CategoryFilters {
|
interface CategoryFilters {
|
||||||
search: string;
|
search: string;
|
||||||
parent: string;
|
type: string;
|
||||||
performance: string;
|
performance: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TYPE_LABELS: Record<number, string> = {
|
||||||
|
10: 'Section',
|
||||||
|
11: 'Category',
|
||||||
|
12: 'Subcategory',
|
||||||
|
13: 'Sub-subcategory',
|
||||||
|
20: 'Theme',
|
||||||
|
21: 'Subtheme'
|
||||||
|
};
|
||||||
|
|
||||||
|
function getCategoryStatusVariant(status: string): "default" | "secondary" | "destructive" | "outline" {
|
||||||
|
switch (status.toLowerCase()) {
|
||||||
|
case 'active':
|
||||||
|
return 'default';
|
||||||
|
case 'inactive':
|
||||||
|
return 'secondary';
|
||||||
|
case 'archived':
|
||||||
|
return 'destructive';
|
||||||
|
default:
|
||||||
|
return 'outline';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function Categories() {
|
export function Categories() {
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [sortColumn, setSortColumn] = useState<keyof Category>("name");
|
const [sortColumn] = useState<keyof Category>("name");
|
||||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
const [sortDirection] = useState<"asc" | "desc">("asc");
|
||||||
const [filters, setFilters] = useState<CategoryFilters>({
|
const [filters, setFilters] = useState<CategoryFilters>({
|
||||||
search: "",
|
search: "",
|
||||||
parent: "all",
|
type: "all",
|
||||||
performance: "all",
|
performance: "all",
|
||||||
});
|
});
|
||||||
const [] = useState({
|
|
||||||
column: 'name',
|
|
||||||
direction: 'asc'
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["categories"],
|
queryKey: ["categories"],
|
||||||
@@ -68,19 +97,15 @@ export function Categories() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply parent filter
|
// Apply type filter
|
||||||
if (filters.parent !== 'all') {
|
if (filters.type !== 'all') {
|
||||||
if (filters.parent === 'none') {
|
filtered = filtered.filter(category => category.type === parseInt(filters.type));
|
||||||
filtered = filtered.filter(category => !category.parent_category);
|
|
||||||
} else {
|
|
||||||
filtered = filtered.filter(category => category.parent_category === filters.parent);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply performance filter
|
// Apply performance filter
|
||||||
if (filters.performance !== 'all') {
|
if (filters.performance !== 'all') {
|
||||||
filtered = filtered.filter(category => {
|
filtered = filtered.filter(category => {
|
||||||
const growth = category.growth_rate ?? 0;
|
const growth = category.metrics?.growth_rate ?? 0;
|
||||||
switch (filters.performance) {
|
switch (filters.performance) {
|
||||||
case 'high_growth': return growth >= 20;
|
case 'high_growth': return growth >= 20;
|
||||||
case 'growing': return growth >= 5 && growth < 20;
|
case 'growing': return growth >= 5 && growth < 20;
|
||||||
@@ -93,6 +118,19 @@ export function Categories() {
|
|||||||
|
|
||||||
// Apply sorting
|
// Apply sorting
|
||||||
filtered.sort((a, b) => {
|
filtered.sort((a, b) => {
|
||||||
|
// First sort by type if not explicitly sorting by another column
|
||||||
|
if (sortColumn === "name") {
|
||||||
|
if (a.type !== b.type) {
|
||||||
|
return a.type - b.type;
|
||||||
|
}
|
||||||
|
// Then by parent hierarchy
|
||||||
|
if (a.parent_id !== b.parent_id) {
|
||||||
|
if (!a.parent_id) return -1;
|
||||||
|
if (!b.parent_id) return 1;
|
||||||
|
return a.parent_id - b.parent_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const aVal = a[sortColumn];
|
const aVal = a[sortColumn];
|
||||||
const bVal = b[sortColumn];
|
const bVal = b[sortColumn];
|
||||||
|
|
||||||
@@ -123,9 +161,9 @@ export function Categories() {
|
|||||||
if (!filteredData.length) return data?.stats;
|
if (!filteredData.length) return data?.stats;
|
||||||
|
|
||||||
const activeCategories = filteredData.filter(c => c.status === 'active').length;
|
const activeCategories = filteredData.filter(c => c.status === 'active').length;
|
||||||
const totalValue = filteredData.reduce((sum, c) => sum + (c.total_value || 0), 0);
|
const totalValue = filteredData.reduce((sum, c) => sum + (c.metrics?.total_value || 0), 0);
|
||||||
const margins = filteredData.map(c => c.avg_margin || 0).filter(m => m !== 0);
|
const margins = filteredData.map(c => c.metrics?.avg_margin || 0).filter(m => m !== 0);
|
||||||
const growthRates = filteredData.map(c => c.growth_rate || 0).filter(g => g !== 0);
|
const growthRates = filteredData.map(c => c.metrics?.growth_rate || 0).filter(g => g !== 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalCategories: filteredData.length,
|
totalCategories: filteredData.length,
|
||||||
@@ -136,20 +174,7 @@ export function Categories() {
|
|||||||
};
|
};
|
||||||
}, [filteredData, data?.stats]);
|
}, [filteredData, data?.stats]);
|
||||||
|
|
||||||
const handleSort = (column: keyof Category) => {
|
|
||||||
setSortDirection(prev => {
|
|
||||||
if (sortColumn !== column) return "asc";
|
|
||||||
return prev === "asc" ? "desc" : "asc";
|
|
||||||
});
|
|
||||||
setSortColumn(column);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPerformanceBadge = (growth: number) => {
|
|
||||||
if (growth >= 20) return <Badge variant="default">High Growth</Badge>;
|
|
||||||
if (growth >= 5) return <Badge variant="secondary">Growing</Badge>;
|
|
||||||
if (growth >= -5) return <Badge variant="outline">Stable</Badge>;
|
|
||||||
return <Badge variant="destructive">Declining</Badge>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCurrency = (value: number) => {
|
const formatCurrency = (value: number) => {
|
||||||
return new Intl.NumberFormat('en-US', {
|
return new Intl.NumberFormat('en-US', {
|
||||||
@@ -245,17 +270,18 @@ export function Categories() {
|
|||||||
className="h-8 w-[150px] lg:w-[250px]"
|
className="h-8 w-[150px] lg:w-[250px]"
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
value={filters.parent}
|
value={filters.type}
|
||||||
onValueChange={(value) => setFilters(prev => ({ ...prev, parent: value }))}
|
onValueChange={(value) => setFilters(prev => ({ ...prev, type: value }))}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 w-[180px]">
|
<SelectTrigger className="h-8 w-[180px]">
|
||||||
<SelectValue placeholder="Parent Category" />
|
<SelectValue placeholder="Category Type" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Categories</SelectItem>
|
<SelectItem value="all">All Types</SelectItem>
|
||||||
<SelectItem value="none">Top Level Only</SelectItem>
|
{data?.typeCounts?.map((tc: TypeCount) => (
|
||||||
{data?.parentCategories?.map((parent: string) => (
|
<SelectItem key={tc.type} value={tc.type.toString()}>
|
||||||
<SelectItem key={parent} value={parent}>{parent}</SelectItem>
|
{TYPE_LABELS[tc.type]} ({tc.count})
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -281,48 +307,66 @@ export function Categories() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead onClick={() => handleSort("name")} className="cursor-pointer">Name</TableHead>
|
<TableHead>Type</TableHead>
|
||||||
<TableHead onClick={() => handleSort("parent_category")} className="cursor-pointer">Parent</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead onClick={() => handleSort("product_count")} className="cursor-pointer">Products</TableHead>
|
<TableHead>Parent</TableHead>
|
||||||
<TableHead onClick={() => handleSort("total_value")} className="cursor-pointer">Value</TableHead>
|
<TableHead className="text-right">Products</TableHead>
|
||||||
<TableHead onClick={() => handleSort("avg_margin")} className="cursor-pointer">Margin</TableHead>
|
<TableHead className="text-right">Active</TableHead>
|
||||||
<TableHead onClick={() => handleSort("turnover_rate")} className="cursor-pointer">Turnover</TableHead>
|
<TableHead className="text-right">Value</TableHead>
|
||||||
<TableHead onClick={() => handleSort("growth_rate")} className="cursor-pointer">Growth</TableHead>
|
<TableHead className="text-right">Margin</TableHead>
|
||||||
<TableHead onClick={() => handleSort("status")} className="cursor-pointer">Status</TableHead>
|
<TableHead className="text-right">Turnover</TableHead>
|
||||||
|
<TableHead className="text-right">Growth</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8} className="text-center py-8">
|
<TableCell colSpan={10} className="text-center py-8">
|
||||||
Loading categories...
|
Loading categories...
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : paginatedData.map((category: Category) => (
|
) : paginatedData.map((category: Category) => (
|
||||||
<TableRow key={category.category_id}>
|
<TableRow key={category.cat_id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="font-medium">{category.name}</div>
|
<Badge variant="outline">
|
||||||
<div className="text-sm text-muted-foreground">{category.description}</div>
|
{TYPE_LABELS[category.type]}
|
||||||
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{category.parent_category || "—"}</TableCell>
|
|
||||||
<TableCell>{category.product_count?.toLocaleString() ?? 0}</TableCell>
|
|
||||||
<TableCell>{formatCurrency(category.total_value ?? 0)}</TableCell>
|
|
||||||
<TableCell>{typeof category.avg_margin === 'number' ? category.avg_margin.toFixed(1) : "0.0"}%</TableCell>
|
|
||||||
<TableCell>{typeof category.turnover_rate === 'number' ? category.turnover_rate.toFixed(1) : "0.0"}x</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2" style={{ minWidth: '120px' }}>
|
<div className="flex flex-col gap-1">
|
||||||
<div style={{ width: '50px', textAlign: 'right' }}>
|
<div className="flex items-center gap-2">
|
||||||
{typeof category.growth_rate === 'number' ? category.growth_rate.toFixed(1) : "0.0"}%
|
<span className="font-medium">{category.name}</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{getPerformanceBadge(category.growth_rate ?? 0)}
|
{category.description && (
|
||||||
|
<div className="text-xs text-muted-foreground">{category.description}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{category.status}</TableCell>
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{category.type === 10 ? category.name : // Section
|
||||||
|
category.type === 11 ? `${category.parent_name}` : // Category
|
||||||
|
category.type === 12 ? `${category.parent_name} > ${category.name}` : // Subcategory
|
||||||
|
category.type === 13 ? `${category.parent_name} > ${category.name}` : // Sub-subcategory
|
||||||
|
category.parent_name ? `${category.parent_name} > ${category.name}` : category.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">{category.metrics?.product_count || 0}</TableCell>
|
||||||
|
<TableCell className="text-right">{category.metrics?.active_products || 0}</TableCell>
|
||||||
|
<TableCell className="text-right">{formatCurrency(category.metrics?.total_value || 0)}</TableCell>
|
||||||
|
<TableCell className="text-right">{category.metrics?.avg_margin?.toFixed(1)}%</TableCell>
|
||||||
|
<TableCell className="text-right">{category.metrics?.turnover_rate?.toFixed(2)}</TableCell>
|
||||||
|
<TableCell className="text-right">{category.metrics?.growth_rate?.toFixed(1)}%</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={getCategoryStatusVariant(category.status)}>
|
||||||
|
{category.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
{!isLoading && !paginatedData.length && (
|
{!isLoading && !paginatedData.length && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
|
<TableCell colSpan={10} className="text-center py-8 text-muted-foreground">
|
||||||
No categories found
|
No categories found
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -60,19 +60,23 @@ export default function Forecasting() {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data.map((item: any) => ({
|
return data.map((item: any) => ({
|
||||||
category: item.category_name,
|
category: item.category_name,
|
||||||
|
categoryPath: item.path,
|
||||||
avgDailySales: Number(item.avg_daily_sales) || 0,
|
avgDailySales: Number(item.avg_daily_sales) || 0,
|
||||||
totalSold: Number(item.total_sold) || 0,
|
totalSold: Number(item.total_sold) || 0,
|
||||||
numProducts: Number(item.num_products) || 0,
|
numProducts: Number(item.num_products) || 0,
|
||||||
avgPrice: Number(item.avg_price) || 0,
|
avgPrice: Number(item.avg_price) || 0,
|
||||||
avgTotalSold: Number(item.avgTotalSold) || 0,
|
avgTotalSold: Number(item.avgTotalSold) || 0,
|
||||||
products: item.products?.map((p: any) => ({
|
products: item.products?.map((p: any) => ({
|
||||||
product_id: p.product_id,
|
pid: p.pid,
|
||||||
name: p.title,
|
title: p.title,
|
||||||
sku: p.sku,
|
sku: p.sku,
|
||||||
stock_quantity: Number(p.stock_quantity) || 0,
|
stock_quantity: Number(p.stock_quantity) || 0,
|
||||||
total_sold: Number(p.total_sold) || 0,
|
total_sold: Number(p.total_sold) || 0,
|
||||||
avg_price: Number(p.avg_price) || 0,
|
daily_sales_avg: Number(p.daily_sales_avg) || 0,
|
||||||
first_received_date: p.first_received_date,
|
forecast_units: Number(p.forecast_units) || 0,
|
||||||
|
forecast_revenue: Number(p.forecast_revenue) || 0,
|
||||||
|
confidence_level: Number(p.confidence_level) || 0,
|
||||||
|
categoryPath: item.path
|
||||||
}))
|
}))
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -503,7 +503,7 @@ export function Products() {
|
|||||||
columnDefs={AVAILABLE_COLUMNS}
|
columnDefs={AVAILABLE_COLUMNS}
|
||||||
columnOrder={columnOrder}
|
columnOrder={columnOrder}
|
||||||
onColumnOrderChange={handleColumnOrderChange}
|
onColumnOrderChange={handleColumnOrderChange}
|
||||||
onRowClick={(product) => setSelectedProductId(product.product_id)}
|
onRowClick={(product) => setSelectedProductId(product.pid)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
|
|||||||
@@ -20,12 +20,21 @@ import {
|
|||||||
PaginationPrevious,
|
PaginationPrevious,
|
||||||
} from '../components/ui/pagination';
|
} from '../components/ui/pagination';
|
||||||
import { motion } from 'motion/react';
|
import { motion } from 'motion/react';
|
||||||
|
import {
|
||||||
|
PurchaseOrderStatus,
|
||||||
|
ReceivingStatus as ReceivingStatusCode,
|
||||||
|
getPurchaseOrderStatusLabel,
|
||||||
|
getReceivingStatusLabel,
|
||||||
|
getPurchaseOrderStatusVariant,
|
||||||
|
getReceivingStatusVariant
|
||||||
|
} from '../types/status-codes';
|
||||||
|
|
||||||
interface PurchaseOrder {
|
interface PurchaseOrder {
|
||||||
id: number;
|
id: number;
|
||||||
vendor_name: string;
|
vendor_name: string;
|
||||||
order_date: string;
|
order_date: string;
|
||||||
status: string;
|
status: number;
|
||||||
|
receiving_status: number;
|
||||||
total_items: number;
|
total_items: number;
|
||||||
total_quantity: number;
|
total_quantity: number;
|
||||||
total_cost: number;
|
total_cost: number;
|
||||||
@@ -113,6 +122,16 @@ export default function PurchaseOrders() {
|
|||||||
limit: 100,
|
limit: 100,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const STATUS_FILTER_OPTIONS = [
|
||||||
|
{ value: 'all', label: 'All Statuses' },
|
||||||
|
{ value: String(PurchaseOrderStatus.Created), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Created) },
|
||||||
|
{ value: String(PurchaseOrderStatus.ElectronicallyReadySend), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.ElectronicallyReadySend) },
|
||||||
|
{ value: String(PurchaseOrderStatus.Ordered), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Ordered) },
|
||||||
|
{ value: String(PurchaseOrderStatus.ReceivingStarted), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.ReceivingStarted) },
|
||||||
|
{ value: String(PurchaseOrderStatus.Done), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Done) },
|
||||||
|
{ value: String(PurchaseOrderStatus.Canceled), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Canceled) },
|
||||||
|
];
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
try {
|
try {
|
||||||
const searchParams = new URLSearchParams({
|
const searchParams = new URLSearchParams({
|
||||||
@@ -171,16 +190,25 @@ export default function PurchaseOrders() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusBadge = (status: string) => {
|
const getStatusBadge = (status: number, receivingStatus: number) => {
|
||||||
const variants: Record<string, { variant: "default" | "secondary" | "destructive" | "outline"; label: string }> = {
|
// If the PO is canceled, show that status
|
||||||
pending: { variant: "outline", label: "Pending" },
|
if (status === PurchaseOrderStatus.Canceled) {
|
||||||
received: { variant: "default", label: "Received" },
|
return <Badge variant={getPurchaseOrderStatusVariant(status)}>
|
||||||
partial: { variant: "secondary", label: "Partial" },
|
{getPurchaseOrderStatusLabel(status)}
|
||||||
cancelled: { variant: "destructive", label: "Cancelled" },
|
</Badge>;
|
||||||
};
|
}
|
||||||
|
|
||||||
const statusConfig = variants[status.toLowerCase()] || variants.pending;
|
// If receiving has started, show receiving status
|
||||||
return <Badge variant={statusConfig.variant}>{statusConfig.label}</Badge>;
|
if (status >= PurchaseOrderStatus.ReceivingStarted) {
|
||||||
|
return <Badge variant={getReceivingStatusVariant(receivingStatus)}>
|
||||||
|
{getReceivingStatusLabel(receivingStatus)}
|
||||||
|
</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise show PO status
|
||||||
|
return <Badge variant={getPurchaseOrderStatusVariant(status)}>
|
||||||
|
{getPurchaseOrderStatusLabel(status)}
|
||||||
|
</Badge>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatNumber = (value: number) => {
|
const formatNumber = (value: number) => {
|
||||||
@@ -252,27 +280,25 @@ export default function PurchaseOrders() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-center">
|
<div className="mb-4 flex items-center gap-4">
|
||||||
<div className="flex items-center gap-2 flex-1">
|
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search orders..."
|
placeholder="Search orders..."
|
||||||
value={filters.search}
|
value={filters.search}
|
||||||
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
|
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
|
||||||
className="h-8 w-[300px]"
|
className="max-w-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Select
|
<Select
|
||||||
value={filters.status}
|
value={filters.status}
|
||||||
onValueChange={(value) => setFilters(prev => ({ ...prev, status: value }))}
|
onValueChange={(value) => setFilters(prev => ({ ...prev, status: value }))}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 w-[180px]">
|
<SelectTrigger className="w-[180px]">
|
||||||
<SelectValue placeholder="Status" />
|
<SelectValue placeholder="Select status" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Statuses</SelectItem>
|
{STATUS_FILTER_OPTIONS.map(option => (
|
||||||
{filterOptions.statuses.map(status => (
|
<SelectItem key={option.value} value={option.value}>
|
||||||
<SelectItem key={status} value={status}>{status}</SelectItem>
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -280,18 +306,19 @@ export default function PurchaseOrders() {
|
|||||||
value={filters.vendor}
|
value={filters.vendor}
|
||||||
onValueChange={(value) => setFilters(prev => ({ ...prev, vendor: value }))}
|
onValueChange={(value) => setFilters(prev => ({ ...prev, vendor: value }))}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 w-[180px]">
|
<SelectTrigger className="w-[180px]">
|
||||||
<SelectValue placeholder="Vendor" />
|
<SelectValue placeholder="Select vendor" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Vendors</SelectItem>
|
<SelectItem value="all">All Vendors</SelectItem>
|
||||||
{filterOptions.vendors.map(vendor => (
|
{filterOptions.vendors.map(vendor => (
|
||||||
<SelectItem key={vendor} value={vendor}>{vendor}</SelectItem>
|
<SelectItem key={vendor} value={vendor}>
|
||||||
|
{vendor}
|
||||||
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Purchase Orders Table */}
|
{/* Purchase Orders Table */}
|
||||||
<Card className="mb-6">
|
<Card className="mb-6">
|
||||||
@@ -343,7 +370,7 @@ export default function PurchaseOrders() {
|
|||||||
<TableCell>{po.id}</TableCell>
|
<TableCell>{po.id}</TableCell>
|
||||||
<TableCell>{po.vendor_name}</TableCell>
|
<TableCell>{po.vendor_name}</TableCell>
|
||||||
<TableCell>{new Date(po.order_date).toLocaleDateString()}</TableCell>
|
<TableCell>{new Date(po.order_date).toLocaleDateString()}</TableCell>
|
||||||
<TableCell>{getStatusBadge(po.status)}</TableCell>
|
<TableCell>{getStatusBadge(po.status, po.receiving_status)}</TableCell>
|
||||||
<TableCell>{po.total_items.toLocaleString()}</TableCell>
|
<TableCell>{po.total_items.toLocaleString()}</TableCell>
|
||||||
<TableCell>{po.total_quantity.toLocaleString()}</TableCell>
|
<TableCell>{po.total_quantity.toLocaleString()}</TableCell>
|
||||||
<TableCell>${formatNumber(po.total_cost)}</TableCell>
|
<TableCell>${formatNumber(po.total_cost)}</TableCell>
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
export interface Product {
|
export interface Product {
|
||||||
product_id: number;
|
pid: number;
|
||||||
title: string;
|
title: string;
|
||||||
SKU: string;
|
SKU: string;
|
||||||
stock_quantity: number;
|
stock_quantity: number;
|
||||||
price: number;
|
price: string; // DECIMAL(15,3)
|
||||||
regular_price: number;
|
regular_price: string; // DECIMAL(15,3)
|
||||||
cost_price: number;
|
cost_price: string; // DECIMAL(15,3)
|
||||||
landing_cost_price: number | null;
|
landing_cost_price: string | null; // DECIMAL(15,3)
|
||||||
barcode: string;
|
barcode: string;
|
||||||
vendor: string;
|
vendor: string;
|
||||||
vendor_reference: string;
|
vendor_reference: string;
|
||||||
brand: string;
|
brand: string | 'Unbranded';
|
||||||
categories: string[];
|
categories: string[];
|
||||||
tags: string[];
|
tags: string[];
|
||||||
options: Record<string, any>;
|
options: Record<string, any>;
|
||||||
@@ -24,32 +24,32 @@ export interface Product {
|
|||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|
||||||
// Metrics
|
// Metrics
|
||||||
daily_sales_avg?: number;
|
daily_sales_avg?: string; // DECIMAL(15,3)
|
||||||
weekly_sales_avg?: number;
|
weekly_sales_avg?: string; // DECIMAL(15,3)
|
||||||
monthly_sales_avg?: number;
|
monthly_sales_avg?: string; // DECIMAL(15,3)
|
||||||
avg_quantity_per_order?: number;
|
avg_quantity_per_order?: string; // DECIMAL(15,3)
|
||||||
number_of_orders?: number;
|
number_of_orders?: number;
|
||||||
first_sale_date?: string;
|
first_sale_date?: string;
|
||||||
last_sale_date?: string;
|
last_sale_date?: string;
|
||||||
last_purchase_date?: string;
|
last_purchase_date?: string;
|
||||||
days_of_inventory?: number;
|
days_of_inventory?: string; // DECIMAL(15,3)
|
||||||
weeks_of_inventory?: number;
|
weeks_of_inventory?: string; // DECIMAL(15,3)
|
||||||
reorder_point?: number;
|
reorder_point?: string; // DECIMAL(15,3)
|
||||||
safety_stock?: number;
|
safety_stock?: string; // DECIMAL(15,3)
|
||||||
avg_margin_percent?: number;
|
avg_margin_percent?: string; // DECIMAL(15,3)
|
||||||
total_revenue?: number;
|
total_revenue?: string; // DECIMAL(15,3)
|
||||||
inventory_value?: number;
|
inventory_value?: string; // DECIMAL(15,3)
|
||||||
cost_of_goods_sold?: number;
|
cost_of_goods_sold?: string; // DECIMAL(15,3)
|
||||||
gross_profit?: number;
|
gross_profit?: string; // DECIMAL(15,3)
|
||||||
gmroi?: number;
|
gmroi?: string; // DECIMAL(15,3)
|
||||||
avg_lead_time_days?: number;
|
avg_lead_time_days?: string; // DECIMAL(15,3)
|
||||||
last_received_date?: string;
|
last_received_date?: string;
|
||||||
abc_class?: string;
|
abc_class?: string;
|
||||||
stock_status?: string;
|
stock_status?: string;
|
||||||
turnover_rate?: number;
|
turnover_rate?: string; // DECIMAL(15,3)
|
||||||
current_lead_time?: number;
|
current_lead_time?: string; // DECIMAL(15,3)
|
||||||
target_lead_time?: number;
|
target_lead_time?: string; // DECIMAL(15,3)
|
||||||
lead_time_status?: string;
|
lead_time_status?: string;
|
||||||
reorder_qty?: number;
|
reorder_qty?: number;
|
||||||
overstocked_amt?: number;
|
overstocked_amt?: string; // DECIMAL(15,3)
|
||||||
}
|
}
|
||||||
|
|||||||
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