Compare commits
13 Commits
92ff80fba2
...
fix-number
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cb41a7e4c | |||
| d05d27494d | |||
| 4ed734e5c0 | |||
| 1e3be5d4cb | |||
| 8dd852dd6a | |||
| eeff5817ea | |||
| 1b19feb172 | |||
| 80ff8124ec | |||
| 8508bfac93 | |||
| ac14179bd2 | |||
| 00249f7c33 | |||
| f271f3aae4 | |||
| 43f76e4ac0 |
271
docs/routes-cleanup.md
Normal file
271
docs/routes-cleanup.md
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
**Analysis of Potential Issues**
|
||||||
|
|
||||||
|
1. **Obsolete Functionality:**
|
||||||
|
* **`config.js` Legacy Endpoints:** The endpoints `GET /config/`, `PUT /config/stock-thresholds/:id`, `PUT /config/lead-time-thresholds/:id`, `PUT /config/sales-velocity/:id`, `PUT /config/abc-classification/:id`, `PUT /config/safety-stock/:id`, and `PUT /config/turnover/:id` appear **highly likely to be obsolete**. They reference older, single-row config tables (`stock_thresholds`, etc.) while newer endpoints (`/config/global`, `/config/products`, `/config/vendors`) manage settings in more structured tables (`settings_global`, `settings_product`, `settings_vendor`). Unless specifically required for backward compatibility, these legacy endpoints should be removed to avoid confusion and potential data conflicts.
|
||||||
|
* **`analytics.js` Forecast Endpoint (`GET /analytics/forecast`):** This endpoint uses **MySQL syntax** (`DATEDIFF`, `DATE_FORMAT`, `JSON_OBJECT`, `?` placeholders) but seems intended to run within the analytics module which otherwise uses PostgreSQL (`req.app.locals.pool`, `date_trunc`, `::text`, `$1` placeholders). This endpoint is likely **obsolete or misplaced** and will not function correctly against the PostgreSQL database.
|
||||||
|
* **`csv.js` Redundant Actions:**
|
||||||
|
* `POST /csv/update` seems redundant with `POST /csv/full-update`. The latter uses the `runScript` helper and dedicated state (`activeFullUpdate`), appearing more robust. `/csv/update` might be older or incomplete.
|
||||||
|
* `POST /csv/reset` seems redundant with `POST /csv/full-reset`. Similar reasoning applies; `/csv/full-reset` appears preferred.
|
||||||
|
* **`products.js` Import Endpoint (`POST /products/import`):** This is **dangerous duplication**. The `/csv` module handles imports (`/csv/import`, `/csv/import-from-prod`) with locking (`activeImport`) to prevent concurrent operations. This endpoint lacks such locking and could corrupt data if run simultaneously with other CSV/reset operations. It should likely be removed.
|
||||||
|
* **`products.js` Metrics Endpoint (`GET /products/:id/metrics`):** This is redundant. The `/metrics/:pid` endpoint provides the same, possibly more comprehensive, data directly from the `product_metrics` table. Clients should use `/metrics/:pid` instead.
|
||||||
|
|
||||||
|
2. **Overlap or Inappropriate Duplication of Effort:**
|
||||||
|
* **AI Prompt Getters:** `GET /ai-prompts/type/general` and `GET /ai-prompts/type/system` could potentially be handled by adding a query parameter filter to `GET /ai-prompts/` (e.g., `GET /ai-prompts?prompt_type=general`). However, dedicated endpoints for single, specific items can sometimes be simpler. This is more of a design choice than a major issue.
|
||||||
|
* **Vendor Performance/Metrics:** There are multiple ways to get vendor performance data:
|
||||||
|
* `GET /analytics/vendors` (uses `vendor_metrics`)
|
||||||
|
* `GET /dashboard/vendor/performance` (uses `purchase_orders`)
|
||||||
|
* `GET /purchase-orders/vendor-metrics` (uses `purchase_orders`)
|
||||||
|
* `GET /vendors-aggregate/` (uses `vendor_metrics`, augmented with `purchase_orders`)
|
||||||
|
This suggests significant overlap. The `/vendors-aggregate` endpoint seems the most comprehensive, combining pre-aggregated data with some real-time info. The others, especially `/dashboard/vendor/performance` and `/purchase-orders/vendor-metrics` which calculate directly from `purchase_orders`, might be redundant or less performant.
|
||||||
|
* **Product Listing:**
|
||||||
|
* `GET /products/` lists products joining `products`, `product_metrics`, and `categories`.
|
||||||
|
* `GET /metrics/` lists products primarily from `product_metrics`.
|
||||||
|
They offer similar filtering/sorting. If `product_metrics` contains all necessary display fields, `GET /products/` might be partly redundant for simple listing views, although it does provide aggregated category names. Evaluate if both full list endpoints are necessary.
|
||||||
|
* **Image Uploads/Management:** Image handling is split:
|
||||||
|
* `products-import.js`: Uploads temporary images for product import to `/uploads/products/`, schedules deletion.
|
||||||
|
* `reusable-images.js`: Uploads persistent images to `/uploads/reusable/`, stores metadata in DB.
|
||||||
|
* `products-import.js` has `/check-file` and `/list-uploads` that can see *both* directories, while `reusable-images.js` has a `/check-file` that only sees its own. This separation could be confusing. Clarify the purpose and lifecycle of images in each directory.
|
||||||
|
* **Background Task Management (`csv.js`):** The use of `activeImport` for multiple unrelated tasks (import, reset, metrics calc) prevents concurrency, which might be too restrictive. The cancellation logic (`/cancel`) only targets `full-update`/`full-reset`, not tasks locked by `activeImport`. This needs unification.
|
||||||
|
* **Analytics/Dashboard Base Table Queries:** Several endpoints in `analytics.js` (`/pricing`, `/categories`) and `dashboard.js` (`/best-sellers`, `/sales/metrics`, `/trending/products`, `/key-metrics`, `/inventory-health`, `/sales-overview`) query base tables (`orders`, `products`, `purchase_orders`) directly, while many others leverage pre-aggregated `_metrics` tables. This inconsistency can lead to performance differences and suggests potential for optimization by using aggregates where possible.
|
||||||
|
|
||||||
|
3. **Obvious Mistakes / Data Issues:**
|
||||||
|
* **AI Prompt Fetching:** `GET /ai-prompts/company/:companyId`, `/type/general`, `/type/system` return `result.rows[0]`. This assumes uniqueness. If the underlying DB constraints (`unique_company_prompt`, etc.) fail or aren't present, this could silently hide data if multiple rows match. The use of unique constraint handling in POST/PUT suggests this is likely intended and safe *if* DB constraints are solid.
|
||||||
|
* **Mixed Databases & SSH Tunnels:** The heavy reliance in `ai_validation.js` and `products-import.js` on connecting to a production MySQL DB via SSH tunnel while also using a local PostgreSQL DB adds significant architectural complexity.
|
||||||
|
* **Inefficiency:** In `ai_validation.js` (`generateDebugResponse`), an SSH tunnel and MySQL connection (`promptTunnel`, `promptConnection`) are established but seem unused when fetching prompts (which correctly come from the PG pool `res.app.locals.pool`). This is wasted effort.
|
||||||
|
* **Improvement:** The `getDbConnection` function in `products-import.js` implements caching/pooling for the SSH/MySQL connection – this is much better and should ideally be used consistently wherever the production DB is accessed (e.g., in `ai_validation.js`).
|
||||||
|
* **`products.js` Brand Filtering:** `GET /products/brands` filters brands based on having associated purchase orders with a cost >= 500. This seems arbitrary for a general list of brands and might return incomplete results depending on the use case.
|
||||||
|
* **Type Handling:** Ensure `parseValue` handles all required types and edge cases correctly, especially for filtering complex queries in `*-aggregate` and `metrics` routes. Explicit type casting in SQL (`::numeric`, `::text`, etc.) is generally good practice in PostgreSQL.
|
||||||
|
* **Dummy Data:** Several `dashboard.js` endpoints return hardcoded dummy data on errors or when no data is found. While this prevents UI crashes, it can mask real issues. Ensure logging is robust when fallbacks are used.
|
||||||
|
|
||||||
|
**Summary of Endpoints**
|
||||||
|
|
||||||
|
Here's a summary of the available endpoints, grouped by their likely file/module:
|
||||||
|
|
||||||
|
**1. AI Prompts (`ai_prompts.js`)**
|
||||||
|
* `GET /`: Get all AI prompts.
|
||||||
|
* `GET /:id`: Get a specific AI prompt by its ID.
|
||||||
|
* `GET /company/:companyId`: Get the AI prompt for a specific company (expects one). **(Deprecated)**
|
||||||
|
* `GET /type/general`: Get the general AI prompt (expects one). **(Deprecated)**
|
||||||
|
* `GET /type/system`: Get the system AI prompt (expects one). **(Deprecated)**
|
||||||
|
* `GET /by-type`: Get AI prompt by type (general, system, company_specific) with optional company parameter. **(New Consolidated Endpoint)**
|
||||||
|
* `POST /`: Create a new AI prompt.
|
||||||
|
* `PUT /:id`: Update an existing AI prompt.
|
||||||
|
* `DELETE /:id`: Delete an AI prompt.
|
||||||
|
|
||||||
|
**2. AI Validation (`ai_validation.js`)**
|
||||||
|
* `POST /debug`: Generate and view the structure of prompts and taxonomy data (for debugging, doesn't call OpenAI). Connects to Prod MySQL (taxonomy) and Local PG (prompts, performance).
|
||||||
|
* `POST /validate`: Validate product data using OpenAI. Connects to Prod MySQL (taxonomy) and Local PG (prompts, performance).
|
||||||
|
* `GET /test-taxonomy`: Test endpoint to query sample taxonomy data from Prod MySQL.
|
||||||
|
|
||||||
|
**3. Analytics (`analytics.js`)**
|
||||||
|
* `GET /stats`: Get overall business statistics from metrics tables.
|
||||||
|
* `GET /profit`: Get profit analysis data (by category, over time, top products) from metrics tables.
|
||||||
|
* `GET /vendors`: Get vendor performance analysis from `vendor_metrics`.
|
||||||
|
* `GET /stock`: Get stock analysis data (turnover, levels, critical items) from metrics tables.
|
||||||
|
* `GET /pricing`: Get pricing analysis (price points, elasticity, recommendations) - **uses `orders` table**.
|
||||||
|
* `GET /categories`: Get category performance analysis (revenue, profit, growth, distribution, trends) - **uses `orders` and `products` tables**.
|
||||||
|
* `GET /forecast`: (**Likely Obsolete/Broken**) Attempts to get forecast data using MySQL syntax.
|
||||||
|
|
||||||
|
**4. Brands Aggregate (`brands-aggregate.js`)**
|
||||||
|
* `GET /filter-options`: Get distinct brand names and statuses for UI filters (from `brand_metrics`).
|
||||||
|
* `GET /stats`: Get overall statistics related to brands (from `brand_metrics`).
|
||||||
|
* `GET /`: List brands with aggregated metrics, supporting filtering, sorting, pagination (from `brand_metrics`).
|
||||||
|
|
||||||
|
**5. Categories Aggregate (`categories-aggregate.js`)**
|
||||||
|
* `GET /filter-options`: Get distinct category types, statuses, and counts for UI filters (from `category_metrics` & `categories`).
|
||||||
|
* `GET /stats`: Get overall statistics related to categories (from `category_metrics` & `categories`).
|
||||||
|
* `GET /`: List categories with aggregated metrics, supporting filtering, sorting (incl. hierarchy), pagination (from `category_metrics` & `categories`).
|
||||||
|
|
||||||
|
**6. Configuration (`config.js`)**
|
||||||
|
* **(New)** `GET /global`: Get all global settings.
|
||||||
|
* **(New)** `PUT /global`: Update global settings.
|
||||||
|
* **(New)** `GET /products`: List product-specific settings with pagination/search.
|
||||||
|
* **(New)** `PUT /products/:pid`: Update/Create product-specific settings.
|
||||||
|
* **(New)** `POST /products/:pid/reset`: Reset product settings to defaults.
|
||||||
|
* **(New)** `GET /vendors`: List vendor-specific settings with pagination/search.
|
||||||
|
* **(New)** `PUT /vendors/:vendor`: Update/Create vendor-specific settings.
|
||||||
|
* **(New)** `POST /vendors/:vendor/reset`: Reset vendor settings to defaults.
|
||||||
|
* **(Legacy/Obsolete)** `GET /`: Get all config from old single-row tables.
|
||||||
|
* **(Legacy/Obsolete)** `PUT /stock-thresholds/:id`: Update old stock thresholds.
|
||||||
|
* **(Legacy/Obsolete)** `PUT /lead-time-thresholds/:id`: Update old lead time thresholds.
|
||||||
|
* **(Legacy/Obsolete)** `PUT /sales-velocity/:id`: Update old sales velocity config.
|
||||||
|
* **(Legacy/Obsolete)** `PUT /abc-classification/:id`: Update old ABC config.
|
||||||
|
* **(Legacy/Obsolete)** `PUT /safety-stock/:id`: Update old safety stock config.
|
||||||
|
* **(Legacy/Obsolete)** `PUT /turnover/:id`: Update old turnover config.
|
||||||
|
|
||||||
|
**7. CSV Operations & Background Tasks (`csv.js`)**
|
||||||
|
* `GET /:type/progress`: SSE endpoint for full update/reset progress.
|
||||||
|
* `GET /test`: Simple test endpoint.
|
||||||
|
* `GET /status`: Check status of the generic background task lock (`activeImport`).
|
||||||
|
* `GET /calculate-metrics/status`: Check status of metrics calculation.
|
||||||
|
* `GET /history/import`: Get recent import history.
|
||||||
|
* `GET /history/calculate`: Get recent metrics calculation history.
|
||||||
|
* `GET /status/modules`: Get last calculation time per module.
|
||||||
|
* `GET /status/tables`: Get last sync time per table.
|
||||||
|
* `GET /status/table-counts`: Get record counts for key tables.
|
||||||
|
* `POST /update`: (**Potentially Obsolete**) Trigger `update-csv.js` script.
|
||||||
|
* `POST /import`: Trigger `import-csv.js` script.
|
||||||
|
* `POST /cancel`: Cancel `/full-update` or `/full-reset` task.
|
||||||
|
* `POST /reset`: (**Potentially Obsolete**) Trigger `reset-db.js` script.
|
||||||
|
* `POST /reset-metrics`: Trigger `reset-metrics.js` script.
|
||||||
|
* `POST /calculate-metrics`: Trigger `calculate-metrics.js` script.
|
||||||
|
* `POST /import-from-prod`: Trigger `import-from-prod.js` script.
|
||||||
|
* `POST /full-update`: Trigger `full-update.js` script (preferred update).
|
||||||
|
* `POST /full-reset`: Trigger `full-reset.js` script (preferred reset).
|
||||||
|
|
||||||
|
**8. Dashboard (`dashboard.js`)**
|
||||||
|
* `GET /stock/metrics`: Get dashboard stock summary metrics & brand breakdown.
|
||||||
|
* `GET /purchase/metrics`: Get dashboard purchase order summary metrics & vendor breakdown.
|
||||||
|
* `GET /replenishment/metrics`: Get dashboard replenishment summary & top variants.
|
||||||
|
* `GET /forecast/metrics`: Get dashboard forecast summary, daily, and category breakdown.
|
||||||
|
* `GET /overstock/metrics`: Get dashboard overstock summary & category breakdown.
|
||||||
|
* `GET /overstock/products`: Get list of top overstocked products.
|
||||||
|
* `GET /best-sellers`: Get dashboard best-selling products, brands, categories - **uses `orders`, `products`**.
|
||||||
|
* `GET /sales/metrics`: Get dashboard sales summary for a period - **uses `orders`**.
|
||||||
|
* `GET /low-stock/products`: Get list of top low stock/critical products.
|
||||||
|
* `GET /trending/products`: Get list of trending products - **uses `orders`, `products`**.
|
||||||
|
* `GET /vendor/performance`: Get dashboard vendor performance details - **uses `purchase_orders`**.
|
||||||
|
* `GET /key-metrics`: Get dashboard summary KPIs - **uses multiple base tables**.
|
||||||
|
* `GET /inventory-health`: Get dashboard inventory health overview - **uses `products`, `product_metrics`**.
|
||||||
|
* `GET /replenish/products`: Get list of products needing replenishment (overlaps `/low-stock/products`).
|
||||||
|
* `GET /sales-overview`: Get daily sales totals for chart - **uses `orders`**.
|
||||||
|
|
||||||
|
**9. Product Import Utilities (`products-import.js`)**
|
||||||
|
* `POST /upload-image`: Upload temporary product image, schedule deletion.
|
||||||
|
* `DELETE /delete-image`: Delete temporary product image.
|
||||||
|
* `GET /field-options`: Get dropdown options for product fields from Prod MySQL (cached).
|
||||||
|
* `GET /product-lines/:companyId`: Get product lines for a company from Prod MySQL (cached).
|
||||||
|
* `GET /sublines/:lineId`: Get sublines for a line from Prod MySQL (cached).
|
||||||
|
* `GET /check-file/:filename`: Check existence/permissions of uploaded file (temp or reusable).
|
||||||
|
* `GET /list-uploads`: List files in upload directories.
|
||||||
|
* `GET /search-products`: Search products in Prod MySQL DB.
|
||||||
|
* `GET /check-upc-and-generate-sku`: Check UPC existence and generate SKU suggestion based on Prod MySQL data.
|
||||||
|
* `GET /product-categories/:pid`: Get assigned categories for a product from Prod MySQL.
|
||||||
|
|
||||||
|
**10. Product Metrics (`product-metrics.js`)**
|
||||||
|
* `GET /filter-options`: Get distinct filter values (vendor, brand, abcClass) from `product_metrics`.
|
||||||
|
* `GET /`: List detailed product metrics with filtering, sorting, pagination (primary data access).
|
||||||
|
* `GET /:pid`: Get full metrics record for a single product.
|
||||||
|
|
||||||
|
**11. Orders (`orders.js`)**
|
||||||
|
* `GET /`: List orders with summary info, filtering, sorting, pagination, and stats.
|
||||||
|
* `GET /:orderNumber`: Get details for a single order, including items.
|
||||||
|
|
||||||
|
**12. Products (`products.js`)**
|
||||||
|
* `GET /brands`: Get distinct brands (filtered by PO value).
|
||||||
|
* `GET /`: List products with core data + metrics, filtering, sorting, pagination.
|
||||||
|
* `GET /trending`: Get trending products based on `product_metrics`.
|
||||||
|
* `GET /:id`: Get details for a single product (core data + metrics).
|
||||||
|
* `POST /import`: (**Likely Obsolete/Dangerous**) Import products from CSV.
|
||||||
|
* `PUT /:id`: Update core product data.
|
||||||
|
* `GET /:id/metrics`: (**Redundant**) Get metrics for a single product.
|
||||||
|
* `GET /:id/time-series`: Get sales/PO history for a single product.
|
||||||
|
|
||||||
|
**13. Purchase Orders (`purchase-orders.js`)**
|
||||||
|
* `GET /`: List purchase orders with summary info, filtering, sorting, pagination, and summary stats.
|
||||||
|
* `GET /vendor-metrics`: Calculate vendor performance metrics from `purchase_orders`.
|
||||||
|
* `GET /cost-analysis`: Calculate cost analysis by category from `purchase_orders`.
|
||||||
|
* `GET /receiving-status`: Get summary counts based on PO receiving status.
|
||||||
|
* `GET /order-vs-received`: List product ordered vs. received quantities.
|
||||||
|
|
||||||
|
**14. Reusable Images (`reusable-images.js`)**
|
||||||
|
* `GET /`: List all reusable images.
|
||||||
|
* `GET /by-company/:companyId`: List global and company-specific images.
|
||||||
|
* `GET /global`: List only global images.
|
||||||
|
* `GET /:id`: Get a single reusable image record.
|
||||||
|
* `POST /upload`: Upload a new reusable image and create DB record.
|
||||||
|
* `PUT /:id`: Update reusable image metadata (name, global, company).
|
||||||
|
* `DELETE /:id`: Delete reusable image record and file.
|
||||||
|
* `GET /check-file/:filename`: Check existence/permissions of a reusable image file.
|
||||||
|
|
||||||
|
**15. Templates (`templates.js`)**
|
||||||
|
* `GET /`: List all product data templates.
|
||||||
|
* `GET /:company/:productType`: Get a specific template.
|
||||||
|
* `POST /`: Create a new template.
|
||||||
|
* `PUT /:id`: Update an existing template.
|
||||||
|
* `DELETE /:id`: Delete a template.
|
||||||
|
|
||||||
|
**16. Vendors Aggregate (`vendors-aggregate.js`)**
|
||||||
|
* `GET /filter-options`: Get distinct vendor names and statuses for UI filters (from `vendor_metrics`).
|
||||||
|
* `GET /stats`: Get overall statistics related to vendors (from `vendor_metrics` & `purchase_orders`).
|
||||||
|
* `GET /`: List vendors with aggregated metrics, supporting filtering, sorting, pagination (from `vendor_metrics` & `purchase_orders`).
|
||||||
|
|
||||||
|
**Recommendations:**
|
||||||
|
|
||||||
|
1. **Address Obsolete Endpoints:** Prioritize removing or confirming the necessity of the endpoints marked as obsolete/redundant (legacy config, `/analytics/forecast`, `/csv/update`, `/csv/reset`, `/products/import`, `/products/:id/metrics`).
|
||||||
|
2. **Consolidate Overlapping Functionality:** Review the multiple vendor performance and product listing endpoints. Decide on the primary method (e.g., using aggregate tables via `/vendors-aggregate` and `/metrics`) and refactor or remove the others. Clarify the image upload strategies.
|
||||||
|
3. **Standardize Data Access:** Decide whether `dashboard` and `analytics` endpoints should primarily use aggregate tables (like `/metrics`, `/brands-aggregate`, etc.) or if direct access to base tables is sometimes necessary. Aim for consistency and document the reasoning. Optimize queries hitting base tables if they must remain.
|
||||||
|
4. **Improve Background Task Management:** Refactor `csv.js` to use a unified locking mechanism (maybe separate locks per task type?) and a consistent cancellation strategy for all spawned/managed processes. Clarify the purpose of `update` vs `full-update` and `reset` vs `full-reset`.
|
||||||
|
5. **Optimize DB Connections:** Ensure the `getDbConnection` pooling/caching helper from `products-import.js` is used *consistently* across all modules interacting with the production MySQL database (especially `ai_validation.js`). Remove unnecessary tunnel creations.
|
||||||
|
6. **Review Data Integrity:** Double-check the assumptions made (e.g., uniqueness of AI prompts) and ensure database constraints enforce them. Review the `GET /products/brands` filtering logic.
|
||||||
|
|
||||||
|
## Changes Made
|
||||||
|
|
||||||
|
1. **Removed Obsolete Legacy Endpoints in `config.js`**:
|
||||||
|
- Removed `GET /config/` endpoint
|
||||||
|
- Removed `PUT /config/stock-thresholds/:id` endpoint
|
||||||
|
- Removed `PUT /config/lead-time-thresholds/:id` endpoint
|
||||||
|
- Removed `PUT /config/sales-velocity/:id` endpoint
|
||||||
|
- Removed `PUT /config/abc-classification/:id` endpoint
|
||||||
|
- Removed `PUT /config/safety-stock/:id` endpoint
|
||||||
|
- Removed `PUT /config/turnover/:id` endpoint
|
||||||
|
|
||||||
|
These endpoints were obsolete as they referenced older, single-row config tables that have been replaced by newer endpoints using the structured tables `settings_global`, `settings_product`, and `settings_vendor`.
|
||||||
|
|
||||||
|
2. **Removed MySQL Syntax `/forecast` Endpoint in `analytics.js`**:
|
||||||
|
- Removed `GET /analytics/forecast` endpoint that was using MySQL-specific syntax incompatible with the PostgreSQL database used elsewhere in the application.
|
||||||
|
|
||||||
|
3. **Renamed and Removed Redundant Endpoints**:
|
||||||
|
- Renamed `csv.js` to `data-management.js` while maintaining the same `/csv/*` endpoint paths for consistency
|
||||||
|
- Removed deprecated `/csv/update` endpoint (now fully replaced by `/csv/full-update`)
|
||||||
|
- Removed deprecated `/csv/reset` endpoint (now fully replaced by `/csv/full-reset`)
|
||||||
|
- Removed deprecated `/products/import` endpoint (now handled by `/csv/import`)
|
||||||
|
- Removed deprecated `/products/:id/metrics` endpoint (now handled by `/metrics/:pid`)
|
||||||
|
|
||||||
|
4. **Fixed Data Integrity Issues**:
|
||||||
|
- Improved `GET /products/brands` endpoint by removing the arbitrary filtering logic that was only showing brands with purchase orders that had a total cost of at least $500
|
||||||
|
- The updated endpoint now returns all distinct brands from visible products, providing more complete data
|
||||||
|
|
||||||
|
5. **Optimized Database Connections**:
|
||||||
|
- Created a new `dbConnection.js` utility file that encapsulates the optimized database connection management logic
|
||||||
|
- Improved the `ai-validation.js` file to use this shared connection management, eliminating unnecessary repeated tunnel creation
|
||||||
|
- Added proper connection pooling with timeout-based connection reuse, reducing the overhead of repeatedly creating SSH tunnels
|
||||||
|
- Added query result caching for frequently accessed data to improve performance
|
||||||
|
|
||||||
|
These changes improve maintainability by removing duplicate code, enhance consistency by standardizing on the newer endpoint patterns, and optimize performance by reducing redundant database connections.
|
||||||
|
|
||||||
|
## Additional Improvements
|
||||||
|
|
||||||
|
1. **Further Database Connection Optimizations**:
|
||||||
|
- Extended the use of the optimized database connection utility to additional endpoints in `ai-validation.js`
|
||||||
|
- Updated the `/validate` endpoint and `/test-taxonomy` endpoint to use `getDbConnection`
|
||||||
|
- Ensured consistent connection management across all routes that access the production database
|
||||||
|
|
||||||
|
2. **AI Prompts Data Integrity Verification**:
|
||||||
|
- Confirmed proper uniqueness constraints are in place in the database schema for AI prompts
|
||||||
|
- The schema includes:
|
||||||
|
- `unique_company_prompt` constraint ensuring only one prompt per company
|
||||||
|
- `idx_unique_general_prompt` index ensuring only one general prompt in the system
|
||||||
|
- `idx_unique_system_prompt` index ensuring only one system prompt in the system
|
||||||
|
- Endpoint handlers properly handle uniqueness constraint violations with appropriate 409 Conflict responses
|
||||||
|
- Validation ensures company-specific prompts have company IDs, while general/system prompts do not
|
||||||
|
|
||||||
|
3. **AI Prompts Endpoint Consolidation**:
|
||||||
|
- Added a new consolidated `/by-type` endpoint that handles all types of prompts (general, system, company_specific)
|
||||||
|
- Marked the existing separate endpoints as deprecated with console warnings
|
||||||
|
- Maintained backward compatibility while providing a cleaner API moving forward
|
||||||
|
|
||||||
|
## Completed Items
|
||||||
|
|
||||||
|
✅ Removed obsolete legacy endpoints in `config.js`
|
||||||
|
✅ Removed MySQL syntax `/forecast` endpoint in `analytics.js`
|
||||||
|
✅ Fixed `GET /products/brands` endpoint filtering logic
|
||||||
|
✅ Created reusable database connection utility (`dbConnection.js`)
|
||||||
|
✅ Optimized database connections in `ai-validation.js`
|
||||||
|
✅ Verified data integrity in AI prompts handling
|
||||||
|
✅ Consolidated AI prompts endpoints with a unified `/by-type` endpoint
|
||||||
|
|
||||||
|
## Remaining Items
|
||||||
|
|
||||||
|
- Consider adding additional error handling and logging for database connections
|
||||||
|
- Perform load testing on the optimized database connections to ensure they handle high traffic properly
|
||||||
112
docs/split-up-pos.md
Normal file
112
docs/split-up-pos.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
Okay, I understand completely now. The core issue is that the previous approaches tried too hard to reconcile every receipt back to a specific PO line within the `purchase_orders` table structure, which doesn't reflect the reality where receipts can be independent events. Your downstream scripts, especially `daily_snapshots` and `product_metrics`, rely on having a complete picture of *all* receivings.
|
||||||
|
|
||||||
|
Let's pivot to a model that respects both distinct data streams: **Orders (Intent)** and **Receivings (Actuals)**.
|
||||||
|
|
||||||
|
**Proposed Solution: Separate `purchase_orders` and `receivings` Tables**
|
||||||
|
|
||||||
|
This is the cleanest way to model the reality you've described.
|
||||||
|
|
||||||
|
1. **`purchase_orders` Table:**
|
||||||
|
* **Purpose:** Tracks the status and details of purchase *orders* placed. Represents the *intent* to receive goods.
|
||||||
|
* **Key Columns:** `po_id`, `pid`, `ordered` (quantity ordered), `po_cost_price`, `date` (order/created date), `expected_date`, `status` (PO lifecycle: 'ordered', 'canceled', 'done'), `vendor`, `notes`, etc.
|
||||||
|
* **Crucially:** This table *does not* need a `received` column or a `receiving_history` column derived from complex allocations. It focuses solely on the PO itself.
|
||||||
|
|
||||||
|
2. **`receivings` Table (New or Refined):**
|
||||||
|
* **Purpose:** Tracks every single line item received, regardless of whether it was linked to a PO during the receiving process. Represents the *actual* goods that arrived.
|
||||||
|
* **Key Columns:**
|
||||||
|
* `receiving_id` (Identifier for the overall receiving document/batch)
|
||||||
|
* `pid` (Product ID received)
|
||||||
|
* `received_qty` (Quantity received for this specific line)
|
||||||
|
* `cost_each` (Actual cost paid for this item on this receiving)
|
||||||
|
* `received_date` (Actual date the item was received)
|
||||||
|
* `received_by` (Employee ID/Name)
|
||||||
|
* `source_po_id` (The `po_id` entered on the receiving screen, *nullable*. Stores the original link attempt, even if it was wrong or missing)
|
||||||
|
* `source_receiving_status` (The status from the source `receivings` table: 'partial_received', 'full_received', 'paid', 'canceled')
|
||||||
|
|
||||||
|
**How the Import Script Changes:**
|
||||||
|
|
||||||
|
1. **Fetch POs:** Fetch data from `po` and `po_products`.
|
||||||
|
2. **Populate `purchase_orders`:**
|
||||||
|
* Insert/Update rows into `purchase_orders` based directly on the fetched PO data.
|
||||||
|
* Set `po_id`, `pid`, `ordered`, `po_cost_price`, `date` (`COALESCE(date_ordered, date_created)`), `expected_date`.
|
||||||
|
* Set `status` by mapping the source `po.status` code directly ('ordered', 'canceled', 'done', etc.).
|
||||||
|
* **No complex allocation needed here.**
|
||||||
|
3. **Fetch Receivings:** Fetch data from `receivings` and `receivings_products`.
|
||||||
|
4. **Populate `receivings`:**
|
||||||
|
* For *every* line item fetched from `receivings_products`:
|
||||||
|
* Perform necessary data validation (dates, numbers).
|
||||||
|
* Insert a new row into `receivings` with all the relevant details (`receiving_id`, `pid`, `received_qty`, `cost_each`, `received_date`, `received_by`, `source_po_id`, `source_receiving_status`).
|
||||||
|
* Use `ON CONFLICT (receiving_id, pid)` (or similar unique key based on your source data) `DO UPDATE SET ...` for incremental updates if necessary, or simply delete/re-insert based on `receiving_id` for simplicity if performance allows.
|
||||||
|
|
||||||
|
**Impact on Downstream Scripts (and how to adapt):**
|
||||||
|
|
||||||
|
* **Initial Query (Active POs):**
|
||||||
|
* `SELECT ... FROM purchase_orders po WHERE po.status NOT IN ('canceled', 'done', 'paid_equivalent_status?') AND po.date >= ...`
|
||||||
|
* `active_pos`: `COUNT(DISTINCT po.po_id)` based on the filtered POs.
|
||||||
|
* `overdue_pos`: Add `AND po.expected_date < CURRENT_DATE`.
|
||||||
|
* `total_units`: `SUM(po.ordered)`. Represents total units *ordered* on active POs.
|
||||||
|
* `total_cost`: `SUM(po.ordered * po.po_cost_price)`. Cost of units *ordered*.
|
||||||
|
* `total_retail`: `SUM(po.ordered * pm.current_price)`. Retail value of units *ordered*.
|
||||||
|
* **Result:** This query now cleanly reports on the status of *orders* placed, which seems closer to its original intent. The filter `po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')` is replaced by `po.status NOT IN ('canceled', 'done', 'paid_equivalent?')`. The 90% received check is removed as `received` is not reliably tracked *on the PO* anymore.
|
||||||
|
|
||||||
|
* **`daily_product_snapshots`:**
|
||||||
|
* **`SalesData` CTE:** No change needed.
|
||||||
|
* **`ReceivingData` CTE:** **Must be changed.** Query the **`receivings`** table instead of `purchase_orders`.
|
||||||
|
```sql
|
||||||
|
ReceivingData AS (
|
||||||
|
SELECT
|
||||||
|
rl.pid,
|
||||||
|
COUNT(DISTINCT rl.receiving_id) as receiving_doc_count,
|
||||||
|
SUM(rl.received_qty) AS units_received,
|
||||||
|
SUM(rl.received_qty * rl.cost_each) AS cost_received
|
||||||
|
FROM public.receivings rl
|
||||||
|
WHERE rl.received_date::date = _date
|
||||||
|
-- Optional: Filter out canceled receivings if needed
|
||||||
|
-- AND rl.source_receiving_status <> 'canceled'
|
||||||
|
GROUP BY rl.pid
|
||||||
|
),
|
||||||
|
```
|
||||||
|
* **Result:** This now accurately reflects *all* units received on a given day from the definitive source.
|
||||||
|
|
||||||
|
* **`update_product_metrics`:**
|
||||||
|
* **`CurrentInfo` CTE:** No change needed (pulls from `products`).
|
||||||
|
* **`OnOrderInfo` CTE:** Needs re-evaluation. How do you want to define "On Order"?
|
||||||
|
* **Option A (Strict PO View):** `SUM(po.ordered)` from `purchase_orders po WHERE po.status NOT IN ('canceled', 'done', 'paid_equivalent?')`. This is quantity on *open orders*, ignoring fulfillment state. Simple, but might overestimate if items arrived unlinked.
|
||||||
|
* **Option B (Approximate Fulfillment):** `SUM(po.ordered)` from open POs MINUS `SUM(rl.received_qty)` from `receivings rl` where `rl.source_po_id = po.po_id` (summing only directly linked receivings). Better, but still misses fulfillment via unlinked receivings.
|
||||||
|
* **Option C (Heuristic):** `SUM(po.ordered)` from open POs MINUS `SUM(rl.received_qty)` from `receivings rl` where `rl.pid = po.pid` and `rl.received_date >= po.date`. This *tries* to account for unlinked receivings but is imprecise.
|
||||||
|
* **Recommendation:** Start with **Option A** for simplicity, clearly labeling it "Quantity on Open POs". You might need a separate process or metric for a more nuanced view of expected vs. actual pipeline.
|
||||||
|
```sql
|
||||||
|
-- Example for Option A
|
||||||
|
OnOrderInfo AS (
|
||||||
|
SELECT
|
||||||
|
pid,
|
||||||
|
SUM(ordered) AS on_order_qty, -- Total qty on open POs
|
||||||
|
SUM(ordered * po_cost_price) AS on_order_cost -- Cost of qty on open POs
|
||||||
|
FROM public.purchase_orders
|
||||||
|
WHERE status NOT IN ('canceled', 'done', 'paid_equivalent?') -- Define your open statuses
|
||||||
|
GROUP BY pid
|
||||||
|
),
|
||||||
|
```
|
||||||
|
* **`HistoricalDates` CTE:**
|
||||||
|
* `date_first_sold`, `max_order_date`: No change (queries `orders`).
|
||||||
|
* `date_first_received_calc`, `date_last_received_calc`: **Must be changed.** Query `MIN(rl.received_date)` and `MAX(rl.received_date)` from the **`receivings`** table grouped by `pid`.
|
||||||
|
* **`SnapshotAggregates` CTE:**
|
||||||
|
* `received_qty_30d`, `received_cost_30d`: These are calculated from `daily_product_snapshots`, which are now correctly sourced from `receivings`, so this part is fine.
|
||||||
|
* **Forecasting Calculations:** Will use the chosen definition of `on_order_qty`. Be aware of the implications of Option A (potentially inflated if unlinked receivings fulfill orders).
|
||||||
|
* **Result:** Metrics are calculated based on distinct order data and complete receiving data. The definition of "on order" needs careful consideration.
|
||||||
|
|
||||||
|
**Summary of this Approach:**
|
||||||
|
|
||||||
|
* **Pros:**
|
||||||
|
* Accurately models distinct order and receiving events.
|
||||||
|
* Provides a definitive source (`receivings`) for all received inventory.
|
||||||
|
* Simplifies the `purchase_orders` table and its import logic.
|
||||||
|
* Avoids complex/potentially inaccurate allocation logic for unlinked receivings within the main tables.
|
||||||
|
* Avoids synthetic records.
|
||||||
|
* Fixes downstream reporting (`daily_snapshots` receiving data).
|
||||||
|
* **Cons:**
|
||||||
|
* Requires creating/managing the `receivings` table.
|
||||||
|
* Requires modifying downstream queries (`ReceivingData`, `OnOrderInfo`, `HistoricalDates`).
|
||||||
|
* Calculating a precise "net quantity still expected to arrive" (true on-order minus all relevant fulfillment) becomes more complex and may require specific business rules or heuristics outside the basic table structure if Option A for `OnOrderInfo` isn't sufficient.
|
||||||
|
|
||||||
|
This two-table approach (`purchase_orders` + `receivings`) seems the most robust and accurate way to handle your requirement for complete receiving records independent of potentially flawed PO linking. It directly addresses the shortcomings of the previous attempts.
|
||||||
@@ -7,7 +7,7 @@ BEGIN
|
|||||||
-- Check which table is being updated and use the appropriate column
|
-- Check which table is being updated and use the appropriate column
|
||||||
IF TG_TABLE_NAME = 'categories' THEN
|
IF TG_TABLE_NAME = 'categories' THEN
|
||||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
ELSIF TG_TABLE_NAME IN ('products', 'orders', 'purchase_orders') THEN
|
ELSIF TG_TABLE_NAME IN ('products', 'orders', 'purchase_orders', 'receivings') THEN
|
||||||
NEW.updated = CURRENT_TIMESTAMP;
|
NEW.updated = CURRENT_TIMESTAMP;
|
||||||
END IF;
|
END IF;
|
||||||
RETURN NEW;
|
RETURN NEW;
|
||||||
@@ -159,27 +159,24 @@ CREATE INDEX idx_orders_pid_date ON orders(pid, date);
|
|||||||
CREATE INDEX idx_orders_updated ON orders(updated);
|
CREATE INDEX idx_orders_updated ON orders(updated);
|
||||||
|
|
||||||
-- Create purchase_orders table with its indexes
|
-- Create purchase_orders table with its indexes
|
||||||
|
-- This table now focuses solely on purchase order intent, not receivings
|
||||||
CREATE TABLE purchase_orders (
|
CREATE TABLE purchase_orders (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
po_id TEXT NOT NULL,
|
po_id TEXT NOT NULL,
|
||||||
vendor TEXT NOT NULL,
|
vendor TEXT NOT NULL,
|
||||||
date DATE NOT NULL,
|
date TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
expected_date DATE,
|
expected_date DATE,
|
||||||
pid BIGINT NOT NULL,
|
pid BIGINT NOT NULL,
|
||||||
sku TEXT NOT NULL,
|
sku TEXT NOT NULL,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
cost_price NUMERIC(14, 4) NOT NULL,
|
|
||||||
po_cost_price NUMERIC(14, 4) NOT NULL,
|
po_cost_price NUMERIC(14, 4) NOT NULL,
|
||||||
status TEXT DEFAULT 'created',
|
status TEXT DEFAULT 'created',
|
||||||
receiving_status TEXT DEFAULT 'created',
|
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
long_note TEXT,
|
long_note TEXT,
|
||||||
ordered INTEGER NOT NULL,
|
ordered INTEGER NOT NULL,
|
||||||
received INTEGER DEFAULT 0,
|
supplier_id INTEGER,
|
||||||
received_date DATE,
|
date_created TIMESTAMP WITH TIME ZONE,
|
||||||
last_received_date DATE,
|
date_ordered TIMESTAMP WITH TIME ZONE,
|
||||||
received_by TEXT,
|
|
||||||
receiving_history JSONB,
|
|
||||||
updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
FOREIGN KEY (pid) REFERENCES products(pid) ON DELETE CASCADE,
|
FOREIGN KEY (pid) REFERENCES products(pid) ON DELETE CASCADE,
|
||||||
UNIQUE (po_id, pid)
|
UNIQUE (po_id, pid)
|
||||||
@@ -192,21 +189,61 @@ CREATE TRIGGER update_purchase_orders_updated
|
|||||||
EXECUTE FUNCTION update_updated_column();
|
EXECUTE FUNCTION update_updated_column();
|
||||||
|
|
||||||
COMMENT ON COLUMN purchase_orders.name IS 'Product name from products.description';
|
COMMENT ON COLUMN purchase_orders.name IS 'Product name from products.description';
|
||||||
COMMENT ON COLUMN purchase_orders.po_cost_price IS 'Original cost from PO, before receiving adjustments';
|
COMMENT ON COLUMN purchase_orders.po_cost_price IS 'Original cost from PO';
|
||||||
COMMENT ON COLUMN purchase_orders.status IS 'canceled, created, electronically_ready_send, ordered, preordered, electronically_sent, receiving_started, done';
|
COMMENT ON COLUMN purchase_orders.status IS 'canceled, created, electronically_ready_send, ordered, preordered, electronically_sent, receiving_started, done';
|
||||||
COMMENT ON COLUMN purchase_orders.receiving_status IS 'canceled, created, partial_received, full_received, paid';
|
|
||||||
COMMENT ON COLUMN purchase_orders.receiving_history IS 'Array of receiving records with qty, date, cost, receiving_id, and alt_po flag';
|
|
||||||
|
|
||||||
CREATE INDEX idx_po_id ON purchase_orders(po_id);
|
CREATE INDEX idx_po_id ON purchase_orders(po_id);
|
||||||
CREATE INDEX idx_po_sku ON purchase_orders(sku);
|
CREATE INDEX idx_po_sku ON purchase_orders(sku);
|
||||||
CREATE INDEX idx_po_vendor ON purchase_orders(vendor);
|
CREATE INDEX idx_po_vendor ON purchase_orders(vendor);
|
||||||
CREATE INDEX idx_po_status ON purchase_orders(status);
|
CREATE INDEX idx_po_status ON purchase_orders(status);
|
||||||
CREATE INDEX idx_po_receiving_status ON purchase_orders(receiving_status);
|
|
||||||
CREATE INDEX idx_po_expected_date ON purchase_orders(expected_date);
|
CREATE INDEX idx_po_expected_date ON purchase_orders(expected_date);
|
||||||
CREATE INDEX idx_po_last_received_date ON purchase_orders(last_received_date);
|
|
||||||
CREATE INDEX idx_po_pid_status ON purchase_orders(pid, status);
|
CREATE INDEX idx_po_pid_status ON purchase_orders(pid, status);
|
||||||
CREATE INDEX idx_po_pid_date ON purchase_orders(pid, date);
|
CREATE INDEX idx_po_pid_date ON purchase_orders(pid, date);
|
||||||
CREATE INDEX idx_po_updated ON purchase_orders(updated);
|
CREATE INDEX idx_po_updated ON purchase_orders(updated);
|
||||||
|
CREATE INDEX idx_po_supplier_id ON purchase_orders(supplier_id);
|
||||||
|
|
||||||
|
-- Create receivings table to track actual receipt of goods
|
||||||
|
CREATE TABLE receivings (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
receiving_id TEXT NOT NULL,
|
||||||
|
pid BIGINT NOT NULL,
|
||||||
|
sku TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
vendor TEXT,
|
||||||
|
qty_each INTEGER NOT NULL,
|
||||||
|
qty_each_orig INTEGER,
|
||||||
|
cost_each NUMERIC(14, 5) NOT NULL,
|
||||||
|
cost_each_orig NUMERIC(14, 5),
|
||||||
|
received_by INTEGER,
|
||||||
|
received_by_name TEXT,
|
||||||
|
received_date TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
|
receiving_created_date TIMESTAMP WITH TIME ZONE,
|
||||||
|
supplier_id INTEGER,
|
||||||
|
status TEXT DEFAULT 'created',
|
||||||
|
updated TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (pid) REFERENCES products(pid) ON DELETE CASCADE,
|
||||||
|
UNIQUE (receiving_id, pid)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Create trigger for receivings
|
||||||
|
CREATE TRIGGER update_receivings_updated
|
||||||
|
BEFORE UPDATE ON receivings
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_column();
|
||||||
|
|
||||||
|
COMMENT ON COLUMN receivings.status IS 'canceled, created, partial_received, full_received, paid';
|
||||||
|
COMMENT ON COLUMN receivings.qty_each_orig IS 'Original quantity from the source system';
|
||||||
|
COMMENT ON COLUMN receivings.cost_each_orig IS 'Original cost from the source system';
|
||||||
|
COMMENT ON COLUMN receivings.vendor IS 'Vendor name, same as in purchase_orders';
|
||||||
|
|
||||||
|
CREATE INDEX idx_receivings_id ON receivings(receiving_id);
|
||||||
|
CREATE INDEX idx_receivings_pid ON receivings(pid);
|
||||||
|
CREATE INDEX idx_receivings_sku ON receivings(sku);
|
||||||
|
CREATE INDEX idx_receivings_status ON receivings(status);
|
||||||
|
CREATE INDEX idx_receivings_received_date ON receivings(received_date);
|
||||||
|
CREATE INDEX idx_receivings_supplier_id ON receivings(supplier_id);
|
||||||
|
CREATE INDEX idx_receivings_vendor ON receivings(vendor);
|
||||||
|
CREATE INDEX idx_receivings_updated ON receivings(updated);
|
||||||
|
|
||||||
SET session_replication_role = 'origin'; -- Re-enable foreign key checks
|
SET session_replication_role = 'origin'; -- Re-enable foreign key checks
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
|||||||
DROP TABLE IF EXISTS temp_order_discounts;
|
DROP TABLE IF EXISTS temp_order_discounts;
|
||||||
DROP TABLE IF EXISTS temp_order_taxes;
|
DROP TABLE IF EXISTS temp_order_taxes;
|
||||||
DROP TABLE IF EXISTS temp_order_costs;
|
DROP TABLE IF EXISTS temp_order_costs;
|
||||||
|
DROP TABLE IF EXISTS temp_main_discounts;
|
||||||
|
DROP TABLE IF EXISTS temp_item_discounts;
|
||||||
|
|
||||||
CREATE TEMP TABLE temp_order_items (
|
CREATE TEMP TABLE temp_order_items (
|
||||||
order_id INTEGER NOT NULL,
|
order_id INTEGER NOT NULL,
|
||||||
@@ -144,6 +146,21 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
|||||||
PRIMARY KEY (order_id, pid)
|
PRIMARY KEY (order_id, pid)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TEMP TABLE temp_main_discounts (
|
||||||
|
order_id INTEGER NOT NULL,
|
||||||
|
discount_id INTEGER NOT NULL,
|
||||||
|
discount_amount_subtotal NUMERIC(14, 4) DEFAULT 0.0000,
|
||||||
|
PRIMARY KEY (order_id, discount_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TEMP TABLE temp_item_discounts (
|
||||||
|
order_id INTEGER NOT NULL,
|
||||||
|
pid INTEGER NOT NULL,
|
||||||
|
discount_id INTEGER NOT NULL,
|
||||||
|
amount NUMERIC(14, 4) NOT NULL,
|
||||||
|
PRIMARY KEY (order_id, pid, discount_id)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TEMP TABLE temp_order_taxes (
|
CREATE TEMP TABLE temp_order_taxes (
|
||||||
order_id INTEGER NOT NULL,
|
order_id INTEGER NOT NULL,
|
||||||
pid INTEGER NOT NULL,
|
pid INTEGER NOT NULL,
|
||||||
@@ -163,6 +180,9 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
|||||||
CREATE INDEX idx_temp_order_discounts_order_pid ON temp_order_discounts(order_id, pid);
|
CREATE INDEX idx_temp_order_discounts_order_pid ON temp_order_discounts(order_id, pid);
|
||||||
CREATE INDEX idx_temp_order_taxes_order_pid ON temp_order_taxes(order_id, pid);
|
CREATE INDEX idx_temp_order_taxes_order_pid ON temp_order_taxes(order_id, pid);
|
||||||
CREATE INDEX idx_temp_order_costs_order_pid ON temp_order_costs(order_id, pid);
|
CREATE INDEX idx_temp_order_costs_order_pid ON temp_order_costs(order_id, pid);
|
||||||
|
CREATE INDEX idx_temp_main_discounts_discount_id ON temp_main_discounts(discount_id);
|
||||||
|
CREATE INDEX idx_temp_item_discounts_order_pid ON temp_item_discounts(order_id, pid);
|
||||||
|
CREATE INDEX idx_temp_item_discounts_discount_id ON temp_item_discounts(discount_id);
|
||||||
`);
|
`);
|
||||||
await localConnection.commit();
|
await localConnection.commit();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -296,19 +316,18 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
|||||||
};
|
};
|
||||||
|
|
||||||
const processDiscountsBatch = async (batchIds) => {
|
const processDiscountsBatch = async (batchIds) => {
|
||||||
const [discounts] = await prodConnection.query(`
|
// First, load main discount records
|
||||||
SELECT order_id, pid, SUM(amount) as discount
|
const [mainDiscounts] = await prodConnection.query(`
|
||||||
FROM order_discount_items
|
SELECT order_id, discount_id, discount_amount_subtotal
|
||||||
|
FROM order_discounts
|
||||||
WHERE order_id IN (?)
|
WHERE order_id IN (?)
|
||||||
GROUP BY order_id, pid
|
|
||||||
`, [batchIds]);
|
`, [batchIds]);
|
||||||
|
|
||||||
if (discounts.length === 0) return;
|
if (mainDiscounts.length > 0) {
|
||||||
|
|
||||||
await localConnection.beginTransaction();
|
await localConnection.beginTransaction();
|
||||||
try {
|
try {
|
||||||
for (let j = 0; j < discounts.length; j += PG_BATCH_SIZE) {
|
for (let j = 0; j < mainDiscounts.length; j += PG_BATCH_SIZE) {
|
||||||
const subBatch = discounts.slice(j, j + PG_BATCH_SIZE);
|
const subBatch = mainDiscounts.slice(j, j + PG_BATCH_SIZE);
|
||||||
if (subBatch.length === 0) continue;
|
if (subBatch.length === 0) continue;
|
||||||
|
|
||||||
const placeholders = subBatch.map((_, idx) =>
|
const placeholders = subBatch.map((_, idx) =>
|
||||||
@@ -317,17 +336,77 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
|||||||
|
|
||||||
const values = subBatch.flatMap(d => [
|
const values = subBatch.flatMap(d => [
|
||||||
d.order_id,
|
d.order_id,
|
||||||
d.pid,
|
d.discount_id,
|
||||||
d.discount || 0
|
d.discount_amount_subtotal || 0
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await localConnection.query(`
|
await localConnection.query(`
|
||||||
INSERT INTO temp_order_discounts (order_id, pid, discount)
|
INSERT INTO temp_main_discounts (order_id, discount_id, discount_amount_subtotal)
|
||||||
VALUES ${placeholders}
|
VALUES ${placeholders}
|
||||||
ON CONFLICT (order_id, pid) DO UPDATE SET
|
ON CONFLICT (order_id, discount_id) DO UPDATE SET
|
||||||
discount = EXCLUDED.discount
|
discount_amount_subtotal = EXCLUDED.discount_amount_subtotal
|
||||||
`, values);
|
`, values);
|
||||||
}
|
}
|
||||||
|
await localConnection.commit();
|
||||||
|
} catch (error) {
|
||||||
|
await localConnection.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then, load item discount records
|
||||||
|
const [discounts] = await prodConnection.query(`
|
||||||
|
SELECT order_id, pid, discount_id, amount
|
||||||
|
FROM order_discount_items
|
||||||
|
WHERE order_id IN (?)
|
||||||
|
`, [batchIds]);
|
||||||
|
|
||||||
|
if (discounts.length === 0) return;
|
||||||
|
|
||||||
|
// Process in memory to handle potential duplicates
|
||||||
|
const discountMap = new Map();
|
||||||
|
for (const d of discounts) {
|
||||||
|
const key = `${d.order_id}-${d.pid}-${d.discount_id}`;
|
||||||
|
discountMap.set(key, d);
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueDiscounts = Array.from(discountMap.values());
|
||||||
|
|
||||||
|
await localConnection.beginTransaction();
|
||||||
|
try {
|
||||||
|
for (let j = 0; j < uniqueDiscounts.length; j += PG_BATCH_SIZE) {
|
||||||
|
const subBatch = uniqueDiscounts.slice(j, j + PG_BATCH_SIZE);
|
||||||
|
if (subBatch.length === 0) continue;
|
||||||
|
|
||||||
|
const placeholders = subBatch.map((_, idx) =>
|
||||||
|
`($${idx * 4 + 1}, $${idx * 4 + 2}, $${idx * 4 + 3}, $${idx * 4 + 4})`
|
||||||
|
).join(",");
|
||||||
|
|
||||||
|
const values = subBatch.flatMap(d => [
|
||||||
|
d.order_id,
|
||||||
|
d.pid,
|
||||||
|
d.discount_id,
|
||||||
|
d.amount || 0
|
||||||
|
]);
|
||||||
|
|
||||||
|
await localConnection.query(`
|
||||||
|
INSERT INTO temp_item_discounts (order_id, pid, discount_id, amount)
|
||||||
|
VALUES ${placeholders}
|
||||||
|
ON CONFLICT (order_id, pid, discount_id) DO UPDATE SET
|
||||||
|
amount = EXCLUDED.amount
|
||||||
|
`, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create aggregated view with a simpler, safer query that avoids duplicates
|
||||||
|
await localConnection.query(`
|
||||||
|
TRUNCATE temp_order_discounts;
|
||||||
|
|
||||||
|
INSERT INTO temp_order_discounts (order_id, pid, discount)
|
||||||
|
SELECT order_id, pid, SUM(amount) as discount
|
||||||
|
FROM temp_item_discounts
|
||||||
|
GROUP BY order_id, pid
|
||||||
|
`);
|
||||||
|
|
||||||
await localConnection.commit();
|
await localConnection.commit();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await localConnection.rollback();
|
await localConnection.rollback();
|
||||||
@@ -485,11 +564,16 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
|||||||
SELECT
|
SELECT
|
||||||
oi.order_id,
|
oi.order_id,
|
||||||
oi.pid,
|
oi.pid,
|
||||||
SUM(COALESCE(od.discount, 0)) as promo_discount,
|
-- Instead of using ARRAY_AGG which can cause duplicate issues, use SUM with a CASE
|
||||||
|
SUM(CASE
|
||||||
|
WHEN COALESCE(md.discount_amount_subtotal, 0) > 0 THEN id.amount
|
||||||
|
ELSE 0
|
||||||
|
END) as promo_discount_sum,
|
||||||
COALESCE(ot.tax, 0) as total_tax,
|
COALESCE(ot.tax, 0) as total_tax,
|
||||||
COALESCE(oc.costeach, oi.price * 0.5) as costeach
|
COALESCE(oc.costeach, oi.price * 0.5) as costeach
|
||||||
FROM temp_order_items oi
|
FROM temp_order_items oi
|
||||||
LEFT JOIN temp_order_discounts od ON oi.order_id = od.order_id AND oi.pid = od.pid
|
LEFT JOIN temp_item_discounts id ON oi.order_id = id.order_id AND oi.pid = id.pid
|
||||||
|
LEFT JOIN temp_main_discounts md ON id.order_id = md.order_id AND id.discount_id = md.discount_id
|
||||||
LEFT JOIN temp_order_taxes ot ON oi.order_id = ot.order_id AND oi.pid = ot.pid
|
LEFT JOIN temp_order_taxes ot ON oi.order_id = ot.order_id AND oi.pid = ot.pid
|
||||||
LEFT JOIN temp_order_costs oc ON oi.order_id = oc.order_id AND oi.pid = oc.pid
|
LEFT JOIN temp_order_costs oc ON oi.order_id = oc.order_id AND oi.pid = oc.pid
|
||||||
WHERE oi.order_id = ANY($1)
|
WHERE oi.order_id = ANY($1)
|
||||||
@@ -513,8 +597,8 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
|||||||
ELSE 0
|
ELSE 0
|
||||||
END
|
END
|
||||||
+
|
+
|
||||||
-- Part 3: Specific Promo Code Discount (if applicable)
|
-- Part 3: Specific Item-Level Discount (only if parent discount affected subtotal)
|
||||||
COALESCE(ot.promo_discount, 0)
|
COALESCE(ot.promo_discount_sum, 0)
|
||||||
)::NUMERIC(14, 4) as discount,
|
)::NUMERIC(14, 4) as discount,
|
||||||
COALESCE(ot.total_tax, 0)::NUMERIC(14, 4) as tax,
|
COALESCE(ot.total_tax, 0)::NUMERIC(14, 4) as tax,
|
||||||
false as tax_included,
|
false as tax_included,
|
||||||
@@ -649,6 +733,8 @@ async function importOrders(prodConnection, localConnection, incrementalUpdate =
|
|||||||
DROP TABLE IF EXISTS temp_order_discounts;
|
DROP TABLE IF EXISTS temp_order_discounts;
|
||||||
DROP TABLE IF EXISTS temp_order_taxes;
|
DROP TABLE IF EXISTS temp_order_taxes;
|
||||||
DROP TABLE IF EXISTS temp_order_costs;
|
DROP TABLE IF EXISTS temp_order_costs;
|
||||||
|
DROP TABLE IF EXISTS temp_main_discounts;
|
||||||
|
DROP TABLE IF EXISTS temp_item_discounts;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Commit final transaction
|
// Commit final transaction
|
||||||
|
|||||||
@@ -194,7 +194,10 @@ async function importMissingProducts(prodConnection, localConnection, missingPid
|
|||||||
p.country_of_origin,
|
p.country_of_origin,
|
||||||
(SELECT COUNT(*) FROM mybasket mb WHERE mb.item = p.pid AND mb.qty > 0) AS baskets,
|
(SELECT COUNT(*) FROM mybasket mb WHERE mb.item = p.pid AND mb.qty > 0) AS baskets,
|
||||||
(SELECT COUNT(*) FROM product_notify pn WHERE pn.pid = p.pid) AS notifies,
|
(SELECT COUNT(*) FROM product_notify pn WHERE pn.pid = p.pid) AS notifies,
|
||||||
(SELECT COALESCE(SUM(oi.qty_ordered), 0) FROM order_items oi WHERE oi.prod_pid = p.pid) AS total_sold,
|
(SELECT COALESCE(SUM(oi.qty_ordered), 0)
|
||||||
|
FROM order_items oi
|
||||||
|
JOIN _order o ON oi.order_id = o.order_id
|
||||||
|
WHERE oi.prod_pid = p.pid AND o.order_status >= 20) AS total_sold,
|
||||||
pls.date_sold as date_last_sold,
|
pls.date_sold as date_last_sold,
|
||||||
(SELECT iid FROM product_images WHERE pid = p.pid AND \`order\` = 255 LIMIT 1) AS primary_iid,
|
(SELECT iid FROM product_images WHERE pid = p.pid AND \`order\` = 255 LIMIT 1) AS primary_iid,
|
||||||
GROUP_CONCAT(DISTINCT CASE
|
GROUP_CONCAT(DISTINCT CASE
|
||||||
@@ -397,7 +400,10 @@ async function materializeCalculations(prodConnection, localConnection, incremen
|
|||||||
p.country_of_origin,
|
p.country_of_origin,
|
||||||
(SELECT COUNT(*) FROM mybasket mb WHERE mb.item = p.pid AND mb.qty > 0) AS baskets,
|
(SELECT COUNT(*) FROM mybasket mb WHERE mb.item = p.pid AND mb.qty > 0) AS baskets,
|
||||||
(SELECT COUNT(*) FROM product_notify pn WHERE pn.pid = p.pid) AS notifies,
|
(SELECT COUNT(*) FROM product_notify pn WHERE pn.pid = p.pid) AS notifies,
|
||||||
(SELECT COALESCE(SUM(oi.qty_ordered), 0) FROM order_items oi WHERE oi.prod_pid = p.pid) AS total_sold,
|
(SELECT COALESCE(SUM(oi.qty_ordered), 0)
|
||||||
|
FROM order_items oi
|
||||||
|
JOIN _order o ON oi.order_id = o.order_id
|
||||||
|
WHERE oi.prod_pid = p.pid AND o.order_status >= 20) AS total_sold,
|
||||||
pls.date_sold as date_last_sold,
|
pls.date_sold as date_last_sold,
|
||||||
(SELECT iid FROM product_images WHERE pid = p.pid AND \`order\` = 255 LIMIT 1) AS primary_iid,
|
(SELECT iid FROM product_images WHERE pid = p.pid AND \`order\` = 255 LIMIT 1) AS primary_iid,
|
||||||
GROUP_CONCAT(DISTINCT CASE
|
GROUP_CONCAT(DISTINCT CASE
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function validateDate(mysqlDate) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Imports purchase orders and receivings from a production MySQL database to a local PostgreSQL database.
|
* Imports purchase orders and receivings from a production MySQL database to a local PostgreSQL database.
|
||||||
* Implements FIFO allocation of receivings to purchase orders.
|
* Handles these as separate data streams without complex FIFO allocation.
|
||||||
*
|
*
|
||||||
* @param {object} prodConnection - A MySQL connection to production DB
|
* @param {object} prodConnection - A MySQL connection to production DB
|
||||||
* @param {object} localConnection - A PostgreSQL connection to local DB
|
* @param {object} localConnection - A PostgreSQL connection to local DB
|
||||||
@@ -44,8 +44,12 @@ function validateDate(mysqlDate) {
|
|||||||
*/
|
*/
|
||||||
async function importPurchaseOrders(prodConnection, localConnection, incrementalUpdate = true) {
|
async function importPurchaseOrders(prodConnection, localConnection, incrementalUpdate = true) {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
let recordsAdded = 0;
|
let poRecordsAdded = 0;
|
||||||
let recordsUpdated = 0;
|
let poRecordsUpdated = 0;
|
||||||
|
let poRecordsDeleted = 0;
|
||||||
|
let receivingRecordsAdded = 0;
|
||||||
|
let receivingRecordsUpdated = 0;
|
||||||
|
let receivingRecordsDeleted = 0;
|
||||||
let totalProcessed = 0;
|
let totalProcessed = 0;
|
||||||
|
|
||||||
// Batch size constants
|
// Batch size constants
|
||||||
@@ -68,8 +72,8 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
await localConnection.query(`
|
await localConnection.query(`
|
||||||
DROP TABLE IF EXISTS temp_purchase_orders;
|
DROP TABLE IF EXISTS temp_purchase_orders;
|
||||||
DROP TABLE IF EXISTS temp_receivings;
|
DROP TABLE IF EXISTS temp_receivings;
|
||||||
DROP TABLE IF EXISTS temp_receiving_allocations;
|
|
||||||
DROP TABLE IF EXISTS employee_names;
|
DROP TABLE IF EXISTS employee_names;
|
||||||
|
DROP TABLE IF EXISTS temp_supplier_names;
|
||||||
|
|
||||||
-- Temporary table for purchase orders
|
-- Temporary table for purchase orders
|
||||||
CREATE TEMP TABLE temp_purchase_orders (
|
CREATE TEMP TABLE temp_purchase_orders (
|
||||||
@@ -94,11 +98,16 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
-- Temporary table for receivings
|
-- Temporary table for receivings
|
||||||
CREATE TEMP TABLE temp_receivings (
|
CREATE TEMP TABLE temp_receivings (
|
||||||
receiving_id TEXT NOT NULL,
|
receiving_id TEXT NOT NULL,
|
||||||
po_id TEXT,
|
|
||||||
pid BIGINT NOT NULL,
|
pid BIGINT NOT NULL,
|
||||||
|
sku TEXT,
|
||||||
|
name TEXT,
|
||||||
|
vendor TEXT,
|
||||||
qty_each INTEGER,
|
qty_each INTEGER,
|
||||||
cost_each NUMERIC(14, 4),
|
qty_each_orig INTEGER,
|
||||||
|
cost_each NUMERIC(14, 5),
|
||||||
|
cost_each_orig NUMERIC(14, 5),
|
||||||
received_by INTEGER,
|
received_by INTEGER,
|
||||||
|
received_by_name TEXT,
|
||||||
received_date TIMESTAMP WITH TIME ZONE,
|
received_date TIMESTAMP WITH TIME ZONE,
|
||||||
receiving_created_date TIMESTAMP WITH TIME ZONE,
|
receiving_created_date TIMESTAMP WITH TIME ZONE,
|
||||||
supplier_id INTEGER,
|
supplier_id INTEGER,
|
||||||
@@ -106,18 +115,6 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
PRIMARY KEY (receiving_id, pid)
|
PRIMARY KEY (receiving_id, pid)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Temporary table for tracking FIFO allocations
|
|
||||||
CREATE TEMP TABLE temp_receiving_allocations (
|
|
||||||
po_id TEXT NOT NULL,
|
|
||||||
pid BIGINT NOT NULL,
|
|
||||||
receiving_id TEXT NOT NULL,
|
|
||||||
allocated_qty INTEGER NOT NULL,
|
|
||||||
cost_each NUMERIC(14, 4) NOT NULL,
|
|
||||||
received_date TIMESTAMP WITH TIME ZONE NOT NULL,
|
|
||||||
received_by INTEGER,
|
|
||||||
PRIMARY KEY (po_id, pid, receiving_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Temporary table for employee names
|
-- Temporary table for employee names
|
||||||
CREATE TEMP TABLE employee_names (
|
CREATE TEMP TABLE employee_names (
|
||||||
employeeid INTEGER PRIMARY KEY,
|
employeeid INTEGER PRIMARY KEY,
|
||||||
@@ -128,7 +125,6 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
-- Create indexes for efficient joins
|
-- Create indexes for efficient joins
|
||||||
CREATE INDEX idx_temp_po_pid ON temp_purchase_orders(pid);
|
CREATE INDEX idx_temp_po_pid ON temp_purchase_orders(pid);
|
||||||
CREATE INDEX idx_temp_receiving_pid ON temp_receivings(pid);
|
CREATE INDEX idx_temp_receiving_pid ON temp_receivings(pid);
|
||||||
CREATE INDEX idx_temp_receiving_po_id ON temp_receivings(po_id);
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Map status codes to text values
|
// Map status codes to text values
|
||||||
@@ -191,7 +187,56 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
`, employeeValues);
|
`, employeeValues);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. First, fetch all relevant POs
|
// Add this section before the PO import to create a supplier names mapping
|
||||||
|
outputProgress({
|
||||||
|
status: "running",
|
||||||
|
operation: "Purchase orders import",
|
||||||
|
message: "Fetching supplier data for vendor mapping"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch supplier data from production and store in a temp table
|
||||||
|
const [suppliers] = await prodConnection.query(`
|
||||||
|
SELECT
|
||||||
|
supplierid,
|
||||||
|
companyname
|
||||||
|
FROM suppliers
|
||||||
|
WHERE companyname IS NOT NULL AND companyname != ''
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (suppliers.length > 0) {
|
||||||
|
// Create temp table for supplier names
|
||||||
|
await localConnection.query(`
|
||||||
|
DROP TABLE IF EXISTS temp_supplier_names;
|
||||||
|
CREATE TEMP TABLE temp_supplier_names (
|
||||||
|
supplier_id INTEGER PRIMARY KEY,
|
||||||
|
company_name TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Insert supplier data in batches
|
||||||
|
for (let i = 0; i < suppliers.length; i += INSERT_BATCH_SIZE) {
|
||||||
|
const batch = suppliers.slice(i, i + INSERT_BATCH_SIZE);
|
||||||
|
|
||||||
|
const placeholders = batch.map((_, idx) => {
|
||||||
|
const base = idx * 2;
|
||||||
|
return `($${base + 1}, $${base + 2})`;
|
||||||
|
}).join(',');
|
||||||
|
|
||||||
|
const values = batch.flatMap(s => [
|
||||||
|
s.supplierid,
|
||||||
|
s.companyname || 'Unnamed Supplier'
|
||||||
|
]);
|
||||||
|
|
||||||
|
await localConnection.query(`
|
||||||
|
INSERT INTO temp_supplier_names (supplier_id, company_name)
|
||||||
|
VALUES ${placeholders}
|
||||||
|
ON CONFLICT (supplier_id) DO UPDATE SET
|
||||||
|
company_name = EXCLUDED.company_name
|
||||||
|
`, values);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Fetch and process purchase orders
|
||||||
outputProgress({
|
outputProgress({
|
||||||
status: "running",
|
status: "running",
|
||||||
operation: "Purchase orders import",
|
operation: "Purchase orders import",
|
||||||
@@ -214,6 +259,10 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
const totalPOs = poCount[0].total;
|
const totalPOs = poCount[0].total;
|
||||||
console.log(`Found ${totalPOs} relevant purchase orders`);
|
console.log(`Found ${totalPOs} relevant purchase orders`);
|
||||||
|
|
||||||
|
// Skip processing if no POs to process
|
||||||
|
if (totalPOs === 0) {
|
||||||
|
console.log('No purchase orders to process, skipping PO import step');
|
||||||
|
} else {
|
||||||
// Fetch and process POs in batches
|
// Fetch and process POs in batches
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
let allPOsProcessed = false;
|
let allPOsProcessed = false;
|
||||||
@@ -358,6 +407,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
allPOsProcessed = true;
|
allPOsProcessed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Next, fetch all relevant receivings
|
// 2. Next, fetch all relevant receivings
|
||||||
outputProgress({
|
outputProgress({
|
||||||
@@ -381,6 +431,10 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
const totalReceivings = receivingCount[0].total;
|
const totalReceivings = receivingCount[0].total;
|
||||||
console.log(`Found ${totalReceivings} relevant receivings`);
|
console.log(`Found ${totalReceivings} relevant receivings`);
|
||||||
|
|
||||||
|
// Skip processing if no receivings to process
|
||||||
|
if (totalReceivings === 0) {
|
||||||
|
console.log('No receivings to process, skipping receivings import step');
|
||||||
|
} else {
|
||||||
// Fetch and process receivings in batches
|
// Fetch and process receivings in batches
|
||||||
offset = 0; // Reset offset for receivings
|
offset = 0; // Reset offset for receivings
|
||||||
let allReceivingsProcessed = false;
|
let allReceivingsProcessed = false;
|
||||||
@@ -389,10 +443,16 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
const [receivingList] = await prodConnection.query(`
|
const [receivingList] = await prodConnection.query(`
|
||||||
SELECT
|
SELECT
|
||||||
r.receiving_id,
|
r.receiving_id,
|
||||||
r.po_id,
|
|
||||||
r.supplier_id,
|
r.supplier_id,
|
||||||
r.status,
|
r.status,
|
||||||
r.date_created
|
r.notes,
|
||||||
|
r.shipping,
|
||||||
|
r.total_amount,
|
||||||
|
r.hold,
|
||||||
|
r.for_storefront,
|
||||||
|
r.date_created,
|
||||||
|
r.date_paid,
|
||||||
|
r.date_checked
|
||||||
FROM receivings r
|
FROM receivings r
|
||||||
WHERE r.date_created >= DATE_SUB(CURRENT_DATE, INTERVAL ${yearInterval} YEAR)
|
WHERE r.date_created >= DATE_SUB(CURRENT_DATE, INTERVAL ${yearInterval} YEAR)
|
||||||
${incrementalUpdate ? `
|
${incrementalUpdate ? `
|
||||||
@@ -418,12 +478,17 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
rp.receiving_id,
|
rp.receiving_id,
|
||||||
rp.pid,
|
rp.pid,
|
||||||
rp.qty_each,
|
rp.qty_each,
|
||||||
|
rp.qty_each_orig,
|
||||||
rp.cost_each,
|
rp.cost_each,
|
||||||
|
rp.cost_each_orig,
|
||||||
rp.received_by,
|
rp.received_by,
|
||||||
rp.received_date,
|
rp.received_date,
|
||||||
r.date_created as receiving_created_date
|
r.date_created as receiving_created_date,
|
||||||
|
COALESCE(p.itemnumber, 'NO-SKU') AS sku,
|
||||||
|
COALESCE(p.description, 'Unknown Product') AS name
|
||||||
FROM receivings_products rp
|
FROM receivings_products rp
|
||||||
JOIN receivings r ON rp.receiving_id = r.receiving_id
|
JOIN receivings r ON rp.receiving_id = r.receiving_id
|
||||||
|
LEFT JOIN products p ON rp.pid = p.pid
|
||||||
WHERE rp.receiving_id IN (?)
|
WHERE rp.receiving_id IN (?)
|
||||||
`, [receivingIds]);
|
`, [receivingIds]);
|
||||||
|
|
||||||
@@ -433,13 +498,46 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
const receiving = receivingList.find(r => r.receiving_id == product.receiving_id);
|
const receiving = receivingList.find(r => r.receiving_id == product.receiving_id);
|
||||||
if (!receiving) continue;
|
if (!receiving) continue;
|
||||||
|
|
||||||
|
// Get employee name if available
|
||||||
|
let receivedByName = null;
|
||||||
|
if (product.received_by) {
|
||||||
|
const [employeeResult] = await localConnection.query(`
|
||||||
|
SELECT CONCAT(firstname, ' ', lastname) as full_name
|
||||||
|
FROM employee_names
|
||||||
|
WHERE employeeid = $1
|
||||||
|
`, [product.received_by]);
|
||||||
|
|
||||||
|
if (employeeResult.rows.length > 0) {
|
||||||
|
receivedByName = employeeResult.rows[0].full_name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get vendor name if available
|
||||||
|
let vendorName = 'Unknown Vendor';
|
||||||
|
if (receiving.supplier_id) {
|
||||||
|
const [vendorResult] = await localConnection.query(`
|
||||||
|
SELECT company_name
|
||||||
|
FROM temp_supplier_names
|
||||||
|
WHERE supplier_id = $1
|
||||||
|
`, [receiving.supplier_id]);
|
||||||
|
|
||||||
|
if (vendorResult.rows.length > 0) {
|
||||||
|
vendorName = vendorResult.rows[0].company_name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
completeReceivings.push({
|
completeReceivings.push({
|
||||||
receiving_id: receiving.receiving_id.toString(),
|
receiving_id: receiving.receiving_id.toString(),
|
||||||
po_id: receiving.po_id ? receiving.po_id.toString() : null,
|
|
||||||
pid: product.pid,
|
pid: product.pid,
|
||||||
|
sku: product.sku,
|
||||||
|
name: product.name,
|
||||||
|
vendor: vendorName,
|
||||||
qty_each: product.qty_each,
|
qty_each: product.qty_each,
|
||||||
|
qty_each_orig: product.qty_each_orig,
|
||||||
cost_each: product.cost_each,
|
cost_each: product.cost_each,
|
||||||
|
cost_each_orig: product.cost_each_orig,
|
||||||
received_by: product.received_by,
|
received_by: product.received_by,
|
||||||
|
received_by_name: receivedByName,
|
||||||
received_date: validateDate(product.received_date) || validateDate(product.receiving_created_date),
|
received_date: validateDate(product.received_date) || validateDate(product.receiving_created_date),
|
||||||
receiving_created_date: validateDate(product.receiving_created_date),
|
receiving_created_date: validateDate(product.receiving_created_date),
|
||||||
supplier_id: receiving.supplier_id,
|
supplier_id: receiving.supplier_id,
|
||||||
@@ -452,17 +550,22 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
const batch = completeReceivings.slice(i, i + INSERT_BATCH_SIZE);
|
const batch = completeReceivings.slice(i, i + INSERT_BATCH_SIZE);
|
||||||
|
|
||||||
const placeholders = batch.map((_, idx) => {
|
const placeholders = batch.map((_, idx) => {
|
||||||
const base = idx * 10;
|
const base = idx * 15;
|
||||||
return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, $${base + 8}, $${base + 9}, $${base + 10})`;
|
return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, $${base + 8}, $${base + 9}, $${base + 10}, $${base + 11}, $${base + 12}, $${base + 13}, $${base + 14}, $${base + 15})`;
|
||||||
}).join(',');
|
}).join(',');
|
||||||
|
|
||||||
const values = batch.flatMap(r => [
|
const values = batch.flatMap(r => [
|
||||||
r.receiving_id,
|
r.receiving_id,
|
||||||
r.po_id,
|
|
||||||
r.pid,
|
r.pid,
|
||||||
|
r.sku,
|
||||||
|
r.name,
|
||||||
|
r.vendor,
|
||||||
r.qty_each,
|
r.qty_each,
|
||||||
|
r.qty_each_orig,
|
||||||
r.cost_each,
|
r.cost_each,
|
||||||
|
r.cost_each_orig,
|
||||||
r.received_by,
|
r.received_by,
|
||||||
|
r.received_by_name,
|
||||||
r.received_date,
|
r.received_date,
|
||||||
r.receiving_created_date,
|
r.receiving_created_date,
|
||||||
r.supplier_id,
|
r.supplier_id,
|
||||||
@@ -471,15 +574,21 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
|
|
||||||
await localConnection.query(`
|
await localConnection.query(`
|
||||||
INSERT INTO temp_receivings (
|
INSERT INTO temp_receivings (
|
||||||
receiving_id, po_id, pid, qty_each, cost_each, received_by,
|
receiving_id, pid, sku, name, vendor, qty_each, qty_each_orig,
|
||||||
|
cost_each, cost_each_orig, received_by, received_by_name,
|
||||||
received_date, receiving_created_date, supplier_id, status
|
received_date, receiving_created_date, supplier_id, status
|
||||||
)
|
)
|
||||||
VALUES ${placeholders}
|
VALUES ${placeholders}
|
||||||
ON CONFLICT (receiving_id, pid) DO UPDATE SET
|
ON CONFLICT (receiving_id, pid) DO UPDATE SET
|
||||||
po_id = EXCLUDED.po_id,
|
sku = EXCLUDED.sku,
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
vendor = EXCLUDED.vendor,
|
||||||
qty_each = EXCLUDED.qty_each,
|
qty_each = EXCLUDED.qty_each,
|
||||||
|
qty_each_orig = EXCLUDED.qty_each_orig,
|
||||||
cost_each = EXCLUDED.cost_each,
|
cost_each = EXCLUDED.cost_each,
|
||||||
|
cost_each_orig = EXCLUDED.cost_each_orig,
|
||||||
received_by = EXCLUDED.received_by,
|
received_by = EXCLUDED.received_by,
|
||||||
|
received_by_name = EXCLUDED.received_by_name,
|
||||||
received_date = EXCLUDED.received_date,
|
received_date = EXCLUDED.received_date,
|
||||||
receiving_created_date = EXCLUDED.receiving_created_date,
|
receiving_created_date = EXCLUDED.receiving_created_date,
|
||||||
supplier_id = EXCLUDED.supplier_id,
|
supplier_id = EXCLUDED.supplier_id,
|
||||||
@@ -505,16 +614,15 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
allReceivingsProcessed = true;
|
allReceivingsProcessed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Implement FIFO allocation of receivings to purchase orders
|
// Add this section to filter out invalid PIDs before final import
|
||||||
outputProgress({
|
outputProgress({
|
||||||
status: "running",
|
status: "running",
|
||||||
operation: "Purchase orders import",
|
operation: "Purchase orders import",
|
||||||
message: "Validating product IDs before allocation"
|
message: "Validating product IDs before final import"
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add this section to filter out invalid PIDs before allocation
|
|
||||||
// This will check all PIDs in our temp tables against the products table
|
|
||||||
await localConnection.query(`
|
await localConnection.query(`
|
||||||
-- Create temp table to store invalid PIDs
|
-- Create temp table to store invalid PIDs
|
||||||
DROP TABLE IF EXISTS temp_invalid_pids;
|
DROP TABLE IF EXISTS temp_invalid_pids;
|
||||||
@@ -552,362 +660,157 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
console.log(`Filtered out ${filteredCount} items with invalid product IDs`);
|
console.log(`Filtered out ${filteredCount} items with invalid product IDs`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Break FIFO allocation into steps with progress tracking
|
// 3. Insert final purchase order records to the actual table
|
||||||
const fifoSteps = [
|
|
||||||
{
|
|
||||||
name: "Direct allocations",
|
|
||||||
query: `
|
|
||||||
INSERT INTO temp_receiving_allocations (
|
|
||||||
po_id, pid, receiving_id, allocated_qty, cost_each, received_date, received_by
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
r.po_id,
|
|
||||||
r.pid,
|
|
||||||
r.receiving_id,
|
|
||||||
LEAST(r.qty_each, po.ordered) as allocated_qty,
|
|
||||||
r.cost_each,
|
|
||||||
COALESCE(r.received_date, NOW()) as received_date,
|
|
||||||
r.received_by
|
|
||||||
FROM temp_receivings r
|
|
||||||
JOIN temp_purchase_orders po ON r.po_id = po.po_id AND r.pid = po.pid
|
|
||||||
WHERE r.po_id IS NOT NULL
|
|
||||||
`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Handling standalone receivings",
|
|
||||||
query: `
|
|
||||||
INSERT INTO temp_purchase_orders (
|
|
||||||
po_id, pid, sku, name, vendor, date, status,
|
|
||||||
ordered, po_cost_price, supplier_id, date_created, date_ordered
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
r.receiving_id::text as po_id,
|
|
||||||
r.pid,
|
|
||||||
COALESCE(p.sku, 'NO-SKU') as sku,
|
|
||||||
COALESCE(p.name, 'Unknown Product') as name,
|
|
||||||
COALESCE(
|
|
||||||
(SELECT vendor FROM temp_purchase_orders
|
|
||||||
WHERE supplier_id = r.supplier_id LIMIT 1),
|
|
||||||
'Unknown Vendor'
|
|
||||||
) as vendor,
|
|
||||||
COALESCE(r.received_date, r.receiving_created_date) as date,
|
|
||||||
'created' as status,
|
|
||||||
NULL as ordered,
|
|
||||||
r.cost_each as po_cost_price,
|
|
||||||
r.supplier_id,
|
|
||||||
COALESCE(r.receiving_created_date, r.received_date) as date_created,
|
|
||||||
NULL as date_ordered
|
|
||||||
FROM temp_receivings r
|
|
||||||
LEFT JOIN (
|
|
||||||
SELECT DISTINCT pid, sku, name FROM temp_purchase_orders
|
|
||||||
) p ON r.pid = p.pid
|
|
||||||
WHERE r.po_id IS NULL
|
|
||||||
OR NOT EXISTS (
|
|
||||||
SELECT 1 FROM temp_purchase_orders po
|
|
||||||
WHERE po.po_id = r.po_id AND po.pid = r.pid
|
|
||||||
)
|
|
||||||
ON CONFLICT (po_id, pid) DO NOTHING
|
|
||||||
`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Allocating standalone receivings",
|
|
||||||
query: `
|
|
||||||
INSERT INTO temp_receiving_allocations (
|
|
||||||
po_id, pid, receiving_id, allocated_qty, cost_each, received_date, received_by
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
r.receiving_id::text as po_id,
|
|
||||||
r.pid,
|
|
||||||
r.receiving_id,
|
|
||||||
r.qty_each as allocated_qty,
|
|
||||||
r.cost_each,
|
|
||||||
COALESCE(r.received_date, NOW()) as received_date,
|
|
||||||
r.received_by
|
|
||||||
FROM temp_receivings r
|
|
||||||
WHERE r.po_id IS NULL
|
|
||||||
OR NOT EXISTS (
|
|
||||||
SELECT 1 FROM temp_purchase_orders po
|
|
||||||
WHERE po.po_id = r.po_id AND po.pid = r.pid
|
|
||||||
)
|
|
||||||
`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "FIFO allocation logic",
|
|
||||||
query: `
|
|
||||||
WITH
|
|
||||||
-- Calculate remaining quantities after direct allocations
|
|
||||||
remaining_po_quantities AS (
|
|
||||||
SELECT
|
|
||||||
po.po_id,
|
|
||||||
po.pid,
|
|
||||||
po.ordered,
|
|
||||||
COALESCE(SUM(ra.allocated_qty), 0) as already_allocated,
|
|
||||||
po.ordered - COALESCE(SUM(ra.allocated_qty), 0) as remaining_qty,
|
|
||||||
po.date_ordered,
|
|
||||||
po.date_created
|
|
||||||
FROM temp_purchase_orders po
|
|
||||||
LEFT JOIN temp_receiving_allocations ra ON po.po_id = ra.po_id AND po.pid = ra.pid
|
|
||||||
WHERE po.ordered IS NOT NULL
|
|
||||||
GROUP BY po.po_id, po.pid, po.ordered, po.date_ordered, po.date_created
|
|
||||||
HAVING po.ordered > COALESCE(SUM(ra.allocated_qty), 0)
|
|
||||||
),
|
|
||||||
remaining_receiving_quantities AS (
|
|
||||||
SELECT
|
|
||||||
r.receiving_id,
|
|
||||||
r.pid,
|
|
||||||
r.qty_each,
|
|
||||||
COALESCE(SUM(ra.allocated_qty), 0) as already_allocated,
|
|
||||||
r.qty_each - COALESCE(SUM(ra.allocated_qty), 0) as remaining_qty,
|
|
||||||
r.received_date,
|
|
||||||
r.cost_each,
|
|
||||||
r.received_by
|
|
||||||
FROM temp_receivings r
|
|
||||||
LEFT JOIN temp_receiving_allocations ra ON r.receiving_id = ra.receiving_id AND r.pid = ra.pid
|
|
||||||
GROUP BY r.receiving_id, r.pid, r.qty_each, r.received_date, r.cost_each, r.received_by
|
|
||||||
HAVING r.qty_each > COALESCE(SUM(ra.allocated_qty), 0)
|
|
||||||
),
|
|
||||||
-- Rank POs by age, with a cutoff for very old POs (1 year)
|
|
||||||
ranked_pos AS (
|
|
||||||
SELECT
|
|
||||||
po.po_id,
|
|
||||||
po.pid,
|
|
||||||
po.remaining_qty,
|
|
||||||
CASE
|
|
||||||
WHEN po.date_ordered IS NULL OR po.date_ordered < NOW() - INTERVAL '1 year' THEN 2
|
|
||||||
ELSE 1
|
|
||||||
END as age_group,
|
|
||||||
ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY po.pid, (CASE WHEN po.date_ordered IS NULL OR po.date_ordered < NOW() - INTERVAL '1 year' THEN 2 ELSE 1 END)
|
|
||||||
ORDER BY COALESCE(po.date_ordered, po.date_created, NOW())
|
|
||||||
) as rank_in_group
|
|
||||||
FROM remaining_po_quantities po
|
|
||||||
),
|
|
||||||
-- Rank receivings by date
|
|
||||||
ranked_receivings AS (
|
|
||||||
SELECT
|
|
||||||
r.receiving_id,
|
|
||||||
r.pid,
|
|
||||||
r.remaining_qty,
|
|
||||||
r.received_date,
|
|
||||||
r.cost_each,
|
|
||||||
r.received_by,
|
|
||||||
ROW_NUMBER() OVER (PARTITION BY r.pid ORDER BY COALESCE(r.received_date, NOW())) as rank
|
|
||||||
FROM remaining_receiving_quantities r
|
|
||||||
),
|
|
||||||
-- First allocate to recent POs
|
|
||||||
allocations_recent AS (
|
|
||||||
SELECT
|
|
||||||
po.po_id,
|
|
||||||
po.pid,
|
|
||||||
r.receiving_id,
|
|
||||||
LEAST(po.remaining_qty, r.remaining_qty) as allocated_qty,
|
|
||||||
r.cost_each,
|
|
||||||
COALESCE(r.received_date, NOW()) as received_date,
|
|
||||||
r.received_by,
|
|
||||||
po.age_group,
|
|
||||||
po.rank_in_group,
|
|
||||||
r.rank,
|
|
||||||
'recent' as allocation_type
|
|
||||||
FROM ranked_pos po
|
|
||||||
JOIN ranked_receivings r ON po.pid = r.pid
|
|
||||||
WHERE po.age_group = 1
|
|
||||||
ORDER BY po.pid, po.rank_in_group, r.rank
|
|
||||||
),
|
|
||||||
-- Then allocate to older POs
|
|
||||||
remaining_after_recent AS (
|
|
||||||
SELECT
|
|
||||||
r.receiving_id,
|
|
||||||
r.pid,
|
|
||||||
r.remaining_qty - COALESCE(SUM(a.allocated_qty), 0) as remaining_qty,
|
|
||||||
r.received_date,
|
|
||||||
r.cost_each,
|
|
||||||
r.received_by,
|
|
||||||
r.rank
|
|
||||||
FROM ranked_receivings r
|
|
||||||
LEFT JOIN allocations_recent a ON r.receiving_id = a.receiving_id AND r.pid = a.pid
|
|
||||||
GROUP BY r.receiving_id, r.pid, r.remaining_qty, r.received_date, r.cost_each, r.received_by, r.rank
|
|
||||||
HAVING r.remaining_qty > COALESCE(SUM(a.allocated_qty), 0)
|
|
||||||
),
|
|
||||||
allocations_old AS (
|
|
||||||
SELECT
|
|
||||||
po.po_id,
|
|
||||||
po.pid,
|
|
||||||
r.receiving_id,
|
|
||||||
LEAST(po.remaining_qty, r.remaining_qty) as allocated_qty,
|
|
||||||
r.cost_each,
|
|
||||||
COALESCE(r.received_date, NOW()) as received_date,
|
|
||||||
r.received_by,
|
|
||||||
po.age_group,
|
|
||||||
po.rank_in_group,
|
|
||||||
r.rank,
|
|
||||||
'old' as allocation_type
|
|
||||||
FROM ranked_pos po
|
|
||||||
JOIN remaining_after_recent r ON po.pid = r.pid
|
|
||||||
WHERE po.age_group = 2
|
|
||||||
ORDER BY po.pid, po.rank_in_group, r.rank
|
|
||||||
),
|
|
||||||
-- Combine allocations
|
|
||||||
combined_allocations AS (
|
|
||||||
SELECT * FROM allocations_recent
|
|
||||||
UNION ALL
|
|
||||||
SELECT * FROM allocations_old
|
|
||||||
)
|
|
||||||
-- Insert into allocations table
|
|
||||||
INSERT INTO temp_receiving_allocations (
|
|
||||||
po_id, pid, receiving_id, allocated_qty, cost_each, received_date, received_by
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
po_id, pid, receiving_id, allocated_qty, cost_each,
|
|
||||||
COALESCE(received_date, NOW()) as received_date,
|
|
||||||
received_by
|
|
||||||
FROM combined_allocations
|
|
||||||
WHERE allocated_qty > 0
|
|
||||||
`
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Execute FIFO steps with progress tracking
|
|
||||||
for (let i = 0; i < fifoSteps.length; i++) {
|
|
||||||
const step = fifoSteps[i];
|
|
||||||
outputProgress({
|
outputProgress({
|
||||||
status: "running",
|
status: "running",
|
||||||
operation: "Purchase orders import",
|
operation: "Purchase orders import",
|
||||||
message: `FIFO allocation step ${i+1}/${fifoSteps.length}: ${step.name}`,
|
message: "Inserting final purchase order records"
|
||||||
current: i,
|
|
||||||
total: fifoSteps.length
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await localConnection.query(step.query);
|
// Create a temp table to track PO IDs being processed
|
||||||
}
|
await localConnection.query(`
|
||||||
|
DROP TABLE IF EXISTS processed_po_ids;
|
||||||
|
CREATE TEMP TABLE processed_po_ids AS (
|
||||||
|
SELECT DISTINCT po_id FROM temp_purchase_orders
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
// 4. Generate final purchase order records with receiving data
|
// Delete products that were removed from POs and count them
|
||||||
outputProgress({
|
const [poDeletedResult] = await localConnection.query(`
|
||||||
status: "running",
|
WITH deleted AS (
|
||||||
operation: "Purchase orders import",
|
DELETE FROM purchase_orders
|
||||||
message: "Generating final purchase order records"
|
WHERE po_id IN (SELECT po_id FROM processed_po_ids)
|
||||||
});
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM temp_purchase_orders tp
|
||||||
const [finalResult] = await localConnection.query(`
|
WHERE purchase_orders.po_id = tp.po_id AND purchase_orders.pid = tp.pid
|
||||||
WITH
|
|
||||||
receiving_summaries AS (
|
|
||||||
SELECT
|
|
||||||
po_id,
|
|
||||||
pid,
|
|
||||||
SUM(allocated_qty) as total_received,
|
|
||||||
JSONB_AGG(
|
|
||||||
JSONB_BUILD_OBJECT(
|
|
||||||
'receiving_id', receiving_id,
|
|
||||||
'qty', allocated_qty,
|
|
||||||
'date', COALESCE(received_date, NOW()),
|
|
||||||
'cost', cost_each,
|
|
||||||
'received_by', received_by,
|
|
||||||
'received_by_name', CASE
|
|
||||||
WHEN received_by IS NOT NULL AND received_by > 0 THEN
|
|
||||||
(SELECT CONCAT(firstname, ' ', lastname)
|
|
||||||
FROM employee_names
|
|
||||||
WHERE employeeid = received_by)
|
|
||||||
ELSE NULL
|
|
||||||
END
|
|
||||||
) ORDER BY COALESCE(received_date, NOW())
|
|
||||||
) as receiving_history,
|
|
||||||
MIN(COALESCE(received_date, NOW())) as first_received_date,
|
|
||||||
MAX(COALESCE(received_date, NOW())) as last_received_date,
|
|
||||||
STRING_AGG(
|
|
||||||
DISTINCT CASE WHEN received_by IS NOT NULL AND received_by > 0
|
|
||||||
THEN CAST(received_by AS TEXT)
|
|
||||||
ELSE NULL
|
|
||||||
END,
|
|
||||||
','
|
|
||||||
) as received_by_list,
|
|
||||||
STRING_AGG(
|
|
||||||
DISTINCT CASE
|
|
||||||
WHEN ra.received_by IS NOT NULL AND ra.received_by > 0 THEN
|
|
||||||
(SELECT CONCAT(firstname, ' ', lastname)
|
|
||||||
FROM employee_names
|
|
||||||
WHERE employeeid = ra.received_by)
|
|
||||||
ELSE NULL
|
|
||||||
END,
|
|
||||||
', '
|
|
||||||
) as received_by_names
|
|
||||||
FROM temp_receiving_allocations ra
|
|
||||||
GROUP BY po_id, pid
|
|
||||||
),
|
|
||||||
cost_averaging AS (
|
|
||||||
SELECT
|
|
||||||
ra.po_id,
|
|
||||||
ra.pid,
|
|
||||||
SUM(ra.allocated_qty * ra.cost_each) / NULLIF(SUM(ra.allocated_qty), 0) as avg_cost
|
|
||||||
FROM temp_receiving_allocations ra
|
|
||||||
GROUP BY ra.po_id, ra.pid
|
|
||||||
)
|
)
|
||||||
|
RETURNING po_id, pid
|
||||||
|
)
|
||||||
|
SELECT COUNT(*) as count FROM deleted
|
||||||
|
`);
|
||||||
|
|
||||||
|
poRecordsDeleted = poDeletedResult.rows[0]?.count || 0;
|
||||||
|
console.log(`Deleted ${poRecordsDeleted} products that were removed from purchase orders`);
|
||||||
|
|
||||||
|
const [poResult] = await localConnection.query(`
|
||||||
INSERT INTO purchase_orders (
|
INSERT INTO purchase_orders (
|
||||||
po_id, vendor, date, expected_date, pid, sku, name,
|
po_id, vendor, date, expected_date, pid, sku, name,
|
||||||
cost_price, po_cost_price, status, receiving_status, notes, long_note,
|
po_cost_price, status, notes, long_note,
|
||||||
ordered, received, received_date, last_received_date, received_by,
|
ordered, supplier_id, date_created, date_ordered
|
||||||
receiving_history
|
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
po.po_id,
|
po_id,
|
||||||
po.vendor,
|
vendor,
|
||||||
CASE
|
COALESCE(date, date_created, now()) as date,
|
||||||
WHEN po.date IS NOT NULL THEN po.date
|
expected_date,
|
||||||
-- For standalone receivings, try to use the receiving date from history
|
pid,
|
||||||
WHEN po.po_id LIKE 'R%' AND rs.first_received_date IS NOT NULL THEN rs.first_received_date
|
sku,
|
||||||
-- As a last resort for data integrity, use Unix epoch (Jan 1, 1970)
|
name,
|
||||||
ELSE to_timestamp(0)
|
po_cost_price,
|
||||||
END as date,
|
status,
|
||||||
NULLIF(po.expected_date::text, '0000-00-00')::date as expected_date,
|
notes,
|
||||||
po.pid,
|
long_note,
|
||||||
po.sku,
|
ordered,
|
||||||
po.name,
|
supplier_id,
|
||||||
COALESCE(ca.avg_cost, po.po_cost_price) as cost_price,
|
date_created,
|
||||||
po.po_cost_price,
|
date_ordered
|
||||||
COALESCE(po.status, 'created'),
|
FROM temp_purchase_orders
|
||||||
CASE
|
|
||||||
WHEN rs.total_received IS NULL THEN 'created'
|
|
||||||
WHEN rs.total_received = 0 THEN 'created'
|
|
||||||
WHEN rs.total_received < po.ordered THEN 'partial_received'
|
|
||||||
WHEN rs.total_received >= po.ordered THEN 'full_received'
|
|
||||||
ELSE 'created'
|
|
||||||
END as receiving_status,
|
|
||||||
po.notes,
|
|
||||||
po.long_note,
|
|
||||||
COALESCE(po.ordered, 0),
|
|
||||||
COALESCE(rs.total_received, 0),
|
|
||||||
NULLIF(rs.first_received_date::text, '0000-00-00 00:00:00')::timestamp with time zone as received_date,
|
|
||||||
NULLIF(rs.last_received_date::text, '0000-00-00 00:00:00')::timestamp with time zone as last_received_date,
|
|
||||||
CASE
|
|
||||||
WHEN rs.received_by_list IS NULL THEN NULL
|
|
||||||
ELSE rs.received_by_names
|
|
||||||
END as received_by,
|
|
||||||
rs.receiving_history
|
|
||||||
FROM temp_purchase_orders po
|
|
||||||
LEFT JOIN receiving_summaries rs ON po.po_id = rs.po_id AND po.pid = rs.pid
|
|
||||||
LEFT JOIN cost_averaging ca ON po.po_id = ca.po_id AND po.pid = ca.pid
|
|
||||||
ON CONFLICT (po_id, pid) DO UPDATE SET
|
ON CONFLICT (po_id, pid) DO UPDATE SET
|
||||||
vendor = EXCLUDED.vendor,
|
vendor = EXCLUDED.vendor,
|
||||||
date = EXCLUDED.date,
|
date = EXCLUDED.date,
|
||||||
expected_date = EXCLUDED.expected_date,
|
expected_date = EXCLUDED.expected_date,
|
||||||
sku = EXCLUDED.sku,
|
sku = EXCLUDED.sku,
|
||||||
name = EXCLUDED.name,
|
name = EXCLUDED.name,
|
||||||
cost_price = EXCLUDED.cost_price,
|
|
||||||
po_cost_price = EXCLUDED.po_cost_price,
|
po_cost_price = EXCLUDED.po_cost_price,
|
||||||
status = EXCLUDED.status,
|
status = EXCLUDED.status,
|
||||||
receiving_status = EXCLUDED.receiving_status,
|
|
||||||
notes = EXCLUDED.notes,
|
notes = EXCLUDED.notes,
|
||||||
long_note = EXCLUDED.long_note,
|
long_note = EXCLUDED.long_note,
|
||||||
ordered = EXCLUDED.ordered,
|
ordered = EXCLUDED.ordered,
|
||||||
received = EXCLUDED.received,
|
supplier_id = EXCLUDED.supplier_id,
|
||||||
received_date = EXCLUDED.received_date,
|
date_created = EXCLUDED.date_created,
|
||||||
last_received_date = EXCLUDED.last_received_date,
|
date_ordered = EXCLUDED.date_ordered,
|
||||||
received_by = EXCLUDED.received_by,
|
|
||||||
receiving_history = EXCLUDED.receiving_history,
|
|
||||||
updated = CURRENT_TIMESTAMP
|
updated = CURRENT_TIMESTAMP
|
||||||
RETURNING (xmax = 0) as inserted
|
RETURNING (xmax = 0) as inserted
|
||||||
`);
|
`);
|
||||||
|
|
||||||
recordsAdded = finalResult.rows.filter(r => r.inserted).length;
|
poRecordsAdded = poResult.rows.filter(r => r.inserted).length;
|
||||||
recordsUpdated = finalResult.rows.filter(r => !r.inserted).length;
|
poRecordsUpdated = poResult.rows.filter(r => !r.inserted).length;
|
||||||
|
|
||||||
|
// 4. Insert final receiving records to the actual table
|
||||||
|
outputProgress({
|
||||||
|
status: "running",
|
||||||
|
operation: "Purchase orders import",
|
||||||
|
message: "Inserting final receiving records"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a temp table to track receiving IDs being processed
|
||||||
|
await localConnection.query(`
|
||||||
|
DROP TABLE IF EXISTS processed_receiving_ids;
|
||||||
|
CREATE TEMP TABLE processed_receiving_ids AS (
|
||||||
|
SELECT DISTINCT receiving_id FROM temp_receivings
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Delete products that were removed from receivings and count them
|
||||||
|
const [receivingDeletedResult] = await localConnection.query(`
|
||||||
|
WITH deleted AS (
|
||||||
|
DELETE FROM receivings
|
||||||
|
WHERE receiving_id IN (SELECT receiving_id FROM processed_receiving_ids)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM temp_receivings tr
|
||||||
|
WHERE receivings.receiving_id = tr.receiving_id AND receivings.pid = tr.pid
|
||||||
|
)
|
||||||
|
RETURNING receiving_id, pid
|
||||||
|
)
|
||||||
|
SELECT COUNT(*) as count FROM deleted
|
||||||
|
`);
|
||||||
|
|
||||||
|
receivingRecordsDeleted = receivingDeletedResult.rows[0]?.count || 0;
|
||||||
|
console.log(`Deleted ${receivingRecordsDeleted} products that were removed from receivings`);
|
||||||
|
|
||||||
|
const [receivingsResult] = await localConnection.query(`
|
||||||
|
INSERT INTO receivings (
|
||||||
|
receiving_id, pid, sku, name, vendor, qty_each, qty_each_orig,
|
||||||
|
cost_each, cost_each_orig, received_by, received_by_name,
|
||||||
|
received_date, receiving_created_date, supplier_id, status
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
receiving_id,
|
||||||
|
pid,
|
||||||
|
sku,
|
||||||
|
name,
|
||||||
|
vendor,
|
||||||
|
qty_each,
|
||||||
|
qty_each_orig,
|
||||||
|
cost_each,
|
||||||
|
cost_each_orig,
|
||||||
|
received_by,
|
||||||
|
received_by_name,
|
||||||
|
COALESCE(received_date, receiving_created_date, now()) as received_date,
|
||||||
|
receiving_created_date,
|
||||||
|
supplier_id,
|
||||||
|
status
|
||||||
|
FROM temp_receivings
|
||||||
|
ON CONFLICT (receiving_id, pid) DO UPDATE SET
|
||||||
|
sku = EXCLUDED.sku,
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
vendor = EXCLUDED.vendor,
|
||||||
|
qty_each = EXCLUDED.qty_each,
|
||||||
|
qty_each_orig = EXCLUDED.qty_each_orig,
|
||||||
|
cost_each = EXCLUDED.cost_each,
|
||||||
|
cost_each_orig = EXCLUDED.cost_each_orig,
|
||||||
|
received_by = EXCLUDED.received_by,
|
||||||
|
received_by_name = EXCLUDED.received_by_name,
|
||||||
|
received_date = EXCLUDED.received_date,
|
||||||
|
receiving_created_date = EXCLUDED.receiving_created_date,
|
||||||
|
supplier_id = EXCLUDED.supplier_id,
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
updated = CURRENT_TIMESTAMP
|
||||||
|
RETURNING (xmax = 0) as inserted
|
||||||
|
`);
|
||||||
|
|
||||||
|
receivingRecordsAdded = receivingsResult.rows.filter(r => r.inserted).length;
|
||||||
|
receivingRecordsUpdated = receivingsResult.rows.filter(r => !r.inserted).length;
|
||||||
|
|
||||||
// Update sync status
|
// Update sync status
|
||||||
await localConnection.query(`
|
await localConnection.query(`
|
||||||
@@ -921,8 +824,11 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
await localConnection.query(`
|
await localConnection.query(`
|
||||||
DROP TABLE IF EXISTS temp_purchase_orders;
|
DROP TABLE IF EXISTS temp_purchase_orders;
|
||||||
DROP TABLE IF EXISTS temp_receivings;
|
DROP TABLE IF EXISTS temp_receivings;
|
||||||
DROP TABLE IF EXISTS temp_receiving_allocations;
|
|
||||||
DROP TABLE IF EXISTS employee_names;
|
DROP TABLE IF EXISTS employee_names;
|
||||||
|
DROP TABLE IF EXISTS temp_supplier_names;
|
||||||
|
DROP TABLE IF EXISTS temp_invalid_pids;
|
||||||
|
DROP TABLE IF EXISTS processed_po_ids;
|
||||||
|
DROP TABLE IF EXISTS processed_receiving_ids;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Commit transaction
|
// Commit transaction
|
||||||
@@ -930,8 +836,15 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
status: "complete",
|
status: "complete",
|
||||||
recordsAdded: recordsAdded || 0,
|
recordsAdded: poRecordsAdded + receivingRecordsAdded,
|
||||||
recordsUpdated: recordsUpdated || 0,
|
recordsUpdated: poRecordsUpdated + receivingRecordsUpdated,
|
||||||
|
recordsDeleted: poRecordsDeleted + receivingRecordsDeleted,
|
||||||
|
poRecordsAdded,
|
||||||
|
poRecordsUpdated,
|
||||||
|
poRecordsDeleted,
|
||||||
|
receivingRecordsAdded,
|
||||||
|
receivingRecordsUpdated,
|
||||||
|
receivingRecordsDeleted,
|
||||||
totalRecords: totalProcessed
|
totalRecords: totalProcessed
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -949,6 +862,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental
|
|||||||
error: error.message,
|
error: error.message,
|
||||||
recordsAdded: 0,
|
recordsAdded: 0,
|
||||||
recordsUpdated: 0,
|
recordsUpdated: 0,
|
||||||
|
recordsDeleted: 0,
|
||||||
totalRecords: 0
|
totalRecords: 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,287 @@ function cancelCalculation() {
|
|||||||
process.on('SIGTERM', cancelCalculation);
|
process.on('SIGTERM', cancelCalculation);
|
||||||
process.on('SIGINT', cancelCalculation);
|
process.on('SIGINT', cancelCalculation);
|
||||||
|
|
||||||
|
const calculateInitialMetrics = (client, onProgress) => {
|
||||||
|
return client.query(`
|
||||||
|
-- Truncate the existing metrics tables to ensure clean data
|
||||||
|
TRUNCATE TABLE public.daily_product_snapshots;
|
||||||
|
TRUNCATE TABLE public.product_metrics;
|
||||||
|
|
||||||
|
-- First let's create daily snapshots for all products with order activity
|
||||||
|
WITH SalesData AS (
|
||||||
|
SELECT
|
||||||
|
p.pid,
|
||||||
|
p.sku,
|
||||||
|
o.date::date AS order_date,
|
||||||
|
-- Count orders to ensure we only include products with real activity
|
||||||
|
COUNT(o.id) as order_count,
|
||||||
|
-- Aggregate Sales (Quantity > 0, Status not Canceled/Returned)
|
||||||
|
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.quantity ELSE 0 END), 0) AS units_sold,
|
||||||
|
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.price * o.quantity ELSE 0 END), 0.00) AS gross_revenue_unadjusted,
|
||||||
|
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN o.discount ELSE 0 END), 0.00) AS discounts,
|
||||||
|
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN COALESCE(o.costeach, p.landing_cost_price, p.cost_price) * o.quantity ELSE 0 END), 0.00) AS cogs,
|
||||||
|
COALESCE(SUM(CASE WHEN o.quantity > 0 AND COALESCE(o.status, 'pending') NOT IN ('canceled', 'returned') THEN p.regular_price * o.quantity ELSE 0 END), 0.00) AS gross_regular_revenue,
|
||||||
|
|
||||||
|
-- Aggregate Returns (Quantity < 0 or Status = Returned)
|
||||||
|
COALESCE(SUM(CASE WHEN o.quantity < 0 OR COALESCE(o.status, 'pending') = 'returned' THEN ABS(o.quantity) ELSE 0 END), 0) AS units_returned,
|
||||||
|
COALESCE(SUM(CASE WHEN o.quantity < 0 OR COALESCE(o.status, 'pending') = 'returned' THEN o.price * ABS(o.quantity) ELSE 0 END), 0.00) AS returns_revenue
|
||||||
|
FROM public.products p
|
||||||
|
LEFT JOIN public.orders o ON p.pid = o.pid
|
||||||
|
GROUP BY p.pid, p.sku, o.date::date
|
||||||
|
HAVING COUNT(o.id) > 0 -- Only include products with actual orders
|
||||||
|
),
|
||||||
|
ReceivingData AS (
|
||||||
|
SELECT
|
||||||
|
r.pid,
|
||||||
|
r.received_date::date AS receiving_date,
|
||||||
|
-- Count receiving documents to ensure we only include products with real activity
|
||||||
|
COUNT(DISTINCT r.receiving_id) as receiving_count,
|
||||||
|
-- Calculate received quantity for this day
|
||||||
|
SUM(r.received_quantity) AS units_received,
|
||||||
|
-- Calculate received cost for this day
|
||||||
|
SUM(r.received_quantity * r.unit_cost) AS cost_received
|
||||||
|
FROM public.receivings r
|
||||||
|
GROUP BY r.pid, r.received_date::date
|
||||||
|
HAVING COUNT(DISTINCT r.receiving_id) > 0 OR SUM(r.received_quantity) > 0
|
||||||
|
),
|
||||||
|
-- Get current stock quantities
|
||||||
|
StockData AS (
|
||||||
|
SELECT
|
||||||
|
p.pid,
|
||||||
|
p.stock_quantity,
|
||||||
|
COALESCE(p.landing_cost_price, p.cost_price, 0.00) as effective_cost_price,
|
||||||
|
COALESCE(p.price, 0.00) as current_price,
|
||||||
|
COALESCE(p.regular_price, 0.00) as current_regular_price
|
||||||
|
FROM public.products p
|
||||||
|
),
|
||||||
|
-- Combine sales and receiving dates to get all activity dates
|
||||||
|
DatePidCombos AS (
|
||||||
|
SELECT DISTINCT pid, order_date AS activity_date FROM SalesData
|
||||||
|
UNION
|
||||||
|
SELECT DISTINCT pid, receiving_date FROM ReceivingData
|
||||||
|
),
|
||||||
|
-- Insert daily snapshots for all product-date combinations
|
||||||
|
SnapshotInsert AS (
|
||||||
|
INSERT INTO public.daily_product_snapshots (
|
||||||
|
snapshot_date,
|
||||||
|
pid,
|
||||||
|
sku,
|
||||||
|
eod_stock_quantity,
|
||||||
|
eod_stock_cost,
|
||||||
|
eod_stock_retail,
|
||||||
|
eod_stock_gross,
|
||||||
|
stockout_flag,
|
||||||
|
units_sold,
|
||||||
|
units_returned,
|
||||||
|
gross_revenue,
|
||||||
|
discounts,
|
||||||
|
returns_revenue,
|
||||||
|
net_revenue,
|
||||||
|
cogs,
|
||||||
|
gross_regular_revenue,
|
||||||
|
profit,
|
||||||
|
units_received,
|
||||||
|
cost_received,
|
||||||
|
calculation_timestamp
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
d.activity_date AS snapshot_date,
|
||||||
|
d.pid,
|
||||||
|
p.sku,
|
||||||
|
-- Use current stock as approximation, since historical stock data is not available
|
||||||
|
s.stock_quantity AS eod_stock_quantity,
|
||||||
|
s.stock_quantity * s.effective_cost_price AS eod_stock_cost,
|
||||||
|
s.stock_quantity * s.current_price AS eod_stock_retail,
|
||||||
|
s.stock_quantity * s.current_regular_price AS eod_stock_gross,
|
||||||
|
(s.stock_quantity <= 0) AS stockout_flag,
|
||||||
|
-- Sales metrics
|
||||||
|
COALESCE(sd.units_sold, 0),
|
||||||
|
COALESCE(sd.units_returned, 0),
|
||||||
|
COALESCE(sd.gross_revenue_unadjusted, 0.00),
|
||||||
|
COALESCE(sd.discounts, 0.00),
|
||||||
|
COALESCE(sd.returns_revenue, 0.00),
|
||||||
|
COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00) AS net_revenue,
|
||||||
|
COALESCE(sd.cogs, 0.00),
|
||||||
|
COALESCE(sd.gross_regular_revenue, 0.00),
|
||||||
|
(COALESCE(sd.gross_revenue_unadjusted, 0.00) - COALESCE(sd.discounts, 0.00)) - COALESCE(sd.cogs, 0.00) AS profit,
|
||||||
|
-- Receiving metrics
|
||||||
|
COALESCE(rd.units_received, 0),
|
||||||
|
COALESCE(rd.cost_received, 0.00),
|
||||||
|
now() -- calculation timestamp
|
||||||
|
FROM DatePidCombos d
|
||||||
|
JOIN public.products p ON d.pid = p.pid
|
||||||
|
LEFT JOIN SalesData sd ON d.pid = sd.pid AND d.activity_date = sd.order_date
|
||||||
|
LEFT JOIN ReceivingData rd ON d.pid = rd.pid AND d.activity_date = rd.receiving_date
|
||||||
|
LEFT JOIN StockData s ON d.pid = s.pid
|
||||||
|
RETURNING pid, snapshot_date
|
||||||
|
),
|
||||||
|
-- Now build the aggregated product metrics from the daily snapshots
|
||||||
|
MetricsInsert AS (
|
||||||
|
INSERT INTO public.product_metrics (
|
||||||
|
pid,
|
||||||
|
sku,
|
||||||
|
current_stock_quantity,
|
||||||
|
current_stock_cost,
|
||||||
|
current_stock_retail,
|
||||||
|
current_stock_msrp,
|
||||||
|
is_out_of_stock,
|
||||||
|
total_units_sold,
|
||||||
|
total_units_returned,
|
||||||
|
return_rate,
|
||||||
|
gross_revenue,
|
||||||
|
total_discounts,
|
||||||
|
total_returns,
|
||||||
|
net_revenue,
|
||||||
|
total_cogs,
|
||||||
|
total_gross_revenue,
|
||||||
|
total_profit,
|
||||||
|
profit_margin,
|
||||||
|
avg_daily_units,
|
||||||
|
reorder_point,
|
||||||
|
reorder_alert,
|
||||||
|
days_of_supply,
|
||||||
|
sales_velocity,
|
||||||
|
sales_velocity_score,
|
||||||
|
rank_by_revenue,
|
||||||
|
rank_by_quantity,
|
||||||
|
rank_by_profit,
|
||||||
|
total_received_quantity,
|
||||||
|
total_received_cost,
|
||||||
|
last_sold_date,
|
||||||
|
last_received_date,
|
||||||
|
days_since_last_sale,
|
||||||
|
days_since_last_received,
|
||||||
|
calculation_timestamp
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
p.pid,
|
||||||
|
p.sku,
|
||||||
|
p.stock_quantity AS current_stock_quantity,
|
||||||
|
p.stock_quantity * COALESCE(p.landing_cost_price, p.cost_price, 0) AS current_stock_cost,
|
||||||
|
p.stock_quantity * COALESCE(p.price, 0) AS current_stock_retail,
|
||||||
|
p.stock_quantity * COALESCE(p.regular_price, 0) AS current_stock_msrp,
|
||||||
|
(p.stock_quantity <= 0) AS is_out_of_stock,
|
||||||
|
-- Aggregate metrics
|
||||||
|
COALESCE(SUM(ds.units_sold), 0) AS total_units_sold,
|
||||||
|
COALESCE(SUM(ds.units_returned), 0) AS total_units_returned,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(SUM(ds.units_sold), 0) > 0
|
||||||
|
THEN COALESCE(SUM(ds.units_returned), 0)::float / NULLIF(COALESCE(SUM(ds.units_sold), 0), 0)
|
||||||
|
ELSE 0
|
||||||
|
END AS return_rate,
|
||||||
|
COALESCE(SUM(ds.gross_revenue), 0) AS gross_revenue,
|
||||||
|
COALESCE(SUM(ds.discounts), 0) AS total_discounts,
|
||||||
|
COALESCE(SUM(ds.returns_revenue), 0) AS total_returns,
|
||||||
|
COALESCE(SUM(ds.net_revenue), 0) AS net_revenue,
|
||||||
|
COALESCE(SUM(ds.cogs), 0) AS total_cogs,
|
||||||
|
COALESCE(SUM(ds.gross_regular_revenue), 0) AS total_gross_revenue,
|
||||||
|
COALESCE(SUM(ds.profit), 0) AS total_profit,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(SUM(ds.net_revenue), 0) > 0
|
||||||
|
THEN COALESCE(SUM(ds.profit), 0) / NULLIF(COALESCE(SUM(ds.net_revenue), 0), 0)
|
||||||
|
ELSE 0
|
||||||
|
END AS profit_margin,
|
||||||
|
-- Calculate average daily units
|
||||||
|
COALESCE(AVG(ds.units_sold), 0) AS avg_daily_units,
|
||||||
|
-- Calculate reorder point (simplified, can be enhanced with lead time and safety stock)
|
||||||
|
CEILING(COALESCE(AVG(ds.units_sold) * 14, 0)) AS reorder_point,
|
||||||
|
(p.stock_quantity <= CEILING(COALESCE(AVG(ds.units_sold) * 14, 0))) AS reorder_alert,
|
||||||
|
-- Days of supply based on average daily sales
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(AVG(ds.units_sold), 0) > 0
|
||||||
|
THEN p.stock_quantity / NULLIF(COALESCE(AVG(ds.units_sold), 0), 0)
|
||||||
|
ELSE NULL
|
||||||
|
END AS days_of_supply,
|
||||||
|
-- Sales velocity (average units sold per day over last 30 days)
|
||||||
|
(SELECT COALESCE(AVG(recent.units_sold), 0)
|
||||||
|
FROM public.daily_product_snapshots recent
|
||||||
|
WHERE recent.pid = p.pid
|
||||||
|
AND recent.snapshot_date >= CURRENT_DATE - INTERVAL '30 days'
|
||||||
|
) AS sales_velocity,
|
||||||
|
-- Placeholder for sales velocity score (can be calculated based on velocity)
|
||||||
|
0 AS sales_velocity_score,
|
||||||
|
-- Will be updated later by ranking procedure
|
||||||
|
0 AS rank_by_revenue,
|
||||||
|
0 AS rank_by_quantity,
|
||||||
|
0 AS rank_by_profit,
|
||||||
|
-- Receiving data
|
||||||
|
COALESCE(SUM(ds.units_received), 0) AS total_received_quantity,
|
||||||
|
COALESCE(SUM(ds.cost_received), 0) AS total_received_cost,
|
||||||
|
-- Date metrics
|
||||||
|
(SELECT MAX(sd.snapshot_date)
|
||||||
|
FROM public.daily_product_snapshots sd
|
||||||
|
WHERE sd.pid = p.pid AND sd.units_sold > 0
|
||||||
|
) AS last_sold_date,
|
||||||
|
(SELECT MAX(rd.snapshot_date)
|
||||||
|
FROM public.daily_product_snapshots rd
|
||||||
|
WHERE rd.pid = p.pid AND rd.units_received > 0
|
||||||
|
) AS last_received_date,
|
||||||
|
-- Calculate days since last sale/received
|
||||||
|
CASE
|
||||||
|
WHEN (SELECT MAX(sd.snapshot_date)
|
||||||
|
FROM public.daily_product_snapshots sd
|
||||||
|
WHERE sd.pid = p.pid AND sd.units_sold > 0) IS NOT NULL
|
||||||
|
THEN (CURRENT_DATE - (SELECT MAX(sd.snapshot_date)
|
||||||
|
FROM public.daily_product_snapshots sd
|
||||||
|
WHERE sd.pid = p.pid AND sd.units_sold > 0))::integer
|
||||||
|
ELSE NULL
|
||||||
|
END AS days_since_last_sale,
|
||||||
|
CASE
|
||||||
|
WHEN (SELECT MAX(rd.snapshot_date)
|
||||||
|
FROM public.daily_product_snapshots rd
|
||||||
|
WHERE rd.pid = p.pid AND rd.units_received > 0) IS NOT NULL
|
||||||
|
THEN (CURRENT_DATE - (SELECT MAX(rd.snapshot_date)
|
||||||
|
FROM public.daily_product_snapshots rd
|
||||||
|
WHERE rd.pid = p.pid AND rd.units_received > 0))::integer
|
||||||
|
ELSE NULL
|
||||||
|
END AS days_since_last_received,
|
||||||
|
now() -- calculation timestamp
|
||||||
|
FROM public.products p
|
||||||
|
LEFT JOIN public.daily_product_snapshots ds ON p.pid = ds.pid
|
||||||
|
GROUP BY p.pid, p.sku, p.stock_quantity, p.landing_cost_price, p.cost_price, p.price, p.regular_price
|
||||||
|
)
|
||||||
|
|
||||||
|
-- Update the calculate_status table
|
||||||
|
INSERT INTO public.calculate_status (module_name, last_calculation_timestamp)
|
||||||
|
VALUES
|
||||||
|
('daily_snapshots', now()),
|
||||||
|
('product_metrics', now())
|
||||||
|
ON CONFLICT (module_name) DO UPDATE
|
||||||
|
SET last_calculation_timestamp = now();
|
||||||
|
|
||||||
|
-- Finally, update the ranks for products
|
||||||
|
UPDATE public.product_metrics pm SET
|
||||||
|
rank_by_revenue = rev_ranks.rank
|
||||||
|
FROM (
|
||||||
|
SELECT pid, RANK() OVER (ORDER BY net_revenue DESC) AS rank
|
||||||
|
FROM public.product_metrics
|
||||||
|
WHERE net_revenue > 0
|
||||||
|
) rev_ranks
|
||||||
|
WHERE pm.pid = rev_ranks.pid;
|
||||||
|
|
||||||
|
UPDATE public.product_metrics pm SET
|
||||||
|
rank_by_quantity = qty_ranks.rank
|
||||||
|
FROM (
|
||||||
|
SELECT pid, RANK() OVER (ORDER BY total_units_sold DESC) AS rank
|
||||||
|
FROM public.product_metrics
|
||||||
|
WHERE total_units_sold > 0
|
||||||
|
) qty_ranks
|
||||||
|
WHERE pm.pid = qty_ranks.pid;
|
||||||
|
|
||||||
|
UPDATE public.product_metrics pm SET
|
||||||
|
rank_by_profit = profit_ranks.rank
|
||||||
|
FROM (
|
||||||
|
SELECT pid, RANK() OVER (ORDER BY total_profit DESC) AS rank
|
||||||
|
FROM public.product_metrics
|
||||||
|
WHERE total_profit > 0
|
||||||
|
) profit_ranks
|
||||||
|
WHERE pm.pid = profit_ranks.pid;
|
||||||
|
|
||||||
|
-- Return count of products with metrics
|
||||||
|
SELECT COUNT(*) AS product_count FROM public.product_metrics
|
||||||
|
`);
|
||||||
|
};
|
||||||
|
|
||||||
async function populateInitialMetrics() {
|
async function populateInitialMetrics() {
|
||||||
let connection;
|
let connection;
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
-- historically backfilled daily_product_snapshots and current product/PO data.
|
-- historically backfilled daily_product_snapshots and current product/PO data.
|
||||||
-- Calculates all metrics considering the full available history up to 'yesterday'.
|
-- Calculates all metrics considering the full available history up to 'yesterday'.
|
||||||
-- Run ONCE after backfill_historical_snapshots_final.sql completes successfully.
|
-- Run ONCE after backfill_historical_snapshots_final.sql completes successfully.
|
||||||
-- Dependencies: Core import tables (products, purchase_orders), daily_product_snapshots (historically populated),
|
-- Dependencies: Core import tables (products, purchase_orders, receivings), daily_product_snapshots (historically populated),
|
||||||
-- configuration tables (settings_*), product_metrics table must exist.
|
-- configuration tables (settings_*), product_metrics table must exist.
|
||||||
-- Frequency: Run ONCE.
|
-- Frequency: Run ONCE.
|
||||||
DO $$
|
DO $$
|
||||||
@@ -31,42 +31,34 @@ BEGIN
|
|||||||
p.stock_quantity as current_stock, -- Use actual current stock for forecast base
|
p.stock_quantity as current_stock, -- Use actual current stock for forecast base
|
||||||
p.created_at, p.first_received, p.date_last_sold,
|
p.created_at, p.first_received, p.date_last_sold,
|
||||||
p.moq,
|
p.moq,
|
||||||
p.uom
|
p.uom,
|
||||||
|
p.total_sold as historical_total_sold -- Add historical total_sold from products table
|
||||||
FROM public.products p
|
FROM public.products p
|
||||||
),
|
),
|
||||||
OnOrderInfo AS (
|
OnOrderInfo AS (
|
||||||
-- Calculates current on-order quantities and costs
|
-- Calculates current on-order quantities and costs
|
||||||
SELECT
|
SELECT
|
||||||
pid,
|
pid,
|
||||||
COALESCE(SUM(ordered - received), 0) AS on_order_qty,
|
SUM(ordered) AS on_order_qty,
|
||||||
COALESCE(SUM((ordered - received) * cost_price), 0.00) AS on_order_cost,
|
SUM(ordered * po_cost_price) AS on_order_cost,
|
||||||
MIN(expected_date) AS earliest_expected_date
|
MIN(expected_date) AS earliest_expected_date
|
||||||
FROM public.purchase_orders
|
FROM public.purchase_orders
|
||||||
-- Use the most common statuses representing active, unfulfilled POs
|
-- Use the most common statuses representing active, unfulfilled POs
|
||||||
WHERE status IN ('open', 'partially_received', 'ordered', 'preordered', 'receiving_started', 'electronically_sent', 'electronically_ready_send')
|
WHERE status IN ('created', 'ordered', 'preordered', 'electronically_sent', 'electronically_ready_send', 'receiving_started')
|
||||||
AND (ordered - received) > 0
|
AND status NOT IN ('canceled', 'done')
|
||||||
GROUP BY pid
|
GROUP BY pid
|
||||||
),
|
),
|
||||||
HistoricalDates AS (
|
HistoricalDates AS (
|
||||||
-- Determines key historical dates from orders and PO history (receiving_history)
|
-- Determines key historical dates from orders and receivings
|
||||||
SELECT
|
SELECT
|
||||||
p.pid,
|
p.pid,
|
||||||
MIN(o.date)::date AS date_first_sold,
|
MIN(o.date)::date AS date_first_sold,
|
||||||
MAX(o.date)::date AS max_order_date, -- Used as fallback for date_last_sold
|
MAX(o.date)::date AS max_order_date, -- Used as fallback for date_last_sold
|
||||||
MIN(rh.first_receipt_date) AS date_first_received_calc,
|
MIN(r.received_date)::date AS date_first_received_calc,
|
||||||
MAX(rh.last_receipt_date) AS date_last_received_calc
|
MAX(r.received_date)::date AS date_last_received_calc
|
||||||
FROM public.products p
|
FROM public.products p
|
||||||
LEFT JOIN public.orders o ON p.pid = o.pid AND o.quantity > 0 AND o.status NOT IN ('canceled', 'returned')
|
LEFT JOIN public.orders o ON p.pid = o.pid AND o.quantity > 0 AND o.status NOT IN ('canceled', 'returned')
|
||||||
LEFT JOIN (
|
LEFT JOIN public.receivings r ON p.pid = r.pid
|
||||||
SELECT
|
|
||||||
po.pid,
|
|
||||||
MIN((rh.item->>'received_at')::date) as first_receipt_date,
|
|
||||||
MAX((rh.item->>'received_at')::date) as last_receipt_date
|
|
||||||
FROM public.purchase_orders po
|
|
||||||
CROSS JOIN LATERAL jsonb_array_elements(po.receiving_history) AS rh(item)
|
|
||||||
WHERE jsonb_typeof(po.receiving_history) = 'array' AND jsonb_array_length(po.receiving_history) > 0
|
|
||||||
GROUP BY po.pid
|
|
||||||
) rh ON p.pid = rh.pid
|
|
||||||
GROUP BY p.pid
|
GROUP BY p.pid
|
||||||
),
|
),
|
||||||
SnapshotAggregates AS (
|
SnapshotAggregates AS (
|
||||||
@@ -99,9 +91,30 @@ BEGIN
|
|||||||
AVG(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '29 days' AND _calculation_date THEN eod_stock_retail END) AS avg_stock_retail_30d,
|
AVG(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '29 days' AND _calculation_date THEN eod_stock_retail END) AS avg_stock_retail_30d,
|
||||||
AVG(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '29 days' AND _calculation_date THEN eod_stock_gross END) AS avg_stock_gross_30d,
|
AVG(CASE WHEN snapshot_date BETWEEN _calculation_date - INTERVAL '29 days' AND _calculation_date THEN eod_stock_gross END) AS avg_stock_gross_30d,
|
||||||
|
|
||||||
-- Lifetime (Sum over ALL available snapshots up to calculation date)
|
-- Lifetime (Using historical total from products table)
|
||||||
SUM(units_sold) AS lifetime_sales,
|
(SELECT total_sold FROM public.products WHERE public.products.pid = daily_product_snapshots.pid) AS lifetime_sales,
|
||||||
SUM(net_revenue) AS lifetime_revenue,
|
COALESCE(
|
||||||
|
-- Option 1: Use 30-day average price if available
|
||||||
|
CASE WHEN SUM(CASE WHEN snapshot_date >= _calculation_date - INTERVAL '29 days' AND snapshot_date <= _calculation_date THEN units_sold ELSE 0 END) > 0 THEN
|
||||||
|
(SELECT total_sold FROM public.products WHERE public.products.pid = daily_product_snapshots.pid) * (
|
||||||
|
SUM(CASE WHEN snapshot_date >= _calculation_date - INTERVAL '29 days' AND snapshot_date <= _calculation_date THEN net_revenue ELSE 0 END) /
|
||||||
|
NULLIF(SUM(CASE WHEN snapshot_date >= _calculation_date - INTERVAL '29 days' AND snapshot_date <= _calculation_date THEN units_sold ELSE 0 END), 0)
|
||||||
|
)
|
||||||
|
ELSE NULL END,
|
||||||
|
-- Option 2: Try 365-day average price if available
|
||||||
|
CASE WHEN SUM(CASE WHEN snapshot_date >= _calculation_date - INTERVAL '364 days' AND snapshot_date <= _calculation_date THEN units_sold ELSE 0 END) > 0 THEN
|
||||||
|
(SELECT total_sold FROM public.products WHERE public.products.pid = daily_product_snapshots.pid) * (
|
||||||
|
SUM(CASE WHEN snapshot_date >= _calculation_date - INTERVAL '364 days' AND snapshot_date <= _calculation_date THEN net_revenue ELSE 0 END) /
|
||||||
|
NULLIF(SUM(CASE WHEN snapshot_date >= _calculation_date - INTERVAL '364 days' AND snapshot_date <= _calculation_date THEN units_sold ELSE 0 END), 0)
|
||||||
|
)
|
||||||
|
ELSE NULL END,
|
||||||
|
-- Option 3: Use current price from products table
|
||||||
|
(SELECT total_sold * price FROM public.products WHERE public.products.pid = daily_product_snapshots.pid),
|
||||||
|
-- Option 4: Use regular price if current price might be zero
|
||||||
|
(SELECT total_sold * regular_price FROM public.products WHERE public.products.pid = daily_product_snapshots.pid),
|
||||||
|
-- Final fallback: Use accumulated revenue (less accurate for old products)
|
||||||
|
SUM(net_revenue)
|
||||||
|
) AS lifetime_revenue,
|
||||||
|
|
||||||
-- Yesterday (Sales for the specific _calculation_date)
|
-- Yesterday (Sales for the specific _calculation_date)
|
||||||
SUM(CASE WHEN snapshot_date = _calculation_date THEN units_sold ELSE 0 END) as yesterday_sales
|
SUM(CASE WHEN snapshot_date = _calculation_date THEN units_sold ELSE 0 END) as yesterday_sales
|
||||||
@@ -143,22 +156,23 @@ BEGIN
|
|||||||
LEFT JOIN public.settings_vendor sv ON p.vendor = sv.vendor
|
LEFT JOIN public.settings_vendor sv ON p.vendor = sv.vendor
|
||||||
),
|
),
|
||||||
AvgLeadTime AS (
|
AvgLeadTime AS (
|
||||||
-- Calculate Average Lead Time from historical POs
|
-- Calculate Average Lead Time by joining purchase_orders with receivings
|
||||||
SELECT
|
SELECT
|
||||||
pid,
|
po.pid,
|
||||||
AVG(GREATEST(1,
|
AVG(GREATEST(1,
|
||||||
CASE
|
CASE
|
||||||
WHEN last_received_date IS NOT NULL AND date IS NOT NULL
|
WHEN r.received_date IS NOT NULL AND po.date IS NOT NULL
|
||||||
THEN (last_received_date::date - date::date)
|
THEN (r.received_date::date - po.date::date)
|
||||||
ELSE 1
|
ELSE 1
|
||||||
END
|
END
|
||||||
))::int AS avg_lead_time_days_calc
|
))::int AS avg_lead_time_days_calc
|
||||||
FROM public.purchase_orders
|
FROM public.purchase_orders po
|
||||||
WHERE status = 'received' -- Assumes 'received' marks full receipt
|
JOIN public.receivings r ON r.pid = po.pid
|
||||||
AND last_received_date IS NOT NULL
|
WHERE po.status = 'done' -- Completed POs
|
||||||
AND date IS NOT NULL
|
AND r.received_date IS NOT NULL
|
||||||
AND last_received_date >= date
|
AND po.date IS NOT NULL
|
||||||
GROUP BY pid
|
AND r.received_date >= po.date
|
||||||
|
GROUP BY po.pid
|
||||||
),
|
),
|
||||||
RankedForABC AS (
|
RankedForABC AS (
|
||||||
-- Ranks products based on the configured ABC metric (using historical data)
|
-- Ranks products based on the configured ABC metric (using historical data)
|
||||||
@@ -176,7 +190,7 @@ BEGIN
|
|||||||
WHEN 'sales_30d' THEN COALESCE(sa.sales_30d, 0)
|
WHEN 'sales_30d' THEN COALESCE(sa.sales_30d, 0)
|
||||||
WHEN 'lifetime_revenue' THEN COALESCE(sa.lifetime_revenue, 0)::numeric
|
WHEN 'lifetime_revenue' THEN COALESCE(sa.lifetime_revenue, 0)::numeric
|
||||||
ELSE COALESCE(sa.revenue_30d, 0)
|
ELSE COALESCE(sa.revenue_30d, 0)
|
||||||
END) > 0 -- Exclude zero-value products from ranking
|
END) > 0 -- Only include products with non-zero contribution
|
||||||
),
|
),
|
||||||
CumulativeABC AS (
|
CumulativeABC AS (
|
||||||
-- Calculates cumulative metric values for ABC ranking
|
-- Calculates cumulative metric values for ABC ranking
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
-- Description: Rebuilds daily product snapshots from scratch using real orders data.
|
-- Description: Rebuilds daily product snapshots from scratch using real orders data.
|
||||||
-- Fixes issues with duplicated/inflated metrics.
|
-- Fixes issues with duplicated/inflated metrics.
|
||||||
-- Dependencies: Core import tables (products, orders, purchase_orders).
|
-- Dependencies: Core import tables (products, orders, receivings).
|
||||||
-- Frequency: One-time run to clear out problematic data.
|
-- Frequency: One-time run to clear out problematic data.
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
@@ -51,65 +51,17 @@ BEGIN
|
|||||||
),
|
),
|
||||||
ReceivingData AS (
|
ReceivingData AS (
|
||||||
SELECT
|
SELECT
|
||||||
po.pid,
|
r.pid,
|
||||||
-- Count POs to ensure we only include products with real activity
|
-- Count receiving documents to ensure we only include products with real activity
|
||||||
COUNT(po.po_id) as po_count,
|
COUNT(DISTINCT r.receiving_id) as receiving_count,
|
||||||
-- Calculate received quantity for this day
|
-- Calculate received quantity for this day
|
||||||
COALESCE(
|
SUM(r.qty_each) AS units_received,
|
||||||
-- First try the received field from purchase_orders table (if received on this date)
|
-- Calculate received cost for this day
|
||||||
SUM(CASE WHEN po.date::date = _date THEN po.received ELSE 0 END),
|
SUM(r.qty_each * r.cost_each) AS cost_received
|
||||||
|
FROM public.receivings r
|
||||||
-- Otherwise try receiving_history JSON
|
WHERE r.received_date::date = _date
|
||||||
SUM(
|
GROUP BY r.pid
|
||||||
CASE
|
HAVING COUNT(DISTINCT r.receiving_id) > 0 OR SUM(r.qty_each) > 0
|
||||||
WHEN (rh.item->>'date')::date = _date THEN (rh.item->>'qty')::numeric
|
|
||||||
WHEN (rh.item->>'received_at')::date = _date THEN (rh.item->>'qty')::numeric
|
|
||||||
WHEN (rh.item->>'receipt_date')::date = _date THEN (rh.item->>'qty')::numeric
|
|
||||||
ELSE 0
|
|
||||||
END
|
|
||||||
),
|
|
||||||
0
|
|
||||||
) AS units_received,
|
|
||||||
|
|
||||||
COALESCE(
|
|
||||||
-- First try the actual cost_price from purchase_orders
|
|
||||||
SUM(CASE WHEN po.date::date = _date THEN po.received * po.cost_price ELSE 0 END),
|
|
||||||
|
|
||||||
-- Otherwise try receiving_history JSON
|
|
||||||
SUM(
|
|
||||||
CASE
|
|
||||||
WHEN (rh.item->>'date')::date = _date THEN (rh.item->>'qty')::numeric
|
|
||||||
WHEN (rh.item->>'received_at')::date = _date THEN (rh.item->>'qty')::numeric
|
|
||||||
WHEN (rh.item->>'receipt_date')::date = _date THEN (rh.item->>'qty')::numeric
|
|
||||||
ELSE 0
|
|
||||||
END
|
|
||||||
* COALESCE((rh.item->>'cost')::numeric, po.cost_price)
|
|
||||||
),
|
|
||||||
0.00
|
|
||||||
) AS cost_received
|
|
||||||
FROM public.purchase_orders po
|
|
||||||
LEFT JOIN LATERAL jsonb_array_elements(po.receiving_history) AS rh(item) ON
|
|
||||||
jsonb_typeof(po.receiving_history) = 'array' AND
|
|
||||||
jsonb_array_length(po.receiving_history) > 0 AND
|
|
||||||
(
|
|
||||||
(rh.item->>'date')::date = _date OR
|
|
||||||
(rh.item->>'received_at')::date = _date OR
|
|
||||||
(rh.item->>'receipt_date')::date = _date
|
|
||||||
)
|
|
||||||
-- Include POs with the current date or relevant receiving_history
|
|
||||||
WHERE
|
|
||||||
po.date::date = _date OR
|
|
||||||
jsonb_typeof(po.receiving_history) = 'array' AND
|
|
||||||
jsonb_array_length(po.receiving_history) > 0
|
|
||||||
GROUP BY po.pid
|
|
||||||
HAVING COUNT(po.po_id) > 0 OR SUM(
|
|
||||||
CASE
|
|
||||||
WHEN (rh.item->>'date')::date = _date THEN (rh.item->>'qty')::numeric
|
|
||||||
WHEN (rh.item->>'received_at')::date = _date THEN (rh.item->>'qty')::numeric
|
|
||||||
WHEN (rh.item->>'receipt_date')::date = _date THEN (rh.item->>'qty')::numeric
|
|
||||||
ELSE 0
|
|
||||||
END
|
|
||||||
) > 0
|
|
||||||
),
|
),
|
||||||
-- Get stock quantities for the day - note this is approximate since we're using current products data
|
-- Get stock quantities for the day - note this is approximate since we're using current products data
|
||||||
StockData AS (
|
StockData AS (
|
||||||
@@ -170,7 +122,7 @@ BEGIN
|
|||||||
FROM SalesData sd
|
FROM SalesData sd
|
||||||
FULL OUTER JOIN ReceivingData rd ON sd.pid = rd.pid
|
FULL OUTER JOIN ReceivingData rd ON sd.pid = rd.pid
|
||||||
LEFT JOIN StockData s ON COALESCE(sd.pid, rd.pid) = s.pid
|
LEFT JOIN StockData s ON COALESCE(sd.pid, rd.pid) = s.pid
|
||||||
WHERE (COALESCE(sd.order_count, 0) > 0 OR COALESCE(rd.po_count, 0) > 0);
|
WHERE (COALESCE(sd.order_count, 0) > 0 OR COALESCE(rd.receiving_count, 0) > 0);
|
||||||
|
|
||||||
-- Get record count for this day
|
-- Get record count for this day
|
||||||
GET DIAGNOSTICS _count = ROW_COUNT;
|
GET DIAGNOSTICS _count = ROW_COUNT;
|
||||||
|
|||||||
@@ -45,19 +45,26 @@ BEGIN
|
|||||||
GROUP BY p.vendor
|
GROUP BY p.vendor
|
||||||
),
|
),
|
||||||
VendorPOAggregates AS (
|
VendorPOAggregates AS (
|
||||||
-- Aggregate PO related stats
|
-- Aggregate PO related stats including lead time calculated from POs to receivings
|
||||||
SELECT
|
SELECT
|
||||||
vendor,
|
po.vendor,
|
||||||
COUNT(DISTINCT po_id) AS po_count_365d,
|
COUNT(DISTINCT po.po_id) AS po_count_365d,
|
||||||
AVG(GREATEST(1, CASE WHEN last_received_date IS NOT NULL AND date IS NOT NULL THEN (last_received_date::date - date::date) ELSE NULL END))::int AS avg_lead_time_days_hist -- Avg lead time from HISTORICAL received POs
|
-- Calculate lead time by averaging the days between PO date and receiving date
|
||||||
FROM public.purchase_orders
|
AVG(GREATEST(1, CASE
|
||||||
WHERE vendor IS NOT NULL AND vendor <> ''
|
WHEN r.received_date IS NOT NULL AND po.date IS NOT NULL
|
||||||
AND date >= CURRENT_DATE - INTERVAL '1 year' -- Look at POs created in the last year
|
THEN (r.received_date::date - po.date::date)
|
||||||
AND status = 'received' -- Only calculate lead time on fully received POs
|
ELSE NULL
|
||||||
AND last_received_date IS NOT NULL
|
END))::int AS avg_lead_time_days_hist -- Avg lead time from HISTORICAL received POs
|
||||||
AND date IS NOT NULL
|
FROM public.purchase_orders po
|
||||||
AND last_received_date >= date
|
-- Join to receivings table to find when items were received
|
||||||
GROUP BY vendor
|
LEFT JOIN public.receivings r ON r.pid = po.pid
|
||||||
|
WHERE po.vendor IS NOT NULL AND po.vendor <> ''
|
||||||
|
AND po.date >= CURRENT_DATE - INTERVAL '1 year' -- Look at POs created in the last year
|
||||||
|
AND po.status = 'done' -- Only calculate lead time on completed POs
|
||||||
|
AND r.received_date IS NOT NULL
|
||||||
|
AND po.date IS NOT NULL
|
||||||
|
AND r.received_date >= po.date
|
||||||
|
GROUP BY po.vendor
|
||||||
),
|
),
|
||||||
AllVendors AS (
|
AllVendors AS (
|
||||||
-- Ensure all vendors from products table are included
|
-- Ensure all vendors from products table are included
|
||||||
|
|||||||
@@ -101,66 +101,20 @@ BEGIN
|
|||||||
),
|
),
|
||||||
ReceivingData AS (
|
ReceivingData AS (
|
||||||
SELECT
|
SELECT
|
||||||
po.pid,
|
r.pid,
|
||||||
-- Track number of POs to ensure we have real data
|
-- Track number of receiving docs to ensure we have real data
|
||||||
COUNT(po.po_id) as po_count,
|
COUNT(DISTINCT r.receiving_id) as receiving_doc_count,
|
||||||
-- Prioritize the actual table fields over the JSON data
|
-- Sum the quantities received on this date
|
||||||
COALESCE(
|
SUM(r.qty_each) AS units_received,
|
||||||
-- First try the received field from purchase_orders table
|
-- Calculate the cost received (qty * cost)
|
||||||
SUM(CASE WHEN po.date::date = _target_date THEN po.received ELSE 0 END),
|
SUM(r.qty_each * r.cost_each) AS cost_received
|
||||||
|
FROM public.receivings r
|
||||||
-- Otherwise fall back to the receiving_history JSON as secondary source
|
WHERE r.received_date::date = _target_date
|
||||||
SUM(
|
-- Optional: Filter out canceled receivings if needed
|
||||||
CASE
|
-- AND r.status <> 'canceled'
|
||||||
WHEN (rh.item->>'date')::date = _target_date THEN (rh.item->>'qty')::numeric
|
GROUP BY r.pid
|
||||||
WHEN (rh.item->>'received_at')::date = _target_date THEN (rh.item->>'qty')::numeric
|
-- Only include products with actual receiving activity
|
||||||
WHEN (rh.item->>'receipt_date')::date = _target_date THEN (rh.item->>'qty')::numeric
|
HAVING COUNT(DISTINCT r.receiving_id) > 0 OR SUM(r.qty_each) > 0
|
||||||
ELSE 0
|
|
||||||
END
|
|
||||||
),
|
|
||||||
0
|
|
||||||
) AS units_received,
|
|
||||||
|
|
||||||
COALESCE(
|
|
||||||
-- First try the actual cost_price from purchase_orders
|
|
||||||
SUM(CASE WHEN po.date::date = _target_date THEN po.received * po.cost_price ELSE 0 END),
|
|
||||||
|
|
||||||
-- Otherwise fall back to receiving_history JSON
|
|
||||||
SUM(
|
|
||||||
CASE
|
|
||||||
WHEN (rh.item->>'date')::date = _target_date THEN (rh.item->>'qty')::numeric
|
|
||||||
WHEN (rh.item->>'received_at')::date = _target_date THEN (rh.item->>'qty')::numeric
|
|
||||||
WHEN (rh.item->>'receipt_date')::date = _target_date THEN (rh.item->>'qty')::numeric
|
|
||||||
ELSE 0
|
|
||||||
END
|
|
||||||
* COALESCE((rh.item->>'cost')::numeric, po.cost_price)
|
|
||||||
),
|
|
||||||
0.00
|
|
||||||
) AS cost_received
|
|
||||||
FROM public.purchase_orders po
|
|
||||||
LEFT JOIN LATERAL jsonb_array_elements(po.receiving_history) AS rh(item) ON
|
|
||||||
jsonb_typeof(po.receiving_history) = 'array' AND
|
|
||||||
jsonb_array_length(po.receiving_history) > 0 AND
|
|
||||||
(
|
|
||||||
(rh.item->>'date')::date = _target_date OR
|
|
||||||
(rh.item->>'received_at')::date = _target_date OR
|
|
||||||
(rh.item->>'receipt_date')::date = _target_date
|
|
||||||
)
|
|
||||||
-- Include POs with the current date or relevant receiving_history
|
|
||||||
WHERE
|
|
||||||
po.date::date = _target_date OR
|
|
||||||
jsonb_typeof(po.receiving_history) = 'array' AND
|
|
||||||
jsonb_array_length(po.receiving_history) > 0
|
|
||||||
GROUP BY po.pid
|
|
||||||
-- CRITICAL: Only include products with actual receiving activity
|
|
||||||
HAVING COUNT(po.po_id) > 0 OR SUM(
|
|
||||||
CASE
|
|
||||||
WHEN (rh.item->>'date')::date = _target_date THEN (rh.item->>'qty')::numeric
|
|
||||||
WHEN (rh.item->>'received_at')::date = _target_date THEN (rh.item->>'qty')::numeric
|
|
||||||
WHEN (rh.item->>'receipt_date')::date = _target_date THEN (rh.item->>'qty')::numeric
|
|
||||||
ELSE 0
|
|
||||||
END
|
|
||||||
) > 0
|
|
||||||
),
|
),
|
||||||
CurrentStock AS (
|
CurrentStock AS (
|
||||||
-- Select current stock values directly from products table
|
-- Select current stock values directly from products table
|
||||||
|
|||||||
@@ -24,14 +24,17 @@ BEGIN
|
|||||||
RAISE NOTICE 'Calculating Average Lead Time...';
|
RAISE NOTICE 'Calculating Average Lead Time...';
|
||||||
WITH LeadTimes AS (
|
WITH LeadTimes AS (
|
||||||
SELECT
|
SELECT
|
||||||
pid,
|
po.pid,
|
||||||
AVG(GREATEST(1, (last_received_date::date - date::date))) AS avg_days -- Use GREATEST(1,...) to avoid 0 or negative days
|
-- Calculate lead time by looking at when items ordered on POs were received
|
||||||
FROM public.purchase_orders
|
AVG(GREATEST(1, (r.received_date::date - po.date::date))) AS avg_days -- Use GREATEST(1,...) to avoid 0 or negative days
|
||||||
WHERE status = 'received' -- Or potentially 'full_received' if using that status
|
FROM public.purchase_orders po
|
||||||
AND last_received_date IS NOT NULL
|
-- Join to receivings table to find actual receipts
|
||||||
AND date IS NOT NULL
|
JOIN public.receivings r ON r.pid = po.pid
|
||||||
AND last_received_date >= date -- Ensure received date is not before order date
|
WHERE po.status = 'done' -- Only include completed POs
|
||||||
GROUP BY pid
|
AND r.received_date >= po.date -- Ensure received date is not before order date
|
||||||
|
-- Optional: add check to make sure receiving is related to PO if you have source_po_id
|
||||||
|
-- AND (r.source_po_id = po.po_id OR r.source_po_id IS NULL)
|
||||||
|
GROUP BY po.pid
|
||||||
)
|
)
|
||||||
UPDATE public.product_metrics pm
|
UPDATE public.product_metrics pm
|
||||||
SET avg_lead_time_days = lt.avg_days::int
|
SET avg_lead_time_days = lt.avg_days::int
|
||||||
|
|||||||
@@ -57,18 +57,19 @@ BEGIN
|
|||||||
p.created_at,
|
p.created_at,
|
||||||
p.first_received,
|
p.first_received,
|
||||||
p.date_last_sold,
|
p.date_last_sold,
|
||||||
|
p.total_sold as historical_total_sold, -- Add historical total_sold from products table
|
||||||
p.uom -- Assuming UOM logic is handled elsewhere or simple (e.g., 1=each)
|
p.uom -- Assuming UOM logic is handled elsewhere or simple (e.g., 1=each)
|
||||||
FROM public.products p
|
FROM public.products p
|
||||||
),
|
),
|
||||||
OnOrderInfo AS (
|
OnOrderInfo AS (
|
||||||
SELECT
|
SELECT
|
||||||
pid,
|
pid,
|
||||||
COALESCE(SUM(ordered - received), 0) AS on_order_qty,
|
SUM(ordered) AS on_order_qty,
|
||||||
COALESCE(SUM((ordered - received) * cost_price), 0.00) AS on_order_cost,
|
SUM(ordered * po_cost_price) AS on_order_cost,
|
||||||
MIN(expected_date) AS earliest_expected_date
|
MIN(expected_date) AS earliest_expected_date
|
||||||
FROM public.purchase_orders
|
FROM public.purchase_orders
|
||||||
WHERE status IN ('open', 'partially_received', 'ordered', 'preordered', 'receiving_started', 'electronically_sent', 'electronically_ready_send') -- Adjust based on your status workflow representing active POs not fully received
|
WHERE status IN ('created', 'ordered', 'preordered', 'electronically_sent', 'electronically_ready_send', 'receiving_started')
|
||||||
AND (ordered - received) > 0
|
AND status NOT IN ('canceled', 'done')
|
||||||
GROUP BY pid
|
GROUP BY pid
|
||||||
),
|
),
|
||||||
HistoricalDates AS (
|
HistoricalDates AS (
|
||||||
@@ -79,45 +80,14 @@ BEGIN
|
|||||||
MIN(o.date)::date AS date_first_sold,
|
MIN(o.date)::date AS date_first_sold,
|
||||||
MAX(o.date)::date AS max_order_date, -- Use MAX for potential recalc of date_last_sold
|
MAX(o.date)::date AS max_order_date, -- Use MAX for potential recalc of date_last_sold
|
||||||
|
|
||||||
-- For first received date, try table data first then fall back to JSON
|
-- For first received, use the new receivings table
|
||||||
COALESCE(
|
MIN(r.received_date)::date AS date_first_received_calc,
|
||||||
MIN(po.date)::date, -- Try purchase_order date first
|
|
||||||
MIN(rh.first_receipt_date) -- Fall back to JSON data if needed
|
|
||||||
) AS date_first_received_calc,
|
|
||||||
|
|
||||||
-- If we only have one receipt date (first = last), use that for last_received too
|
-- For last received, use the new receivings table
|
||||||
COALESCE(
|
MAX(r.received_date)::date AS date_last_received_calc
|
||||||
MAX(po.date)::date, -- Try purchase_order date first
|
|
||||||
NULLIF(MAX(rh.last_receipt_date), NULL),
|
|
||||||
MIN(rh.first_receipt_date)
|
|
||||||
) AS date_last_received_calc
|
|
||||||
FROM public.products p
|
FROM public.products p
|
||||||
LEFT JOIN public.orders o ON p.pid = o.pid AND o.quantity > 0 AND o.status NOT IN ('canceled', 'returned')
|
LEFT JOIN public.orders o ON p.pid = o.pid AND o.quantity > 0 AND o.status NOT IN ('canceled', 'returned')
|
||||||
LEFT JOIN public.purchase_orders po ON p.pid = po.pid AND po.received > 0
|
LEFT JOIN public.receivings r ON p.pid = r.pid
|
||||||
LEFT JOIN (
|
|
||||||
SELECT
|
|
||||||
po.pid,
|
|
||||||
MIN(
|
|
||||||
CASE
|
|
||||||
WHEN rh.item->>'date' IS NOT NULL THEN (rh.item->>'date')::date
|
|
||||||
WHEN rh.item->>'received_at' IS NOT NULL THEN (rh.item->>'received_at')::date
|
|
||||||
WHEN rh.item->>'receipt_date' IS NOT NULL THEN (rh.item->>'receipt_date')::date
|
|
||||||
ELSE NULL
|
|
||||||
END
|
|
||||||
) as first_receipt_date,
|
|
||||||
MAX(
|
|
||||||
CASE
|
|
||||||
WHEN rh.item->>'date' IS NOT NULL THEN (rh.item->>'date')::date
|
|
||||||
WHEN rh.item->>'received_at' IS NOT NULL THEN (rh.item->>'received_at')::date
|
|
||||||
WHEN rh.item->>'receipt_date' IS NOT NULL THEN (rh.item->>'receipt_date')::date
|
|
||||||
ELSE NULL
|
|
||||||
END
|
|
||||||
) as last_receipt_date
|
|
||||||
FROM public.purchase_orders po
|
|
||||||
CROSS JOIN LATERAL jsonb_array_elements(po.receiving_history) AS rh(item)
|
|
||||||
WHERE jsonb_typeof(po.receiving_history) = 'array' AND jsonb_array_length(po.receiving_history) > 0
|
|
||||||
GROUP BY po.pid
|
|
||||||
) rh ON p.pid = rh.pid
|
|
||||||
GROUP BY p.pid
|
GROUP BY p.pid
|
||||||
),
|
),
|
||||||
SnapshotAggregates AS (
|
SnapshotAggregates AS (
|
||||||
@@ -255,9 +225,25 @@ BEGIN
|
|||||||
sa.stockout_days_30d, sa.sales_365d, sa.revenue_365d,
|
sa.stockout_days_30d, sa.sales_365d, sa.revenue_365d,
|
||||||
sa.avg_stock_units_30d, sa.avg_stock_cost_30d, sa.avg_stock_retail_30d, sa.avg_stock_gross_30d,
|
sa.avg_stock_units_30d, sa.avg_stock_cost_30d, sa.avg_stock_retail_30d, sa.avg_stock_gross_30d,
|
||||||
sa.received_qty_30d, sa.received_cost_30d,
|
sa.received_qty_30d, sa.received_cost_30d,
|
||||||
-- Use total counts for lifetime values to ensure we have data even with limited history
|
-- Use total_sold from products table as the source of truth for lifetime sales
|
||||||
COALESCE(sa.total_units_sold, sa.lifetime_sales) AS lifetime_sales,
|
-- This includes all historical data from the production database
|
||||||
COALESCE(sa.total_net_revenue, sa.lifetime_revenue) AS lifetime_revenue,
|
ci.historical_total_sold AS lifetime_sales,
|
||||||
|
COALESCE(
|
||||||
|
-- Option 1: Use 30-day average price if available
|
||||||
|
CASE WHEN sa.sales_30d > 0 THEN
|
||||||
|
ci.historical_total_sold * (sa.revenue_30d / NULLIF(sa.sales_30d, 0))
|
||||||
|
ELSE NULL END,
|
||||||
|
-- Option 2: Try 365-day average price if available
|
||||||
|
CASE WHEN sa.sales_365d > 0 THEN
|
||||||
|
ci.historical_total_sold * (sa.revenue_365d / NULLIF(sa.sales_365d, 0))
|
||||||
|
ELSE NULL END,
|
||||||
|
-- Option 3: Use current price as a reasonable estimate
|
||||||
|
ci.historical_total_sold * ci.current_price,
|
||||||
|
-- Option 4: Use regular price if current price might be zero
|
||||||
|
ci.historical_total_sold * ci.current_regular_price,
|
||||||
|
-- Final fallback: Use accumulated revenue (this is less accurate for old products)
|
||||||
|
sa.total_net_revenue
|
||||||
|
) AS lifetime_revenue,
|
||||||
fpm.first_7_days_sales, fpm.first_7_days_revenue, fpm.first_30_days_sales, fpm.first_30_days_revenue,
|
fpm.first_7_days_sales, fpm.first_7_days_revenue, fpm.first_30_days_sales, fpm.first_30_days_revenue,
|
||||||
fpm.first_60_days_sales, fpm.first_60_days_revenue, fpm.first_90_days_sales, fpm.first_90_days_revenue,
|
fpm.first_60_days_sales, fpm.first_60_days_revenue, fpm.first_90_days_sales, fpm.first_90_days_revenue,
|
||||||
|
|
||||||
|
|||||||
@@ -51,83 +51,67 @@ router.get('/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get prompt by company
|
// Get prompt by type (general, system, company_specific)
|
||||||
router.get('/company/:companyId', async (req, res) => {
|
router.get('/by-type', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { companyId } = req.params;
|
const { type, company } = req.query;
|
||||||
const pool = req.app.locals.pool;
|
const pool = req.app.locals.pool;
|
||||||
|
|
||||||
if (!pool) {
|
if (!pool) {
|
||||||
throw new Error('Database pool not initialized');
|
throw new Error('Database pool not initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await pool.query(`
|
// Validate prompt type
|
||||||
SELECT * FROM ai_prompts
|
if (!type || !['general', 'system', 'company_specific'].includes(type)) {
|
||||||
WHERE company = $1
|
return res.status(400).json({
|
||||||
`, [companyId]);
|
error: 'Valid type query parameter is required (general, system, or company_specific)'
|
||||||
|
});
|
||||||
if (result.rows.length === 0) {
|
|
||||||
return res.status(404).json({ error: 'AI prompt not found for this company' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For company_specific type, company ID is required
|
||||||
|
if (type === 'company_specific' && !company) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Company ID is required for company_specific prompt type'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// For general and system types, company should not be provided
|
||||||
|
if ((type === 'general' || type === 'system') && company) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Company ID should not be provided for general or system prompt types'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the query based on the type
|
||||||
|
let query, params;
|
||||||
|
if (type === 'company_specific') {
|
||||||
|
query = 'SELECT * FROM ai_prompts WHERE prompt_type = $1 AND company = $2';
|
||||||
|
params = [type, company];
|
||||||
|
} else {
|
||||||
|
query = 'SELECT * FROM ai_prompts WHERE prompt_type = $1';
|
||||||
|
params = [type];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the query
|
||||||
|
const result = await pool.query(query, params);
|
||||||
|
|
||||||
|
// Check if any prompt was found
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
let errorMessage;
|
||||||
|
if (type === 'company_specific') {
|
||||||
|
errorMessage = `AI prompt not found for company ${company}`;
|
||||||
|
} else {
|
||||||
|
errorMessage = `${type.charAt(0).toUpperCase() + type.slice(1)} AI prompt not found`;
|
||||||
|
}
|
||||||
|
return res.status(404).json({ error: errorMessage });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the first matching prompt
|
||||||
res.json(result.rows[0]);
|
res.json(result.rows[0]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching AI prompt by company:', error);
|
console.error('Error fetching AI prompt by type:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
error: 'Failed to fetch AI prompt by company',
|
error: 'Failed to fetch AI prompt',
|
||||||
details: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get general prompt
|
|
||||||
router.get('/type/general', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const pool = req.app.locals.pool;
|
|
||||||
if (!pool) {
|
|
||||||
throw new Error('Database pool not initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await pool.query(`
|
|
||||||
SELECT * FROM ai_prompts
|
|
||||||
WHERE prompt_type = 'general'
|
|
||||||
`);
|
|
||||||
|
|
||||||
if (result.rows.length === 0) {
|
|
||||||
return res.status(404).json({ error: 'General AI prompt not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(result.rows[0]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching general AI prompt:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Failed to fetch general AI prompt',
|
|
||||||
details: error instanceof Error ? error.message : 'Unknown error'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get system prompt
|
|
||||||
router.get('/type/system', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const pool = req.app.locals.pool;
|
|
||||||
if (!pool) {
|
|
||||||
throw new Error('Database pool not initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await pool.query(`
|
|
||||||
SELECT * FROM ai_prompts
|
|
||||||
WHERE prompt_type = 'system'
|
|
||||||
`);
|
|
||||||
|
|
||||||
if (result.rows.length === 0) {
|
|
||||||
return res.status(404).json({ error: 'System AI prompt not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(result.rows[0]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching system AI prompt:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Failed to fetch system AI prompt',
|
|
||||||
details: error instanceof Error ? error.message : 'Unknown error'
|
details: error instanceof Error ? error.message : 'Unknown error'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const path = require("path");
|
|||||||
const dotenv = require("dotenv");
|
const dotenv = require("dotenv");
|
||||||
const mysql = require('mysql2/promise');
|
const mysql = require('mysql2/promise');
|
||||||
const { Client } = require('ssh2');
|
const { Client } = require('ssh2');
|
||||||
|
const { getDbConnection } = require('../utils/dbConnection'); // Import the optimized connection function
|
||||||
|
|
||||||
// Ensure environment variables are loaded
|
// Ensure environment variables are loaded
|
||||||
dotenv.config({ path: path.join(__dirname, "../../.env") });
|
dotenv.config({ path: path.join(__dirname, "../../.env") });
|
||||||
@@ -18,50 +19,6 @@ if (!process.env.OPENAI_API_KEY) {
|
|||||||
console.error("Warning: OPENAI_API_KEY is not set in environment variables");
|
console.error("Warning: OPENAI_API_KEY is not set in environment variables");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to setup SSH tunnel to production database
|
|
||||||
async function setupSshTunnel() {
|
|
||||||
const sshConfig = {
|
|
||||||
host: process.env.PROD_SSH_HOST,
|
|
||||||
port: process.env.PROD_SSH_PORT || 22,
|
|
||||||
username: process.env.PROD_SSH_USER,
|
|
||||||
privateKey: process.env.PROD_SSH_KEY_PATH
|
|
||||||
? require('fs').readFileSync(process.env.PROD_SSH_KEY_PATH)
|
|
||||||
: undefined,
|
|
||||||
compress: true
|
|
||||||
};
|
|
||||||
|
|
||||||
const dbConfig = {
|
|
||||||
host: process.env.PROD_DB_HOST || 'localhost',
|
|
||||||
user: process.env.PROD_DB_USER,
|
|
||||||
password: process.env.PROD_DB_PASSWORD,
|
|
||||||
database: process.env.PROD_DB_NAME,
|
|
||||||
port: process.env.PROD_DB_PORT || 3306,
|
|
||||||
timezone: 'Z'
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const ssh = new Client();
|
|
||||||
|
|
||||||
ssh.on('error', (err) => {
|
|
||||||
console.error('SSH connection error:', err);
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
|
|
||||||
ssh.on('ready', () => {
|
|
||||||
ssh.forwardOut(
|
|
||||||
'127.0.0.1',
|
|
||||||
0,
|
|
||||||
dbConfig.host,
|
|
||||||
dbConfig.port,
|
|
||||||
(err, stream) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
resolve({ ssh, stream, dbConfig });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}).connect(sshConfig);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Debug endpoint for viewing prompt
|
// Debug endpoint for viewing prompt
|
||||||
router.post("/debug", async (req, res) => {
|
router.post("/debug", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -195,16 +152,12 @@ async function generateDebugResponse(productsToUse, res) {
|
|||||||
// Load taxonomy data first
|
// Load taxonomy data first
|
||||||
console.log("Loading taxonomy data...");
|
console.log("Loading taxonomy data...");
|
||||||
try {
|
try {
|
||||||
// Setup MySQL connection via SSH tunnel
|
// Use optimized database connection
|
||||||
const tunnel = await setupSshTunnel();
|
const { connection, ssh: connSsh } = await getDbConnection();
|
||||||
ssh = tunnel.ssh;
|
mysqlConnection = connection;
|
||||||
|
ssh = connSsh;
|
||||||
|
|
||||||
mysqlConnection = await mysql.createConnection({
|
console.log("MySQL connection established successfully using optimized connection");
|
||||||
...tunnel.dbConfig,
|
|
||||||
stream: tunnel.stream
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("MySQL connection established successfully");
|
|
||||||
|
|
||||||
taxonomy = await getTaxonomyData(mysqlConnection);
|
taxonomy = await getTaxonomyData(mysqlConnection);
|
||||||
console.log("Successfully loaded taxonomy data");
|
console.log("Successfully loaded taxonomy data");
|
||||||
@@ -218,10 +171,6 @@ async function generateDebugResponse(productsToUse, res) {
|
|||||||
errno: taxonomyError.errno || null,
|
errno: taxonomyError.errno || null,
|
||||||
sql: taxonomyError.sql || null,
|
sql: taxonomyError.sql || null,
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
// Make sure we close the connection
|
|
||||||
if (mysqlConnection) await mysqlConnection.end();
|
|
||||||
if (ssh) ssh.end();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the taxonomy data structure
|
// Verify the taxonomy data structure
|
||||||
@@ -282,11 +231,8 @@ async function generateDebugResponse(productsToUse, res) {
|
|||||||
console.log("Loading prompt...");
|
console.log("Loading prompt...");
|
||||||
|
|
||||||
// Setup a new connection for loading the prompt
|
// Setup a new connection for loading the prompt
|
||||||
const promptTunnel = await setupSshTunnel();
|
// Use optimized connection instead of creating a new one
|
||||||
const promptConnection = await mysql.createConnection({
|
const { connection: promptConnection } = await getDbConnection();
|
||||||
...promptTunnel.dbConfig,
|
|
||||||
stream: promptTunnel.stream
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the local PostgreSQL pool to fetch prompts
|
// Get the local PostgreSQL pool to fetch prompts
|
||||||
@@ -296,7 +242,7 @@ async function generateDebugResponse(productsToUse, res) {
|
|||||||
throw new Error("Database connection not available");
|
throw new Error("Database connection not available");
|
||||||
}
|
}
|
||||||
|
|
||||||
// First, fetch the system prompt
|
// First, fetch the system prompt using the consolidated endpoint approach
|
||||||
const systemPromptResult = await pool.query(`
|
const systemPromptResult = await pool.query(`
|
||||||
SELECT * FROM ai_prompts
|
SELECT * FROM ai_prompts
|
||||||
WHERE prompt_type = 'system'
|
WHERE prompt_type = 'system'
|
||||||
@@ -311,7 +257,7 @@ async function generateDebugResponse(productsToUse, res) {
|
|||||||
console.warn("⚠️ No system prompt found in database, will use default");
|
console.warn("⚠️ No system prompt found in database, will use default");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then, fetch the general prompt
|
// Then, fetch the general prompt using the consolidated endpoint approach
|
||||||
const generalPromptResult = await pool.query(`
|
const generalPromptResult = await pool.query(`
|
||||||
SELECT * FROM ai_prompts
|
SELECT * FROM ai_prompts
|
||||||
WHERE prompt_type = 'general'
|
WHERE prompt_type = 'general'
|
||||||
@@ -458,7 +404,6 @@ async function generateDebugResponse(productsToUse, res) {
|
|||||||
return response;
|
return response;
|
||||||
} finally {
|
} finally {
|
||||||
if (promptConnection) await promptConnection.end();
|
if (promptConnection) await promptConnection.end();
|
||||||
if (promptTunnel.ssh) promptTunnel.ssh.end();
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error generating debug response:", error);
|
console.error("Error generating debug response:", error);
|
||||||
@@ -645,7 +590,7 @@ async function loadPrompt(connection, productsToValidate = null, appPool = null)
|
|||||||
throw new Error("Database connection not available");
|
throw new Error("Database connection not available");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the system prompt
|
// Fetch the system prompt using the consolidated endpoint approach
|
||||||
const systemPromptResult = await pool.query(`
|
const systemPromptResult = await pool.query(`
|
||||||
SELECT * FROM ai_prompts
|
SELECT * FROM ai_prompts
|
||||||
WHERE prompt_type = 'system'
|
WHERE prompt_type = 'system'
|
||||||
@@ -662,7 +607,7 @@ async function loadPrompt(connection, productsToValidate = null, appPool = null)
|
|||||||
console.warn("⚠️ No system prompt found in database, using default");
|
console.warn("⚠️ No system prompt found in database, using default");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch the general prompt
|
// Fetch the general prompt using the consolidated endpoint approach
|
||||||
const generalPromptResult = await pool.query(`
|
const generalPromptResult = await pool.query(`
|
||||||
SELECT * FROM ai_prompts
|
SELECT * FROM ai_prompts
|
||||||
WHERE prompt_type = 'general'
|
WHERE prompt_type = 'general'
|
||||||
@@ -926,15 +871,11 @@ router.post("/validate", async (req, res) => {
|
|||||||
let promptLength = 0; // Track prompt length for performance metrics
|
let promptLength = 0; // Track prompt length for performance metrics
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Setup MySQL connection via SSH tunnel
|
// Use the optimized connection utility instead of direct SSH tunnel
|
||||||
console.log("🔄 Setting up connection to production database...");
|
console.log("🔄 Setting up connection to production database using optimized connection...");
|
||||||
const tunnel = await setupSshTunnel();
|
const { ssh: connSsh, connection: connDB } = await getDbConnection();
|
||||||
ssh = tunnel.ssh;
|
ssh = connSsh;
|
||||||
|
connection = connDB;
|
||||||
connection = await mysql.createConnection({
|
|
||||||
...tunnel.dbConfig,
|
|
||||||
stream: tunnel.stream
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("🔄 MySQL connection established successfully");
|
console.log("🔄 MySQL connection established successfully");
|
||||||
|
|
||||||
@@ -1238,14 +1179,11 @@ router.get("/test-taxonomy", async (req, res) => {
|
|||||||
let connection = null;
|
let connection = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Setup MySQL connection via SSH tunnel
|
// Use the optimized connection utility instead of direct SSH tunnel
|
||||||
const tunnel = await setupSshTunnel();
|
console.log("🔄 Setting up connection to production database using optimized connection...");
|
||||||
ssh = tunnel.ssh;
|
const { ssh: connSsh, connection: connDB } = await getDbConnection();
|
||||||
|
ssh = connSsh;
|
||||||
connection = await mysql.createConnection({
|
connection = connDB;
|
||||||
...tunnel.dbConfig,
|
|
||||||
stream: tunnel.stream
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("MySQL connection established successfully for test");
|
console.log("MySQL connection established successfully for test");
|
||||||
|
|
||||||
|
|||||||
@@ -7,37 +7,33 @@ router.get('/stats', async (req, res) => {
|
|||||||
const pool = req.app.locals.pool;
|
const pool = req.app.locals.pool;
|
||||||
|
|
||||||
const { rows: [results] } = await pool.query(`
|
const { rows: [results] } = await pool.query(`
|
||||||
|
WITH vendor_count AS (
|
||||||
|
SELECT COUNT(DISTINCT vendor_name) AS count
|
||||||
|
FROM vendor_metrics
|
||||||
|
),
|
||||||
|
category_count AS (
|
||||||
|
SELECT COUNT(DISTINCT category_id) AS count
|
||||||
|
FROM category_metrics
|
||||||
|
),
|
||||||
|
metrics_summary AS (
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(
|
AVG(margin_30d) AS avg_profit_margin,
|
||||||
ROUND(
|
AVG(markup_30d) AS avg_markup,
|
||||||
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
AVG(stockturn_30d) AS avg_stock_turnover,
|
||||||
NULLIF(SUM(o.price * o.quantity), 0) * 100)::numeric, 1
|
AVG(asp_30d) AS avg_order_value
|
||||||
),
|
FROM product_metrics
|
||||||
0
|
WHERE sales_30d > 0
|
||||||
) as profitMargin,
|
)
|
||||||
COALESCE(
|
SELECT
|
||||||
ROUND(
|
COALESCE(ms.avg_profit_margin, 0) AS profitMargin,
|
||||||
(AVG(p.price / NULLIF(p.cost_price, 0) - 1) * 100)::numeric, 1
|
COALESCE(ms.avg_markup, 0) AS averageMarkup,
|
||||||
),
|
COALESCE(ms.avg_stock_turnover, 0) AS stockTurnoverRate,
|
||||||
0
|
COALESCE(vc.count, 0) AS vendorCount,
|
||||||
) as averageMarkup,
|
COALESCE(cc.count, 0) AS categoryCount,
|
||||||
COALESCE(
|
COALESCE(ms.avg_order_value, 0) AS averageOrderValue
|
||||||
ROUND(
|
FROM metrics_summary ms
|
||||||
(SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0))::numeric, 2
|
CROSS JOIN vendor_count vc
|
||||||
),
|
CROSS JOIN category_count cc
|
||||||
0
|
|
||||||
) as stockTurnoverRate,
|
|
||||||
COALESCE(COUNT(DISTINCT p.vendor), 0) as vendorCount,
|
|
||||||
COALESCE(COUNT(DISTINCT p.categories), 0) as categoryCount,
|
|
||||||
COALESCE(
|
|
||||||
ROUND(
|
|
||||||
AVG(o.price * o.quantity)::numeric, 2
|
|
||||||
),
|
|
||||||
0
|
|
||||||
) as averageOrderValue
|
|
||||||
FROM products p
|
|
||||||
LEFT JOIN orders o ON p.pid = o.pid
|
|
||||||
WHERE o.date >= CURRENT_DATE - INTERVAL '30 days'
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Ensure all values are numbers
|
// Ensure all values are numbers
|
||||||
@@ -84,43 +80,53 @@ router.get('/profit', async (req, res) => {
|
|||||||
JOIN category_path cp ON c.parent_id = cp.cat_id
|
JOIN category_path cp ON c.parent_id = cp.cat_id
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
c.name as category,
|
cm.category_name as category,
|
||||||
cp.path as categoryPath,
|
COALESCE(cp.path, cm.category_name) as categorypath,
|
||||||
ROUND(
|
cm.avg_margin_30d as profitmargin,
|
||||||
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
cm.revenue_30d as revenue,
|
||||||
NULLIF(SUM(o.price * o.quantity), 0) * 100)::numeric, 1
|
cm.cogs_30d as cost
|
||||||
) as profitMargin,
|
FROM category_metrics cm
|
||||||
ROUND(SUM(o.price * o.quantity)::numeric, 3) as revenue,
|
LEFT JOIN category_path cp ON cm.category_id = cp.cat_id
|
||||||
ROUND(SUM(p.cost_price * o.quantity)::numeric, 3) as cost
|
WHERE cm.revenue_30d > 0
|
||||||
FROM products p
|
ORDER BY cm.revenue_30d DESC
|
||||||
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 >= CURRENT_DATE - INTERVAL '30 days'
|
|
||||||
GROUP BY c.name, cp.path
|
|
||||||
ORDER BY profitMargin DESC
|
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Get profit margin trend over time
|
// Get profit margin over time
|
||||||
const { rows: overTime } = await pool.query(`
|
const { rows: overTime } = await pool.query(`
|
||||||
|
WITH time_series AS (
|
||||||
SELECT
|
SELECT
|
||||||
to_char(o.date, 'YYYY-MM-DD') as date,
|
date_trunc('day', generate_series(
|
||||||
ROUND(
|
CURRENT_DATE - INTERVAL '30 days',
|
||||||
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
CURRENT_DATE,
|
||||||
NULLIF(SUM(o.price * o.quantity), 0) * 100)::numeric, 1
|
'1 day'::interval
|
||||||
) as profitMargin,
|
))::date AS date
|
||||||
ROUND(SUM(o.price * o.quantity)::numeric, 3) as revenue,
|
),
|
||||||
ROUND(SUM(p.cost_price * o.quantity)::numeric, 3) as cost
|
daily_profits AS (
|
||||||
FROM products p
|
SELECT
|
||||||
LEFT JOIN orders o ON p.pid = o.pid
|
snapshot_date as date,
|
||||||
WHERE o.date >= CURRENT_DATE - INTERVAL '30 days'
|
SUM(net_revenue) as revenue,
|
||||||
GROUP BY to_char(o.date, 'YYYY-MM-DD')
|
SUM(cogs) as cost,
|
||||||
ORDER BY date
|
CASE
|
||||||
|
WHEN SUM(net_revenue) > 0
|
||||||
|
THEN (SUM(net_revenue - cogs) / SUM(net_revenue)) * 100
|
||||||
|
ELSE 0
|
||||||
|
END as profit_margin
|
||||||
|
FROM daily_product_snapshots
|
||||||
|
WHERE snapshot_date >= CURRENT_DATE - INTERVAL '30 days'
|
||||||
|
GROUP BY snapshot_date
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
to_char(ts.date, 'YYYY-MM-DD') as date,
|
||||||
|
COALESCE(dp.profit_margin, 0) as profitmargin,
|
||||||
|
COALESCE(dp.revenue, 0) as revenue,
|
||||||
|
COALESCE(dp.cost, 0) as cost
|
||||||
|
FROM time_series ts
|
||||||
|
LEFT JOIN daily_profits dp ON ts.date = dp.date
|
||||||
|
ORDER BY ts.date
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Get top performing products with category paths
|
// Get top performing products by profit margin
|
||||||
const { rows: topProducts } = await pool.query(`
|
const { rows: topProducts } = await pool.query(`
|
||||||
WITH RECURSIVE category_path AS (
|
WITH RECURSIVE category_path AS (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -140,26 +146,28 @@ router.get('/profit', async (req, res) => {
|
|||||||
(cp.path || ' > ' || c.name)::text
|
(cp.path || ' > ' || c.name)::text
|
||||||
FROM categories c
|
FROM categories c
|
||||||
JOIN category_path cp ON c.parent_id = cp.cat_id
|
JOIN category_path cp ON c.parent_id = cp.cat_id
|
||||||
|
),
|
||||||
|
product_categories AS (
|
||||||
|
SELECT
|
||||||
|
pc.pid,
|
||||||
|
c.name as category,
|
||||||
|
COALESCE(cp.path, c.name) as categorypath
|
||||||
|
FROM product_categories pc
|
||||||
|
JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
|
LEFT JOIN category_path cp ON c.cat_id = cp.cat_id
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
p.title as product,
|
pm.title as product,
|
||||||
c.name as category,
|
COALESCE(pc.category, 'Uncategorized') as category,
|
||||||
cp.path as categoryPath,
|
COALESCE(pc.categorypath, 'Uncategorized') as categorypath,
|
||||||
ROUND(
|
pm.margin_30d as profitmargin,
|
||||||
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
pm.revenue_30d as revenue,
|
||||||
NULLIF(SUM(o.price * o.quantity), 0) * 100)::numeric, 1
|
pm.cogs_30d as cost
|
||||||
) as profitMargin,
|
FROM product_metrics pm
|
||||||
ROUND(SUM(o.price * o.quantity)::numeric, 3) as revenue,
|
LEFT JOIN product_categories pc ON pm.pid = pc.pid
|
||||||
ROUND(SUM(p.cost_price * o.quantity)::numeric, 3) as cost
|
WHERE pm.revenue_30d > 100
|
||||||
FROM products p
|
AND pm.margin_30d > 0
|
||||||
LEFT JOIN orders o ON p.pid = o.pid
|
ORDER BY pm.margin_30d DESC
|
||||||
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 >= CURRENT_DATE - INTERVAL '30 days'
|
|
||||||
GROUP BY p.pid, p.title, c.name, cp.path
|
|
||||||
HAVING SUM(o.price * o.quantity) > 0
|
|
||||||
ORDER BY profitMargin DESC
|
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -184,93 +192,52 @@ router.get('/vendors', async (req, res) => {
|
|||||||
|
|
||||||
console.log('Fetching vendor performance data...');
|
console.log('Fetching vendor performance data...');
|
||||||
|
|
||||||
// First check if we have any vendors with sales
|
// Get vendor performance metrics from the vendor_metrics table
|
||||||
const { rows: [checkData] } = await pool.query(`
|
|
||||||
SELECT COUNT(DISTINCT p.vendor) as vendor_count,
|
|
||||||
COUNT(DISTINCT o.order_number) as order_count
|
|
||||||
FROM products p
|
|
||||||
LEFT JOIN orders o ON p.pid = o.pid
|
|
||||||
WHERE p.vendor IS NOT NULL
|
|
||||||
`);
|
|
||||||
|
|
||||||
console.log('Vendor data check:', checkData);
|
|
||||||
|
|
||||||
// Get vendor performance metrics
|
|
||||||
const { rows: rawPerformance } = await pool.query(`
|
const { rows: rawPerformance } = await pool.query(`
|
||||||
WITH monthly_sales AS (
|
|
||||||
SELECT
|
SELECT
|
||||||
p.vendor,
|
vendor_name as vendor,
|
||||||
ROUND(SUM(CASE
|
revenue_30d as sales_volume,
|
||||||
WHEN o.date >= CURRENT_DATE - INTERVAL '30 days'
|
avg_margin_30d as profit_margin,
|
||||||
THEN o.price * o.quantity
|
COALESCE(
|
||||||
|
sales_30d / NULLIF(current_stock_units, 0),
|
||||||
|
0
|
||||||
|
) as stock_turnover,
|
||||||
|
product_count,
|
||||||
|
-- Use an estimate of growth based on 7-day vs 30-day revenue
|
||||||
|
CASE
|
||||||
|
WHEN revenue_30d > 0
|
||||||
|
THEN ((revenue_7d * 4.0) / revenue_30d - 1) * 100
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END)::numeric, 3) as current_month,
|
END as growth
|
||||||
ROUND(SUM(CASE
|
FROM vendor_metrics
|
||||||
WHEN o.date >= CURRENT_DATE - INTERVAL '60 days'
|
WHERE revenue_30d > 0
|
||||||
AND o.date < CURRENT_DATE - INTERVAL '30 days'
|
ORDER BY revenue_30d DESC
|
||||||
THEN o.price * o.quantity
|
LIMIT 20
|
||||||
ELSE 0
|
|
||||||
END)::numeric, 3) as previous_month
|
|
||||||
FROM products p
|
|
||||||
LEFT JOIN orders o ON p.pid = o.pid
|
|
||||||
WHERE p.vendor IS NOT NULL
|
|
||||||
AND o.date >= CURRENT_DATE - INTERVAL '60 days'
|
|
||||||
GROUP BY p.vendor
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
p.vendor,
|
|
||||||
ROUND(SUM(o.price * o.quantity)::numeric, 3) as sales_volume,
|
|
||||||
COALESCE(ROUND(
|
|
||||||
(SUM(o.price * o.quantity - p.cost_price * o.quantity) /
|
|
||||||
NULLIF(SUM(o.price * o.quantity), 0) * 100)::numeric, 1
|
|
||||||
), 0) as profit_margin,
|
|
||||||
COALESCE(ROUND(
|
|
||||||
(SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0))::numeric, 1
|
|
||||||
), 0) as stock_turnover,
|
|
||||||
COUNT(DISTINCT p.pid) as product_count,
|
|
||||||
ROUND(
|
|
||||||
((ms.current_month / NULLIF(ms.previous_month, 0)) - 1) * 100,
|
|
||||||
1
|
|
||||||
) as growth
|
|
||||||
FROM products p
|
|
||||||
LEFT JOIN orders o ON p.pid = o.pid
|
|
||||||
LEFT JOIN monthly_sales ms ON p.vendor = ms.vendor
|
|
||||||
WHERE p.vendor IS NOT NULL
|
|
||||||
AND o.date >= CURRENT_DATE - INTERVAL '30 days'
|
|
||||||
GROUP BY p.vendor, ms.current_month, ms.previous_month
|
|
||||||
ORDER BY sales_volume DESC
|
|
||||||
LIMIT 10
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Transform to camelCase properties for frontend consumption
|
// Format the performance data
|
||||||
const performance = rawPerformance.map(item => ({
|
const performance = rawPerformance.map(vendor => ({
|
||||||
vendor: item.vendor,
|
vendor: vendor.vendor,
|
||||||
salesVolume: Number(item.sales_volume) || 0,
|
salesVolume: Number(vendor.sales_volume) || 0,
|
||||||
profitMargin: Number(item.profit_margin) || 0,
|
profitMargin: Number(vendor.profit_margin) || 0,
|
||||||
stockTurnover: Number(item.stock_turnover) || 0,
|
stockTurnover: Number(vendor.stock_turnover) || 0,
|
||||||
productCount: Number(item.product_count) || 0,
|
productCount: Number(vendor.product_count) || 0,
|
||||||
growth: Number(item.growth) || 0
|
growth: Number(vendor.growth) || 0
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Get vendor comparison metrics (sales per product vs margin)
|
// Get vendor comparison metrics (sales per product vs margin)
|
||||||
const { rows: rawComparison } = await pool.query(`
|
const { rows: rawComparison } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
p.vendor,
|
vendor_name as vendor,
|
||||||
COALESCE(ROUND(
|
CASE
|
||||||
SUM(o.price * o.quantity) / NULLIF(COUNT(DISTINCT p.pid), 0),
|
WHEN active_product_count > 0
|
||||||
2
|
THEN revenue_30d / active_product_count
|
||||||
), 0) as sales_per_product,
|
ELSE 0
|
||||||
COALESCE(ROUND(
|
END as sales_per_product,
|
||||||
AVG((p.price - p.cost_price) / NULLIF(p.cost_price, 0) * 100),
|
avg_margin_30d as average_margin,
|
||||||
2
|
product_count as size
|
||||||
), 0) as average_margin,
|
FROM vendor_metrics
|
||||||
COUNT(DISTINCT p.pid) as size
|
WHERE active_product_count > 0
|
||||||
FROM products p
|
|
||||||
LEFT JOIN orders o ON p.pid = o.pid
|
|
||||||
WHERE p.vendor IS NOT NULL
|
|
||||||
AND o.date >= CURRENT_DATE - INTERVAL '30 days'
|
|
||||||
GROUP BY p.vendor
|
|
||||||
HAVING COUNT(DISTINCT p.pid) > 0
|
|
||||||
ORDER BY sales_per_product DESC
|
ORDER BY sales_per_product DESC
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
`);
|
`);
|
||||||
@@ -294,58 +261,7 @@ router.get('/vendors', async (req, res) => {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching vendor performance:', error);
|
console.error('Error fetching vendor performance:', error);
|
||||||
console.error('Error details:', error.message);
|
res.status(500).json({ error: 'Failed to fetch vendor performance data' });
|
||||||
|
|
||||||
// Return dummy data on error with complete structure
|
|
||||||
res.json({
|
|
||||||
performance: [
|
|
||||||
{
|
|
||||||
vendor: "Example Vendor 1",
|
|
||||||
salesVolume: 10000,
|
|
||||||
profitMargin: 25.5,
|
|
||||||
stockTurnover: 3.2,
|
|
||||||
productCount: 15,
|
|
||||||
growth: 12.3
|
|
||||||
},
|
|
||||||
{
|
|
||||||
vendor: "Example Vendor 2",
|
|
||||||
salesVolume: 8500,
|
|
||||||
profitMargin: 22.8,
|
|
||||||
stockTurnover: 2.9,
|
|
||||||
productCount: 12,
|
|
||||||
growth: 8.7
|
|
||||||
},
|
|
||||||
{
|
|
||||||
vendor: "Example Vendor 3",
|
|
||||||
salesVolume: 6200,
|
|
||||||
profitMargin: 19.5,
|
|
||||||
stockTurnover: 2.5,
|
|
||||||
productCount: 8,
|
|
||||||
growth: 5.2
|
|
||||||
}
|
|
||||||
],
|
|
||||||
comparison: [
|
|
||||||
{
|
|
||||||
vendor: "Example Vendor 1",
|
|
||||||
salesPerProduct: 650,
|
|
||||||
averageMargin: 35.2,
|
|
||||||
size: 15
|
|
||||||
},
|
|
||||||
{
|
|
||||||
vendor: "Example Vendor 2",
|
|
||||||
salesPerProduct: 710,
|
|
||||||
averageMargin: 28.5,
|
|
||||||
size: 12
|
|
||||||
},
|
|
||||||
{
|
|
||||||
vendor: "Example Vendor 3",
|
|
||||||
salesPerProduct: 770,
|
|
||||||
averageMargin: 22.8,
|
|
||||||
size: 8
|
|
||||||
}
|
|
||||||
],
|
|
||||||
trends: []
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -353,108 +269,119 @@ router.get('/vendors', async (req, res) => {
|
|||||||
router.get('/stock', async (req, res) => {
|
router.get('/stock', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const pool = req.app.locals.pool;
|
const pool = req.app.locals.pool;
|
||||||
|
console.log('Fetching stock analysis data...');
|
||||||
|
|
||||||
// Get global configuration values
|
// Use the new metrics tables to get data
|
||||||
const { rows: configs } = await pool.query(`
|
|
||||||
SELECT
|
|
||||||
st.low_stock_threshold,
|
|
||||||
tc.calculation_period_days as turnover_period
|
|
||||||
FROM stock_thresholds st
|
|
||||||
CROSS JOIN turnover_config tc
|
|
||||||
WHERE st.id = 1 AND tc.id = 1
|
|
||||||
`);
|
|
||||||
|
|
||||||
const config = configs[0] || {
|
|
||||||
low_stock_threshold: 5,
|
|
||||||
turnover_period: 30
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get turnover by category
|
// Get turnover by category
|
||||||
const { rows: turnoverByCategory } = await pool.query(`
|
const { rows: turnoverByCategory } = await pool.query(`
|
||||||
|
WITH category_metrics_with_path AS (
|
||||||
|
WITH RECURSIVE category_path AS (
|
||||||
SELECT
|
SELECT
|
||||||
c.name as category,
|
c.cat_id,
|
||||||
ROUND((SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0))::numeric, 1) as turnoverRate,
|
c.name,
|
||||||
ROUND(AVG(p.stock_quantity)::numeric, 0) as averageStock,
|
c.parent_id,
|
||||||
SUM(o.quantity) as totalSales
|
c.name::text as path
|
||||||
FROM products p
|
FROM categories c
|
||||||
LEFT JOIN orders o ON p.pid = o.pid
|
WHERE c.parent_id IS NULL
|
||||||
JOIN product_categories pc ON p.pid = pc.pid
|
|
||||||
JOIN categories c ON pc.cat_id = c.cat_id
|
UNION ALL
|
||||||
WHERE o.date >= CURRENT_DATE - INTERVAL '${config.turnover_period} days'
|
|
||||||
GROUP BY c.name
|
SELECT
|
||||||
HAVING ROUND((SUM(o.quantity) / NULLIF(AVG(p.stock_quantity), 0))::numeric, 1) > 0
|
c.cat_id,
|
||||||
ORDER BY turnoverRate DESC
|
c.name,
|
||||||
|
c.parent_id,
|
||||||
|
(cp.path || ' > ' || c.name)::text
|
||||||
|
FROM categories c
|
||||||
|
JOIN category_path cp ON c.parent_id = cp.cat_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
cm.category_id,
|
||||||
|
cm.category_name,
|
||||||
|
cp.path as category_path,
|
||||||
|
cm.current_stock_units,
|
||||||
|
cm.sales_30d,
|
||||||
|
cm.stock_turn_30d
|
||||||
|
FROM category_metrics cm
|
||||||
|
LEFT JOIN category_path cp ON cm.category_id = cp.cat_id
|
||||||
|
WHERE cm.sales_30d > 0
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
category_name as category,
|
||||||
|
COALESCE(stock_turn_30d, 0) as turnoverRate,
|
||||||
|
current_stock_units as averageStock,
|
||||||
|
sales_30d as totalSales
|
||||||
|
FROM category_metrics_with_path
|
||||||
|
ORDER BY stock_turn_30d DESC NULLS LAST
|
||||||
LIMIT 10
|
LIMIT 10
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Get stock levels over time
|
// Get stock levels over time (last 30 days)
|
||||||
const { rows: stockLevels } = await pool.query(`
|
const { rows: stockLevels } = await pool.query(`
|
||||||
|
WITH date_range AS (
|
||||||
|
SELECT generate_series(
|
||||||
|
CURRENT_DATE - INTERVAL '30 days',
|
||||||
|
CURRENT_DATE,
|
||||||
|
'1 day'::interval
|
||||||
|
)::date AS date
|
||||||
|
),
|
||||||
|
daily_stock_counts AS (
|
||||||
SELECT
|
SELECT
|
||||||
to_char(o.date, 'YYYY-MM-DD') as date,
|
snapshot_date,
|
||||||
SUM(CASE WHEN p.stock_quantity > $1 THEN 1 ELSE 0 END) as inStock,
|
COUNT(DISTINCT pid) as total_products,
|
||||||
SUM(CASE WHEN p.stock_quantity <= $1 AND p.stock_quantity > 0 THEN 1 ELSE 0 END) as lowStock,
|
COUNT(DISTINCT CASE WHEN eod_stock_quantity > 5 THEN pid END) as in_stock,
|
||||||
SUM(CASE WHEN p.stock_quantity = 0 THEN 1 ELSE 0 END) as outOfStock
|
COUNT(DISTINCT CASE WHEN eod_stock_quantity <= 5 AND eod_stock_quantity > 0 THEN pid END) as low_stock,
|
||||||
FROM products p
|
COUNT(DISTINCT CASE WHEN eod_stock_quantity = 0 THEN pid END) as out_of_stock
|
||||||
LEFT JOIN orders o ON p.pid = o.pid
|
FROM daily_product_snapshots
|
||||||
WHERE o.date >= CURRENT_DATE - INTERVAL '${config.turnover_period} days'
|
WHERE snapshot_date >= CURRENT_DATE - INTERVAL '30 days'
|
||||||
GROUP BY to_char(o.date, 'YYYY-MM-DD')
|
GROUP BY snapshot_date
|
||||||
ORDER BY date
|
|
||||||
`, [config.low_stock_threshold]);
|
|
||||||
|
|
||||||
// Get critical stock items
|
|
||||||
const { rows: criticalItems } = await pool.query(`
|
|
||||||
WITH product_thresholds AS (
|
|
||||||
SELECT
|
|
||||||
p.pid,
|
|
||||||
COALESCE(
|
|
||||||
(SELECT reorder_days
|
|
||||||
FROM stock_thresholds st
|
|
||||||
WHERE st.vendor = p.vendor LIMIT 1),
|
|
||||||
(SELECT reorder_days
|
|
||||||
FROM stock_thresholds st
|
|
||||||
WHERE st.vendor IS NULL LIMIT 1),
|
|
||||||
14
|
|
||||||
) as reorder_days
|
|
||||||
FROM products p
|
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
p.title as product,
|
to_char(dr.date, 'YYYY-MM-DD') as date,
|
||||||
p.SKU as sku,
|
COALESCE(dsc.in_stock, 0) as inStock,
|
||||||
p.stock_quantity as stockQuantity,
|
COALESCE(dsc.low_stock, 0) as lowStock,
|
||||||
GREATEST(ROUND((AVG(o.quantity) * pt.reorder_days)::numeric), $1) as reorderPoint,
|
COALESCE(dsc.out_of_stock, 0) as outOfStock
|
||||||
ROUND((SUM(o.quantity) / NULLIF(p.stock_quantity, 0))::numeric, 1) as turnoverRate,
|
FROM date_range dr
|
||||||
CASE
|
LEFT JOIN daily_stock_counts dsc ON dr.date = dsc.snapshot_date
|
||||||
WHEN p.stock_quantity = 0 THEN 0
|
ORDER BY dr.date
|
||||||
ELSE ROUND((p.stock_quantity / NULLIF((SUM(o.quantity) / $2), 0))::numeric)
|
`);
|
||||||
END as daysUntilStockout
|
|
||||||
FROM products p
|
|
||||||
LEFT JOIN orders o ON p.pid = o.pid
|
|
||||||
JOIN product_thresholds pt ON p.pid = pt.pid
|
|
||||||
WHERE o.date >= CURRENT_DATE - INTERVAL '${config.turnover_period} days'
|
|
||||||
AND p.managing_stock = true
|
|
||||||
GROUP BY p.pid, pt.reorder_days
|
|
||||||
HAVING
|
|
||||||
CASE
|
|
||||||
WHEN p.stock_quantity = 0 THEN 0
|
|
||||||
ELSE ROUND((p.stock_quantity / NULLIF((SUM(o.quantity) / $2), 0))::numeric)
|
|
||||||
END < $3
|
|
||||||
AND
|
|
||||||
CASE
|
|
||||||
WHEN p.stock_quantity = 0 THEN 0
|
|
||||||
ELSE ROUND((p.stock_quantity / NULLIF((SUM(o.quantity) / $2), 0))::numeric)
|
|
||||||
END >= 0
|
|
||||||
ORDER BY daysUntilStockout
|
|
||||||
LIMIT 10
|
|
||||||
`, [
|
|
||||||
config.low_stock_threshold,
|
|
||||||
config.turnover_period,
|
|
||||||
config.turnover_period
|
|
||||||
]);
|
|
||||||
|
|
||||||
res.json({ turnoverByCategory, stockLevels, criticalItems });
|
// Get critical items (products that need reordering)
|
||||||
|
const { rows: criticalItems } = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
pm.title as product,
|
||||||
|
pm.sku as sku,
|
||||||
|
pm.current_stock as stockQuantity,
|
||||||
|
COALESCE(pm.config_safety_stock, 0) as reorderPoint,
|
||||||
|
COALESCE(pm.stockturn_30d, 0) as turnoverRate,
|
||||||
|
CASE
|
||||||
|
WHEN pm.sales_velocity_daily > 0
|
||||||
|
THEN ROUND(pm.current_stock / pm.sales_velocity_daily)
|
||||||
|
ELSE 999
|
||||||
|
END as daysUntilStockout
|
||||||
|
FROM product_metrics pm
|
||||||
|
WHERE pm.is_visible = true
|
||||||
|
AND pm.is_replenishable = true
|
||||||
|
AND pm.sales_30d > 0
|
||||||
|
AND pm.current_stock <= pm.config_safety_stock * 2
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN pm.sales_velocity_daily > 0
|
||||||
|
THEN pm.current_stock / pm.sales_velocity_daily
|
||||||
|
ELSE 999
|
||||||
|
END ASC,
|
||||||
|
pm.revenue_30d DESC
|
||||||
|
LIMIT 10
|
||||||
|
`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
turnoverByCategory,
|
||||||
|
stockLevels,
|
||||||
|
criticalItems
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching stock analysis:', error);
|
console.error('Error fetching stock analysis:', error);
|
||||||
res.status(500).json({ error: 'Failed to fetch stock analysis' });
|
res.status(500).json({ error: 'Failed to fetch stock analysis', details: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -685,99 +612,4 @@ router.get('/categories', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Forecast endpoint
|
|
||||||
router.get('/forecast', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { brand, startDate, endDate } = req.query;
|
|
||||||
const pool = req.app.locals.pool;
|
|
||||||
|
|
||||||
const [results] = await pool.query(`
|
|
||||||
WITH RECURSIVE category_path AS (
|
|
||||||
SELECT
|
|
||||||
c.cat_id,
|
|
||||||
c.name,
|
|
||||||
c.parent_id,
|
|
||||||
CAST(c.name AS CHAR(1000)) as path
|
|
||||||
FROM categories c
|
|
||||||
WHERE c.parent_id IS NULL
|
|
||||||
|
|
||||||
UNION ALL
|
|
||||||
|
|
||||||
SELECT
|
|
||||||
c.cat_id,
|
|
||||||
c.name,
|
|
||||||
c.parent_id,
|
|
||||||
CONCAT(cp.path, ' > ', c.name)
|
|
||||||
FROM categories c
|
|
||||||
JOIN category_path cp ON c.parent_id = cp.cat_id
|
|
||||||
),
|
|
||||||
category_metrics AS (
|
|
||||||
SELECT
|
|
||||||
c.cat_id,
|
|
||||||
c.name as category_name,
|
|
||||||
cp.path,
|
|
||||||
p.brand,
|
|
||||||
COUNT(DISTINCT p.pid) as num_products,
|
|
||||||
CAST(COALESCE(ROUND(SUM(o.quantity) / DATEDIFF(?, ?), 2), 0) AS DECIMAL(15,3)) as avg_daily_sales,
|
|
||||||
COALESCE(SUM(o.quantity), 0) as total_sold,
|
|
||||||
CAST(COALESCE(ROUND(SUM(o.quantity) / COUNT(DISTINCT p.pid), 2), 0) AS DECIMAL(15,3)) as avgTotalSold,
|
|
||||||
CAST(COALESCE(ROUND(AVG(o.price), 2), 0) AS DECIMAL(15,3)) as avg_price
|
|
||||||
FROM categories c
|
|
||||||
JOIN product_categories pc ON c.cat_id = pc.cat_id
|
|
||||||
JOIN products p ON pc.pid = p.pid
|
|
||||||
JOIN category_path cp ON c.cat_id = cp.cat_id
|
|
||||||
LEFT JOIN product_metrics pmet ON p.pid = pmet.pid
|
|
||||||
LEFT JOIN orders o ON p.pid = o.pid
|
|
||||||
AND o.date BETWEEN ? AND ?
|
|
||||||
AND o.canceled = false
|
|
||||||
WHERE p.brand = ?
|
|
||||||
AND pmet.first_received_date BETWEEN ? AND ?
|
|
||||||
GROUP BY c.cat_id, c.name, cp.path, p.brand
|
|
||||||
),
|
|
||||||
product_details AS (
|
|
||||||
SELECT
|
|
||||||
p.pid,
|
|
||||||
p.title,
|
|
||||||
p.SKU,
|
|
||||||
p.stock_quantity,
|
|
||||||
pc.cat_id,
|
|
||||||
pmet.first_received_date,
|
|
||||||
COALESCE(SUM(o.quantity), 0) as total_sold,
|
|
||||||
CAST(COALESCE(ROUND(AVG(o.price), 2), 0) AS DECIMAL(15,3)) as avg_price
|
|
||||||
FROM products p
|
|
||||||
JOIN product_categories pc ON p.pid = pc.pid
|
|
||||||
JOIN product_metrics pmet ON p.pid = pmet.pid
|
|
||||||
LEFT JOIN orders o ON p.pid = o.pid
|
|
||||||
AND o.date BETWEEN ? AND ?
|
|
||||||
AND o.canceled = false
|
|
||||||
WHERE p.brand = ?
|
|
||||||
AND pmet.first_received_date BETWEEN ? AND ?
|
|
||||||
GROUP BY p.pid, p.title, p.SKU, p.stock_quantity, pc.cat_id, pmet.first_received_date
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
cm.*,
|
|
||||||
JSON_ARRAYAGG(
|
|
||||||
JSON_OBJECT(
|
|
||||||
'pid', pd.pid,
|
|
||||||
'title', pd.title,
|
|
||||||
'SKU', pd.SKU,
|
|
||||||
'stock_quantity', pd.stock_quantity,
|
|
||||||
'total_sold', pd.total_sold,
|
|
||||||
'avg_price', pd.avg_price,
|
|
||||||
'first_received_date', DATE_FORMAT(pd.first_received_date, '%Y-%m-%d')
|
|
||||||
)
|
|
||||||
) as products
|
|
||||||
FROM category_metrics cm
|
|
||||||
JOIN product_details pd ON cm.cat_id = pd.cat_id
|
|
||||||
GROUP BY cm.cat_id, cm.category_name, cm.path, cm.brand, cm.num_products, cm.avg_daily_sales, cm.total_sold, cm.avgTotalSold, cm.avg_price
|
|
||||||
ORDER BY cm.total_sold DESC
|
|
||||||
`, [endDate, startDate, startDate, endDate, brand, startDate, endDate, startDate, endDate, brand, startDate, endDate]);
|
|
||||||
|
|
||||||
res.json(results);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching forecast data:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to fetch forecast data' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -321,169 +321,5 @@ router.post('/vendors/:vendor/reset', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== LEGACY ENDPOINTS =====
|
|
||||||
// These are kept for backward compatibility but will be removed in future versions
|
|
||||||
|
|
||||||
// Get all configuration values
|
|
||||||
router.get('/', async (req, res) => {
|
|
||||||
const pool = req.app.locals.pool;
|
|
||||||
try {
|
|
||||||
console.log('[Config Route] Fetching configuration values...');
|
|
||||||
|
|
||||||
const { rows: stockThresholds } = await pool.query('SELECT * FROM stock_thresholds WHERE id = 1');
|
|
||||||
console.log('[Config Route] Stock thresholds:', stockThresholds);
|
|
||||||
|
|
||||||
const { rows: leadTimeThresholds } = await pool.query('SELECT * FROM lead_time_thresholds WHERE id = 1');
|
|
||||||
console.log('[Config Route] Lead time thresholds:', leadTimeThresholds);
|
|
||||||
|
|
||||||
const { rows: salesVelocityConfig } = await pool.query('SELECT * FROM sales_velocity_config WHERE id = 1');
|
|
||||||
console.log('[Config Route] Sales velocity config:', salesVelocityConfig);
|
|
||||||
|
|
||||||
const { rows: abcConfig } = await pool.query('SELECT * FROM abc_classification_config WHERE id = 1');
|
|
||||||
console.log('[Config Route] ABC config:', abcConfig);
|
|
||||||
|
|
||||||
const { rows: safetyStockConfig } = await pool.query('SELECT * FROM safety_stock_config WHERE id = 1');
|
|
||||||
console.log('[Config Route] Safety stock config:', safetyStockConfig);
|
|
||||||
|
|
||||||
const { rows: turnoverConfig } = await pool.query('SELECT * FROM turnover_config WHERE id = 1');
|
|
||||||
console.log('[Config Route] Turnover config:', turnoverConfig);
|
|
||||||
|
|
||||||
const response = {
|
|
||||||
stockThresholds: stockThresholds[0],
|
|
||||||
leadTimeThresholds: leadTimeThresholds[0],
|
|
||||||
salesVelocityConfig: salesVelocityConfig[0],
|
|
||||||
abcConfig: abcConfig[0],
|
|
||||||
safetyStockConfig: safetyStockConfig[0],
|
|
||||||
turnoverConfig: turnoverConfig[0]
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log('[Config Route] Sending response:', response);
|
|
||||||
res.json(response);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Config Route] Error fetching configuration:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to fetch configuration', details: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update stock thresholds
|
|
||||||
router.put('/stock-thresholds/:id', async (req, res) => {
|
|
||||||
const pool = req.app.locals.pool;
|
|
||||||
try {
|
|
||||||
const { critical_days, reorder_days, overstock_days, low_stock_threshold, min_reorder_quantity } = req.body;
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
`UPDATE stock_thresholds
|
|
||||||
SET critical_days = $1,
|
|
||||||
reorder_days = $2,
|
|
||||||
overstock_days = $3,
|
|
||||||
low_stock_threshold = $4,
|
|
||||||
min_reorder_quantity = $5
|
|
||||||
WHERE id = $6`,
|
|
||||||
[critical_days, reorder_days, overstock_days, low_stock_threshold, min_reorder_quantity, req.params.id]
|
|
||||||
);
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Config Route] Error updating stock thresholds:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to update stock thresholds' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update lead time thresholds
|
|
||||||
router.put('/lead-time-thresholds/:id', async (req, res) => {
|
|
||||||
const pool = req.app.locals.pool;
|
|
||||||
try {
|
|
||||||
const { target_days, warning_days, critical_days } = req.body;
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
`UPDATE lead_time_thresholds
|
|
||||||
SET target_days = $1,
|
|
||||||
warning_days = $2,
|
|
||||||
critical_days = $3
|
|
||||||
WHERE id = $4`,
|
|
||||||
[target_days, warning_days, critical_days, req.params.id]
|
|
||||||
);
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Config Route] Error updating lead time thresholds:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to update lead time thresholds' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update sales velocity config
|
|
||||||
router.put('/sales-velocity/:id', async (req, res) => {
|
|
||||||
const pool = req.app.locals.pool;
|
|
||||||
try {
|
|
||||||
const { daily_window_days, weekly_window_days, monthly_window_days } = req.body;
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
`UPDATE sales_velocity_config
|
|
||||||
SET daily_window_days = $1,
|
|
||||||
weekly_window_days = $2,
|
|
||||||
monthly_window_days = $3
|
|
||||||
WHERE id = $4`,
|
|
||||||
[daily_window_days, weekly_window_days, monthly_window_days, req.params.id]
|
|
||||||
);
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Config Route] Error updating sales velocity config:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to update sales velocity config' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update ABC classification config
|
|
||||||
router.put('/abc-classification/:id', async (req, res) => {
|
|
||||||
const pool = req.app.locals.pool;
|
|
||||||
try {
|
|
||||||
const { a_threshold, b_threshold, classification_period_days } = req.body;
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
`UPDATE abc_classification_config
|
|
||||||
SET a_threshold = $1,
|
|
||||||
b_threshold = $2,
|
|
||||||
classification_period_days = $3
|
|
||||||
WHERE id = $4`,
|
|
||||||
[a_threshold, b_threshold, classification_period_days, req.params.id]
|
|
||||||
);
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Config Route] Error updating ABC classification config:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to update ABC classification config' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update safety stock config
|
|
||||||
router.put('/safety-stock/:id', async (req, res) => {
|
|
||||||
const pool = req.app.locals.pool;
|
|
||||||
try {
|
|
||||||
const { coverage_days, service_level } = req.body;
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
`UPDATE safety_stock_config
|
|
||||||
SET coverage_days = $1,
|
|
||||||
service_level = $2
|
|
||||||
WHERE id = $3`,
|
|
||||||
[coverage_days, service_level, req.params.id]
|
|
||||||
);
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Config Route] Error updating safety stock config:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to update safety stock config' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update turnover config
|
|
||||||
router.put('/turnover/:id', async (req, res) => {
|
|
||||||
const pool = req.app.locals.pool;
|
|
||||||
try {
|
|
||||||
const { calculation_period_days, target_rate } = req.body;
|
|
||||||
const { rows } = await pool.query(
|
|
||||||
`UPDATE turnover_config
|
|
||||||
SET calculation_period_days = $1,
|
|
||||||
target_rate = $2
|
|
||||||
WHERE id = $3`,
|
|
||||||
[calculation_period_days, target_rate, req.params.id]
|
|
||||||
);
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Config Route] Error updating turnover config:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to update turnover config' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Export the router
|
// Export the router
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -1,881 +0,0 @@
|
|||||||
const express = require('express');
|
|
||||||
const router = express.Router();
|
|
||||||
const { spawn } = require('child_process');
|
|
||||||
const path = require('path');
|
|
||||||
const db = require('../utils/db');
|
|
||||||
|
|
||||||
// Debug middleware MUST be first
|
|
||||||
router.use((req, res, next) => {
|
|
||||||
console.log(`[CSV Route Debug] ${req.method} ${req.path}`);
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Store active processes and their progress
|
|
||||||
let activeImport = null;
|
|
||||||
let importProgress = null;
|
|
||||||
let activeFullUpdate = null;
|
|
||||||
let activeFullReset = null;
|
|
||||||
|
|
||||||
// SSE clients for progress updates
|
|
||||||
const updateClients = new Set();
|
|
||||||
const importClients = new Set();
|
|
||||||
const resetClients = new Set();
|
|
||||||
const resetMetricsClients = new Set();
|
|
||||||
const calculateMetricsClients = new Set();
|
|
||||||
const fullUpdateClients = new Set();
|
|
||||||
const fullResetClients = new Set();
|
|
||||||
|
|
||||||
// Helper to send progress to specific clients
|
|
||||||
function sendProgressToClients(clients, data) {
|
|
||||||
// If data is a string, send it directly
|
|
||||||
// If it's an object, convert it to JSON
|
|
||||||
const message = typeof data === 'string'
|
|
||||||
? `data: ${data}\n\n`
|
|
||||||
: `data: ${JSON.stringify(data)}\n\n`;
|
|
||||||
|
|
||||||
clients.forEach(client => {
|
|
||||||
try {
|
|
||||||
client.write(message);
|
|
||||||
// Immediately flush the response
|
|
||||||
if (typeof client.flush === 'function') {
|
|
||||||
client.flush();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Silently remove failed client
|
|
||||||
clients.delete(client);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to run a script and stream progress
|
|
||||||
function runScript(scriptPath, type, clients) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
// Kill any existing process of this type
|
|
||||||
let activeProcess;
|
|
||||||
switch (type) {
|
|
||||||
case 'update':
|
|
||||||
if (activeFullUpdate) {
|
|
||||||
try { activeFullUpdate.kill(); } catch (e) { }
|
|
||||||
}
|
|
||||||
activeProcess = activeFullUpdate;
|
|
||||||
break;
|
|
||||||
case 'reset':
|
|
||||||
if (activeFullReset) {
|
|
||||||
try { activeFullReset.kill(); } catch (e) { }
|
|
||||||
}
|
|
||||||
activeProcess = activeFullReset;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const child = spawn('node', [scriptPath], {
|
|
||||||
stdio: ['inherit', 'pipe', 'pipe']
|
|
||||||
});
|
|
||||||
|
|
||||||
switch (type) {
|
|
||||||
case 'update':
|
|
||||||
activeFullUpdate = child;
|
|
||||||
break;
|
|
||||||
case 'reset':
|
|
||||||
activeFullReset = child;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let output = '';
|
|
||||||
|
|
||||||
child.stdout.on('data', (data) => {
|
|
||||||
const text = data.toString();
|
|
||||||
output += text;
|
|
||||||
|
|
||||||
// Split by lines to handle multiple JSON outputs
|
|
||||||
const lines = text.split('\n');
|
|
||||||
lines.filter(line => line.trim()).forEach(line => {
|
|
||||||
try {
|
|
||||||
// Try to parse as JSON but don't let it affect the display
|
|
||||||
const jsonData = JSON.parse(line);
|
|
||||||
// Only end the process if we get a final status
|
|
||||||
if (jsonData.status === 'complete' || jsonData.status === 'error' || jsonData.status === 'cancelled') {
|
|
||||||
if (jsonData.status === 'complete' && !jsonData.operation?.includes('complete')) {
|
|
||||||
// Don't close for intermediate completion messages
|
|
||||||
sendProgressToClients(clients, line);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Close only on final completion/error/cancellation
|
|
||||||
switch (type) {
|
|
||||||
case 'update':
|
|
||||||
activeFullUpdate = null;
|
|
||||||
break;
|
|
||||||
case 'reset':
|
|
||||||
activeFullReset = null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (jsonData.status === 'error') {
|
|
||||||
reject(new Error(jsonData.error || 'Unknown error'));
|
|
||||||
} else {
|
|
||||||
resolve({ output });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Not JSON, just display as is
|
|
||||||
}
|
|
||||||
// Always send the raw line
|
|
||||||
sendProgressToClients(clients, line);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
child.stderr.on('data', (data) => {
|
|
||||||
const text = data.toString();
|
|
||||||
console.error(text);
|
|
||||||
// Send stderr output directly too
|
|
||||||
sendProgressToClients(clients, text);
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on('close', (code) => {
|
|
||||||
switch (type) {
|
|
||||||
case 'update':
|
|
||||||
activeFullUpdate = null;
|
|
||||||
break;
|
|
||||||
case 'reset':
|
|
||||||
activeFullReset = null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (code !== 0) {
|
|
||||||
const error = `Script ${scriptPath} exited with code ${code}`;
|
|
||||||
sendProgressToClients(clients, error);
|
|
||||||
reject(new Error(error));
|
|
||||||
}
|
|
||||||
// Don't resolve here - let the completion message from the script trigger the resolve
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on('error', (err) => {
|
|
||||||
switch (type) {
|
|
||||||
case 'update':
|
|
||||||
activeFullUpdate = null;
|
|
||||||
break;
|
|
||||||
case 'reset':
|
|
||||||
activeFullReset = null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
sendProgressToClients(clients, err.message);
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Progress endpoints
|
|
||||||
router.get('/:type/progress', (req, res) => {
|
|
||||||
const { type } = req.params;
|
|
||||||
if (!['update', 'reset'].includes(type)) {
|
|
||||||
return res.status(400).json({ error: 'Invalid operation type' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.writeHead(200, {
|
|
||||||
'Content-Type': 'text/event-stream',
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
'Connection': 'keep-alive',
|
|
||||||
'Access-Control-Allow-Origin': req.headers.origin || '*',
|
|
||||||
'Access-Control-Allow-Credentials': 'true'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add this client to the correct set
|
|
||||||
const clients = type === 'update' ? fullUpdateClients : fullResetClients;
|
|
||||||
clients.add(res);
|
|
||||||
|
|
||||||
// Send initial connection message
|
|
||||||
sendProgressToClients(new Set([res]), JSON.stringify({
|
|
||||||
status: 'running',
|
|
||||||
operation: 'Initializing connection...'
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Handle client disconnect
|
|
||||||
req.on('close', () => {
|
|
||||||
clients.delete(res);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Debug endpoint to verify route registration
|
|
||||||
router.get('/test', (req, res) => {
|
|
||||||
console.log('CSV test endpoint hit');
|
|
||||||
res.json({ message: 'CSV routes are working' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Route to check import status
|
|
||||||
router.get('/status', (req, res) => {
|
|
||||||
console.log('CSV status endpoint hit');
|
|
||||||
res.json({
|
|
||||||
active: !!activeImport,
|
|
||||||
progress: importProgress
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add calculate-metrics status endpoint
|
|
||||||
router.get('/calculate-metrics/status', (req, res) => {
|
|
||||||
const calculateMetrics = require('../../scripts/calculate-metrics');
|
|
||||||
const progress = calculateMetrics.getProgress();
|
|
||||||
|
|
||||||
// Only consider it active if both the process is running and we have progress
|
|
||||||
const isActive = !!activeImport && !!progress;
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
active: isActive,
|
|
||||||
progress: isActive ? progress : null
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Route to update CSV files
|
|
||||||
router.post('/update', async (req, res, next) => {
|
|
||||||
if (activeImport) {
|
|
||||||
return res.status(409).json({ error: 'Import already in progress' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'update-csv.js');
|
|
||||||
|
|
||||||
if (!require('fs').existsSync(scriptPath)) {
|
|
||||||
return res.status(500).json({ error: 'Update script not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
activeImport = spawn('node', [scriptPath]);
|
|
||||||
|
|
||||||
activeImport.stdout.on('data', (data) => {
|
|
||||||
const output = data.toString().trim();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try to parse as JSON
|
|
||||||
const jsonData = JSON.parse(output);
|
|
||||||
sendProgressToClients(updateClients, {
|
|
||||||
status: 'running',
|
|
||||||
...jsonData
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// If not JSON, send as plain progress
|
|
||||||
sendProgressToClients(updateClients, {
|
|
||||||
status: 'running',
|
|
||||||
progress: output
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
activeImport.stderr.on('data', (data) => {
|
|
||||||
const error = data.toString().trim();
|
|
||||||
try {
|
|
||||||
// Try to parse as JSON
|
|
||||||
const jsonData = JSON.parse(error);
|
|
||||||
sendProgressToClients(updateClients, {
|
|
||||||
status: 'error',
|
|
||||||
...jsonData
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
sendProgressToClients(updateClients, {
|
|
||||||
status: 'error',
|
|
||||||
error
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
activeImport.on('close', (code) => {
|
|
||||||
// Don't treat cancellation (code 143/SIGTERM) as an error
|
|
||||||
if (code === 0 || code === 143) {
|
|
||||||
sendProgressToClients(updateClients, {
|
|
||||||
status: 'complete',
|
|
||||||
operation: code === 143 ? 'Operation cancelled' : 'Update complete'
|
|
||||||
});
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
const errorMsg = `Update process exited with code ${code}`;
|
|
||||||
sendProgressToClients(updateClients, {
|
|
||||||
status: 'error',
|
|
||||||
error: errorMsg
|
|
||||||
});
|
|
||||||
reject(new Error(errorMsg));
|
|
||||||
}
|
|
||||||
activeImport = null;
|
|
||||||
importProgress = null;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating CSV files:', error);
|
|
||||||
activeImport = null;
|
|
||||||
importProgress = null;
|
|
||||||
sendProgressToClients(updateClients, {
|
|
||||||
status: 'error',
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Route to import CSV files
|
|
||||||
router.post('/import', async (req, res) => {
|
|
||||||
if (activeImport) {
|
|
||||||
return res.status(409).json({ error: 'Import already in progress' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'import-csv.js');
|
|
||||||
|
|
||||||
if (!require('fs').existsSync(scriptPath)) {
|
|
||||||
return res.status(500).json({ error: 'Import script not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get test limits from request body
|
|
||||||
const { products = 0, orders = 10000, purchaseOrders = 10000 } = req.body;
|
|
||||||
|
|
||||||
// Create environment variables for the script
|
|
||||||
const env = {
|
|
||||||
...process.env,
|
|
||||||
PRODUCTS_TEST_LIMIT: products.toString(),
|
|
||||||
ORDERS_TEST_LIMIT: orders.toString(),
|
|
||||||
PURCHASE_ORDERS_TEST_LIMIT: purchaseOrders.toString()
|
|
||||||
};
|
|
||||||
|
|
||||||
activeImport = spawn('node', [scriptPath], { env });
|
|
||||||
|
|
||||||
activeImport.stdout.on('data', (data) => {
|
|
||||||
const output = data.toString().trim();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try to parse as JSON
|
|
||||||
const jsonData = JSON.parse(output);
|
|
||||||
sendProgressToClients(importClients, {
|
|
||||||
status: 'running',
|
|
||||||
...jsonData
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// If not JSON, send as plain progress
|
|
||||||
sendProgressToClients(importClients, {
|
|
||||||
status: 'running',
|
|
||||||
progress: output
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
activeImport.stderr.on('data', (data) => {
|
|
||||||
const error = data.toString().trim();
|
|
||||||
try {
|
|
||||||
// Try to parse as JSON
|
|
||||||
const jsonData = JSON.parse(error);
|
|
||||||
sendProgressToClients(importClients, {
|
|
||||||
status: 'error',
|
|
||||||
...jsonData
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
sendProgressToClients(importClients, {
|
|
||||||
status: 'error',
|
|
||||||
error
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
activeImport.on('close', (code) => {
|
|
||||||
// Don't treat cancellation (code 143/SIGTERM) as an error
|
|
||||||
if (code === 0 || code === 143) {
|
|
||||||
sendProgressToClients(importClients, {
|
|
||||||
status: 'complete',
|
|
||||||
operation: code === 143 ? 'Operation cancelled' : 'Import complete'
|
|
||||||
});
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
sendProgressToClients(importClients, {
|
|
||||||
status: 'error',
|
|
||||||
error: `Process exited with code ${code}`
|
|
||||||
});
|
|
||||||
reject(new Error(`Import process exited with code ${code}`));
|
|
||||||
}
|
|
||||||
activeImport = null;
|
|
||||||
importProgress = null;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error importing CSV files:', error);
|
|
||||||
activeImport = null;
|
|
||||||
importProgress = null;
|
|
||||||
sendProgressToClients(importClients, {
|
|
||||||
status: 'error',
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
res.status(500).json({ error: 'Failed to import CSV files', details: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Route to cancel active process
|
|
||||||
router.post('/cancel', (req, res) => {
|
|
||||||
let killed = false;
|
|
||||||
|
|
||||||
// Get the operation type from the request
|
|
||||||
const { type } = req.query;
|
|
||||||
const clients = type === 'update' ? fullUpdateClients : fullResetClients;
|
|
||||||
const activeProcess = type === 'update' ? activeFullUpdate : activeFullReset;
|
|
||||||
|
|
||||||
if (activeProcess) {
|
|
||||||
try {
|
|
||||||
activeProcess.kill('SIGTERM');
|
|
||||||
if (type === 'update') {
|
|
||||||
activeFullUpdate = null;
|
|
||||||
} else {
|
|
||||||
activeFullReset = null;
|
|
||||||
}
|
|
||||||
killed = true;
|
|
||||||
sendProgressToClients(clients, JSON.stringify({
|
|
||||||
status: 'cancelled',
|
|
||||||
operation: 'Operation cancelled'
|
|
||||||
}));
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`Error killing ${type} process:`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (killed) {
|
|
||||||
res.json({ success: true });
|
|
||||||
} else {
|
|
||||||
res.status(404).json({ error: 'No active process to cancel' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Route to reset database
|
|
||||||
router.post('/reset', async (req, res) => {
|
|
||||||
if (activeImport) {
|
|
||||||
return res.status(409).json({ error: 'Import already in progress' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'reset-db.js');
|
|
||||||
|
|
||||||
if (!require('fs').existsSync(scriptPath)) {
|
|
||||||
return res.status(500).json({ error: 'Reset script not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
activeImport = spawn('node', [scriptPath]);
|
|
||||||
|
|
||||||
activeImport.stdout.on('data', (data) => {
|
|
||||||
const output = data.toString().trim();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try to parse as JSON
|
|
||||||
const jsonData = JSON.parse(output);
|
|
||||||
sendProgressToClients(resetClients, {
|
|
||||||
status: 'running',
|
|
||||||
...jsonData
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// If not JSON, send as plain progress
|
|
||||||
sendProgressToClients(resetClients, {
|
|
||||||
status: 'running',
|
|
||||||
progress: output
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
activeImport.stderr.on('data', (data) => {
|
|
||||||
const error = data.toString().trim();
|
|
||||||
try {
|
|
||||||
// Try to parse as JSON
|
|
||||||
const jsonData = JSON.parse(error);
|
|
||||||
sendProgressToClients(resetClients, {
|
|
||||||
status: 'error',
|
|
||||||
...jsonData
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
sendProgressToClients(resetClients, {
|
|
||||||
status: 'error',
|
|
||||||
error
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
activeImport.on('close', (code) => {
|
|
||||||
// Don't treat cancellation (code 143/SIGTERM) as an error
|
|
||||||
if (code === 0 || code === 143) {
|
|
||||||
sendProgressToClients(resetClients, {
|
|
||||||
status: 'complete',
|
|
||||||
operation: code === 143 ? 'Operation cancelled' : 'Reset complete'
|
|
||||||
});
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
const errorMsg = `Reset process exited with code ${code}`;
|
|
||||||
sendProgressToClients(resetClients, {
|
|
||||||
status: 'error',
|
|
||||||
error: errorMsg
|
|
||||||
});
|
|
||||||
reject(new Error(errorMsg));
|
|
||||||
}
|
|
||||||
activeImport = null;
|
|
||||||
importProgress = null;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error resetting database:', error);
|
|
||||||
activeImport = null;
|
|
||||||
importProgress = null;
|
|
||||||
sendProgressToClients(resetClients, {
|
|
||||||
status: 'error',
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
res.status(500).json({ error: 'Failed to reset database', details: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add reset-metrics endpoint
|
|
||||||
router.post('/reset-metrics', async (req, res) => {
|
|
||||||
if (activeImport) {
|
|
||||||
res.status(400).json({ error: 'Operation already in progress' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Set active import to prevent concurrent operations
|
|
||||||
activeImport = {
|
|
||||||
type: 'reset-metrics',
|
|
||||||
status: 'running',
|
|
||||||
operation: 'Starting metrics reset'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send initial response
|
|
||||||
res.status(200).json({ message: 'Reset metrics started' });
|
|
||||||
|
|
||||||
// Send initial progress through SSE
|
|
||||||
sendProgressToClients(resetMetricsClients, {
|
|
||||||
status: 'running',
|
|
||||||
operation: 'Starting metrics reset'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Run the reset metrics script
|
|
||||||
const resetMetrics = require('../../scripts/reset-metrics');
|
|
||||||
await resetMetrics();
|
|
||||||
|
|
||||||
// Send completion through SSE
|
|
||||||
sendProgressToClients(resetMetricsClients, {
|
|
||||||
status: 'complete',
|
|
||||||
operation: 'Metrics reset completed'
|
|
||||||
});
|
|
||||||
|
|
||||||
activeImport = null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error during metrics reset:', error);
|
|
||||||
|
|
||||||
// Send error through SSE
|
|
||||||
sendProgressToClients(resetMetricsClients, {
|
|
||||||
status: 'error',
|
|
||||||
error: error.message || 'Failed to reset metrics'
|
|
||||||
});
|
|
||||||
|
|
||||||
activeImport = null;
|
|
||||||
res.status(500).json({ error: error.message || 'Failed to reset metrics' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add calculate-metrics endpoint
|
|
||||||
router.post('/calculate-metrics', async (req, res) => {
|
|
||||||
if (activeImport) {
|
|
||||||
return res.status(409).json({ error: 'Import already in progress' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const scriptPath = path.join(__dirname, '..', '..', 'scripts', 'calculate-metrics.js');
|
|
||||||
|
|
||||||
if (!require('fs').existsSync(scriptPath)) {
|
|
||||||
return res.status(500).json({ error: 'Calculate metrics script not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
activeImport = spawn('node', [scriptPath]);
|
|
||||||
let wasCancelled = false;
|
|
||||||
|
|
||||||
activeImport.stdout.on('data', (data) => {
|
|
||||||
const output = data.toString().trim();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try to parse as JSON
|
|
||||||
const jsonData = JSON.parse(output);
|
|
||||||
importProgress = {
|
|
||||||
status: 'running',
|
|
||||||
...jsonData.progress
|
|
||||||
};
|
|
||||||
sendProgressToClients(calculateMetricsClients, importProgress);
|
|
||||||
} catch (e) {
|
|
||||||
// If not JSON, send as plain progress
|
|
||||||
importProgress = {
|
|
||||||
status: 'running',
|
|
||||||
progress: output
|
|
||||||
};
|
|
||||||
sendProgressToClients(calculateMetricsClients, importProgress);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
activeImport.stderr.on('data', (data) => {
|
|
||||||
if (wasCancelled) return; // Don't send errors if cancelled
|
|
||||||
|
|
||||||
const error = data.toString().trim();
|
|
||||||
try {
|
|
||||||
// Try to parse as JSON
|
|
||||||
const jsonData = JSON.parse(error);
|
|
||||||
importProgress = {
|
|
||||||
status: 'error',
|
|
||||||
...jsonData.progress
|
|
||||||
};
|
|
||||||
sendProgressToClients(calculateMetricsClients, importProgress);
|
|
||||||
} catch {
|
|
||||||
importProgress = {
|
|
||||||
status: 'error',
|
|
||||||
error
|
|
||||||
};
|
|
||||||
sendProgressToClients(calculateMetricsClients, importProgress);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
|
||||||
activeImport.on('close', (code, signal) => {
|
|
||||||
wasCancelled = signal === 'SIGTERM' || code === 143;
|
|
||||||
activeImport = null;
|
|
||||||
|
|
||||||
if (code === 0 || wasCancelled) {
|
|
||||||
if (wasCancelled) {
|
|
||||||
importProgress = {
|
|
||||||
status: 'cancelled',
|
|
||||||
operation: 'Operation cancelled'
|
|
||||||
};
|
|
||||||
sendProgressToClients(calculateMetricsClients, importProgress);
|
|
||||||
} else {
|
|
||||||
importProgress = {
|
|
||||||
status: 'complete',
|
|
||||||
operation: 'Metrics calculation complete'
|
|
||||||
};
|
|
||||||
sendProgressToClients(calculateMetricsClients, importProgress);
|
|
||||||
}
|
|
||||||
resolve();
|
|
||||||
} else {
|
|
||||||
importProgress = null;
|
|
||||||
reject(new Error(`Metrics calculation process exited with code ${code}`));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ success: true });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error calculating metrics:', error);
|
|
||||||
activeImport = null;
|
|
||||||
importProgress = null;
|
|
||||||
|
|
||||||
// Only send error if it wasn't a cancellation
|
|
||||||
if (!error.message?.includes('code 143') && !error.message?.includes('SIGTERM')) {
|
|
||||||
sendProgressToClients(calculateMetricsClients, {
|
|
||||||
status: 'error',
|
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
res.status(500).json({ error: 'Failed to calculate metrics', details: error.message });
|
|
||||||
} else {
|
|
||||||
res.json({ success: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Route to import from production database
|
|
||||||
router.post('/import-from-prod', async (req, res) => {
|
|
||||||
if (activeImport) {
|
|
||||||
return res.status(409).json({ error: 'Import already in progress' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const importFromProd = require('../../scripts/import-from-prod');
|
|
||||||
|
|
||||||
// Set up progress handler
|
|
||||||
const progressHandler = (data) => {
|
|
||||||
importProgress = data;
|
|
||||||
sendProgressToClients(importClients, data);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Start the import process
|
|
||||||
importFromProd.outputProgress = progressHandler;
|
|
||||||
activeImport = importFromProd; // Store the module for cancellation
|
|
||||||
|
|
||||||
// Run the import in the background
|
|
||||||
importFromProd.main().catch(error => {
|
|
||||||
console.error('Error in import process:', error);
|
|
||||||
activeImport = null;
|
|
||||||
importProgress = {
|
|
||||||
status: error.message === 'Import cancelled' ? 'cancelled' : 'error',
|
|
||||||
operation: 'Import process',
|
|
||||||
error: error.message
|
|
||||||
};
|
|
||||||
sendProgressToClients(importClients, importProgress);
|
|
||||||
}).finally(() => {
|
|
||||||
activeImport = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
res.json({ message: 'Import from production started' });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error starting production import:', error);
|
|
||||||
activeImport = null;
|
|
||||||
res.status(500).json({ error: error.message || 'Failed to start production import' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /csv/full-update - Run full update script
|
|
||||||
router.post('/full-update', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const scriptPath = path.join(__dirname, '../../scripts/full-update.js');
|
|
||||||
runScript(scriptPath, 'update', fullUpdateClients)
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Update failed:', error);
|
|
||||||
});
|
|
||||||
res.status(202).json({ message: 'Update started' });
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// POST /csv/full-reset - Run full reset script
|
|
||||||
router.post('/full-reset', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const scriptPath = path.join(__dirname, '../../scripts/full-reset.js');
|
|
||||||
runScript(scriptPath, 'reset', fullResetClients)
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Reset failed:', error);
|
|
||||||
});
|
|
||||||
res.status(202).json({ message: 'Reset started' });
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /history/import - Get recent import history
|
|
||||||
router.get('/history/import', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const pool = req.app.locals.pool;
|
|
||||||
const { rows } = await pool.query(`
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
start_time,
|
|
||||||
end_time,
|
|
||||||
status,
|
|
||||||
error_message,
|
|
||||||
records_added::integer,
|
|
||||||
records_updated::integer
|
|
||||||
FROM import_history
|
|
||||||
ORDER BY start_time DESC
|
|
||||||
LIMIT 20
|
|
||||||
`);
|
|
||||||
res.json(rows || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching import history:', error);
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /history/calculate - Get recent calculation history
|
|
||||||
router.get('/history/calculate', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const pool = req.app.locals.pool;
|
|
||||||
const { rows } = await pool.query(`
|
|
||||||
SELECT
|
|
||||||
id,
|
|
||||||
start_time,
|
|
||||||
end_time,
|
|
||||||
duration_minutes,
|
|
||||||
status,
|
|
||||||
error_message,
|
|
||||||
total_products,
|
|
||||||
total_orders,
|
|
||||||
total_purchase_orders,
|
|
||||||
processed_products,
|
|
||||||
processed_orders,
|
|
||||||
processed_purchase_orders,
|
|
||||||
additional_info
|
|
||||||
FROM calculate_history
|
|
||||||
ORDER BY start_time DESC
|
|
||||||
LIMIT 20
|
|
||||||
`);
|
|
||||||
res.json(rows || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching calculate history:', error);
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /status/modules - Get module calculation status
|
|
||||||
router.get('/status/modules', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const pool = req.app.locals.pool;
|
|
||||||
const { rows } = await pool.query(`
|
|
||||||
SELECT
|
|
||||||
module_name,
|
|
||||||
last_calculation_timestamp::timestamp
|
|
||||||
FROM calculate_status
|
|
||||||
ORDER BY module_name
|
|
||||||
`);
|
|
||||||
res.json(rows || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching module status:', error);
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /status/tables - Get table sync status
|
|
||||||
router.get('/status/tables', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const pool = req.app.locals.pool;
|
|
||||||
const { rows } = await pool.query(`
|
|
||||||
SELECT
|
|
||||||
table_name,
|
|
||||||
last_sync_timestamp::timestamp
|
|
||||||
FROM sync_status
|
|
||||||
ORDER BY table_name
|
|
||||||
`);
|
|
||||||
res.json(rows || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching table status:', error);
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /status/table-counts - Get record counts for all tables
|
|
||||||
router.get('/status/table-counts', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const pool = req.app.locals.pool;
|
|
||||||
const tables = [
|
|
||||||
// Core tables
|
|
||||||
'products', 'categories', 'product_categories', 'orders', 'purchase_orders',
|
|
||||||
// New metrics tables
|
|
||||||
'product_metrics', 'daily_product_snapshots','brand_metrics','category_metrics','vendor_metrics',
|
|
||||||
// Config tables
|
|
||||||
'settings_global', 'settings_vendor', 'settings_product'
|
|
||||||
];
|
|
||||||
|
|
||||||
const counts = await Promise.all(
|
|
||||||
tables.map(table =>
|
|
||||||
pool.query(`SELECT COUNT(*) as count FROM ${table}`)
|
|
||||||
.then(result => ({
|
|
||||||
table_name: table,
|
|
||||||
count: parseInt(result.rows[0].count)
|
|
||||||
}))
|
|
||||||
.catch(err => ({
|
|
||||||
table_name: table,
|
|
||||||
count: null,
|
|
||||||
error: err.message
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Group tables by type
|
|
||||||
const groupedCounts = {
|
|
||||||
core: counts.filter(c => ['products', 'categories', 'product_categories', 'orders', 'purchase_orders'].includes(c.table_name)),
|
|
||||||
metrics: counts.filter(c => ['product_metrics', 'daily_product_snapshots','brand_metrics','category_metrics','vendor_metrics'].includes(c.table_name)),
|
|
||||||
config: counts.filter(c => ['settings_global', 'settings_vendor', 'settings_product'].includes(c.table_name))
|
|
||||||
};
|
|
||||||
|
|
||||||
res.json(groupedCounts);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching table counts:', error);
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
|
||||||
@@ -22,11 +22,11 @@ router.get('/stock/metrics', async (req, res) => {
|
|||||||
const { rows: [stockMetrics] } = await executeQuery(`
|
const { rows: [stockMetrics] } = await executeQuery(`
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(COUNT(*), 0)::integer as total_products,
|
COALESCE(COUNT(*), 0)::integer as total_products,
|
||||||
COALESCE(COUNT(CASE WHEN stock_quantity > 0 THEN 1 END), 0)::integer as products_in_stock,
|
COALESCE(COUNT(CASE WHEN current_stock > 0 THEN 1 END), 0)::integer as products_in_stock,
|
||||||
COALESCE(SUM(CASE WHEN stock_quantity > 0 THEN stock_quantity END), 0)::integer as total_units,
|
COALESCE(SUM(CASE WHEN current_stock > 0 THEN current_stock END), 0)::integer as total_units,
|
||||||
ROUND(COALESCE(SUM(CASE WHEN stock_quantity > 0 THEN stock_quantity * cost_price END), 0)::numeric, 3) as total_cost,
|
ROUND(COALESCE(SUM(CASE WHEN current_stock > 0 THEN current_stock_cost END), 0)::numeric, 3) as total_cost,
|
||||||
ROUND(COALESCE(SUM(CASE WHEN stock_quantity > 0 THEN stock_quantity * price END), 0)::numeric, 3) as total_retail
|
ROUND(COALESCE(SUM(CASE WHEN current_stock > 0 THEN current_stock_retail END), 0)::numeric, 3) as total_retail
|
||||||
FROM products
|
FROM product_metrics
|
||||||
`);
|
`);
|
||||||
|
|
||||||
console.log('Raw stockMetrics from database:', stockMetrics);
|
console.log('Raw stockMetrics from database:', stockMetrics);
|
||||||
@@ -42,13 +42,13 @@ router.get('/stock/metrics', async (req, res) => {
|
|||||||
SELECT
|
SELECT
|
||||||
COALESCE(brand, 'Unbranded') as brand,
|
COALESCE(brand, 'Unbranded') as brand,
|
||||||
COUNT(DISTINCT pid)::integer as variant_count,
|
COUNT(DISTINCT pid)::integer as variant_count,
|
||||||
COALESCE(SUM(stock_quantity), 0)::integer as stock_units,
|
COALESCE(SUM(current_stock), 0)::integer as stock_units,
|
||||||
ROUND(COALESCE(SUM(stock_quantity * cost_price), 0)::numeric, 3) as stock_cost,
|
ROUND(COALESCE(SUM(current_stock_cost), 0)::numeric, 3) as stock_cost,
|
||||||
ROUND(COALESCE(SUM(stock_quantity * price), 0)::numeric, 3) as stock_retail
|
ROUND(COALESCE(SUM(current_stock_retail), 0)::numeric, 3) as stock_retail
|
||||||
FROM products
|
FROM product_metrics
|
||||||
WHERE stock_quantity > 0
|
WHERE current_stock > 0
|
||||||
GROUP BY COALESCE(brand, 'Unbranded')
|
GROUP BY COALESCE(brand, 'Unbranded')
|
||||||
HAVING ROUND(COALESCE(SUM(stock_quantity * cost_price), 0)::numeric, 3) > 0
|
HAVING ROUND(COALESCE(SUM(current_stock_cost), 0)::numeric, 3) > 0
|
||||||
),
|
),
|
||||||
other_brands AS (
|
other_brands AS (
|
||||||
SELECT
|
SELECT
|
||||||
@@ -108,47 +108,52 @@ router.get('/purchase/metrics', async (req, res) => {
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
const { rows: [poMetrics] } = await executeQuery(`
|
const { rows: [poMetrics] } = await executeQuery(`
|
||||||
|
WITH po_metrics AS (
|
||||||
SELECT
|
SELECT
|
||||||
COALESCE(COUNT(DISTINCT CASE
|
po_id,
|
||||||
WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')
|
status,
|
||||||
THEN po.po_id
|
date,
|
||||||
END), 0)::integer as active_pos,
|
expected_date,
|
||||||
COALESCE(COUNT(DISTINCT CASE
|
pid,
|
||||||
WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')
|
ordered,
|
||||||
AND po.expected_date < CURRENT_DATE
|
po_cost_price
|
||||||
THEN po.po_id
|
|
||||||
END), 0)::integer as overdue_pos,
|
|
||||||
COALESCE(SUM(CASE
|
|
||||||
WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')
|
|
||||||
THEN po.ordered
|
|
||||||
ELSE 0
|
|
||||||
END), 0)::integer as total_units,
|
|
||||||
ROUND(COALESCE(SUM(CASE
|
|
||||||
WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')
|
|
||||||
THEN po.ordered * po.cost_price
|
|
||||||
ELSE 0
|
|
||||||
END), 0)::numeric, 3) as total_cost,
|
|
||||||
ROUND(COALESCE(SUM(CASE
|
|
||||||
WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')
|
|
||||||
THEN po.ordered * p.price
|
|
||||||
ELSE 0
|
|
||||||
END), 0)::numeric, 3) as total_retail
|
|
||||||
FROM purchase_orders po
|
FROM purchase_orders po
|
||||||
JOIN products p ON po.pid = p.pid
|
WHERE po.status NOT IN ('canceled', 'done')
|
||||||
|
AND po.date >= CURRENT_DATE - INTERVAL '6 months'
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT po_id)::integer as active_pos,
|
||||||
|
COUNT(DISTINCT CASE WHEN expected_date < CURRENT_DATE THEN po_id END)::integer as overdue_pos,
|
||||||
|
SUM(ordered)::integer as total_units,
|
||||||
|
ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_cost,
|
||||||
|
ROUND(SUM(ordered * pm.current_price)::numeric, 3) as total_retail
|
||||||
|
FROM po_metrics po
|
||||||
|
JOIN product_metrics pm ON po.pid = pm.pid
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const { rows: vendorOrders } = await executeQuery(`
|
const { rows: vendorOrders } = await executeQuery(`
|
||||||
|
WITH po_by_vendor AS (
|
||||||
SELECT
|
SELECT
|
||||||
po.vendor,
|
vendor,
|
||||||
COUNT(DISTINCT po.po_id)::integer as orders,
|
po_id,
|
||||||
COALESCE(SUM(po.ordered), 0)::integer as units,
|
SUM(ordered) as total_ordered,
|
||||||
ROUND(COALESCE(SUM(po.ordered * po.cost_price), 0)::numeric, 3) as cost,
|
SUM(ordered * po_cost_price) as total_cost
|
||||||
ROUND(COALESCE(SUM(po.ordered * p.price), 0)::numeric, 3) as retail
|
FROM purchase_orders
|
||||||
FROM purchase_orders po
|
WHERE status NOT IN ('canceled', 'done')
|
||||||
JOIN products p ON po.pid = p.pid
|
AND date >= CURRENT_DATE - INTERVAL '6 months'
|
||||||
WHERE po.receiving_status NOT IN ('partial_received', 'full_received', 'paid')
|
GROUP BY vendor, po_id
|
||||||
GROUP BY po.vendor
|
)
|
||||||
HAVING ROUND(COALESCE(SUM(po.ordered * po.cost_price), 0)::numeric, 3) > 0
|
SELECT
|
||||||
|
pv.vendor,
|
||||||
|
COUNT(DISTINCT pv.po_id)::integer as orders,
|
||||||
|
SUM(pv.total_ordered)::integer as units,
|
||||||
|
ROUND(SUM(pv.total_cost)::numeric, 3) as cost,
|
||||||
|
ROUND(SUM(pv.total_ordered * pm.current_price)::numeric, 3) as retail
|
||||||
|
FROM po_by_vendor pv
|
||||||
|
JOIN purchase_orders po ON pv.po_id = po.po_id
|
||||||
|
JOIN product_metrics pm ON po.pid = pm.pid
|
||||||
|
GROUP BY pv.vendor
|
||||||
|
HAVING ROUND(SUM(pv.total_cost)::numeric, 3) > 0
|
||||||
ORDER BY cost DESC
|
ORDER BY cost DESC
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -223,54 +228,35 @@ router.get('/replenishment/metrics', async (req, res) => {
|
|||||||
// Get summary metrics
|
// Get summary metrics
|
||||||
const { rows: [metrics] } = await executeQuery(`
|
const { rows: [metrics] } = await executeQuery(`
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(DISTINCT p.pid)::integer as products_to_replenish,
|
COUNT(DISTINCT pm.pid)::integer as products_to_replenish,
|
||||||
COALESCE(SUM(CASE
|
COALESCE(SUM(pm.replenishment_units), 0)::integer as total_units_needed,
|
||||||
WHEN p.stock_quantity < 0 THEN ABS(p.stock_quantity) + pm.reorder_qty
|
ROUND(COALESCE(SUM(pm.replenishment_cost), 0)::numeric, 3) as total_cost,
|
||||||
ELSE pm.reorder_qty
|
ROUND(COALESCE(SUM(pm.replenishment_retail), 0)::numeric, 3) as total_retail
|
||||||
END), 0)::integer as total_units_needed,
|
FROM product_metrics pm
|
||||||
ROUND(COALESCE(SUM(CASE
|
WHERE pm.is_replenishable = true
|
||||||
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.cost_price
|
AND (pm.status IN ('Critical', 'Reorder')
|
||||||
ELSE pm.reorder_qty * p.cost_price
|
OR pm.current_stock < 0)
|
||||||
END), 0)::numeric, 3) as total_cost,
|
AND pm.replenishment_units > 0
|
||||||
ROUND(COALESCE(SUM(CASE
|
|
||||||
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.price
|
|
||||||
ELSE pm.reorder_qty * p.price
|
|
||||||
END), 0)::numeric, 3) as total_retail
|
|
||||||
FROM products p
|
|
||||||
JOIN product_metrics pm ON p.pid = pm.pid
|
|
||||||
WHERE p.replenishable = true
|
|
||||||
AND (pm.stock_status IN ('Critical', 'Reorder')
|
|
||||||
OR p.stock_quantity < 0)
|
|
||||||
AND pm.reorder_qty > 0
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Get top variants to replenish
|
// Get top variants to replenish
|
||||||
const { rows: variants } = await executeQuery(`
|
const { rows: variants } = await executeQuery(`
|
||||||
SELECT
|
SELECT
|
||||||
p.pid,
|
pm.pid,
|
||||||
p.title,
|
pm.title,
|
||||||
p.stock_quantity::integer as current_stock,
|
pm.current_stock::integer as current_stock,
|
||||||
CASE
|
pm.replenishment_units::integer as replenish_qty,
|
||||||
WHEN p.stock_quantity < 0 THEN ABS(p.stock_quantity) + pm.reorder_qty
|
ROUND(pm.replenishment_cost::numeric, 3) as replenish_cost,
|
||||||
ELSE pm.reorder_qty
|
ROUND(pm.replenishment_retail::numeric, 3) as replenish_retail,
|
||||||
END::integer as replenish_qty,
|
pm.status,
|
||||||
ROUND(CASE
|
pm.planning_period_days::text as planning_period
|
||||||
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.cost_price
|
FROM product_metrics pm
|
||||||
ELSE pm.reorder_qty * p.cost_price
|
WHERE pm.is_replenishable = true
|
||||||
END::numeric, 3) as replenish_cost,
|
AND (pm.status IN ('Critical', 'Reorder')
|
||||||
ROUND(CASE
|
OR pm.current_stock < 0)
|
||||||
WHEN p.stock_quantity < 0 THEN (ABS(p.stock_quantity) + pm.reorder_qty) * p.price
|
AND pm.replenishment_units > 0
|
||||||
ELSE pm.reorder_qty * p.price
|
|
||||||
END::numeric, 3) as replenish_retail,
|
|
||||||
pm.stock_status
|
|
||||||
FROM products p
|
|
||||||
JOIN product_metrics pm ON p.pid = pm.pid
|
|
||||||
WHERE p.replenishable = true
|
|
||||||
AND (pm.stock_status IN ('Critical', 'Reorder')
|
|
||||||
OR p.stock_quantity < 0)
|
|
||||||
AND pm.reorder_qty > 0
|
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE pm.stock_status
|
CASE pm.status
|
||||||
WHEN 'Critical' THEN 1
|
WHEN 'Critical' THEN 1
|
||||||
WHEN 'Reorder' THEN 2
|
WHEN 'Reorder' THEN 2
|
||||||
END,
|
END,
|
||||||
@@ -280,7 +266,7 @@ router.get('/replenishment/metrics', async (req, res) => {
|
|||||||
|
|
||||||
// If no data, provide dummy data
|
// If no data, provide dummy data
|
||||||
if (!metrics || variants.length === 0) {
|
if (!metrics || variants.length === 0) {
|
||||||
console.log('No replenishment metrics found, returning dummy data');
|
console.log('No replenishment metrics found in new schema, returning dummy data');
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
productsToReplenish: 15,
|
productsToReplenish: 15,
|
||||||
@@ -288,11 +274,11 @@ router.get('/replenishment/metrics', async (req, res) => {
|
|||||||
replenishmentCost: 15000.00,
|
replenishmentCost: 15000.00,
|
||||||
replenishmentRetail: 30000.00,
|
replenishmentRetail: 30000.00,
|
||||||
topVariants: [
|
topVariants: [
|
||||||
{ id: 1, title: "Test Product 1", currentStock: 5, replenishQty: 20, replenishCost: 500, replenishRetail: 1000, status: "Critical" },
|
{ id: 1, title: "Test Product 1", currentStock: 5, replenishQty: 20, replenishCost: 500, replenishRetail: 1000, status: "Critical", planningPeriod: "30" },
|
||||||
{ id: 2, title: "Test Product 2", currentStock: 10, replenishQty: 15, replenishCost: 450, replenishRetail: 900, status: "Critical" },
|
{ id: 2, title: "Test Product 2", currentStock: 10, replenishQty: 15, replenishCost: 450, replenishRetail: 900, status: "Critical", planningPeriod: "30" },
|
||||||
{ id: 3, title: "Test Product 3", currentStock: 15, replenishQty: 10, replenishCost: 300, replenishRetail: 600, status: "Reorder" },
|
{ id: 3, title: "Test Product 3", currentStock: 15, replenishQty: 10, replenishCost: 300, replenishRetail: 600, status: "Reorder", planningPeriod: "30" },
|
||||||
{ id: 4, title: "Test Product 4", currentStock: 20, replenishQty: 20, replenishCost: 200, replenishRetail: 400, status: "Reorder" },
|
{ id: 4, title: "Test Product 4", currentStock: 20, replenishQty: 20, replenishCost: 200, replenishRetail: 400, status: "Reorder", planningPeriod: "30" },
|
||||||
{ id: 5, title: "Test Product 5", currentStock: 25, replenishQty: 10, replenishCost: 150, replenishRetail: 300, status: "Reorder" }
|
{ id: 5, title: "Test Product 5", currentStock: 25, replenishQty: 10, replenishCost: 150, replenishRetail: 300, status: "Reorder", planningPeriod: "30" }
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -310,7 +296,8 @@ router.get('/replenishment/metrics', async (req, res) => {
|
|||||||
replenishQty: parseInt(v.replenish_qty) || 0,
|
replenishQty: parseInt(v.replenish_qty) || 0,
|
||||||
replenishCost: parseFloat(v.replenish_cost) || 0,
|
replenishCost: parseFloat(v.replenish_cost) || 0,
|
||||||
replenishRetail: parseFloat(v.replenish_retail) || 0,
|
replenishRetail: parseFloat(v.replenish_retail) || 0,
|
||||||
status: v.stock_status
|
status: v.status,
|
||||||
|
planningPeriod: v.planning_period
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -325,11 +312,11 @@ router.get('/replenishment/metrics', async (req, res) => {
|
|||||||
replenishmentCost: 15000.00,
|
replenishmentCost: 15000.00,
|
||||||
replenishmentRetail: 30000.00,
|
replenishmentRetail: 30000.00,
|
||||||
topVariants: [
|
topVariants: [
|
||||||
{ id: 1, title: "Test Product 1", currentStock: 5, replenishQty: 20, replenishCost: 500, replenishRetail: 1000, status: "Critical" },
|
{ id: 1, title: "Test Product 1", currentStock: 5, replenishQty: 20, replenishCost: 500, replenishRetail: 1000, status: "Critical", planningPeriod: "30" },
|
||||||
{ id: 2, title: "Test Product 2", currentStock: 10, replenishQty: 15, replenishCost: 450, replenishRetail: 900, status: "Critical" },
|
{ id: 2, title: "Test Product 2", currentStock: 10, replenishQty: 15, replenishCost: 450, replenishRetail: 900, status: "Critical", planningPeriod: "30" },
|
||||||
{ id: 3, title: "Test Product 3", currentStock: 15, replenishQty: 10, replenishCost: 300, replenishRetail: 600, status: "Reorder" },
|
{ id: 3, title: "Test Product 3", currentStock: 15, replenishQty: 10, replenishCost: 300, replenishRetail: 600, status: "Reorder", planningPeriod: "30" },
|
||||||
{ id: 4, title: "Test Product 4", currentStock: 20, replenishQty: 20, replenishCost: 200, replenishRetail: 400, status: "Reorder" },
|
{ id: 4, title: "Test Product 4", currentStock: 20, replenishQty: 20, replenishCost: 200, replenishRetail: 400, status: "Reorder", planningPeriod: "30" },
|
||||||
{ id: 5, title: "Test Product 5", currentStock: 25, replenishQty: 10, replenishCost: 150, replenishRetail: 300, status: "Reorder" }
|
{ id: 5, title: "Test Product 5", currentStock: 25, replenishQty: 10, replenishCost: 150, replenishRetail: 300, status: "Reorder", planningPeriod: "30" }
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -499,74 +486,15 @@ router.get('/forecast/metrics', async (req, res) => {
|
|||||||
// Returns overstock metrics by category
|
// Returns overstock metrics by category
|
||||||
router.get('/overstock/metrics', async (req, res) => {
|
router.get('/overstock/metrics', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { rows } = await executeQuery(`
|
// Check if we have any products with Overstock status
|
||||||
WITH category_overstock AS (
|
const { rows: [countCheck] } = await executeQuery(`
|
||||||
SELECT
|
SELECT COUNT(*) as overstock_count FROM product_metrics WHERE status = 'Overstock'
|
||||||
c.cat_id,
|
|
||||||
c.name as category_name,
|
|
||||||
COUNT(DISTINCT CASE
|
|
||||||
WHEN pm.stock_status = 'Overstocked'
|
|
||||||
THEN p.pid
|
|
||||||
END) as overstocked_products,
|
|
||||||
SUM(CASE
|
|
||||||
WHEN pm.stock_status = 'Overstocked'
|
|
||||||
THEN pm.overstocked_amt
|
|
||||||
ELSE 0
|
|
||||||
END) as total_excess_units,
|
|
||||||
SUM(CASE
|
|
||||||
WHEN pm.stock_status = 'Overstocked'
|
|
||||||
THEN pm.overstocked_amt * p.cost_price
|
|
||||||
ELSE 0
|
|
||||||
END) as total_excess_cost,
|
|
||||||
SUM(CASE
|
|
||||||
WHEN pm.stock_status = 'Overstocked'
|
|
||||||
THEN pm.overstocked_amt * p.price
|
|
||||||
ELSE 0
|
|
||||||
END) as total_excess_retail
|
|
||||||
FROM categories c
|
|
||||||
JOIN product_categories pc ON c.cat_id = pc.cat_id
|
|
||||||
JOIN products p ON pc.pid = p.pid
|
|
||||||
JOIN product_metrics pm ON p.pid = pm.pid
|
|
||||||
GROUP BY c.cat_id, c.name
|
|
||||||
),
|
|
||||||
filtered_categories AS (
|
|
||||||
SELECT *
|
|
||||||
FROM category_overstock
|
|
||||||
WHERE overstocked_products > 0
|
|
||||||
ORDER BY total_excess_cost DESC
|
|
||||||
LIMIT 8
|
|
||||||
),
|
|
||||||
summary AS (
|
|
||||||
SELECT
|
|
||||||
SUM(overstocked_products) as total_overstocked,
|
|
||||||
SUM(total_excess_units) as total_excess_units,
|
|
||||||
SUM(total_excess_cost) as total_excess_cost,
|
|
||||||
SUM(total_excess_retail) as total_excess_retail
|
|
||||||
FROM filtered_categories
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
s.total_overstocked,
|
|
||||||
s.total_excess_units,
|
|
||||||
s.total_excess_cost,
|
|
||||||
s.total_excess_retail,
|
|
||||||
json_agg(
|
|
||||||
json_build_object(
|
|
||||||
'category', fc.category_name,
|
|
||||||
'products', fc.overstocked_products,
|
|
||||||
'units', fc.total_excess_units,
|
|
||||||
'cost', fc.total_excess_cost,
|
|
||||||
'retail', fc.total_excess_retail
|
|
||||||
)
|
|
||||||
) as category_data
|
|
||||||
FROM summary s, filtered_categories fc
|
|
||||||
GROUP BY
|
|
||||||
s.total_overstocked,
|
|
||||||
s.total_excess_units,
|
|
||||||
s.total_excess_cost,
|
|
||||||
s.total_excess_retail
|
|
||||||
`);
|
`);
|
||||||
|
|
||||||
if (rows.length === 0) {
|
console.log('Overstock count:', countCheck.overstock_count);
|
||||||
|
|
||||||
|
// If no overstock products, return empty metrics
|
||||||
|
if (parseInt(countCheck.overstock_count) === 0) {
|
||||||
return res.json({
|
return res.json({
|
||||||
overstockedProducts: 0,
|
overstockedProducts: 0,
|
||||||
total_excess_units: 0,
|
total_excess_units: 0,
|
||||||
@@ -576,30 +504,50 @@ router.get('/overstock/metrics', async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate dummy data if the query returned empty results
|
// Get summary metrics in a simpler, more direct query
|
||||||
if (rows[0].total_overstocked === null || rows[0].total_excess_units === null) {
|
const { rows: [summaryMetrics] } = await executeQuery(`
|
||||||
console.log('Empty overstock metrics results, returning dummy data');
|
SELECT
|
||||||
return res.json({
|
COUNT(DISTINCT pid)::integer as total_overstocked,
|
||||||
overstockedProducts: 10,
|
SUM(overstocked_units)::integer as total_excess_units,
|
||||||
total_excess_units: 500,
|
ROUND(SUM(overstocked_cost)::numeric, 3) as total_excess_cost,
|
||||||
total_excess_cost: 5000,
|
ROUND(SUM(overstocked_retail)::numeric, 3) as total_excess_retail
|
||||||
total_excess_retail: 10000,
|
FROM product_metrics
|
||||||
category_data: [
|
WHERE status = 'Overstock'
|
||||||
{ category: "Electronics", products: 3, units: 150, cost: 1500, retail: 3000 },
|
`);
|
||||||
{ category: "Clothing", products: 4, units: 200, cost: 2000, retail: 4000 },
|
|
||||||
{ category: "Home Goods", products: 2, units: 100, cost: 1000, retail: 2000 },
|
// Get category breakdowns separately
|
||||||
{ category: "Office Supplies", products: 1, units: 50, cost: 500, retail: 1000 }
|
const { rows: categoryData } = await executeQuery(`
|
||||||
]
|
SELECT
|
||||||
});
|
c.name as category_name,
|
||||||
}
|
COUNT(DISTINCT pm.pid)::integer as overstocked_products,
|
||||||
|
SUM(pm.overstocked_units)::integer as total_excess_units,
|
||||||
|
ROUND(SUM(pm.overstocked_cost)::numeric, 3) as total_excess_cost,
|
||||||
|
ROUND(SUM(pm.overstocked_retail)::numeric, 3) as total_excess_retail
|
||||||
|
FROM categories c
|
||||||
|
JOIN product_categories pc ON c.cat_id = pc.cat_id
|
||||||
|
JOIN product_metrics pm ON pc.pid = pm.pid
|
||||||
|
WHERE pm.status = 'Overstock'
|
||||||
|
GROUP BY c.name
|
||||||
|
ORDER BY total_excess_cost DESC
|
||||||
|
LIMIT 8
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('Summary metrics:', summaryMetrics);
|
||||||
|
console.log('Category data count:', categoryData.length);
|
||||||
|
|
||||||
// Format response with explicit type conversion
|
// Format response with explicit type conversion
|
||||||
const response = {
|
const response = {
|
||||||
overstockedProducts: parseInt(rows[0].total_overstocked) || 0,
|
overstockedProducts: parseInt(summaryMetrics.total_overstocked) || 0,
|
||||||
total_excess_units: parseInt(rows[0].total_excess_units) || 0,
|
total_excess_units: parseInt(summaryMetrics.total_excess_units) || 0,
|
||||||
total_excess_cost: parseFloat(rows[0].total_excess_cost) || 0,
|
total_excess_cost: parseFloat(summaryMetrics.total_excess_cost) || 0,
|
||||||
total_excess_retail: parseFloat(rows[0].total_excess_retail) || 0,
|
total_excess_retail: parseFloat(summaryMetrics.total_excess_retail) || 0,
|
||||||
category_data: rows[0].category_data || []
|
category_data: categoryData.map(cat => ({
|
||||||
|
category: cat.category_name,
|
||||||
|
products: parseInt(cat.overstocked_products) || 0,
|
||||||
|
units: parseInt(cat.total_excess_units) || 0,
|
||||||
|
cost: parseFloat(cat.total_excess_cost) || 0,
|
||||||
|
retail: parseFloat(cat.total_excess_retail) || 0
|
||||||
|
}))
|
||||||
};
|
};
|
||||||
|
|
||||||
res.json(response);
|
res.json(response);
|
||||||
@@ -629,27 +577,26 @@ router.get('/overstock/products', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { rows } = await executeQuery(`
|
const { rows } = await executeQuery(`
|
||||||
SELECT
|
SELECT
|
||||||
p.pid,
|
pm.pid,
|
||||||
p.SKU,
|
pm.sku AS SKU,
|
||||||
p.title,
|
pm.title,
|
||||||
p.brand,
|
pm.brand,
|
||||||
p.vendor,
|
pm.vendor,
|
||||||
p.stock_quantity,
|
pm.current_stock as stock_quantity,
|
||||||
p.cost_price,
|
pm.current_cost_price as cost_price,
|
||||||
p.price,
|
pm.current_price as price,
|
||||||
pm.daily_sales_avg,
|
pm.sales_velocity_daily as daily_sales_avg,
|
||||||
pm.days_of_inventory,
|
pm.stock_cover_in_days as days_of_inventory,
|
||||||
pm.overstocked_amt,
|
pm.overstocked_units,
|
||||||
(pm.overstocked_amt * p.cost_price) as excess_cost,
|
pm.overstocked_cost as excess_cost,
|
||||||
(pm.overstocked_amt * p.price) as excess_retail,
|
pm.overstocked_retail as excess_retail,
|
||||||
STRING_AGG(c.name, ', ') as categories
|
STRING_AGG(c.name, ', ') as categories
|
||||||
FROM products p
|
FROM product_metrics pm
|
||||||
JOIN product_metrics pm ON p.pid = pm.pid
|
LEFT JOIN product_categories pc ON pm.pid = pc.pid
|
||||||
LEFT JOIN product_categories pc ON p.pid = pc.pid
|
|
||||||
LEFT JOIN categories c ON pc.cat_id = c.cat_id
|
LEFT JOIN categories c ON pc.cat_id = c.cat_id
|
||||||
WHERE pm.stock_status = 'Overstocked'
|
WHERE pm.status = 'Overstock'
|
||||||
GROUP BY p.pid, p.SKU, p.title, p.brand, p.vendor, p.stock_quantity, p.cost_price, p.price,
|
GROUP BY pm.pid, pm.sku, pm.title, pm.brand, pm.vendor, pm.current_stock, pm.current_cost_price, pm.current_price,
|
||||||
pm.daily_sales_avg, pm.days_of_inventory, pm.overstocked_amt
|
pm.sales_velocity_daily, pm.stock_cover_in_days, pm.overstocked_units, pm.overstocked_cost, pm.overstocked_retail
|
||||||
ORDER BY excess_cost DESC
|
ORDER BY excess_cost DESC
|
||||||
LIMIT $1
|
LIMIT $1
|
||||||
`, [limit]);
|
`, [limit]);
|
||||||
@@ -827,42 +774,38 @@ router.get('/sales/metrics', async (req, res) => {
|
|||||||
const endDate = req.query.endDate || today.toISOString();
|
const endDate = req.query.endDate || today.toISOString();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get daily sales data
|
// Get daily orders and totals for the specified period
|
||||||
const { rows: dailyRows } = await executeQuery(`
|
const { rows: dailyRows } = await executeQuery(`
|
||||||
SELECT
|
SELECT
|
||||||
DATE(o.date) as sale_date,
|
DATE(date) as sale_date,
|
||||||
COUNT(DISTINCT o.order_number) as total_orders,
|
COUNT(DISTINCT order_number) as total_orders,
|
||||||
SUM(o.quantity) as total_units,
|
SUM(quantity) as total_units,
|
||||||
SUM(o.price * o.quantity) as total_revenue,
|
SUM(price * quantity) as total_revenue,
|
||||||
SUM(p.cost_price * o.quantity) as total_cogs,
|
SUM(costeach * quantity) as total_cogs
|
||||||
SUM((o.price - p.cost_price) * o.quantity) as total_profit
|
FROM orders
|
||||||
FROM orders o
|
WHERE date BETWEEN $1 AND $2
|
||||||
JOIN products p ON o.pid = p.pid
|
AND canceled = false
|
||||||
WHERE o.canceled = false
|
GROUP BY DATE(date)
|
||||||
AND o.date BETWEEN $1 AND $2
|
|
||||||
GROUP BY DATE(o.date)
|
|
||||||
ORDER BY sale_date
|
ORDER BY sale_date
|
||||||
`, [startDate, endDate]);
|
`, [startDate, endDate]);
|
||||||
|
|
||||||
// Get summary metrics
|
// Get overall metrics for the period
|
||||||
const { rows: metrics } = await executeQuery(`
|
const { rows: [metrics] } = await executeQuery(`
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(DISTINCT o.order_number) as total_orders,
|
COUNT(DISTINCT order_number) as total_orders,
|
||||||
SUM(o.quantity) as total_units,
|
SUM(quantity) as total_units,
|
||||||
SUM(o.price * o.quantity) as total_revenue,
|
SUM(price * quantity) as total_revenue,
|
||||||
SUM(p.cost_price * o.quantity) as total_cogs,
|
SUM(costeach * quantity) as total_cogs
|
||||||
SUM((o.price - p.cost_price) * o.quantity) as total_profit
|
FROM orders
|
||||||
FROM orders o
|
WHERE date BETWEEN $1 AND $2
|
||||||
JOIN products p ON o.pid = p.pid
|
AND canceled = false
|
||||||
WHERE o.canceled = false
|
|
||||||
AND o.date BETWEEN $1 AND $2
|
|
||||||
`, [startDate, endDate]);
|
`, [startDate, endDate]);
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
totalOrders: parseInt(metrics[0]?.total_orders) || 0,
|
totalOrders: parseInt(metrics?.total_orders) || 0,
|
||||||
totalUnitsSold: parseInt(metrics[0]?.total_units) || 0,
|
totalUnitsSold: parseInt(metrics?.total_units) || 0,
|
||||||
totalCogs: parseFloat(metrics[0]?.total_cogs) || 0,
|
totalCogs: parseFloat(metrics?.total_cogs) || 0,
|
||||||
totalRevenue: parseFloat(metrics[0]?.total_revenue) || 0,
|
totalRevenue: parseFloat(metrics?.total_revenue) || 0,
|
||||||
dailySales: dailyRows.map(day => ({
|
dailySales: dailyRows.map(day => ({
|
||||||
date: day.sale_date,
|
date: day.sale_date,
|
||||||
units: parseInt(day.total_units) || 0,
|
units: parseInt(day.total_units) || 0,
|
||||||
@@ -1304,39 +1247,33 @@ router.get('/inventory-health', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// GET /dashboard/replenish/products
|
// GET /dashboard/replenish/products
|
||||||
// Returns top products that need replenishment
|
// Returns list of products to replenish
|
||||||
router.get('/replenish/products', async (req, res) => {
|
router.get('/replenish/products', async (req, res) => {
|
||||||
const limit = Math.max(1, Math.min(100, parseInt(req.query.limit) || 50));
|
const limit = parseInt(req.query.limit) || 50;
|
||||||
try {
|
try {
|
||||||
const { rows: products } = await executeQuery(`
|
const { rows } = await executeQuery(`
|
||||||
SELECT
|
SELECT
|
||||||
p.pid,
|
pm.pid,
|
||||||
p.SKU as sku,
|
pm.sku,
|
||||||
p.title,
|
pm.title,
|
||||||
p.stock_quantity,
|
pm.current_stock AS stock_quantity,
|
||||||
pm.daily_sales_avg,
|
pm.sales_velocity_daily AS daily_sales_avg,
|
||||||
pm.reorder_qty,
|
pm.replenishment_units AS reorder_qty,
|
||||||
pm.last_purchase_date
|
pm.date_last_received AS last_purchase_date
|
||||||
FROM products p
|
FROM product_metrics pm
|
||||||
JOIN product_metrics pm ON p.pid = pm.pid
|
WHERE pm.is_replenishable = true
|
||||||
WHERE p.replenishable = true
|
AND (pm.status IN ('Critical', 'Reorder')
|
||||||
AND pm.stock_status IN ('Critical', 'Reorder')
|
OR pm.current_stock < 0)
|
||||||
AND pm.reorder_qty > 0
|
AND pm.replenishment_units > 0
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CASE pm.stock_status
|
CASE pm.status
|
||||||
WHEN 'Critical' THEN 1
|
WHEN 'Critical' THEN 1
|
||||||
WHEN 'Reorder' THEN 2
|
WHEN 'Reorder' THEN 2
|
||||||
END,
|
END,
|
||||||
pm.reorder_qty * p.cost_price DESC
|
pm.replenishment_cost DESC
|
||||||
LIMIT $1
|
LIMIT $1
|
||||||
`, [limit]);
|
`, [limit]);
|
||||||
|
res.json(rows);
|
||||||
res.json(products.map(p => ({
|
|
||||||
...p,
|
|
||||||
stock_quantity: parseInt(p.stock_quantity) || 0,
|
|
||||||
daily_sales_avg: parseFloat(p.daily_sales_avg) || 0,
|
|
||||||
reorder_qty: parseInt(p.reorder_qty) || 0
|
|
||||||
})));
|
|
||||||
} catch (err) {
|
} 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' });
|
||||||
|
|||||||
390
inventory-server/src/routes/data-management.js
Normal file
390
inventory-server/src/routes/data-management.js
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { spawn } = require('child_process');
|
||||||
|
const path = require('path');
|
||||||
|
const db = require('../utils/db');
|
||||||
|
|
||||||
|
// Debug middleware MUST be first
|
||||||
|
router.use((req, res, next) => {
|
||||||
|
console.log(`[CSV Route Debug] ${req.method} ${req.path}`);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store active processes and their progress
|
||||||
|
let activeImport = null;
|
||||||
|
let importProgress = null;
|
||||||
|
let activeFullUpdate = null;
|
||||||
|
let activeFullReset = null;
|
||||||
|
|
||||||
|
// SSE clients for progress updates
|
||||||
|
const updateClients = new Set();
|
||||||
|
const importClients = new Set();
|
||||||
|
const resetClients = new Set();
|
||||||
|
const resetMetricsClients = new Set();
|
||||||
|
const calculateMetricsClients = new Set();
|
||||||
|
const fullUpdateClients = new Set();
|
||||||
|
const fullResetClients = new Set();
|
||||||
|
|
||||||
|
// Helper to send progress to specific clients
|
||||||
|
function sendProgressToClients(clients, data) {
|
||||||
|
// If data is a string, send it directly
|
||||||
|
// If it's an object, convert it to JSON
|
||||||
|
const message = typeof data === 'string'
|
||||||
|
? `data: ${data}\n\n`
|
||||||
|
: `data: ${JSON.stringify(data)}\n\n`;
|
||||||
|
|
||||||
|
clients.forEach(client => {
|
||||||
|
try {
|
||||||
|
client.write(message);
|
||||||
|
// Immediately flush the response
|
||||||
|
if (typeof client.flush === 'function') {
|
||||||
|
client.flush();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Silently remove failed client
|
||||||
|
clients.delete(client);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to run a script and stream progress
|
||||||
|
function runScript(scriptPath, type, clients) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// Kill any existing process of this type
|
||||||
|
let activeProcess;
|
||||||
|
switch (type) {
|
||||||
|
case 'update':
|
||||||
|
if (activeFullUpdate) {
|
||||||
|
try { activeFullUpdate.kill(); } catch (e) { }
|
||||||
|
}
|
||||||
|
activeProcess = activeFullUpdate;
|
||||||
|
break;
|
||||||
|
case 'reset':
|
||||||
|
if (activeFullReset) {
|
||||||
|
try { activeFullReset.kill(); } catch (e) { }
|
||||||
|
}
|
||||||
|
activeProcess = activeFullReset;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = spawn('node', [scriptPath], {
|
||||||
|
stdio: ['inherit', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'update':
|
||||||
|
activeFullUpdate = child;
|
||||||
|
break;
|
||||||
|
case 'reset':
|
||||||
|
activeFullReset = child;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = '';
|
||||||
|
|
||||||
|
child.stdout.on('data', (data) => {
|
||||||
|
const text = data.toString();
|
||||||
|
output += text;
|
||||||
|
|
||||||
|
// Split by lines to handle multiple JSON outputs
|
||||||
|
const lines = text.split('\n');
|
||||||
|
lines.filter(line => line.trim()).forEach(line => {
|
||||||
|
try {
|
||||||
|
// Try to parse as JSON but don't let it affect the display
|
||||||
|
const jsonData = JSON.parse(line);
|
||||||
|
// Only end the process if we get a final status
|
||||||
|
if (jsonData.status === 'complete' || jsonData.status === 'error' || jsonData.status === 'cancelled') {
|
||||||
|
if (jsonData.status === 'complete' && !jsonData.operation?.includes('complete')) {
|
||||||
|
// Don't close for intermediate completion messages
|
||||||
|
sendProgressToClients(clients, line);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Close only on final completion/error/cancellation
|
||||||
|
switch (type) {
|
||||||
|
case 'update':
|
||||||
|
activeFullUpdate = null;
|
||||||
|
break;
|
||||||
|
case 'reset':
|
||||||
|
activeFullReset = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (jsonData.status === 'error') {
|
||||||
|
reject(new Error(jsonData.error || 'Unknown error'));
|
||||||
|
} else {
|
||||||
|
resolve({ output });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Not JSON, just display as is
|
||||||
|
}
|
||||||
|
// Always send the raw line
|
||||||
|
sendProgressToClients(clients, line);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr.on('data', (data) => {
|
||||||
|
const text = data.toString();
|
||||||
|
console.error(text);
|
||||||
|
// Send stderr output directly too
|
||||||
|
sendProgressToClients(clients, text);
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('close', (code) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'update':
|
||||||
|
activeFullUpdate = null;
|
||||||
|
break;
|
||||||
|
case 'reset':
|
||||||
|
activeFullReset = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
const error = `Script ${scriptPath} exited with code ${code}`;
|
||||||
|
sendProgressToClients(clients, error);
|
||||||
|
reject(new Error(error));
|
||||||
|
}
|
||||||
|
// Don't resolve here - let the completion message from the script trigger the resolve
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', (err) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'update':
|
||||||
|
activeFullUpdate = null;
|
||||||
|
break;
|
||||||
|
case 'reset':
|
||||||
|
activeFullReset = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
sendProgressToClients(clients, err.message);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress endpoints
|
||||||
|
router.get('/:type/progress', (req, res) => {
|
||||||
|
const { type } = req.params;
|
||||||
|
if (!['update', 'reset'].includes(type)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid operation type' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive',
|
||||||
|
'Access-Control-Allow-Origin': req.headers.origin || '*',
|
||||||
|
'Access-Control-Allow-Credentials': 'true'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add this client to the correct set
|
||||||
|
const clients = type === 'update' ? fullUpdateClients : fullResetClients;
|
||||||
|
clients.add(res);
|
||||||
|
|
||||||
|
// Send initial connection message
|
||||||
|
sendProgressToClients(new Set([res]), JSON.stringify({
|
||||||
|
status: 'running',
|
||||||
|
operation: 'Initializing connection...'
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Handle client disconnect
|
||||||
|
req.on('close', () => {
|
||||||
|
clients.delete(res);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route to cancel active process
|
||||||
|
router.post('/cancel', (req, res) => {
|
||||||
|
let killed = false;
|
||||||
|
|
||||||
|
// Get the operation type from the request
|
||||||
|
const { type } = req.query;
|
||||||
|
const clients = type === 'update' ? fullUpdateClients : fullResetClients;
|
||||||
|
const activeProcess = type === 'update' ? activeFullUpdate : activeFullReset;
|
||||||
|
|
||||||
|
if (activeProcess) {
|
||||||
|
try {
|
||||||
|
activeProcess.kill('SIGTERM');
|
||||||
|
if (type === 'update') {
|
||||||
|
activeFullUpdate = null;
|
||||||
|
} else {
|
||||||
|
activeFullReset = null;
|
||||||
|
}
|
||||||
|
killed = true;
|
||||||
|
sendProgressToClients(clients, JSON.stringify({
|
||||||
|
status: 'cancelled',
|
||||||
|
operation: 'Operation cancelled'
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error killing ${type} process:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (killed) {
|
||||||
|
res.json({ success: true });
|
||||||
|
} else {
|
||||||
|
res.status(404).json({ error: 'No active process to cancel' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /csv/full-update - Run full update script
|
||||||
|
router.post('/full-update', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const scriptPath = path.join(__dirname, '../../scripts/full-update.js');
|
||||||
|
runScript(scriptPath, 'update', fullUpdateClients)
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Update failed:', error);
|
||||||
|
});
|
||||||
|
res.status(202).json({ message: 'Update started' });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /csv/full-reset - Run full reset script
|
||||||
|
router.post('/full-reset', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const scriptPath = path.join(__dirname, '../../scripts/full-reset.js');
|
||||||
|
runScript(scriptPath, 'reset', fullResetClients)
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Reset failed:', error);
|
||||||
|
});
|
||||||
|
res.status(202).json({ message: 'Reset started' });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /history/import - Get recent import history
|
||||||
|
router.get('/history/import', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
const { rows } = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
|
status,
|
||||||
|
error_message,
|
||||||
|
records_added::integer,
|
||||||
|
records_updated::integer
|
||||||
|
FROM import_history
|
||||||
|
ORDER BY start_time DESC
|
||||||
|
LIMIT 20
|
||||||
|
`);
|
||||||
|
res.json(rows || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching import history:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /history/calculate - Get recent calculation history
|
||||||
|
router.get('/history/calculate', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
const { rows } = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
start_time,
|
||||||
|
end_time,
|
||||||
|
duration_minutes,
|
||||||
|
status,
|
||||||
|
error_message,
|
||||||
|
total_products,
|
||||||
|
total_orders,
|
||||||
|
total_purchase_orders,
|
||||||
|
processed_products,
|
||||||
|
processed_orders,
|
||||||
|
processed_purchase_orders,
|
||||||
|
additional_info
|
||||||
|
FROM calculate_history
|
||||||
|
ORDER BY start_time DESC
|
||||||
|
LIMIT 20
|
||||||
|
`);
|
||||||
|
res.json(rows || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching calculate history:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /status/modules - Get module calculation status
|
||||||
|
router.get('/status/modules', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
const { rows } = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
module_name,
|
||||||
|
last_calculation_timestamp::timestamp
|
||||||
|
FROM calculate_status
|
||||||
|
ORDER BY module_name
|
||||||
|
`);
|
||||||
|
res.json(rows || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching module status:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /status/tables - Get table sync status
|
||||||
|
router.get('/status/tables', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
const { rows } = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
table_name,
|
||||||
|
last_sync_timestamp::timestamp
|
||||||
|
FROM sync_status
|
||||||
|
ORDER BY table_name
|
||||||
|
`);
|
||||||
|
res.json(rows || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching table status:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /status/table-counts - Get record counts for all tables
|
||||||
|
router.get('/status/table-counts', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
const tables = [
|
||||||
|
// Core tables
|
||||||
|
'products', 'categories', 'product_categories', 'orders', 'purchase_orders', 'receivings',
|
||||||
|
// New metrics tables
|
||||||
|
'product_metrics', 'daily_product_snapshots','brand_metrics','category_metrics','vendor_metrics',
|
||||||
|
// Config tables
|
||||||
|
'settings_global', 'settings_vendor', 'settings_product'
|
||||||
|
];
|
||||||
|
|
||||||
|
const counts = await Promise.all(
|
||||||
|
tables.map(table =>
|
||||||
|
pool.query(`SELECT COUNT(*) as count FROM ${table}`)
|
||||||
|
.then(result => ({
|
||||||
|
table_name: table,
|
||||||
|
count: parseInt(result.rows[0].count)
|
||||||
|
}))
|
||||||
|
.catch(err => ({
|
||||||
|
table_name: table,
|
||||||
|
count: null,
|
||||||
|
error: err.message
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Group tables by type
|
||||||
|
const groupedCounts = {
|
||||||
|
core: counts.filter(c => ['products', 'categories', 'product_categories', 'orders', 'purchase_orders', 'receivings'].includes(c.table_name)),
|
||||||
|
metrics: counts.filter(c => ['product_metrics', 'daily_product_snapshots','brand_metrics','category_metrics','vendor_metrics'].includes(c.table_name)),
|
||||||
|
config: counts.filter(c => ['settings_global', 'settings_vendor', 'settings_product'].includes(c.table_name))
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(groupedCounts);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching table counts:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -23,10 +23,7 @@ router.get('/brands', async (req, res) => {
|
|||||||
const { rows } = await pool.query(`
|
const { rows } = await pool.query(`
|
||||||
SELECT DISTINCT COALESCE(p.brand, 'Unbranded') as brand
|
SELECT DISTINCT COALESCE(p.brand, 'Unbranded') as brand
|
||||||
FROM products p
|
FROM products p
|
||||||
JOIN purchase_orders po ON p.pid = po.pid
|
|
||||||
WHERE p.visible = true
|
WHERE p.visible = true
|
||||||
GROUP BY COALESCE(p.brand, 'Unbranded')
|
|
||||||
HAVING SUM(po.cost_price * po.received) >= 500
|
|
||||||
ORDER BY COALESCE(p.brand, 'Unbranded')
|
ORDER BY COALESCE(p.brand, 'Unbranded')
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -629,163 +626,6 @@ router.get('/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Import products from CSV
|
|
||||||
router.post('/import', upload.single('file'), async (req, res) => {
|
|
||||||
if (!req.file) {
|
|
||||||
return res.status(400).json({ error: 'No file uploaded' });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await importProductsFromCSV(req.file.path, req.app.locals.pool);
|
|
||||||
// Clean up the uploaded file
|
|
||||||
require('fs').unlinkSync(req.file.path);
|
|
||||||
res.json(result);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error importing products:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to import products' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update a product
|
|
||||||
router.put('/:id', async (req, res) => {
|
|
||||||
const pool = req.app.locals.pool;
|
|
||||||
try {
|
|
||||||
const {
|
|
||||||
title,
|
|
||||||
sku,
|
|
||||||
stock_quantity,
|
|
||||||
price,
|
|
||||||
regular_price,
|
|
||||||
cost_price,
|
|
||||||
vendor,
|
|
||||||
brand,
|
|
||||||
categories,
|
|
||||||
visible,
|
|
||||||
managing_stock
|
|
||||||
} = req.body;
|
|
||||||
|
|
||||||
const { rowCount } = await pool.query(
|
|
||||||
`UPDATE products
|
|
||||||
SET title = $1,
|
|
||||||
sku = $2,
|
|
||||||
stock_quantity = $3,
|
|
||||||
price = $4,
|
|
||||||
regular_price = $5,
|
|
||||||
cost_price = $6,
|
|
||||||
vendor = $7,
|
|
||||||
brand = $8,
|
|
||||||
categories = $9,
|
|
||||||
visible = $10,
|
|
||||||
managing_stock = $11
|
|
||||||
WHERE pid = $12`,
|
|
||||||
[
|
|
||||||
title,
|
|
||||||
sku,
|
|
||||||
stock_quantity,
|
|
||||||
price,
|
|
||||||
regular_price,
|
|
||||||
cost_price,
|
|
||||||
vendor,
|
|
||||||
brand,
|
|
||||||
categories,
|
|
||||||
visible,
|
|
||||||
managing_stock,
|
|
||||||
req.params.id
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (rowCount === 0) {
|
|
||||||
return res.status(404).json({ error: 'Product not found' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({ message: 'Product updated successfully' });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating product:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to update product' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get product metrics
|
|
||||||
router.get('/:id/metrics', async (req, res) => {
|
|
||||||
const pool = req.app.locals.pool;
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
// Get metrics from product_metrics table with inventory health data
|
|
||||||
const { rows: metrics } = await pool.query(`
|
|
||||||
WITH inventory_status AS (
|
|
||||||
SELECT
|
|
||||||
p.pid,
|
|
||||||
CASE
|
|
||||||
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 * 14) THEN 'Reorder'
|
|
||||||
WHEN p.stock_quantity > (pm.daily_sales_avg * 90) THEN 'Overstocked'
|
|
||||||
ELSE 'Healthy'
|
|
||||||
END as calculated_status
|
|
||||||
FROM products p
|
|
||||||
LEFT JOIN product_metrics pm ON p.pid = pm.pid
|
|
||||||
WHERE p.pid = $1
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
COALESCE(pm.daily_sales_avg, 0) as daily_sales_avg,
|
|
||||||
COALESCE(pm.weekly_sales_avg, 0) as weekly_sales_avg,
|
|
||||||
COALESCE(pm.monthly_sales_avg, 0) as monthly_sales_avg,
|
|
||||||
COALESCE(pm.days_of_inventory, 0) as days_of_inventory,
|
|
||||||
COALESCE(pm.reorder_point, CEIL(COALESCE(pm.daily_sales_avg, 0) * 14)) as reorder_point,
|
|
||||||
COALESCE(pm.safety_stock, CEIL(COALESCE(pm.daily_sales_avg, 0) * 7)) as safety_stock,
|
|
||||||
COALESCE(pm.avg_margin_percent,
|
|
||||||
((p.price - COALESCE(p.cost_price, 0)) / NULLIF(p.price, 0)) * 100
|
|
||||||
) as avg_margin_percent,
|
|
||||||
COALESCE(pm.total_revenue, 0) as total_revenue,
|
|
||||||
COALESCE(pm.inventory_value, p.stock_quantity * COALESCE(p.cost_price, 0)) as inventory_value,
|
|
||||||
COALESCE(pm.turnover_rate, 0) as turnover_rate,
|
|
||||||
COALESCE(pm.abc_class, 'C') as abc_class,
|
|
||||||
COALESCE(pm.stock_status, is.calculated_status) as stock_status,
|
|
||||||
COALESCE(pm.avg_lead_time_days, 0) as avg_lead_time_days,
|
|
||||||
COALESCE(pm.current_lead_time, 0) as current_lead_time,
|
|
||||||
COALESCE(pm.target_lead_time, 14) as target_lead_time,
|
|
||||||
COALESCE(pm.lead_time_status, 'Unknown') as lead_time_status,
|
|
||||||
COALESCE(pm.reorder_qty, 0) as reorder_qty,
|
|
||||||
COALESCE(pm.overstocked_amt, 0) as overstocked_amt
|
|
||||||
FROM products p
|
|
||||||
LEFT JOIN product_metrics pm ON p.pid = pm.pid
|
|
||||||
LEFT JOIN inventory_status is ON p.pid = is.pid
|
|
||||||
WHERE p.pid = $2
|
|
||||||
`, [id, id]);
|
|
||||||
|
|
||||||
if (!metrics.length) {
|
|
||||||
// Return default metrics structure if no data found
|
|
||||||
res.json({
|
|
||||||
daily_sales_avg: 0,
|
|
||||||
weekly_sales_avg: 0,
|
|
||||||
monthly_sales_avg: 0,
|
|
||||||
days_of_inventory: 0,
|
|
||||||
reorder_point: 0,
|
|
||||||
safety_stock: 0,
|
|
||||||
avg_margin_percent: 0,
|
|
||||||
total_revenue: 0,
|
|
||||||
inventory_value: 0,
|
|
||||||
turnover_rate: 0,
|
|
||||||
abc_class: 'C',
|
|
||||||
stock_status: 'New',
|
|
||||||
avg_lead_time_days: 0,
|
|
||||||
current_lead_time: 0,
|
|
||||||
target_lead_time: 14,
|
|
||||||
lead_time_status: 'Unknown',
|
|
||||||
reorder_qty: 0,
|
|
||||||
overstocked_amt: 0
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(metrics[0]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching product metrics:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to fetch product metrics' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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 { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ const { initPool } = require('./utils/db');
|
|||||||
const productsRouter = require('./routes/products');
|
const productsRouter = require('./routes/products');
|
||||||
const dashboardRouter = require('./routes/dashboard');
|
const dashboardRouter = require('./routes/dashboard');
|
||||||
const ordersRouter = require('./routes/orders');
|
const ordersRouter = require('./routes/orders');
|
||||||
const csvRouter = require('./routes/csv');
|
const csvRouter = require('./routes/data-management');
|
||||||
const analyticsRouter = require('./routes/analytics');
|
const analyticsRouter = require('./routes/analytics');
|
||||||
const purchaseOrdersRouter = require('./routes/purchase-orders');
|
const purchaseOrdersRouter = require('./routes/purchase-orders');
|
||||||
const configRouter = require('./routes/config');
|
const configRouter = require('./routes/config');
|
||||||
|
|||||||
239
inventory-server/src/utils/dbConnection.js
Normal file
239
inventory-server/src/utils/dbConnection.js
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
const { Client } = require('ssh2');
|
||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// Connection pooling and cache configuration
|
||||||
|
const connectionCache = {
|
||||||
|
ssh: null,
|
||||||
|
dbConnection: null,
|
||||||
|
lastUsed: 0,
|
||||||
|
isConnecting: false,
|
||||||
|
connectionPromise: null,
|
||||||
|
// Cache expiration time in milliseconds (5 minutes)
|
||||||
|
expirationTime: 5 * 60 * 1000,
|
||||||
|
// Cache for query results (key: query string, value: {data, timestamp})
|
||||||
|
queryCache: new Map(),
|
||||||
|
// Cache duration for different query types in milliseconds
|
||||||
|
cacheDuration: {
|
||||||
|
'field-options': 30 * 60 * 1000, // 30 minutes for field options
|
||||||
|
'product-lines': 10 * 60 * 1000, // 10 minutes for product lines
|
||||||
|
'sublines': 10 * 60 * 1000, // 10 minutes for sublines
|
||||||
|
'taxonomy': 30 * 60 * 1000, // 30 minutes for taxonomy data
|
||||||
|
'default': 60 * 1000 // 1 minute default
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a database connection with connection pooling
|
||||||
|
* @returns {Promise<{ssh: object, connection: object}>} The SSH and database connection
|
||||||
|
*/
|
||||||
|
async function getDbConnection() {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Check if we need to refresh the connection due to inactivity
|
||||||
|
const needsRefresh = !connectionCache.ssh ||
|
||||||
|
!connectionCache.dbConnection ||
|
||||||
|
(now - connectionCache.lastUsed > connectionCache.expirationTime);
|
||||||
|
|
||||||
|
// If connection is still valid, update last used time and return existing connection
|
||||||
|
if (!needsRefresh) {
|
||||||
|
connectionCache.lastUsed = now;
|
||||||
|
return {
|
||||||
|
ssh: connectionCache.ssh,
|
||||||
|
connection: connectionCache.dbConnection
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// If another request is already establishing a connection, wait for that promise
|
||||||
|
if (connectionCache.isConnecting && connectionCache.connectionPromise) {
|
||||||
|
try {
|
||||||
|
await connectionCache.connectionPromise;
|
||||||
|
return {
|
||||||
|
ssh: connectionCache.ssh,
|
||||||
|
connection: connectionCache.dbConnection
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// If that connection attempt failed, we'll try again below
|
||||||
|
console.error('Error waiting for existing connection:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close existing connections if they exist
|
||||||
|
if (connectionCache.dbConnection) {
|
||||||
|
try {
|
||||||
|
await connectionCache.dbConnection.end();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error closing existing database connection:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connectionCache.ssh) {
|
||||||
|
try {
|
||||||
|
connectionCache.ssh.end();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error closing existing SSH connection:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark that we're establishing a new connection
|
||||||
|
connectionCache.isConnecting = true;
|
||||||
|
|
||||||
|
// Create a new promise for this connection attempt
|
||||||
|
connectionCache.connectionPromise = setupSshTunnel().then(tunnel => {
|
||||||
|
const { ssh, stream, dbConfig } = tunnel;
|
||||||
|
|
||||||
|
return mysql.createConnection({
|
||||||
|
...dbConfig,
|
||||||
|
stream
|
||||||
|
}).then(connection => {
|
||||||
|
// Store the new connections
|
||||||
|
connectionCache.ssh = ssh;
|
||||||
|
connectionCache.dbConnection = connection;
|
||||||
|
connectionCache.lastUsed = Date.now();
|
||||||
|
connectionCache.isConnecting = false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ssh,
|
||||||
|
connection
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}).catch(error => {
|
||||||
|
connectionCache.isConnecting = false;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for the connection to be established
|
||||||
|
return connectionCache.connectionPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached query results or execute query if not cached
|
||||||
|
* @param {string} cacheKey - Unique key to identify the query
|
||||||
|
* @param {string} queryType - Type of query (field-options, product-lines, etc.)
|
||||||
|
* @param {Function} queryFn - Function to execute if cache miss
|
||||||
|
* @returns {Promise<any>} The query result
|
||||||
|
*/
|
||||||
|
async function getCachedQuery(cacheKey, queryType, queryFn) {
|
||||||
|
// Get cache duration based on query type
|
||||||
|
const cacheDuration = connectionCache.cacheDuration[queryType] || connectionCache.cacheDuration.default;
|
||||||
|
|
||||||
|
// Check if we have a valid cached result
|
||||||
|
const cachedResult = connectionCache.queryCache.get(cacheKey);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (cachedResult && (now - cachedResult.timestamp < cacheDuration)) {
|
||||||
|
console.log(`Cache hit for ${queryType} query: ${cacheKey}`);
|
||||||
|
return cachedResult.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No valid cache found, execute the query
|
||||||
|
console.log(`Cache miss for ${queryType} query: ${cacheKey}`);
|
||||||
|
const result = await queryFn();
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
connectionCache.queryCache.set(cacheKey, {
|
||||||
|
data: result,
|
||||||
|
timestamp: now
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup SSH tunnel to production database
|
||||||
|
* @private - Should only be used by getDbConnection
|
||||||
|
* @returns {Promise<{ssh: object, stream: object, dbConfig: object}>}
|
||||||
|
*/
|
||||||
|
async function setupSshTunnel() {
|
||||||
|
const sshConfig = {
|
||||||
|
host: process.env.PROD_SSH_HOST,
|
||||||
|
port: process.env.PROD_SSH_PORT || 22,
|
||||||
|
username: process.env.PROD_SSH_USER,
|
||||||
|
privateKey: process.env.PROD_SSH_KEY_PATH
|
||||||
|
? fs.readFileSync(process.env.PROD_SSH_KEY_PATH)
|
||||||
|
: undefined,
|
||||||
|
compress: true
|
||||||
|
};
|
||||||
|
|
||||||
|
const dbConfig = {
|
||||||
|
host: process.env.PROD_DB_HOST || 'localhost',
|
||||||
|
user: process.env.PROD_DB_USER,
|
||||||
|
password: process.env.PROD_DB_PASSWORD,
|
||||||
|
database: process.env.PROD_DB_NAME,
|
||||||
|
port: process.env.PROD_DB_PORT || 3306,
|
||||||
|
timezone: 'Z'
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const ssh = new Client();
|
||||||
|
|
||||||
|
ssh.on('error', (err) => {
|
||||||
|
console.error('SSH connection error:', err);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
ssh.on('ready', () => {
|
||||||
|
ssh.forwardOut(
|
||||||
|
'127.0.0.1',
|
||||||
|
0,
|
||||||
|
dbConfig.host,
|
||||||
|
dbConfig.port,
|
||||||
|
(err, stream) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
resolve({ ssh, stream, dbConfig });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}).connect(sshConfig);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear cached query results
|
||||||
|
* @param {string} [cacheKey] - Specific cache key to clear (clears all if not provided)
|
||||||
|
*/
|
||||||
|
function clearQueryCache(cacheKey) {
|
||||||
|
if (cacheKey) {
|
||||||
|
connectionCache.queryCache.delete(cacheKey);
|
||||||
|
console.log(`Cleared cache for key: ${cacheKey}`);
|
||||||
|
} else {
|
||||||
|
connectionCache.queryCache.clear();
|
||||||
|
console.log('Cleared all query cache');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force close all active connections
|
||||||
|
* Useful for server shutdown or manual connection reset
|
||||||
|
*/
|
||||||
|
async function closeAllConnections() {
|
||||||
|
if (connectionCache.dbConnection) {
|
||||||
|
try {
|
||||||
|
await connectionCache.dbConnection.end();
|
||||||
|
console.log('Closed database connection');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error closing database connection:', error);
|
||||||
|
}
|
||||||
|
connectionCache.dbConnection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connectionCache.ssh) {
|
||||||
|
try {
|
||||||
|
connectionCache.ssh.end();
|
||||||
|
console.log('Closed SSH connection');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error closing SSH connection:', error);
|
||||||
|
}
|
||||||
|
connectionCache.ssh = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectionCache.lastUsed = 0;
|
||||||
|
connectionCache.isConnecting = false;
|
||||||
|
connectionCache.connectionPromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getDbConnection,
|
||||||
|
getCachedQuery,
|
||||||
|
clearQueryCache,
|
||||||
|
closeAllConnections
|
||||||
|
};
|
||||||
@@ -38,21 +38,22 @@ export function CategoryPerformance() {
|
|||||||
const rawData = await response.json();
|
const rawData = await response.json();
|
||||||
return {
|
return {
|
||||||
performance: rawData.performance.map((item: any) => ({
|
performance: rawData.performance.map((item: any) => ({
|
||||||
...item,
|
category: item.category || '',
|
||||||
categoryPath: item.categoryPath || item.category,
|
categoryPath: 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,
|
||||||
productCount: Number(item.productCount) || 0
|
productCount: Number(item.productCount) || Number(item.productcount) || 0
|
||||||
})),
|
})),
|
||||||
distribution: rawData.distribution.map((item: any) => ({
|
distribution: rawData.distribution.map((item: any) => ({
|
||||||
...item,
|
category: item.category || '',
|
||||||
categoryPath: item.categoryPath || item.category,
|
categoryPath: 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,
|
category: item.category || '',
|
||||||
categoryPath: item.categoryPath || item.category,
|
categoryPath: item.categoryPath || item.categorypath || item.category || '',
|
||||||
|
month: item.month || '',
|
||||||
sales: Number(item.sales) || 0
|
sales: Number(item.sales) || 0
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,41 +25,91 @@ interface PriceData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PriceAnalysis() {
|
export function PriceAnalysis() {
|
||||||
const { data, isLoading } = useQuery<PriceData>({
|
const { data, isLoading, error } = useQuery<PriceData>({
|
||||||
queryKey: ['price-analysis'],
|
queryKey: ['price-analysis'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
const response = await fetch(`${config.apiUrl}/analytics/pricing`);
|
const response = await fetch(`${config.apiUrl}/analytics/pricing`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch price analysis');
|
throw new Error(`Failed to fetch: ${response.status}`);
|
||||||
}
|
}
|
||||||
const rawData = await response.json();
|
const rawData = await response.json();
|
||||||
|
|
||||||
|
if (!rawData || !rawData.pricePoints) {
|
||||||
return {
|
return {
|
||||||
pricePoints: rawData.pricePoints.map((item: any) => ({
|
pricePoints: [],
|
||||||
...item,
|
elasticity: [],
|
||||||
|
recommendations: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
pricePoints: (rawData.pricePoints || []).map((item: any) => ({
|
||||||
price: Number(item.price) || 0,
|
price: Number(item.price) || 0,
|
||||||
salesVolume: Number(item.salesVolume) || 0,
|
salesVolume: Number(item.salesVolume || item.salesvolume) || 0,
|
||||||
revenue: Number(item.revenue) || 0
|
revenue: Number(item.revenue) || 0,
|
||||||
|
category: item.category || ''
|
||||||
})),
|
})),
|
||||||
elasticity: rawData.elasticity.map((item: any) => ({
|
elasticity: (rawData.elasticity || []).map((item: any) => ({
|
||||||
...item,
|
date: item.date || '',
|
||||||
price: Number(item.price) || 0,
|
price: Number(item.price) || 0,
|
||||||
demand: Number(item.demand) || 0
|
demand: Number(item.demand) || 0
|
||||||
})),
|
})),
|
||||||
recommendations: rawData.recommendations.map((item: any) => ({
|
recommendations: (rawData.recommendations || []).map((item: any) => ({
|
||||||
...item,
|
product: item.product || '',
|
||||||
currentPrice: Number(item.currentPrice) || 0,
|
currentPrice: Number(item.currentPrice || item.currentprice) || 0,
|
||||||
recommendedPrice: Number(item.recommendedPrice) || 0,
|
recommendedPrice: Number(item.recommendedPrice || item.recommendedprice) || 0,
|
||||||
potentialRevenue: Number(item.potentialRevenue) || 0,
|
potentialRevenue: Number(item.potentialRevenue || item.potentialrevenue) || 0,
|
||||||
confidence: Number(item.confidence) || 0
|
confidence: Number(item.confidence) || 0
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching price data:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
retry: 1
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading || !data) {
|
if (isLoading) {
|
||||||
return <div>Loading price analysis...</div>;
|
return <div>Loading price analysis...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<Card className="mb-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Price Analysis</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-red-500">
|
||||||
|
Unable to load price analysis. The price metrics may need to be set up in the database.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Early return if no data to display
|
||||||
|
if (
|
||||||
|
data.pricePoints.length === 0 &&
|
||||||
|
data.elasticity.length === 0 &&
|
||||||
|
data.recommendations.length === 0
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Card className="mb-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Price Analysis</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
No price data available. This may be because the price metrics haven't been calculated yet.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
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">
|
||||||
|
|||||||
@@ -38,22 +38,23 @@ export function ProfitAnalysis() {
|
|||||||
const rawData = await response.json();
|
const rawData = await response.json();
|
||||||
return {
|
return {
|
||||||
byCategory: rawData.byCategory.map((item: any) => ({
|
byCategory: rawData.byCategory.map((item: any) => ({
|
||||||
...item,
|
category: item.category || '',
|
||||||
categoryPath: item.categoryPath || item.category,
|
categoryPath: item.categorypath || item.category || '',
|
||||||
profitMargin: Number(item.profitMargin) || 0,
|
profitMargin: item.profitmargin !== null ? Number(item.profitmargin) : 0,
|
||||||
revenue: Number(item.revenue) || 0,
|
revenue: Number(item.revenue) || 0,
|
||||||
cost: Number(item.cost) || 0
|
cost: Number(item.cost) || 0
|
||||||
})),
|
})),
|
||||||
overTime: rawData.overTime.map((item: any) => ({
|
overTime: rawData.overTime.map((item: any) => ({
|
||||||
...item,
|
date: item.date || '',
|
||||||
profitMargin: Number(item.profitMargin) || 0,
|
profitMargin: item.profitmargin !== null ? Number(item.profitmargin) : 0,
|
||||||
revenue: Number(item.revenue) || 0,
|
revenue: Number(item.revenue) || 0,
|
||||||
cost: Number(item.cost) || 0
|
cost: Number(item.cost) || 0
|
||||||
})),
|
})),
|
||||||
topProducts: rawData.topProducts.map((item: any) => ({
|
topProducts: rawData.topProducts.map((item: any) => ({
|
||||||
...item,
|
product: item.product || '',
|
||||||
categoryPath: item.categoryPath || item.category,
|
category: item.category || '',
|
||||||
profitMargin: Number(item.profitMargin) || 0,
|
categoryPath: item.categorypath || item.category || '',
|
||||||
|
profitMargin: item.profitmargin !== null ? Number(item.profitmargin) : 0,
|
||||||
revenue: Number(item.revenue) || 0,
|
revenue: Number(item.revenue) || 0,
|
||||||
cost: Number(item.cost) || 0
|
cost: Number(item.cost) || 0
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -28,42 +28,93 @@ interface StockData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function StockAnalysis() {
|
export function StockAnalysis() {
|
||||||
const { data, isLoading } = useQuery<StockData>({
|
const { data, isLoading, error } = useQuery<StockData>({
|
||||||
queryKey: ['stock-analysis'],
|
queryKey: ['stock-analysis'],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
try {
|
||||||
const response = await fetch(`${config.apiUrl}/analytics/stock`);
|
const response = await fetch(`${config.apiUrl}/analytics/stock`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to fetch stock analysis');
|
throw new Error(`Failed to fetch: ${response.status}`);
|
||||||
}
|
}
|
||||||
const rawData = await response.json();
|
const rawData = await response.json();
|
||||||
|
|
||||||
|
if (!rawData || !rawData.turnoverByCategory) {
|
||||||
return {
|
return {
|
||||||
turnoverByCategory: rawData.turnoverByCategory.map((item: any) => ({
|
turnoverByCategory: [],
|
||||||
...item,
|
stockLevels: [],
|
||||||
turnoverRate: Number(item.turnoverRate) || 0,
|
criticalItems: []
|
||||||
averageStock: Number(item.averageStock) || 0,
|
};
|
||||||
totalSales: Number(item.totalSales) || 0
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
turnoverByCategory: (rawData.turnoverByCategory || []).map((item: any) => ({
|
||||||
|
category: item.category || '',
|
||||||
|
turnoverRate: Number(item.turnoverRate || item.turnoverrate) || 0,
|
||||||
|
averageStock: Number(item.averageStock || item.averagestock) || 0,
|
||||||
|
totalSales: Number(item.totalSales || item.totalsales) || 0
|
||||||
})),
|
})),
|
||||||
stockLevels: rawData.stockLevels.map((item: any) => ({
|
stockLevels: (rawData.stockLevels || []).map((item: any) => ({
|
||||||
...item,
|
date: item.date || '',
|
||||||
inStock: Number(item.inStock) || 0,
|
inStock: Number(item.inStock || item.instock) || 0,
|
||||||
lowStock: Number(item.lowStock) || 0,
|
lowStock: Number(item.lowStock || item.lowstock) || 0,
|
||||||
outOfStock: Number(item.outOfStock) || 0
|
outOfStock: Number(item.outOfStock || item.outofstock) || 0
|
||||||
})),
|
})),
|
||||||
criticalItems: rawData.criticalItems.map((item: any) => ({
|
criticalItems: (rawData.criticalItems || []).map((item: any) => ({
|
||||||
...item,
|
product: item.product || '',
|
||||||
stockQuantity: Number(item.stockQuantity) || 0,
|
sku: item.sku || '',
|
||||||
reorderPoint: Number(item.reorderPoint) || 0,
|
stockQuantity: Number(item.stockQuantity || item.stockquantity) || 0,
|
||||||
turnoverRate: Number(item.turnoverRate) || 0,
|
reorderPoint: Number(item.reorderPoint || item.reorderpoint) || 0,
|
||||||
daysUntilStockout: Number(item.daysUntilStockout) || 0
|
turnoverRate: Number(item.turnoverRate || item.turnoverrate) || 0,
|
||||||
|
daysUntilStockout: Number(item.daysUntilStockout || item.daysuntilstockout) || 0
|
||||||
}))
|
}))
|
||||||
};
|
};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching stock data:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
retry: 1
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLoading || !data) {
|
if (isLoading) {
|
||||||
return <div>Loading stock analysis...</div>;
|
return <div>Loading stock analysis...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<Card className="mb-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Stock Analysis</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-red-500">
|
||||||
|
Unable to load stock analysis. The stock metrics may need to be set up in the database.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Early return if no data to display
|
||||||
|
if (
|
||||||
|
data.turnoverByCategory.length === 0 &&
|
||||||
|
data.stockLevels.length === 0 &&
|
||||||
|
data.criticalItems.length === 0
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Card className="mb-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Stock Analysis</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
No stock data available. This may be because the stock metrics haven't been calculated yet.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const getStockStatus = (daysUntilStockout: number) => {
|
const getStockStatus = (daysUntilStockout: number) => {
|
||||||
if (daysUntilStockout <= 7) {
|
if (daysUntilStockout <= 7) {
|
||||||
return <Badge variant="destructive">Critical</Badge>;
|
return <Badge variant="destructive">Critical</Badge>;
|
||||||
|
|||||||
@@ -58,22 +58,22 @@ export function VendorPerformance() {
|
|||||||
// Create a complete structure even if some parts are missing
|
// Create a complete structure even if some parts are missing
|
||||||
const data: VendorData = {
|
const data: VendorData = {
|
||||||
performance: rawData.performance.map((vendor: any) => ({
|
performance: rawData.performance.map((vendor: any) => ({
|
||||||
vendor: vendor.vendor,
|
vendor: vendor.vendor || '',
|
||||||
salesVolume: Number(vendor.salesVolume) || 0,
|
salesVolume: vendor.salesVolume !== null ? Number(vendor.salesVolume) : 0,
|
||||||
profitMargin: Number(vendor.profitMargin) || 0,
|
profitMargin: vendor.profitMargin !== null ? Number(vendor.profitMargin) : 0,
|
||||||
stockTurnover: Number(vendor.stockTurnover) || 0,
|
stockTurnover: vendor.stockTurnover !== null ? Number(vendor.stockTurnover) : 0,
|
||||||
productCount: Number(vendor.productCount) || 0,
|
productCount: Number(vendor.productCount) || 0,
|
||||||
growth: Number(vendor.growth) || 0
|
growth: vendor.growth !== null ? Number(vendor.growth) : 0
|
||||||
})),
|
})),
|
||||||
comparison: rawData.comparison?.map((vendor: any) => ({
|
comparison: rawData.comparison?.map((vendor: any) => ({
|
||||||
vendor: vendor.vendor,
|
vendor: vendor.vendor || '',
|
||||||
salesPerProduct: Number(vendor.salesPerProduct) || 0,
|
salesPerProduct: vendor.salesPerProduct !== null ? Number(vendor.salesPerProduct) : 0,
|
||||||
averageMargin: Number(vendor.averageMargin) || 0,
|
averageMargin: vendor.averageMargin !== null ? Number(vendor.averageMargin) : 0,
|
||||||
size: Number(vendor.size) || 0
|
size: Number(vendor.size) || 0
|
||||||
})) || [],
|
})) || [],
|
||||||
trends: rawData.trends?.map((vendor: any) => ({
|
trends: rawData.trends?.map((vendor: any) => ({
|
||||||
vendor: vendor.vendor,
|
vendor: vendor.vendor || '',
|
||||||
month: vendor.month,
|
month: vendor.month || '',
|
||||||
sales: Number(vendor.sales) || 0
|
sales: Number(vendor.sales) || 0
|
||||||
})) || []
|
})) || []
|
||||||
};
|
};
|
||||||
|
|||||||
383
inventory/src/components/purchase-orders/CategoryMetricsCard.tsx
Normal file
383
inventory/src/components/purchase-orders/CategoryMetricsCard.tsx
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../components/ui/card";
|
||||||
|
import { Skeleton } from "../../components/ui/skeleton";
|
||||||
|
import { BarChart3, Loader2 } from "lucide-react";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "../../components/ui/dialog";
|
||||||
|
import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../components/ui/table";
|
||||||
|
|
||||||
|
// Add this constant for pie chart colors
|
||||||
|
const COLORS = [
|
||||||
|
"#0088FE",
|
||||||
|
"#00C49F",
|
||||||
|
"#FFBB28",
|
||||||
|
"#FF8042",
|
||||||
|
"#8884D8",
|
||||||
|
"#82CA9D",
|
||||||
|
"#FFC658",
|
||||||
|
"#FF7C43",
|
||||||
|
];
|
||||||
|
|
||||||
|
// The renderActiveShape function for pie charts
|
||||||
|
const renderActiveShape = (props: any) => {
|
||||||
|
const {
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
innerRadius,
|
||||||
|
outerRadius,
|
||||||
|
startAngle,
|
||||||
|
endAngle,
|
||||||
|
fill,
|
||||||
|
category,
|
||||||
|
total_spend,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
// Split category name into words and create lines of max 12 chars
|
||||||
|
const words = category.split(" ");
|
||||||
|
const lines: string[] = [];
|
||||||
|
let currentLine = "";
|
||||||
|
|
||||||
|
words.forEach((word: string) => {
|
||||||
|
if ((currentLine + " " + word).length <= 12) {
|
||||||
|
currentLine = currentLine ? `${currentLine} ${word}` : word;
|
||||||
|
} else {
|
||||||
|
if (currentLine) lines.push(currentLine);
|
||||||
|
currentLine = word;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (currentLine) lines.push(currentLine);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<Sector
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
innerRadius={innerRadius}
|
||||||
|
outerRadius={outerRadius}
|
||||||
|
startAngle={startAngle}
|
||||||
|
endAngle={endAngle}
|
||||||
|
fill={fill}
|
||||||
|
/>
|
||||||
|
<Sector
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
startAngle={startAngle}
|
||||||
|
endAngle={endAngle}
|
||||||
|
innerRadius={outerRadius - 1}
|
||||||
|
outerRadius={outerRadius + 4}
|
||||||
|
fill={fill}
|
||||||
|
/>
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<text
|
||||||
|
key={i}
|
||||||
|
x={cx}
|
||||||
|
y={cy}
|
||||||
|
dy={-20 + i * 16}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="#888888"
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{line}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
<text
|
||||||
|
x={cx}
|
||||||
|
y={cy}
|
||||||
|
dy={lines.length * 16 - 10}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="#000000"
|
||||||
|
className="text-base font-medium"
|
||||||
|
>
|
||||||
|
{`$${Number(total_spend).toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}`}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CategoryMetricsCardProps {
|
||||||
|
loading: boolean;
|
||||||
|
yearlyCategoryData: {
|
||||||
|
category: string;
|
||||||
|
unique_products?: number;
|
||||||
|
total_spend: number;
|
||||||
|
percentage?: number;
|
||||||
|
avg_cost?: number;
|
||||||
|
cost_variance?: number;
|
||||||
|
}[];
|
||||||
|
yearlyDataLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CategoryMetricsCard({
|
||||||
|
loading,
|
||||||
|
yearlyCategoryData,
|
||||||
|
yearlyDataLoading,
|
||||||
|
}: CategoryMetricsCardProps) {
|
||||||
|
const [costAnalysisOpen, setCostAnalysisOpen] = useState(false);
|
||||||
|
const [activeSpendingIndex, setActiveSpendingIndex] = useState<
|
||||||
|
number | undefined
|
||||||
|
>();
|
||||||
|
const [initialLoading, setInitialLoading] = useState(true);
|
||||||
|
|
||||||
|
// Only show loading state on initial load, not during table refreshes
|
||||||
|
useEffect(() => {
|
||||||
|
if (yearlyCategoryData.length > 0 && !yearlyDataLoading) {
|
||||||
|
setInitialLoading(false);
|
||||||
|
}
|
||||||
|
}, [yearlyCategoryData, yearlyDataLoading]);
|
||||||
|
|
||||||
|
const formatNumber = (value: number) => {
|
||||||
|
return value.toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) => {
|
||||||
|
return `$${formatNumber(value)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPercent = (value: number) => {
|
||||||
|
return (
|
||||||
|
(value * 100).toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
}) + "%"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prepare spending chart data
|
||||||
|
const prepareSpendingChartData = () => {
|
||||||
|
if (!yearlyCategoryData.length) return [];
|
||||||
|
|
||||||
|
// Make a copy to avoid modifying state directly
|
||||||
|
const categoryArray = [...yearlyCategoryData];
|
||||||
|
const totalSpend = categoryArray.reduce(
|
||||||
|
(sum, cat) => sum + cat.total_spend,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Split into significant categories (>=1%) and others
|
||||||
|
const significantCategories = categoryArray.filter(
|
||||||
|
(cat) => cat.total_spend / totalSpend >= 0.01
|
||||||
|
);
|
||||||
|
|
||||||
|
const otherCategories = categoryArray.filter(
|
||||||
|
(cat) => cat.total_spend / totalSpend < 0.01
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = [...significantCategories];
|
||||||
|
|
||||||
|
// Add "Other" category if needed
|
||||||
|
if (otherCategories.length > 0) {
|
||||||
|
const otherTotalSpend = otherCategories.reduce(
|
||||||
|
(sum, cat) => sum + cat.total_spend,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
category: "Other",
|
||||||
|
total_spend: otherTotalSpend,
|
||||||
|
percentage: otherTotalSpend / totalSpend,
|
||||||
|
unique_products: otherCategories.reduce(
|
||||||
|
(sum, cat) => sum + (cat.unique_products || 0),
|
||||||
|
0
|
||||||
|
),
|
||||||
|
avg_cost:
|
||||||
|
otherTotalSpend /
|
||||||
|
otherCategories.reduce(
|
||||||
|
(sum, cat) => sum + (cat.unique_products || 0),
|
||||||
|
1
|
||||||
|
),
|
||||||
|
cost_variance: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by spend amount descending
|
||||||
|
return result.sort((a, b) => b.total_spend - a.total_spend);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cost analysis table component
|
||||||
|
const CostAnalysisTable = () => {
|
||||||
|
if (!yearlyCategoryData.length) {
|
||||||
|
return yearlyDataLoading ? (
|
||||||
|
<div className="flex justify-center p-4">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center p-4 text-muted-foreground">
|
||||||
|
No category data available for the past 12 months
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{yearlyDataLoading ? (
|
||||||
|
<div className="flex justify-center p-4">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-sm font-medium mb-2 px-4 flex justify-between">
|
||||||
|
<span>
|
||||||
|
Showing received inventory by category for the past 12 months
|
||||||
|
</span>
|
||||||
|
<span>{yearlyCategoryData.length} categories found</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground px-4 mb-2">
|
||||||
|
Note: items can be in multiple categories, so the sum of the
|
||||||
|
categories will not equal the total spend.
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Category</TableHead>
|
||||||
|
<TableHead>Products</TableHead>
|
||||||
|
<TableHead>Avg. Cost</TableHead>
|
||||||
|
<TableHead>Price Variance</TableHead>
|
||||||
|
<TableHead>Total Spend</TableHead>
|
||||||
|
<TableHead>% of Total</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{yearlyCategoryData.map((category) => {
|
||||||
|
// Calculate percentage of total spend
|
||||||
|
const totalSpendPercentage =
|
||||||
|
"percentage" in category &&
|
||||||
|
typeof category.percentage === "number"
|
||||||
|
? category.percentage
|
||||||
|
: yearlyCategoryData.reduce(
|
||||||
|
(sum, cat) => sum + cat.total_spend,
|
||||||
|
0
|
||||||
|
) > 0
|
||||||
|
? category.total_spend /
|
||||||
|
yearlyCategoryData.reduce(
|
||||||
|
(sum, cat) => sum + cat.total_spend,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={category.category}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{category.category || "Uncategorized"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{category.unique_products?.toLocaleString() || "N/A"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{category.avg_cost !== undefined
|
||||||
|
? formatCurrency(category.avg_cost)
|
||||||
|
: "N/A"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{category.cost_variance !== undefined
|
||||||
|
? parseFloat(
|
||||||
|
category.cost_variance.toFixed(2)
|
||||||
|
).toLocaleString()
|
||||||
|
: "N/A"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{formatCurrency(category.total_spend)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{formatPercent(totalSpendPercentage)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Received by Category
|
||||||
|
</CardTitle>
|
||||||
|
<Dialog open={costAnalysisOpen} onOpenChange={setCostAnalysisOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" disabled={initialLoading || loading}>
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-[90%] w-fit">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-5 w-5" />
|
||||||
|
<span>Received Inventory by Category</span>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="overflow-auto max-h-[70vh]">
|
||||||
|
<CostAnalysisTable />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{initialLoading || loading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-[170px]">
|
||||||
|
<Skeleton className="h-[170px] w-[170px] rounded-full" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="h-[170px] relative">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart margin={{ top: 30, right: 0, left: 0, bottom: 30 }}>
|
||||||
|
<Pie
|
||||||
|
data={prepareSpendingChartData()}
|
||||||
|
dataKey="total_spend"
|
||||||
|
nameKey="category"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={1}
|
||||||
|
activeIndex={activeSpendingIndex}
|
||||||
|
activeShape={renderActiveShape}
|
||||||
|
onMouseEnter={(_, index) => setActiveSpendingIndex(index)}
|
||||||
|
onMouseLeave={() => setActiveSpendingIndex(undefined)}
|
||||||
|
>
|
||||||
|
{prepareSpendingChartData().map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={entry.category}
|
||||||
|
fill={COLORS[index % COLORS.length]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
inventory/src/components/purchase-orders/FilterControls.tsx
Normal file
155
inventory/src/components/purchase-orders/FilterControls.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import { Input } from "../../components/ui/input";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "../../components/ui/select";
|
||||||
|
import {
|
||||||
|
PurchaseOrderStatus,
|
||||||
|
getPurchaseOrderStatusLabel
|
||||||
|
} from "../../types/status-codes";
|
||||||
|
|
||||||
|
interface FilterControlsProps {
|
||||||
|
searchInput: string;
|
||||||
|
setSearchInput: (value: string) => void;
|
||||||
|
filterValues: {
|
||||||
|
search: string;
|
||||||
|
status: string;
|
||||||
|
vendor: string;
|
||||||
|
recordType: string;
|
||||||
|
};
|
||||||
|
handleStatusChange: (value: string) => void;
|
||||||
|
handleVendorChange: (value: string) => void;
|
||||||
|
handleRecordTypeChange: (value: string) => void;
|
||||||
|
clearFilters: () => void;
|
||||||
|
filterOptions: {
|
||||||
|
vendors: string[];
|
||||||
|
statuses: number[];
|
||||||
|
};
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 RECORD_TYPE_FILTER_OPTIONS = [
|
||||||
|
{ value: "all", label: "All Records" },
|
||||||
|
{ value: "po_only", label: "PO Only" },
|
||||||
|
{ value: "po_with_receiving", label: "PO with Receiving" },
|
||||||
|
{ value: "receiving_only", label: "Receiving Only" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function FilterControls({
|
||||||
|
searchInput,
|
||||||
|
setSearchInput,
|
||||||
|
filterValues,
|
||||||
|
handleStatusChange,
|
||||||
|
handleVendorChange,
|
||||||
|
handleRecordTypeChange,
|
||||||
|
clearFilters,
|
||||||
|
filterOptions,
|
||||||
|
loading,
|
||||||
|
}: FilterControlsProps) {
|
||||||
|
return (
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-4">
|
||||||
|
<Input
|
||||||
|
placeholder="Search orders..."
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
className="max-w-xs"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={filterValues.status}
|
||||||
|
onValueChange={handleStatusChange}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Select status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{STATUS_FILTER_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
value={filterValues.vendor}
|
||||||
|
onValueChange={handleVendorChange}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Select supplier" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Suppliers</SelectItem>
|
||||||
|
{filterOptions?.vendors?.map((vendor) => (
|
||||||
|
<SelectItem key={vendor} value={vendor}>
|
||||||
|
{vendor}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
value={filterValues.recordType}
|
||||||
|
onValueChange={handleRecordTypeChange}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[180px]">
|
||||||
|
<SelectValue placeholder="Record Type" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{RECORD_TYPE_FILTER_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{(filterValues.search || filterValues.status !== "all" || filterValues.vendor !== "all" || filterValues.recordType !== "all") && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={clearFilters}
|
||||||
|
disabled={loading}
|
||||||
|
title="Clear filters"
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
<span>Clear</span> ✕
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
inventory/src/components/purchase-orders/OrderMetricsCard.tsx
Normal file
122
inventory/src/components/purchase-orders/OrderMetricsCard.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../components/ui/card";
|
||||||
|
import { Skeleton } from "../../components/ui/skeleton";
|
||||||
|
|
||||||
|
type ReceivingStatus = {
|
||||||
|
order_count: number;
|
||||||
|
total_ordered: number;
|
||||||
|
total_received: number;
|
||||||
|
fulfillment_rate: number;
|
||||||
|
total_value: number;
|
||||||
|
avg_cost: number;
|
||||||
|
avg_delivery_days?: number;
|
||||||
|
max_delivery_days?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface OrderMetricsCardProps {
|
||||||
|
summary: ReceivingStatus | null;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OrderMetricsCard({
|
||||||
|
summary,
|
||||||
|
loading,
|
||||||
|
}: OrderMetricsCardProps) {
|
||||||
|
const [initialLoading, setInitialLoading] = useState(true);
|
||||||
|
|
||||||
|
// Only show loading state on initial load, not during table refreshes
|
||||||
|
useEffect(() => {
|
||||||
|
if (summary) {
|
||||||
|
setInitialLoading(false);
|
||||||
|
}
|
||||||
|
}, [summary]);
|
||||||
|
|
||||||
|
const formatNumber = (value: number) => {
|
||||||
|
return value.toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) => {
|
||||||
|
return `$${formatNumber(value)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPercent = (value: number) => {
|
||||||
|
return (
|
||||||
|
(value * 100).toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
}) + "%"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Order Metrics</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{initialLoading || loading ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{/* 5 rows of skeleton metrics */}
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<div key={i} className="flex items-baseline justify-between">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-6 w-16" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
Avg. Cost per PO
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold">
|
||||||
|
{formatCurrency(summary?.avg_cost || 0)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
Overall Fulfillment Rate
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold">
|
||||||
|
{formatPercent(summary?.fulfillment_rate || 0)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
Total Orders
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold">
|
||||||
|
{summary?.order_count.toLocaleString() || 0}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
Avg. Delivery Days
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold">
|
||||||
|
{summary?.avg_delivery_days ? summary.avg_delivery_days.toFixed(1) : "N/A"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
Longest Delivery Days
|
||||||
|
</p>
|
||||||
|
<p className="text-lg font-bold">
|
||||||
|
{summary?.max_delivery_days ? summary.max_delivery_days.toFixed(0) : "N/A"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
140
inventory/src/components/purchase-orders/PaginationControls.tsx
Normal file
140
inventory/src/components/purchase-orders/PaginationControls.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationEllipsis,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from "../../components/ui/pagination";
|
||||||
|
|
||||||
|
interface PaginationControlsProps {
|
||||||
|
pagination: {
|
||||||
|
total: number;
|
||||||
|
pages: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
};
|
||||||
|
currentPage: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PaginationControls({
|
||||||
|
pagination,
|
||||||
|
currentPage,
|
||||||
|
onPageChange,
|
||||||
|
}: PaginationControlsProps) {
|
||||||
|
// Generate pagination items
|
||||||
|
const getPaginationItems = () => {
|
||||||
|
const items = [];
|
||||||
|
const totalPages = pagination.pages;
|
||||||
|
|
||||||
|
// Always show first page
|
||||||
|
if (totalPages > 0) {
|
||||||
|
items.push(
|
||||||
|
<PaginationItem key="first">
|
||||||
|
<PaginationLink
|
||||||
|
isActive={currentPage === 1}
|
||||||
|
onClick={() => currentPage !== 1 && onPageChange(1)}
|
||||||
|
>
|
||||||
|
1
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add ellipsis if needed
|
||||||
|
if (currentPage > 3) {
|
||||||
|
items.push(
|
||||||
|
<PaginationItem key="ellipsis-1">
|
||||||
|
<PaginationEllipsis />
|
||||||
|
</PaginationItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add pages around current page
|
||||||
|
const startPage = Math.max(2, currentPage - 1);
|
||||||
|
const endPage = Math.min(totalPages - 1, currentPage + 1);
|
||||||
|
|
||||||
|
for (let i = startPage; i <= endPage; i++) {
|
||||||
|
if (i <= 1 || i >= totalPages) continue; // Skip first and last page as they're handled separately
|
||||||
|
items.push(
|
||||||
|
<PaginationItem key={i}>
|
||||||
|
<PaginationLink
|
||||||
|
isActive={currentPage === i}
|
||||||
|
onClick={() => currentPage !== i && onPageChange(i)}
|
||||||
|
>
|
||||||
|
{i}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add ellipsis if needed
|
||||||
|
if (currentPage < totalPages - 2) {
|
||||||
|
items.push(
|
||||||
|
<PaginationItem key="ellipsis-2">
|
||||||
|
<PaginationEllipsis />
|
||||||
|
</PaginationItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always show last page if there are multiple pages
|
||||||
|
if (totalPages > 1) {
|
||||||
|
items.push(
|
||||||
|
<PaginationItem key="last">
|
||||||
|
<PaginationLink
|
||||||
|
isActive={currentPage === totalPages}
|
||||||
|
onClick={() => currentPage !== totalPages && onPageChange(totalPages)}
|
||||||
|
>
|
||||||
|
{totalPages}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pagination.pages <= 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center mb-6">
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (currentPage > 1) onPageChange(currentPage - 1);
|
||||||
|
}}
|
||||||
|
aria-disabled={currentPage === 1}
|
||||||
|
className={currentPage === 1 ? "pointer-events-none opacity-50" : ""}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
|
||||||
|
{getPaginationItems()}
|
||||||
|
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (currentPage < pagination.pages) onPageChange(currentPage + 1);
|
||||||
|
}}
|
||||||
|
aria-disabled={currentPage === pagination.pages}
|
||||||
|
className={
|
||||||
|
currentPage === pagination.pages
|
||||||
|
? "pointer-events-none opacity-50"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../ui/table";
|
||||||
|
import { Skeleton } from "../ui/skeleton";
|
||||||
|
|
||||||
|
// Define the structure of purchase order items
|
||||||
|
interface PurchaseOrderItem {
|
||||||
|
id: string | number;
|
||||||
|
pid: string | number;
|
||||||
|
product_name: string;
|
||||||
|
sku: string;
|
||||||
|
upc: string;
|
||||||
|
ordered: number;
|
||||||
|
received: number;
|
||||||
|
po_cost_price: number;
|
||||||
|
cost_each?: number; // For receiving items
|
||||||
|
qty_each?: number; // For receiving items
|
||||||
|
total_cost: number;
|
||||||
|
receiving_status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PurchaseOrder {
|
||||||
|
id: number | string;
|
||||||
|
vendor_name: string;
|
||||||
|
order_date: string | null;
|
||||||
|
receiving_date: string | null;
|
||||||
|
status: number;
|
||||||
|
total_items: number;
|
||||||
|
total_quantity: number;
|
||||||
|
total_cost: number;
|
||||||
|
total_received: number;
|
||||||
|
fulfillment_rate: number;
|
||||||
|
short_note: string | null;
|
||||||
|
record_type: "po_only" | "po_with_receiving" | "receiving_only";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PurchaseOrderAccordionProps {
|
||||||
|
purchaseOrder: PurchaseOrder;
|
||||||
|
children: React.ReactNode;
|
||||||
|
rowClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PurchaseOrderAccordion({
|
||||||
|
purchaseOrder,
|
||||||
|
children,
|
||||||
|
rowClassName,
|
||||||
|
}: PurchaseOrderAccordionProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [items, setItems] = useState<PurchaseOrderItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Clone the TableRow (children) and add the onClick handler and className
|
||||||
|
const enhancedRow = React.cloneElement(children as React.ReactElement, {
|
||||||
|
onClick: () => setIsOpen(!isOpen),
|
||||||
|
className: `${(children as React.ReactElement).props.className || ""} cursor-pointer ${isOpen ? 'bg-gray-100' : ''} ${rowClassName || ""}`.trim(),
|
||||||
|
"data-state": isOpen ? "open" : "closed"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Format currency
|
||||||
|
const formatCurrency = (value: number) => {
|
||||||
|
return `$${value.toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Only fetch items when the accordion is open
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const fetchItems = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Endpoint path will depend on the type of record
|
||||||
|
const endpoint = purchaseOrder.record_type === "receiving_only"
|
||||||
|
? `/api/purchase-orders/receiving/${purchaseOrder.id}/items`
|
||||||
|
: `/api/purchase-orders/${purchaseOrder.id}/items`;
|
||||||
|
|
||||||
|
const response = await fetch(endpoint);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch items: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setItems(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching purchase order items:", err);
|
||||||
|
setError(err instanceof Error ? err.message : "Unknown error occurred");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchItems();
|
||||||
|
}, [purchaseOrder.id, purchaseOrder.record_type, isOpen]);
|
||||||
|
|
||||||
|
// Render purchase order items list
|
||||||
|
const renderItemsList = () => {
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 text-red-500">
|
||||||
|
Error loading items: {error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-h-[600px] overflow-y-auto bg-gray-50 rounded-md p-2">
|
||||||
|
<Table className="w-full">
|
||||||
|
<TableHeader className="bg-white sticky top-0 z-10">
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[100px]">Item Number</TableHead>
|
||||||
|
<TableHead className="w-auto">Product</TableHead>
|
||||||
|
<TableHead className="w-[100px]">UPC</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-right">Ordered</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-right">Received</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-right">Unit Cost</TableHead>
|
||||||
|
<TableHead className="w-[100px] text-right">Total Cost</TableHead>
|
||||||
|
{purchaseOrder.record_type !== "po_only" && (
|
||||||
|
<TableHead className="w-[120px] text-right">Status</TableHead>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
// Loading skeleton
|
||||||
|
Array(5).fill(0).map((_, i) => (
|
||||||
|
<TableRow key={`skeleton-${i}`}>
|
||||||
|
<TableCell><Skeleton className="h-5 w-full" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-5 w-full" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-5 w-full" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-5 w-full" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-5 w-full" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-5 w-full" /></TableCell>
|
||||||
|
<TableCell><Skeleton className="h-5 w-full" /></TableCell>
|
||||||
|
{purchaseOrder.record_type !== "po_only" && (
|
||||||
|
<TableCell><Skeleton className="h-5 w-full" /></TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
items.map((item) => (
|
||||||
|
<TableRow key={item.id} className="hover:bg-gray-100">
|
||||||
|
<TableCell className="">
|
||||||
|
<a
|
||||||
|
href={`https://backend.acherryontop.com/product/${item.pid}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:underline"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{item.sku}
|
||||||
|
</a>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<a
|
||||||
|
href={`https://backend.acherryontop.com/product/${item.pid}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:underline"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{item.product_name}
|
||||||
|
</a>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="">
|
||||||
|
<a
|
||||||
|
href={`https://backend.acherryontop.com/product/${item.pid}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:underline"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{item.upc}
|
||||||
|
</a>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{item.ordered}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{item.received || 0}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatCurrency(item.po_cost_price || item.cost_each || 0)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{formatCurrency(item.total_cost)}
|
||||||
|
</TableCell>
|
||||||
|
{purchaseOrder.record_type !== "po_only" && (
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{item.receiving_status || "Unknown"}
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && items.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={purchaseOrder.record_type === "po_only" ? 7 : 8} className="text-center py-4 text-muted-foreground">
|
||||||
|
No items found for this order
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* First render the row which will serve as the trigger */}
|
||||||
|
{enhancedRow}
|
||||||
|
|
||||||
|
{/* Then render the accordion content conditionally if open */}
|
||||||
|
{isOpen && (
|
||||||
|
<TableRow className="p-0 border-0">
|
||||||
|
<TableCell colSpan={12} className="p-0 border-0">
|
||||||
|
<div className="pt-2 pb-4 px-4 bg-gray-50 border-t border-b">
|
||||||
|
<div className="mb-2 text-sm text-muted-foreground">
|
||||||
|
{purchaseOrder.total_items} product{purchaseOrder.total_items !== 1 ? "s" : ""} in this {purchaseOrder.record_type === "receiving_only" ? "receiving" : "purchase order"}
|
||||||
|
</div>
|
||||||
|
{renderItemsList()}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
436
inventory/src/components/purchase-orders/PurchaseOrdersTable.tsx
Normal file
436
inventory/src/components/purchase-orders/PurchaseOrdersTable.tsx
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../ui/table";
|
||||||
|
import { Badge } from "../ui/badge";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { Skeleton } from "../ui/skeleton";
|
||||||
|
import { FileText } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "../ui/tooltip";
|
||||||
|
import {
|
||||||
|
getPurchaseOrderStatusLabel,
|
||||||
|
getReceivingStatusLabel,
|
||||||
|
getPurchaseOrderStatusVariant,
|
||||||
|
getReceivingStatusVariant,
|
||||||
|
} from "../../types/status-codes";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../ui/card";
|
||||||
|
import PurchaseOrderAccordion from "./PurchaseOrderAccordion";
|
||||||
|
|
||||||
|
interface PurchaseOrder {
|
||||||
|
id: number | string;
|
||||||
|
vendor_name: string;
|
||||||
|
order_date: string | null;
|
||||||
|
receiving_date: string | null;
|
||||||
|
status: number;
|
||||||
|
total_items: number;
|
||||||
|
total_quantity: number;
|
||||||
|
total_cost: number;
|
||||||
|
total_received: number;
|
||||||
|
fulfillment_rate: number;
|
||||||
|
short_note: string | null;
|
||||||
|
record_type: "po_only" | "po_with_receiving" | "receiving_only";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PurchaseOrdersTableProps {
|
||||||
|
purchaseOrders: PurchaseOrder[];
|
||||||
|
loading: boolean;
|
||||||
|
summary: { order_count: number } | null;
|
||||||
|
sortColumn: string;
|
||||||
|
sortDirection: "asc" | "desc";
|
||||||
|
handleSort: (column: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PurchaseOrdersTable({
|
||||||
|
purchaseOrders,
|
||||||
|
loading,
|
||||||
|
summary,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection,
|
||||||
|
handleSort
|
||||||
|
}: PurchaseOrdersTableProps) {
|
||||||
|
// Helper functions
|
||||||
|
const getRecordTypeIndicator = (recordType: string) => {
|
||||||
|
switch (recordType) {
|
||||||
|
case "po_with_receiving":
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="flex items-center justify-center border-green-500 text-green-700 bg-green-50 px-0 text-xs w-[85px]"
|
||||||
|
>
|
||||||
|
Received PO
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
case "po_only":
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="flex items-center justify-center border-blue-500 text-blue-700 bg-blue-50 px-0 text-xs w-[85px]"
|
||||||
|
>
|
||||||
|
PO
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
case "receiving_only":
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="flex items-center justify-center border-amber-500 text-amber-700 bg-amber-50 px-0 text-xs w-[85px]"
|
||||||
|
>
|
||||||
|
Receiving
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="flex items-center justify-center border-gray-500 text-gray-700 bg-gray-50 px-0 text-xs w-[85px]"
|
||||||
|
>
|
||||||
|
{recordType || "Unknown"}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadge = (status: number, recordType: string) => {
|
||||||
|
if (recordType === "receiving_only") {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
className="w-[115px] flex items-center text-xs justify-center px-1"
|
||||||
|
variant={getReceivingStatusVariant(status)}
|
||||||
|
>
|
||||||
|
{getReceivingStatusLabel(status)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
className="w-[115px] flex items-center text-xs justify-center px-1"
|
||||||
|
variant={getPurchaseOrderStatusVariant(status)}
|
||||||
|
>
|
||||||
|
{getPurchaseOrderStatusLabel(status)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatNumber = (value: number) => {
|
||||||
|
return value.toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) => {
|
||||||
|
return `$${formatNumber(value)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPercent = (value: number) => {
|
||||||
|
return (
|
||||||
|
(value * 100).toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
}) + "%"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update sort indicators in table headers
|
||||||
|
const getSortIndicator = (column: string) => {
|
||||||
|
if (sortColumn !== column) return null;
|
||||||
|
return sortDirection === "asc" ? " ↑" : " ↓";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="mb-6">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<CardTitle>Purchase Orders & Receivings</CardTitle>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{loading ? (
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
) : (
|
||||||
|
`${summary?.order_count.toLocaleString()} orders`
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table
|
||||||
|
className="table-fixed"
|
||||||
|
style={{ tableLayout: "fixed", width: "100%"}}
|
||||||
|
>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-[100px] text-center">Type</TableHead>
|
||||||
|
<TableHead className="w-[60px] text-center">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => !loading && handleSort("id")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
ID{getSortIndicator("id")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[140px] text-center">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => !loading && handleSort("vendor_name")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Supplier{getSortIndicator("vendor_name")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[115px] text-center">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => !loading && handleSort("status")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Status{getSortIndicator("status")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[150px] text-center">Note</TableHead>
|
||||||
|
<TableHead className="w-[90px] text-center">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => !loading && handleSort("total_cost")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Total Cost{getSortIndicator("total_cost")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[70px] text-center">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => !loading && handleSort("total_items")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Products{getSortIndicator("total_items")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[90px] text-center">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => !loading && handleSort("order_date")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Order Date{getSortIndicator("order_date")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[90px] text-center">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => !loading && handleSort("receiving_date")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Rec'd Date{getSortIndicator("receiving_date")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[70px] text-center">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => !loading && handleSort("total_quantity")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Ordered{getSortIndicator("total_quantity")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-center">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => !loading && handleSort("total_received")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Received{getSortIndicator("total_received")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-[80px] text-center">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => !loading && handleSort("fulfillment_rate")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
% Fulfilled{getSortIndicator("fulfillment_rate")}
|
||||||
|
</Button>
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
// Skeleton rows for loading state
|
||||||
|
Array(50)
|
||||||
|
.fill(0)
|
||||||
|
.map((_, index) => (
|
||||||
|
<TableRow key={`skeleton-${index}`}>
|
||||||
|
<TableCell className="w-[100px]">
|
||||||
|
<Skeleton className="h-6 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[60px]">
|
||||||
|
<Skeleton className="h-5 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[140px]">
|
||||||
|
<Skeleton className="h-5 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[115px]">
|
||||||
|
<Skeleton className="h-6 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[150px]">
|
||||||
|
<Skeleton className="h-5 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[90px]">
|
||||||
|
<Skeleton className="h-5 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[70px]">
|
||||||
|
<Skeleton className="h-5 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[90px]">
|
||||||
|
<Skeleton className="h-5 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[90px]">
|
||||||
|
<Skeleton className="h-5 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[70px]">
|
||||||
|
<Skeleton className="h-5 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[80px]">
|
||||||
|
<Skeleton className="h-5 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-[80px]">
|
||||||
|
<Skeleton className="h-5 w-full" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : purchaseOrders.length > 0 ? (
|
||||||
|
purchaseOrders.map((po) => {
|
||||||
|
// Determine row styling based on record type
|
||||||
|
let rowClassName = "border-l-4 border-l-gray-300"; // Default
|
||||||
|
|
||||||
|
if (po.record_type === "po_with_receiving") {
|
||||||
|
rowClassName = "border-l-4 border-l-green-500";
|
||||||
|
} else if (po.record_type === "po_only") {
|
||||||
|
rowClassName = "border-l-4 border-l-blue-500";
|
||||||
|
} else if (po.record_type === "receiving_only") {
|
||||||
|
rowClassName = "border-l-4 border-l-amber-500";
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<PurchaseOrderAccordion
|
||||||
|
key={`${po.id}-${po.record_type}`}
|
||||||
|
purchaseOrder={po}
|
||||||
|
rowClassName={rowClassName}
|
||||||
|
>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{getRecordTypeIndicator(po.record_type)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-semibold text-center">
|
||||||
|
<a
|
||||||
|
href={po.record_type === "po_only"
|
||||||
|
? `https://backend.acherryontop.com/po/edit/${po.id}`
|
||||||
|
: `https://backend.acherryontop.com/receiving/edit/${po.id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
{po.id}
|
||||||
|
</a>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{po.vendor_name}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{getStatusBadge(po.status, po.record_type)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="truncate text-center">
|
||||||
|
{po.short_note ? (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger className="text-left flex items-center gap-1">
|
||||||
|
<FileText className="h-3 w-3" />
|
||||||
|
<span className="truncate">
|
||||||
|
{po.short_note}
|
||||||
|
</span>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{po.short_note}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
) : (
|
||||||
|
""
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{formatCurrency(po.total_cost)}</TableCell>
|
||||||
|
<TableCell className="text-center">{po.total_items.toLocaleString()}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{po.order_date
|
||||||
|
? new Date(po.order_date).toLocaleDateString(
|
||||||
|
"en-US",
|
||||||
|
{
|
||||||
|
month: "numeric",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{po.receiving_date
|
||||||
|
? new Date(po.receiving_date).toLocaleDateString(
|
||||||
|
"en-US",
|
||||||
|
{
|
||||||
|
month: "numeric",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{po.record_type === "receiving_only" ? "-" : po.total_quantity.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
{po.record_type === "po_only" ? "-" : po.total_received.toLocaleString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center" >
|
||||||
|
{po.record_type === "po_with_receiving"
|
||||||
|
? (po.fulfillment_rate === null ? "N/A" : formatPercent(po.fulfillment_rate))
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</PurchaseOrderAccordion>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={12}
|
||||||
|
className="text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
No purchase orders found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
354
inventory/src/components/purchase-orders/VendorMetricsCard.tsx
Normal file
354
inventory/src/components/purchase-orders/VendorMetricsCard.tsx
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "../../components/ui/card";
|
||||||
|
import { Skeleton } from "../../components/ui/skeleton";
|
||||||
|
import { BarChart3, Loader2 } from "lucide-react";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "../../components/ui/dialog";
|
||||||
|
import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../components/ui/table";
|
||||||
|
|
||||||
|
// Add this constant for pie chart colors
|
||||||
|
const COLORS = [
|
||||||
|
"#0088FE",
|
||||||
|
"#00C49F",
|
||||||
|
"#FFBB28",
|
||||||
|
"#FF8042",
|
||||||
|
"#8884D8",
|
||||||
|
"#82CA9D",
|
||||||
|
"#FFC658",
|
||||||
|
"#FF7C43",
|
||||||
|
];
|
||||||
|
|
||||||
|
// The renderActiveShape function for pie charts
|
||||||
|
const renderActiveShape = (props: any) => {
|
||||||
|
const {
|
||||||
|
cx,
|
||||||
|
cy,
|
||||||
|
innerRadius,
|
||||||
|
outerRadius,
|
||||||
|
startAngle,
|
||||||
|
endAngle,
|
||||||
|
fill,
|
||||||
|
category,
|
||||||
|
total_spend,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
// Split category name into words and create lines of max 12 chars
|
||||||
|
const words = category.split(" ");
|
||||||
|
const lines: string[] = [];
|
||||||
|
let currentLine = "";
|
||||||
|
|
||||||
|
words.forEach((word: string) => {
|
||||||
|
if ((currentLine + " " + word).length <= 12) {
|
||||||
|
currentLine = currentLine ? `${currentLine} ${word}` : word;
|
||||||
|
} else {
|
||||||
|
if (currentLine) lines.push(currentLine);
|
||||||
|
currentLine = word;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (currentLine) lines.push(currentLine);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<Sector
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
innerRadius={innerRadius}
|
||||||
|
outerRadius={outerRadius}
|
||||||
|
startAngle={startAngle}
|
||||||
|
endAngle={endAngle}
|
||||||
|
fill={fill}
|
||||||
|
/>
|
||||||
|
<Sector
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
startAngle={startAngle}
|
||||||
|
endAngle={endAngle}
|
||||||
|
innerRadius={outerRadius - 1}
|
||||||
|
outerRadius={outerRadius + 4}
|
||||||
|
fill={fill}
|
||||||
|
/>
|
||||||
|
{lines.map((line, i) => (
|
||||||
|
<text
|
||||||
|
key={i}
|
||||||
|
x={cx}
|
||||||
|
y={cy}
|
||||||
|
dy={-20 + i * 16}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="#888888"
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{line}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
<text
|
||||||
|
x={cx}
|
||||||
|
y={cy}
|
||||||
|
dy={lines.length * 16 - 10}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill="#000000"
|
||||||
|
className="text-base font-medium"
|
||||||
|
>
|
||||||
|
{`$${Number(total_spend).toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}`}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface VendorMetricsCardProps {
|
||||||
|
loading: boolean;
|
||||||
|
yearlyVendorData: {
|
||||||
|
vendor: string;
|
||||||
|
orders: number;
|
||||||
|
total_spend: number;
|
||||||
|
percentage?: number;
|
||||||
|
}[];
|
||||||
|
yearlyDataLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VendorMetricsCard({
|
||||||
|
loading,
|
||||||
|
yearlyVendorData,
|
||||||
|
yearlyDataLoading,
|
||||||
|
}: VendorMetricsCardProps) {
|
||||||
|
const [vendorAnalysisOpen, setVendorAnalysisOpen] = useState(false);
|
||||||
|
const [activeVendorIndex, setActiveVendorIndex] = useState<
|
||||||
|
number | undefined
|
||||||
|
>();
|
||||||
|
const [initialLoading, setInitialLoading] = useState(true);
|
||||||
|
|
||||||
|
// Only show loading state on initial load, not during table refreshes
|
||||||
|
useEffect(() => {
|
||||||
|
if (yearlyVendorData.length > 0 && !yearlyDataLoading) {
|
||||||
|
setInitialLoading(false);
|
||||||
|
}
|
||||||
|
}, [yearlyVendorData, yearlyDataLoading]);
|
||||||
|
|
||||||
|
const formatNumber = (value: number) => {
|
||||||
|
return value.toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) => {
|
||||||
|
return `$${formatNumber(value)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatPercent = (value: number) => {
|
||||||
|
return (
|
||||||
|
(value * 100).toLocaleString("en-US", {
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
}) + "%"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Prepare vendor chart data
|
||||||
|
const prepareVendorChartData = () => {
|
||||||
|
if (!yearlyVendorData.length) return [];
|
||||||
|
|
||||||
|
// Make a copy to avoid modifying state directly
|
||||||
|
const vendorArray = [...yearlyVendorData];
|
||||||
|
const totalSpend = vendorArray.reduce(
|
||||||
|
(sum, vendor) => sum + vendor.total_spend,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
// Split into significant vendors (>=1%) and others
|
||||||
|
const significantVendors = vendorArray.filter(
|
||||||
|
(vendor) => vendor.total_spend / totalSpend >= 0.01
|
||||||
|
);
|
||||||
|
|
||||||
|
const otherVendors = vendorArray.filter(
|
||||||
|
(vendor) => vendor.total_spend / totalSpend < 0.01
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = [...significantVendors];
|
||||||
|
|
||||||
|
// Add "Other" category if needed
|
||||||
|
if (otherVendors.length > 0) {
|
||||||
|
const otherTotalSpend = otherVendors.reduce(
|
||||||
|
(sum, vendor) => sum + vendor.total_spend,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
vendor: "Other Vendors",
|
||||||
|
total_spend: otherTotalSpend,
|
||||||
|
percentage: otherTotalSpend / totalSpend,
|
||||||
|
orders: otherVendors.reduce((sum, vendor) => sum + vendor.orders, 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by spend amount descending
|
||||||
|
return result.sort((a, b) => b.total_spend - a.total_spend);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get all vendors for table
|
||||||
|
const getAllVendorsForTable = () => {
|
||||||
|
if (!yearlyVendorData.length) return [];
|
||||||
|
return [...yearlyVendorData].sort((a, b) => b.total_spend - a.total_spend);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Vendor analysis table component
|
||||||
|
const VendorAnalysisTable = () => {
|
||||||
|
const vendorData = getAllVendorsForTable();
|
||||||
|
|
||||||
|
if (!vendorData.length) {
|
||||||
|
return yearlyDataLoading ? (
|
||||||
|
<div className="flex justify-center p-4">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center p-4 text-muted-foreground">
|
||||||
|
No supplier data available for the past 12 months
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{yearlyDataLoading ? (
|
||||||
|
<div className="flex justify-center p-4">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-sm font-medium mb-2 flex justify-between items-center px-4">
|
||||||
|
<span>
|
||||||
|
Showing received inventory by supplier for the past 12 months
|
||||||
|
</span>
|
||||||
|
<span>{vendorData.length} suppliers found</span>
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Supplier</TableHead>
|
||||||
|
<TableHead>Orders</TableHead>
|
||||||
|
<TableHead>Total Spend</TableHead>
|
||||||
|
<TableHead>% of Total</TableHead>
|
||||||
|
<TableHead>Avg. Order Value</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{vendorData.map((vendor) => {
|
||||||
|
return (
|
||||||
|
<TableRow key={vendor.vendor}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{vendor.vendor}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{vendor.orders.toLocaleString()}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{formatCurrency(vendor.total_spend)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{formatPercent(vendor.percentage || 0)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{formatCurrency(
|
||||||
|
vendor.orders ? vendor.total_spend / vendor.orders : 0
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">
|
||||||
|
Received by Supplier
|
||||||
|
</CardTitle>
|
||||||
|
<Dialog
|
||||||
|
open={vendorAnalysisOpen}
|
||||||
|
onOpenChange={setVendorAnalysisOpen}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" disabled={initialLoading || loading}>
|
||||||
|
<BarChart3 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-[90%] w-fit">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<BarChart3 className="h-5 w-5" />
|
||||||
|
<span>Received Inventory by Supplier</span>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="overflow-auto max-h-[70vh]">
|
||||||
|
<VendorAnalysisTable />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{initialLoading || loading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-[170px]">
|
||||||
|
<Skeleton className="h-[170px] w-[170px] rounded-full" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="h-[170px] relative">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={prepareVendorChartData()}
|
||||||
|
dataKey="total_spend"
|
||||||
|
nameKey="vendor"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={80}
|
||||||
|
paddingAngle={1}
|
||||||
|
activeIndex={activeVendorIndex}
|
||||||
|
activeShape={(props: any) =>
|
||||||
|
renderActiveShape({ ...props, category: props.vendor })
|
||||||
|
}
|
||||||
|
onMouseEnter={(_, index) => setActiveVendorIndex(index)}
|
||||||
|
onMouseLeave={() => setActiveVendorIndex(undefined)}
|
||||||
|
>
|
||||||
|
{prepareVendorChartData().map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={entry.vendor}
|
||||||
|
fill={COLORS[index % COLORS.length]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -801,7 +801,7 @@ export function DataManagement() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="md:col-start-2 md:row-span-2 h-[550px]">
|
<Card className="md:col-start-2 md:row-span-2 h-[580px]">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle>Table Record Counts</CardTitle>
|
<CardTitle>Table Record Counts</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -953,7 +953,7 @@ export function DataManagement() {
|
|||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
{/* Table Status */}
|
{/* Table Status */}
|
||||||
<div className="space-y-4 flex flex-col h-[550px]">
|
<div className="space-y-4 flex flex-col h-[580px]">
|
||||||
<Card className="flex-1">
|
<Card className="flex-1">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle>Last Import Times</CardTitle>
|
<CardTitle>Last Import Times</CardTitle>
|
||||||
|
|||||||
@@ -1,50 +1,31 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useRef, useMemo } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
|
import OrderMetricsCard from "../components/purchase-orders/OrderMetricsCard";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../components/ui/table';
|
import VendorMetricsCard from "../components/purchase-orders/VendorMetricsCard";
|
||||||
import { Loader2, ArrowUpDown } from 'lucide-react';
|
import CategoryMetricsCard from "../components/purchase-orders/CategoryMetricsCard";
|
||||||
import { Button } from '../components/ui/button';
|
import PaginationControls from "../components/purchase-orders/PaginationControls";
|
||||||
import { Input } from '../components/ui/input';
|
import PurchaseOrdersTable from "../components/purchase-orders/PurchaseOrdersTable";
|
||||||
import { Badge } from '../components/ui/badge';
|
import FilterControls from "../components/purchase-orders/FilterControls";
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '../components/ui/select';
|
|
||||||
import {
|
|
||||||
Pagination,
|
|
||||||
PaginationContent,
|
|
||||||
PaginationItem,
|
|
||||||
PaginationNext,
|
|
||||||
PaginationPrevious,
|
|
||||||
} from '../components/ui/pagination';
|
|
||||||
import { motion } from 'motion/react';
|
|
||||||
import {
|
|
||||||
PurchaseOrderStatus,
|
|
||||||
getPurchaseOrderStatusLabel,
|
|
||||||
getReceivingStatusLabel,
|
|
||||||
getPurchaseOrderStatusVariant,
|
|
||||||
getReceivingStatusVariant
|
|
||||||
} from '../types/status-codes';
|
|
||||||
|
|
||||||
interface PurchaseOrder {
|
interface PurchaseOrder {
|
||||||
id: number;
|
id: number | string;
|
||||||
vendor_name: string;
|
vendor_name: string;
|
||||||
order_date: string;
|
order_date: string | null;
|
||||||
|
receiving_date: string | null;
|
||||||
status: number;
|
status: number;
|
||||||
receiving_status: number;
|
|
||||||
total_items: number;
|
total_items: number;
|
||||||
total_quantity: number;
|
total_quantity: number;
|
||||||
total_cost: number;
|
total_cost: number;
|
||||||
total_received: number;
|
total_received: number;
|
||||||
fulfillment_rate: number;
|
fulfillment_rate: number;
|
||||||
|
short_note: string | null;
|
||||||
|
record_type: "po_only" | "po_with_receiving" | "receiving_only";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VendorMetrics {
|
interface VendorMetrics {
|
||||||
vendor_name: string;
|
vendor_name: string;
|
||||||
total_orders: number;
|
total_orders: number;
|
||||||
avg_delivery_days: number;
|
avg_delivery_days: number;
|
||||||
|
max_delivery_days: number;
|
||||||
fulfillment_rate: number;
|
fulfillment_rate: number;
|
||||||
avg_unit_cost: number;
|
avg_unit_cost: number;
|
||||||
total_spend: number;
|
total_spend: number;
|
||||||
@@ -59,6 +40,9 @@ interface CostAnalysis {
|
|||||||
total_spend_by_category: {
|
total_spend_by_category: {
|
||||||
category: string;
|
category: string;
|
||||||
total_spend: number;
|
total_spend: number;
|
||||||
|
unique_products?: number;
|
||||||
|
avg_cost?: number;
|
||||||
|
cost_variance?: number;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,50 +53,33 @@ interface ReceivingStatus {
|
|||||||
fulfillment_rate: number;
|
fulfillment_rate: number;
|
||||||
total_value: number;
|
total_value: number;
|
||||||
avg_cost: number;
|
avg_cost: number;
|
||||||
|
avg_delivery_days?: number;
|
||||||
|
max_delivery_days?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PurchaseOrdersResponse {
|
|
||||||
orders: PurchaseOrder[];
|
|
||||||
summary: {
|
|
||||||
order_count: number;
|
|
||||||
total_ordered: number;
|
|
||||||
total_received: number;
|
|
||||||
fulfillment_rate: number;
|
|
||||||
total_value: number;
|
|
||||||
avg_cost: number;
|
|
||||||
};
|
|
||||||
pagination: {
|
|
||||||
total: number;
|
|
||||||
pages: number;
|
|
||||||
page: number;
|
|
||||||
limit: number;
|
|
||||||
};
|
|
||||||
filters: {
|
|
||||||
vendors: string[];
|
|
||||||
statuses: string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PurchaseOrders() {
|
export default function PurchaseOrders() {
|
||||||
const [purchaseOrders, setPurchaseOrders] = useState<PurchaseOrder[]>([]);
|
const [purchaseOrders, setPurchaseOrders] = useState<PurchaseOrder[]>([]);
|
||||||
const [, setVendorMetrics] = useState<VendorMetrics[]>([]);
|
const [, setVendorMetrics] = useState<VendorMetrics[]>([]);
|
||||||
const [costAnalysis, setCostAnalysis] = useState<CostAnalysis | null>(null);
|
const [, setCostAnalysis] = useState<CostAnalysis | null>(null);
|
||||||
const [summary, setSummary] = useState<ReceivingStatus | null>(null);
|
const [summary, setSummary] = useState<ReceivingStatus | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [sortColumn, setSortColumn] = useState<string>('order_date');
|
const [sortColumn, setSortColumn] = useState<string>("order_date");
|
||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
|
||||||
const [filters, setFilters] = useState({
|
const [searchInput, setSearchInput] = useState("");
|
||||||
search: '',
|
const [filterValues, setFilterValues] = useState({
|
||||||
status: 'all',
|
search: "",
|
||||||
vendor: 'all',
|
status: "all",
|
||||||
|
vendor: "all",
|
||||||
|
recordType: "all",
|
||||||
});
|
});
|
||||||
const [filterOptions, setFilterOptions] = useState<{
|
const [filterOptions, setFilterOptions] = useState<{
|
||||||
vendors: string[];
|
vendors: string[];
|
||||||
statuses: string[];
|
statuses: number[];
|
||||||
}>({
|
}>({
|
||||||
vendors: [],
|
vendors: [],
|
||||||
statuses: []
|
statuses: [],
|
||||||
});
|
});
|
||||||
const [pagination, setPagination] = useState({
|
const [pagination, setPagination] = useState({
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -120,99 +87,173 @@ export default function PurchaseOrders() {
|
|||||||
page: 1,
|
page: 1,
|
||||||
limit: 100,
|
limit: 100,
|
||||||
});
|
});
|
||||||
|
const [] = useState(false);
|
||||||
|
const [] = useState<
|
||||||
|
number | undefined
|
||||||
|
>();
|
||||||
|
const [] = useState<
|
||||||
|
number | undefined
|
||||||
|
>();
|
||||||
|
const [] = useState(false);
|
||||||
|
const [yearlyVendorData, setYearlyVendorData] = useState<
|
||||||
|
{
|
||||||
|
vendor: string;
|
||||||
|
orders: number;
|
||||||
|
total_spend: number;
|
||||||
|
percentage?: number;
|
||||||
|
}[]
|
||||||
|
>([]);
|
||||||
|
const [yearlyCategoryData, setYearlyCategoryData] = useState<
|
||||||
|
{
|
||||||
|
category: string;
|
||||||
|
unique_products?: number;
|
||||||
|
total_spend: number;
|
||||||
|
percentage?: number;
|
||||||
|
avg_cost?: number;
|
||||||
|
cost_variance?: number;
|
||||||
|
}[]
|
||||||
|
>([]);
|
||||||
|
const [yearlyDataLoading, setYearlyDataLoading] = useState(false);
|
||||||
|
const hasInitialFetchRef = useRef(false);
|
||||||
|
const hasInitialYearlyFetchRef = useRef(false);
|
||||||
|
|
||||||
const STATUS_FILTER_OPTIONS = [
|
// Use useMemo to compute filters only when filterValues change
|
||||||
{ value: 'all', label: 'All Statuses' },
|
const filters = useMemo(() => filterValues, [filterValues]);
|
||||||
{ 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({
|
setLoading(true);
|
||||||
page: page.toString(),
|
|
||||||
limit: '100',
|
|
||||||
sortColumn,
|
|
||||||
sortDirection,
|
|
||||||
...filters.search && { search: filters.search },
|
|
||||||
...filters.status && { status: filters.status },
|
|
||||||
...filters.vendor && { vendor: filters.vendor },
|
|
||||||
});
|
|
||||||
|
|
||||||
const [
|
// Build search params with proper encoding
|
||||||
purchaseOrdersRes,
|
const searchParams = new URLSearchParams();
|
||||||
vendorMetricsRes,
|
searchParams.append('page', page.toString());
|
||||||
costAnalysisRes
|
searchParams.append('limit', '100');
|
||||||
] = await Promise.all([
|
searchParams.append('sortColumn', sortColumn);
|
||||||
fetch(`/api/purchase-orders?${searchParams}`),
|
searchParams.append('sortDirection', sortDirection);
|
||||||
fetch('/api/purchase-orders/vendor-metrics'),
|
|
||||||
fetch('/api/purchase-orders/cost-analysis')
|
if (filters.search) {
|
||||||
|
searchParams.append('search', filters.search);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.status !== 'all') {
|
||||||
|
searchParams.append('status', filters.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.vendor !== 'all') {
|
||||||
|
searchParams.append('vendor', filters.vendor);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters.recordType !== 'all') {
|
||||||
|
searchParams.append('recordType', filters.recordType);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Fetching data with params:", searchParams.toString());
|
||||||
|
|
||||||
|
// Fetch orders first separately to handle errors better
|
||||||
|
const purchaseOrdersRes = await fetch(`/api/purchase-orders?${searchParams.toString()}`);
|
||||||
|
|
||||||
|
if (!purchaseOrdersRes.ok) {
|
||||||
|
const errorText = await purchaseOrdersRes.text();
|
||||||
|
console.error("Failed to fetch purchase orders:", errorText);
|
||||||
|
throw new Error(`Failed to fetch purchase orders: ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const purchaseOrdersData = await purchaseOrdersRes.json();
|
||||||
|
|
||||||
|
// Process orders data immediately
|
||||||
|
const processedOrders = purchaseOrdersData.orders.map((order: any) => ({
|
||||||
|
...order,
|
||||||
|
status: Number(order.status),
|
||||||
|
total_items: Number(order.total_items) || 0,
|
||||||
|
total_quantity: Number(order.total_quantity) || 0,
|
||||||
|
total_cost: Number(order.total_cost) || 0,
|
||||||
|
total_received: Number(order.total_received) || 0,
|
||||||
|
fulfillment_rate: Number(order.fulfillment_rate) || 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Update the main data state
|
||||||
|
setPurchaseOrders(processedOrders);
|
||||||
|
setPagination(purchaseOrdersData.pagination);
|
||||||
|
setFilterOptions(purchaseOrdersData.filters);
|
||||||
|
|
||||||
|
// Now fetch the additional data in parallel
|
||||||
|
const [vendorMetricsRes, costAnalysisRes, deliveryMetricsRes] = await Promise.all([
|
||||||
|
fetch("/api/purchase-orders/vendor-metrics"),
|
||||||
|
fetch("/api/purchase-orders/cost-analysis"),
|
||||||
|
fetch("/api/purchase-orders/delivery-metrics"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Initialize default data
|
let vendorMetricsData = [];
|
||||||
let purchaseOrdersData: PurchaseOrdersResponse = {
|
let costAnalysisData = {
|
||||||
orders: [],
|
|
||||||
summary: {
|
|
||||||
order_count: 0,
|
|
||||||
total_ordered: 0,
|
|
||||||
total_received: 0,
|
|
||||||
fulfillment_rate: 0,
|
|
||||||
total_value: 0,
|
|
||||||
avg_cost: 0
|
|
||||||
},
|
|
||||||
pagination: {
|
|
||||||
total: 0,
|
|
||||||
pages: 0,
|
|
||||||
page: 1,
|
|
||||||
limit: 100
|
|
||||||
},
|
|
||||||
filters: {
|
|
||||||
vendors: [],
|
|
||||||
statuses: []
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let vendorMetricsData: VendorMetrics[] = [];
|
|
||||||
let costAnalysisData: CostAnalysis = {
|
|
||||||
unique_products: 0,
|
unique_products: 0,
|
||||||
avg_cost: 0,
|
avg_cost: 0,
|
||||||
min_cost: 0,
|
min_cost: 0,
|
||||||
max_cost: 0,
|
max_cost: 0,
|
||||||
cost_variance: 0,
|
cost_variance: 0,
|
||||||
total_spend_by_category: []
|
total_spend_by_category: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Only try to parse responses if they were successful
|
let deliveryMetricsData = {
|
||||||
if (purchaseOrdersRes.ok) {
|
avg_delivery_days: 0,
|
||||||
purchaseOrdersData = await purchaseOrdersRes.json();
|
max_delivery_days: 0
|
||||||
} else {
|
};
|
||||||
console.error('Failed to fetch purchase orders:', await purchaseOrdersRes.text());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (vendorMetricsRes.ok) {
|
if (vendorMetricsRes.ok) {
|
||||||
vendorMetricsData = await vendorMetricsRes.json();
|
vendorMetricsData = await vendorMetricsRes.json();
|
||||||
|
setVendorMetrics(vendorMetricsData);
|
||||||
} else {
|
} else {
|
||||||
console.error('Failed to fetch vendor metrics:', await vendorMetricsRes.text());
|
console.error(
|
||||||
|
"Failed to fetch vendor metrics:",
|
||||||
|
await vendorMetricsRes.text()
|
||||||
|
);
|
||||||
|
setVendorMetrics([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (costAnalysisRes.ok) {
|
if (costAnalysisRes.ok) {
|
||||||
costAnalysisData = await costAnalysisRes.json();
|
costAnalysisData = await costAnalysisRes.json();
|
||||||
|
setCostAnalysis(costAnalysisData);
|
||||||
} else {
|
} else {
|
||||||
console.error('Failed to fetch cost analysis:', await costAnalysisRes.text());
|
console.error(
|
||||||
|
"Failed to fetch cost analysis:",
|
||||||
|
await costAnalysisRes.text()
|
||||||
|
);
|
||||||
|
setCostAnalysis({
|
||||||
|
unique_products: 0,
|
||||||
|
avg_cost: 0,
|
||||||
|
min_cost: 0,
|
||||||
|
max_cost: 0,
|
||||||
|
cost_variance: 0,
|
||||||
|
total_spend_by_category: [],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setPurchaseOrders(purchaseOrdersData.orders);
|
if (deliveryMetricsRes.ok) {
|
||||||
setPagination(purchaseOrdersData.pagination);
|
deliveryMetricsData = await deliveryMetricsRes.json();
|
||||||
setFilterOptions(purchaseOrdersData.filters);
|
|
||||||
setSummary(purchaseOrdersData.summary);
|
// Merge delivery metrics into summary
|
||||||
setVendorMetrics(vendorMetricsData);
|
const summaryWithDelivery = {
|
||||||
setCostAnalysis(costAnalysisData);
|
...purchaseOrdersData.summary,
|
||||||
|
avg_delivery_days: deliveryMetricsData.avg_delivery_days,
|
||||||
|
max_delivery_days: deliveryMetricsData.max_delivery_days
|
||||||
|
};
|
||||||
|
|
||||||
|
setSummary(summaryWithDelivery);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"Failed to fetch delivery metrics:",
|
||||||
|
await deliveryMetricsRes.text()
|
||||||
|
);
|
||||||
|
setSummary({
|
||||||
|
...purchaseOrdersData.summary,
|
||||||
|
avg_delivery_days: 0,
|
||||||
|
max_delivery_days: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark that we've completed an initial fetch
|
||||||
|
hasInitialFetchRef.current = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching data:', error);
|
console.error("Error fetching data:", error);
|
||||||
// Set default values in case of error
|
// Set default values in case of error
|
||||||
setPurchaseOrders([]);
|
setPurchaseOrders([]);
|
||||||
setPagination({ total: 0, pages: 0, page: 1, limit: 100 });
|
setPagination({ total: 0, pages: 0, page: 1, limit: 100 });
|
||||||
@@ -223,7 +264,7 @@ export default function PurchaseOrders() {
|
|||||||
total_received: 0,
|
total_received: 0,
|
||||||
fulfillment_rate: 0,
|
fulfillment_rate: 0,
|
||||||
total_value: 0,
|
total_value: 0,
|
||||||
avg_cost: 0
|
avg_cost: 0,
|
||||||
});
|
});
|
||||||
setVendorMetrics([]);
|
setVendorMetrics([]);
|
||||||
setCostAnalysis({
|
setCostAnalysis({
|
||||||
@@ -232,284 +273,209 @@ export default function PurchaseOrders() {
|
|||||||
min_cost: 0,
|
min_cost: 0,
|
||||||
max_cost: 0,
|
max_cost: 0,
|
||||||
cost_variance: 0,
|
cost_variance: 0,
|
||||||
total_spend_by_category: []
|
total_spend_by_category: [],
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Setup debounced search
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData();
|
const timer = setTimeout(() => {
|
||||||
}, [page, sortColumn, sortDirection, filters]);
|
if (searchInput !== filterValues.search) {
|
||||||
|
setFilterValues(prev => ({ ...prev, search: searchInput }));
|
||||||
|
}
|
||||||
|
}, 300); // Use 300ms for better response time
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchInput, filterValues.search]);
|
||||||
|
|
||||||
|
// Reset page to 1 when filters change
|
||||||
|
useEffect(() => {
|
||||||
|
// Reset to page 1 when filters change to ensure proper pagination
|
||||||
|
setPage(1);
|
||||||
|
}, [filterValues]); // Use filterValues directly to avoid unnecessary renders
|
||||||
|
|
||||||
|
// Fetch data when page, sort or filters change
|
||||||
|
useEffect(() => {
|
||||||
|
// Log the current filter state for debugging
|
||||||
|
console.log("Fetching with filters:", filterValues);
|
||||||
|
console.log("Page:", page, "Sort:", sortColumn, sortDirection);
|
||||||
|
|
||||||
|
// Always fetch data - don't use conditional checks that might prevent it
|
||||||
|
fetchData();
|
||||||
|
}, [page, sortColumn, sortDirection, filterValues]);
|
||||||
|
|
||||||
|
// Handle column sorting more consistently
|
||||||
const handleSort = (column: string) => {
|
const handleSort = (column: string) => {
|
||||||
|
// Reset to page 1 when changing sort to ensure we see the first page of results
|
||||||
|
setPage(1);
|
||||||
|
|
||||||
if (sortColumn === column) {
|
if (sortColumn === column) {
|
||||||
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
setSortDirection((prev) => (prev === "asc" ? "desc" : "asc"));
|
||||||
} else {
|
} else {
|
||||||
setSortColumn(column);
|
setSortColumn(column);
|
||||||
setSortDirection('asc');
|
// For most columns, start with descending to show highest values first
|
||||||
|
if (column === 'id' || column === 'vendor_name') {
|
||||||
|
setSortDirection("asc");
|
||||||
|
} else {
|
||||||
|
setSortDirection("desc");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusBadge = (status: number, receivingStatus: number) => {
|
// Update filter handlers
|
||||||
// If the PO is canceled, show that status
|
const handleStatusChange = (value: string) => {
|
||||||
if (status === PurchaseOrderStatus.Canceled) {
|
setFilterValues(prev => ({ ...prev, status: value }));
|
||||||
return <Badge variant={getPurchaseOrderStatusVariant(status)}>
|
|
||||||
{getPurchaseOrderStatusLabel(status)}
|
|
||||||
</Badge>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If receiving has started, show receiving status
|
|
||||||
if (status >= PurchaseOrderStatus.ReceivingStarted) {
|
|
||||||
return <Badge variant={getReceivingStatusVariant(receivingStatus)}>
|
|
||||||
{getReceivingStatusLabel(receivingStatus)}
|
|
||||||
</Badge>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise show PO status
|
|
||||||
return <Badge variant={getPurchaseOrderStatusVariant(status)}>
|
|
||||||
{getPurchaseOrderStatusLabel(status)}
|
|
||||||
</Badge>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatNumber = (value: number) => {
|
const handleVendorChange = (value: string) => {
|
||||||
return value.toLocaleString('en-US', {
|
setFilterValues(prev => ({ ...prev, vendor: value }));
|
||||||
minimumFractionDigits: 2,
|
};
|
||||||
maximumFractionDigits: 2
|
|
||||||
|
const handleRecordTypeChange = (value: string) => {
|
||||||
|
setFilterValues(prev => ({ ...prev, recordType: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear all filters handler
|
||||||
|
const clearFilters = () => {
|
||||||
|
setSearchInput("");
|
||||||
|
setFilterValues({
|
||||||
|
search: "",
|
||||||
|
status: "all",
|
||||||
|
vendor: "all",
|
||||||
|
recordType: "all",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatPercent = (value: number) => {
|
// Update this function to fetch yearly data
|
||||||
return (value * 100).toLocaleString('en-US', {
|
const fetchYearlyData = async () => {
|
||||||
minimumFractionDigits: 1,
|
if (
|
||||||
maximumFractionDigits: 1
|
hasInitialYearlyFetchRef.current &&
|
||||||
}) + '%';
|
import.meta.hot &&
|
||||||
|
(yearlyVendorData.length > 0 || yearlyCategoryData.length > 0)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setYearlyDataLoading(true);
|
||||||
|
|
||||||
|
// Create a date for 1 year ago
|
||||||
|
const oneYearAgo = new Date();
|
||||||
|
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
|
||||||
|
const dateParam = oneYearAgo.toISOString().split("T")[0]; // Format as YYYY-MM-DD
|
||||||
|
|
||||||
|
const [vendorResponse, categoryResponse] = await Promise.all([
|
||||||
|
fetch(`/api/purchase-orders/vendor-analysis?since=${dateParam}`),
|
||||||
|
fetch(`/api/purchase-orders/category-analysis?since=${dateParam}`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (vendorResponse.ok) {
|
||||||
|
const vendorData = await vendorResponse.json();
|
||||||
|
// Calculate percentages before setting state
|
||||||
|
const totalSpend = vendorData.reduce(
|
||||||
|
(sum: number, v: any) => sum + v.total_spend,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
setYearlyVendorData(
|
||||||
|
vendorData.map((v: any) => ({
|
||||||
|
...v,
|
||||||
|
percentage: totalSpend > 0 ? v.total_spend / totalSpend : 0,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"Failed to fetch yearly vendor data:",
|
||||||
|
await vendorResponse.text()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoryResponse.ok) {
|
||||||
|
const categoryData = await categoryResponse.json();
|
||||||
|
// Calculate percentages before setting state
|
||||||
|
const totalSpend = categoryData.reduce(
|
||||||
|
(sum: number, c: any) => sum + c.total_spend,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
setYearlyCategoryData(
|
||||||
|
categoryData.map((c: any) => ({
|
||||||
|
...c,
|
||||||
|
percentage: totalSpend > 0 ? c.total_spend / totalSpend : 0,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"Failed to fetch yearly category data:",
|
||||||
|
await categoryResponse.text()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark that we've completed an initial fetch
|
||||||
|
hasInitialYearlyFetchRef.current = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching yearly data:", error);
|
||||||
|
} finally {
|
||||||
|
setYearlyDataLoading(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
// Fetch yearly data when component mounts, not just when dialogs open
|
||||||
return (
|
useEffect(() => {
|
||||||
<div className="flex h-full items-center justify-center">
|
fetchYearlyData();
|
||||||
<Loader2 className="h-8 w-8 animate-spin" />
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
</div>
|
}, []);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div layout className="container mx-auto py-6">
|
<div className="container mx-auto py-6">
|
||||||
<h1 className="mb-6 text-3xl font-bold">Purchase Orders</h1>
|
<h1 className="mb-6 text-3xl font-bold">Purchase Orders</h1>
|
||||||
|
|
||||||
{/* Metrics Overview */}
|
<div className="mb-4 grid gap-4 md:grid-cols-3">
|
||||||
<div className="mb-6 grid gap-4 md:grid-cols-4">
|
<OrderMetricsCard
|
||||||
<Card>
|
summary={summary}
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
loading={loading}
|
||||||
<CardTitle className="text-sm font-medium">Total Orders</CardTitle>
|
/>
|
||||||
</CardHeader>
|
<VendorMetricsCard
|
||||||
<CardContent>
|
loading={loading}
|
||||||
<div className="text-2xl font-bold">{summary?.order_count.toLocaleString() || 0}</div>
|
yearlyVendorData={yearlyVendorData}
|
||||||
</CardContent>
|
yearlyDataLoading={yearlyDataLoading}
|
||||||
</Card>
|
/>
|
||||||
<Card>
|
<CategoryMetricsCard
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
loading={loading}
|
||||||
<CardTitle className="text-sm font-medium">Total Value</CardTitle>
|
yearlyCategoryData={yearlyCategoryData}
|
||||||
</CardHeader>
|
yearlyDataLoading={yearlyDataLoading}
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
${formatNumber(summary?.total_value || 0)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Fulfillment Rate</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
{formatPercent(summary?.fulfillment_rate || 0)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
||||||
<CardTitle className="text-sm font-medium">Avg Cost per PO</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="text-2xl font-bold">
|
|
||||||
${formatNumber(summary?.avg_cost || 0)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="mb-4 flex items-center gap-4">
|
|
||||||
<Input
|
|
||||||
placeholder="Search orders..."
|
|
||||||
value={filters.search}
|
|
||||||
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
|
|
||||||
className="max-w-xs"
|
|
||||||
/>
|
/>
|
||||||
<Select
|
|
||||||
value={filters.status}
|
|
||||||
onValueChange={(value) => setFilters(prev => ({ ...prev, status: value }))}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[180px]">
|
|
||||||
<SelectValue placeholder="Select status" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{STATUS_FILTER_OPTIONS.map(option => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Select
|
|
||||||
value={filters.vendor}
|
|
||||||
onValueChange={(value) => setFilters(prev => ({ ...prev, vendor: value }))}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[180px]">
|
|
||||||
<SelectValue placeholder="Select vendor" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Vendors</SelectItem>
|
|
||||||
{filterOptions?.vendors?.map(vendor => (
|
|
||||||
<SelectItem key={vendor} value={vendor}>
|
|
||||||
{vendor}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Purchase Orders Table */}
|
<FilterControls
|
||||||
<Card className="mb-6">
|
searchInput={searchInput}
|
||||||
<CardHeader>
|
setSearchInput={setSearchInput}
|
||||||
<CardTitle>Recent Purchase Orders</CardTitle>
|
filterValues={filterValues}
|
||||||
</CardHeader>
|
handleStatusChange={handleStatusChange}
|
||||||
<CardContent>
|
handleVendorChange={handleVendorChange}
|
||||||
<Table>
|
handleRecordTypeChange={handleRecordTypeChange}
|
||||||
<TableHeader>
|
clearFilters={clearFilters}
|
||||||
<TableRow>
|
filterOptions={filterOptions}
|
||||||
<TableHead>
|
loading={loading}
|
||||||
<Button variant="ghost" onClick={() => handleSort('id')}>
|
/>
|
||||||
ID <ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead>
|
|
||||||
<Button variant="ghost" onClick={() => handleSort('vendor_name')}>
|
|
||||||
Vendor <ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead>
|
|
||||||
<Button variant="ghost" onClick={() => handleSort('order_date')}>
|
|
||||||
Order Date <ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead>
|
|
||||||
<Button variant="ghost" onClick={() => handleSort('status')}>
|
|
||||||
Status <ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead>Total Items</TableHead>
|
|
||||||
<TableHead>Total Quantity</TableHead>
|
|
||||||
<TableHead>
|
|
||||||
<Button variant="ghost" onClick={() => handleSort('total_cost')}>
|
|
||||||
Total Cost <ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead>Received</TableHead>
|
|
||||||
<TableHead>
|
|
||||||
<Button variant="ghost" onClick={() => handleSort('fulfillment_rate')}>
|
|
||||||
Fulfillment <ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{purchaseOrders.map((po) => (
|
|
||||||
<TableRow key={po.id}>
|
|
||||||
<TableCell>{po.id}</TableCell>
|
|
||||||
<TableCell>{po.vendor_name}</TableCell>
|
|
||||||
<TableCell>{new Date(po.order_date).toLocaleDateString()}</TableCell>
|
|
||||||
<TableCell>{getStatusBadge(po.status, po.receiving_status)}</TableCell>
|
|
||||||
<TableCell>{po.total_items.toLocaleString()}</TableCell>
|
|
||||||
<TableCell>{po.total_quantity.toLocaleString()}</TableCell>
|
|
||||||
<TableCell>${formatNumber(po.total_cost)}</TableCell>
|
|
||||||
<TableCell>{po.total_received.toLocaleString()}</TableCell>
|
|
||||||
<TableCell>{formatPercent(po.fulfillment_rate)}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
{!purchaseOrders.length && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={9} className="text-center text-muted-foreground">
|
|
||||||
No purchase orders found
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
<PurchaseOrdersTable
|
||||||
{pagination.pages > 1 && (
|
purchaseOrders={purchaseOrders}
|
||||||
<div className="flex justify-center">
|
loading={loading}
|
||||||
<Pagination>
|
summary={summary}
|
||||||
<PaginationContent>
|
sortColumn={sortColumn}
|
||||||
<PaginationItem>
|
sortDirection={sortDirection}
|
||||||
<Button
|
handleSort={handleSort}
|
||||||
onClick={() => setPage(page - 1)}
|
/>
|
||||||
disabled={page === 1}
|
|
||||||
className="h-9 px-4"
|
<PaginationControls
|
||||||
>
|
pagination={pagination}
|
||||||
<PaginationPrevious className="h-4 w-4" />
|
currentPage={page}
|
||||||
</Button>
|
onPageChange={setPage}
|
||||||
</PaginationItem>
|
/>
|
||||||
<PaginationItem>
|
|
||||||
<Button
|
|
||||||
onClick={() => setPage(page + 1)}
|
|
||||||
disabled={page === pagination.pages}
|
|
||||||
className="h-9 px-4"
|
|
||||||
>
|
|
||||||
<PaginationNext className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</PaginationItem>
|
|
||||||
</PaginationContent>
|
|
||||||
</Pagination>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Cost Analysis */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Cost Analysis by Category</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Category</TableHead>
|
|
||||||
<TableHead>Total Spend</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{costAnalysis?.total_spend_by_category?.map((category) => (
|
|
||||||
<TableRow key={category.category}>
|
|
||||||
<TableCell>{category.category}</TableCell>
|
|
||||||
<TableCell>${formatNumber(category.total_spend)}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)) || (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={2} className="text-center text-muted-foreground">
|
|
||||||
No cost analysis data available
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -75,7 +75,7 @@ export function getPurchaseOrderStatusVariant(status: number): 'default' | 'seco
|
|||||||
|
|
||||||
export function getReceivingStatusVariant(status: number): 'default' | 'secondary' | 'destructive' | 'outline' {
|
export function getReceivingStatusVariant(status: number): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||||
if (isReceivingCanceled(status)) return 'destructive';
|
if (isReceivingCanceled(status)) return 'destructive';
|
||||||
if (status === ReceivingStatus.Paid) return 'default';
|
if (status === ReceivingStatus.Paid || status === ReceivingStatus.FullReceived) return 'default';
|
||||||
if (status >= ReceivingStatus.PartialReceived) return 'secondary';
|
if (status >= ReceivingStatus.PartialReceived) return 'secondary';
|
||||||
return 'outline';
|
return 'outline';
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user