diff --git a/.gitignore b/.gitignore index 885bfd0..7903b69 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,9 @@ Thumbs.db *.pdf !docs/*.pdf +# Exclude markdown documentation files in backend/docs +backend/docs/*.md + # Backup files *.backup *.bak diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 8adf7bf..381cbdc 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -1,7 +1,7 @@ # Smart Stock Management System (MRP II) - Final Design Document -**Version:** 5.5 -**Date:** 2025-12-26 +**Version:** 5.9 +**Date:** 2026-01-08 **Status:** Production Ready Design **System Type:** Material Requirements Planning II (MRP II) - Modular Architecture @@ -29,21 +29,36 @@ ## 1. System Overview ### 1.1 Purpose -An enterprise-grade **Material Requirements Planning (MRP)** system with comprehensive inventory management, production planning, procurement, sales order management, and real-time analytics. +An enterprise-grade **Manufacturing Resource Planning (MRP II)** system with comprehensive inventory management, production planning, procurement, sales order management, capacity planning, and real-time analytics. + +**MRP II Components (Implemented):** +- **Material Requirements Planning (MRP)**: Automated calculation of material needs based on demand, inventory levels, and production schedules +- **Capacity Requirements Planning (CRP)**: Capacity analysis and planning for work centers and production resources +- **Master Production Schedule (MPS)**: Production planning through Work Orders and BOM management +- **Shop Floor Control**: Work Order management, operation tracking, and material consumption +- **Inventory Management**: Real-time stock tracking, movements, reservations, and negative stock handling +- **Procurement Planning**: Purchase order recommendations and supplier management +- **Sales & Operations Planning**: Integration of sales orders with production planning + +**MRP II Components (Not Implemented - Future Roadmap):** +- **Financial Planning & Cost Management**: Comprehensive cost accounting, financial planning, and budget management + - **Note**: This is a conscious design decision. The current focus is on planning and inventory management rather than full financial integration. Basic cost tracking (standard cost, average cost, work center costs) exists for operational purposes, but comprehensive financial planning is planned for future releases. ### 1.2 System Characteristics +- **Multi-tenant SaaS Architecture**: Built with multi-tenancy in mind, ready for SaaS deployment - **Multi-language UI**: Complete interface translation via frontend i18n - **Multi-currency**: Support for multiple currencies with exchange rates - **Flexible Architecture**: Dynamic product attributes based on product types - **Scalable**: Designed for growth from small business to enterprise - **Modern Stack**: Laravel 12, PostgreSQL, Redis, Elasticsearch +- **Data Isolation**: Automatic company-level data scoping ensures complete tenant isolation ### 1.3 Key Differentiators - ✅ **Multi-language UI**: Frontend translations (react-i18next / vue-i18n) - ✅ **Single Language Data**: User-entered data stored in user's language - ✅ **Multi-currency Pricing**: Automatic currency conversion, tiered pricing - ✅ **Dynamic Attributes**: Product type-specific attributes with validation -- ✅ **MRP Logic**: Automated material requirement calculations +- ✅ **MRP II Logic**: Complete Manufacturing Resource Planning with MRP, CRP, and Shop Floor Control - ✅ **BOM Management**: Multi-level product structures - ✅ **Advanced Search**: Elasticsearch with fuzzy matching - ✅ **Real-time Performance**: Redis caching layer @@ -60,6 +75,7 @@ SmartStockManagement uses a **modular MRP II architecture** with feature flags f ``` ┌─────────────────────────────────────────────────────────────────────┐ │ SmartStockManagement │ +│ (MRP II System) │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ ┌──────────────┐ ┌──────────────────┐ ┌──────────────────┐ │ @@ -68,19 +84,37 @@ SmartStockManagement uses a **modular MRP II architecture** with feature flags f │ ├──────────────┤ ├──────────────────┤ ├──────────────────┤ │ │ │ - Stock │ │ - Suppliers │ │ - BOM │ │ │ │ - Products │ │ - PurchaseOrders │ │ - WorkOrders │ │ -│ │ - Categories │ │ - Receiving │ │ - Production │ │ -│ │ - Warehouses │ │ - Basic QC │ │ - Basic QC │ │ -│ │ - Attributes │ │ (pass/fail) │ │ (pass/fail) │ │ -│ │ - UoM │ │ │ │ │ │ +│ │ - Categories │ │ - GRN │ │ - Routings │ │ +│ │ - Warehouses │ │ - Receiving │ │ - Work Centers │ │ +│ │ - Attributes │ │ - Receiving QC │ │ - MRP Engine │ │ +│ │ - UoM │ │ (only) │ │ - CRP Engine │ │ +│ │ - Currencies │ │ │ │ - Production │ │ +│ │ │ │ │ │ - Production QC │ │ +│ │ │ │ │ │ (future) │ │ │ └──────────────┘ └──────────────────┘ └──────────────────┘ │ │ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ SALES │ │ QUALITY CONTROL │ │ +│ │ (Optional) │ │ (Optional) │ │ +│ ├──────────────────┤ ├──────────────────┤ │ +│ │ - Customers │ │ - Acceptance │ │ +│ │ - Customer │ │ Rules │ │ +│ │ Groups │ │ - Inspections │ │ +│ │ - Sales Orders │ │ - NCR Reports │ │ +│ │ - Delivery │ │ - Quality │ │ +│ │ Notes │ │ Statistics │ │ +│ │ - Stock │ │ │ │ +│ │ Reservation │ │ │ │ +│ └──────────────────┘ └──────────────────┘ │ +│ │ ├─────────────────────────────────────────────────────────────────────┤ │ INTEGRATION LAYER │ │ ┌──────────────────────────────────────────────────────────────┐ │ -│ │ Webhook API for External Systems (Sales, Finance, etc.) │ │ +│ │ Webhook API for External Systems (Finance, ERP, etc.) │ │ │ │ - Stock reservation webhooks │ │ │ │ - Stock movement notifications │ │ │ │ - Inventory level alerts │ │ +│ │ - Sales order status updates │ │ │ └──────────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────┘ │ @@ -151,15 +185,19 @@ Route::middleware('module:procurement')->group(function () { ### 2.4 Key Design Decisions 1. **Logical Modules, Not Physical**: Module separation via config and middleware, not folder restructuring -2. **Sales/Finance External Only**: No built-in Customer/SalesOrder - external systems integrate via webhooks -3. **Standard QC**: Acceptance rules, inspections, NCR - no CAPA, SPC (can be added later) -4. **Stateless Python Service**: Prediction service has no database - queries Laravel API for data +2. **Sales Module (Optional)**: Customer management, Sales Orders, and Delivery Notes - can be enabled via `MODULE_SALES_ENABLED=true`. External systems can also integrate via webhooks. +3. **Standard QC (Procurement Only)**: Acceptance rules, receiving inspections, NCR - currently implemented for procurement/receiving only. Manufacturing QC (production quality control, in-process inspection) is planned for future releases. No CAPA, SPC (can be added later) +4. **Focus on Planning & Inventory**: System prioritizes material planning, capacity planning, and inventory management over financial integration +5. **Financial Planning Deferred**: Comprehensive financial planning and cost accounting are not implemented - basic cost tracking exists for operational purposes only. This is a conscious design decision to focus on core planning capabilities first. +6. **Stateless Python Service**: Prediction service has no database - queries Laravel API for data 5. **Sync First, Async Later**: Start with HTTP for simplicity - add Redis Queue when needed 6. **Graceful Degradation**: If Python service is down, Laravel continues to work ### 2.5 Quality Control (Standard Level) -The system includes a standard-level QC module within Procurement: +The system includes a standard-level QC module. **Current Implementation**: QC is currently implemented for **Procurement (Receiving)** only. **Manufacturing QC** (production quality control, in-process inspection, work order inspection) is planned for future releases. + +**Implemented (Procurement QC):** ``` ┌─────────────────────────────────────────────────────────────────┐ @@ -179,11 +217,12 @@ The system includes a standard-level QC module within Procurement: │ Tables: acceptance_rules, receiving_inspections, │ │ non_conformance_reports │ │ │ -│ Future Expansion: CAPA, Supplier Ratings, SPC │ +│ Current Scope: Procurement/Receiving QC only │ +│ Future Expansion: Manufacturing QC, CAPA, Supplier Ratings, SPC│ └─────────────────────────────────────────────────────────────────┘ ``` -**QC Workflow:** +**Procurement QC Workflow (Current Implementation):** 1. GRN created → Inspections auto-created per item 2. Inspector records results (pass/fail quantities) 3. Failed items → NCR created @@ -191,6 +230,18 @@ The system includes a standard-level QC module within Procurement: 5. Dispositions: Accept, Reject, Rework, Return to Supplier, Use As-Is 6. Stock quality status updated automatically based on disposition +**Manufacturing QC (Not Implemented - Future Roadmap):** +- **Status**: Not implemented - planned for future releases +- **Planned Features**: + - In-process inspection during production + - Work Order operation inspection + - Finished goods quality control + - Production NCR tracking + - Quality gates in routing operations + - First Article Inspection (FAI) + - Statistical Process Control (SPC) for production +- **Design Philosophy**: Current focus is on receiving quality control. Manufacturing QC will be added in subsequent phases to ensure production quality standards. + **Stock Quality Status Tracking:** ``` ┌──────────────────────────────────────────────────────────────────┐ @@ -348,7 +399,11 @@ Build Tool: Vite - **Localized Formats**: Date, number, currency formatting per locale ### 4.3 Advanced Features -- MRP (Material Requirements Planning) +- **MRP II (Manufacturing Resource Planning)**: + - Material Requirements Planning (MRP) with multi-level BOM explosion + - Capacity Requirements Planning (CRP) for work center capacity analysis + - Master Production Schedule (MPS) through Work Orders + - Shop Floor Control with operation tracking - Demand forecasting - Lot/batch/serial number tracking - Barcode/QR code support @@ -568,6 +623,20 @@ products ├── safety_stock (decimal(15,3), default: 0) ├── lead_time_days (integer, default: 0) │ +├── -- Negative Stock Policy +├── negative_stock_policy (varchar(20), default: 'NEVER') +│ -- Policy: 'NEVER', 'ALLOWED', 'LIMITED' +├── negative_stock_limit (decimal(15,3), default: 0) +│ -- Maximum allowed negative quantity (for LIMITED policy) +│ +├── -- Reservation Policy +├── reservation_policy (varchar(20), default: 'full') +│ -- Policy: 'full', 'partial', 'reject', 'wait' +│ +├── -- Over-Delivery Tolerance +├── over_delivery_tolerance_percentage (decimal(5,2), nullable) +│ -- Product-specific tolerance percentage +│ ├── -- Costing (in base currency) ├── cost_method (enum: fifo, lifo, avg, std) ├── standard_cost (decimal(15,4), default: 0) @@ -842,9 +911,319 @@ INDEX idx_movements_product ON stock_movements(product_id, created_at DESC) INDEX idx_movements_warehouse ON stock_movements(warehouse_id, created_at DESC) ``` +#### Stock Debts (Negative Stock Tracking) +```sql +stock_debts +├── id (bigint, PK) +├── company_id (bigint, FK) +├── product_id (bigint, FK) +├── warehouse_id (bigint, FK) +├── stock_movement_id (bigint, FK, nullable) +│ -- Which movement caused this debt +├── quantity (decimal(15,3), NOT NULL) +│ -- Debt amount (positive value) +├── reconciled_quantity (decimal(15,3), default: 0) +│ -- Settled amount +├── outstanding_quantity (decimal(15,3) GENERATED AS (quantity - reconciled_quantity) STORED) +├── reference_type (varchar(50), nullable) +│ -- DeliveryNote, WorkOrder, etc. +├── reference_id (bigint, nullable) +├── created_at (timestamp) +├── reconciled_at (timestamp, nullable) +└── updated_at (timestamp) + +INDEX idx_stock_debts_outstanding ON stock_debts(company_id, product_id, warehouse_id, outstanding_quantity) +INDEX idx_stock_debts_reference ON stock_debts(reference_type, reference_id) +``` + +--- + +### 6.5 Negative Stock Policy System + +The system implements a controlled negative stock mechanism with product-level policies and automatic debt tracking. This allows operational flexibility while maintaining data consistency and preventing uncontrolled negative stock scenarios. + +#### 6.5.1 Policy Types + +Each product can have a negative stock policy: + +| Policy | Behavior | Use Case | +|--------|----------|----------| +| **NEVER** | Cannot go negative. Reject transaction if insufficient stock. | Finished products, critical materials | +| **ALLOWED** | Can go negative without limit. Stock debt is tracked. | Raw materials, operational flexibility | +| **LIMITED** | Can go negative up to `negative_stock_limit`. Reject if limit exceeded. | Semi-finished products, controlled scenarios | + +#### 6.5.2 Database Schema + +**Products Table:** +```sql +products +├── ... +├── negative_stock_policy (varchar(20), default: 'NEVER') +├── negative_stock_limit (decimal(15,3), default: 0) +└── ... +``` + +**Stock Debts Table:** +See Section 6.4.3 above for schema. + +#### 6.5.3 Automatic Debt Creation + +When stock goes negative: +1. System checks product's `negative_stock_policy` +2. If policy allows (ALLOWED or LIMITED within limit): + - Stock `quantity_on_hand` goes negative + - `StockDebt` record is created + - Debt is linked to the stock movement and reference (DeliveryNote, WorkOrder, etc.) +3. If policy rejects (NEVER or LIMITED exceeded): + - Transaction is rejected with error message + +**StockService::issueStock() Logic:** +```php +if ($quantityAfter < 0) { + $product = $stock->product; + + if (!$this->canGoNegative($product, abs($quantityAfter))) { + throw new BusinessException("Insufficient stock..."); + } + + // Create stock debt + $this->createStockDebt($stock, abs($quantityAfter), $data); +} +``` + +#### 6.5.4 Automatic Debt Reconciliation + +When stock is received: +1. System automatically reconciles outstanding debts (FIFO order) +2. Oldest debts are settled first +3. `reconciled_quantity` is updated +4. `reconciled_at` timestamp is set + +**StockService::receiveStock() Logic:** +```php +// Update stock +$stock->quantity_on_hand += $data['quantity']; +$stock->save(); + +// Automatically reconcile debts +$this->reconcileStockDebts($stock, $data['quantity']); +``` + +#### 6.5.5 MRP II Integration + +Negative stock is considered in MRP (Material Requirements Planning) calculations: +- Negative stock is treated as **priority requirement** +- MRP recommendations are marked as high priority when negative stock exists +- Net requirement calculation includes negative stock impact + +#### 6.5.6 Stock Alert Service + +The system provides alerts for: +- Current negative stock items +- Long-term outstanding debts (>7 days) +- Products approaching negative stock limit +- Debt reconciliation status + +**API Endpoints:** +- `GET /api/stock-debts` - List all stock debts +- `GET /api/stock-debts/outstanding` - List outstanding debts +- `GET /api/stock-debts/{id}` - Get debt details +- `GET /api/stock-alerts/negative-stock` - Get negative stock alerts + +#### 6.5.7 Benefits + +1. **Operational Flexibility**: Process transactions even when stock is temporarily unavailable +2. **Data Consistency**: All movements are recorded, audit trail is maintained +3. **Automatic Tracking**: Stock debts are automatically tracked and reconciled +4. **MRP II Integration**: MRP (Material Requirements Planning) calculations consider negative stock as priority requirement +5. **Controlled Scenarios**: Policy-based control prevents uncontrolled negative stock + +#### 6.5.8 Best Practices + +**Policy Recommendations:** +- **Finished Products**: `NEVER` - Should never go negative +- **Critical Raw Materials**: `NEVER` or `LIMITED` - Can stop production +- **Standard Raw Materials**: `LIMITED` - Controlled negative stock +- **Semi-Finished Products**: `LIMITED` - Internal production flexibility +- **Auxiliary Materials**: `ALLOWED` - Maximum operational flexibility + +**Monitoring:** +- Regular negative stock reports +- Automatic alerts for long-term debts +- Pre-MRP run negative stock checks (MRP II Material Requirements Planning) +- Supplier performance evaluation based on debt patterns + +--- + +### 6.6 Stock Reservation System + +The system implements a flexible stock reservation mechanism with configurable policies to handle stock reservations when insufficient stock is available. Reservations are automatically created and released based on order status transitions. + +#### 6.5.1 Reservation Policy Enum + +The system uses a `ReservationPolicy` enum to define how reservations are handled: + +```php +enum ReservationPolicy: string +{ + case FULL = 'full'; // Only reserve if full quantity available + case PARTIAL = 'partial'; // Reserve available quantity even if less + case REJECT = 'reject'; // Reject reservation if insufficient + case WAIT = 'wait'; // Queue for future auto-retry (TODO: Future) +} +``` + +**Policy Behaviors:** + +| Policy | Behavior | Use Case | +|--------|----------|----------| +| **FULL** | Only reserve if full quantity is available. Reject if insufficient. | Critical orders requiring exact quantities | +| **PARTIAL** | Reserve available quantity even if less than requested. | Flexible orders where partial fulfillment is acceptable | +| **REJECT** | Reject the reservation request if insufficient stock. | Strict inventory control, no partial reservations | +| **WAIT** | Queue and auto-retry when stock becomes available. ⚠️ **NOT YET IMPLEMENTED** | Future: Automated retry mechanism | + +#### 6.5.2 Database Schema + +**Products Table:** +```sql +products +├── ... +├── reservation_policy (varchar(20), default: 'full') +│ -- Policy for this product: 'full', 'partial', 'reject', 'wait' +└── ... +``` + +**Stock Table:** +```sql +stock +├── ... +├── quantity_on_hand (decimal(15,3), default: 0) +├── quantity_reserved (decimal(15,3), default: 0) +├── quantity_available (decimal(15,3) GENERATED AS (quantity_on_hand - quantity_reserved) STORED) +└── ... +``` + +#### 6.5.3 Automatic Reservation Flow + +**Sales Orders:** +1. **Sales Order Confirmed** → Automatically reserve stock for all items + - Uses `StockService::reserveStock()` with product's `reservation_policy` + - Respects policy: FULL, PARTIAL, REJECT, or WAIT +2. **Sales Order Cancelled/Rejected** → Automatically release reservations +3. **Delivery Note Shipped** → Release reservations (physical stock issued) + +**Work Orders:** +1. **Work Order Released** → Automatically reserve materials for all items + - Uses `StockService::reserveStock()` with product's `reservation_policy` + - Materials are reserved from the work order's warehouse +2. **Work Order Cancelled** → Automatically release material reservations +3. **Materials Issued** → Release reservations (physical stock issued) + +#### 6.5.4 Reservation Logic Implementation + +**StockService::reserveStock():** +```php +public function reserveStock( + int $productId, + int $warehouseId, + float $requestedQty, + ?string $lotNumber = null, + string $operationType = 'sale', + bool $skipQualityCheck = false +): StockReservation +{ + // Get product and reservation policy + $product = Product::findOrFail($productId); + $policy = ReservationPolicy::tryFrom($product->reservation_policy ?? 'full') + ?? ReservationPolicy::FULL; + + $stock = $this->findOrCreateStock($productId, $warehouseId, $lotNumber); + $availableQty = $stock->quantity_available; + + // Apply policy logic + if ($availableQty < $requestedQty) { + if ($policy === ReservationPolicy::PARTIAL) { + // Reserve available quantity + $reservedQty = $availableQty; + } elseif ($policy === ReservationPolicy::WAIT) { + // TODO: Queue for future retry + throw new BusinessException("Insufficient stock. WAIT policy not yet implemented."); + } else { + // FULL or REJECT: Reject reservation + throw new BusinessException("Insufficient stock. Available: {$availableQty}, Requested: {$requestedQty}"); + } + } else { + $reservedQty = $requestedQty; + } + + // Create reservation record + // Update stock.quantity_reserved + // Return StockReservation +} +``` + +#### 6.5.5 Reservation Release + +**StockService::releaseReservation():** +```php +public function releaseReservation( + int $productId, + int $warehouseId, + float $quantity, + ?string $lotNumber = null +): void +{ + // Find reservation + // Update stock.quantity_reserved (decrease) + // Mark reservation as released +} +``` + +#### 6.5.6 Service Integration + +**SalesOrderService:** +- `confirm()` → Calls `reserveStockForOrder()` → Automatically reserves stock +- `cancel()` → Calls `releaseStockForOrder()` → Automatically releases reservations +- `reject()` → Calls `releaseStockForOrder()` → Automatically releases reservations + +**DeliveryNoteService:** +- `ship()` → Calls `releaseReservation()` → Releases reservation before issuing stock + +**WorkOrderService:** +- `release()` → Calls `reserveMaterialsForOrder()` → Automatically reserves materials +- `cancel()` → Calls `releaseMaterialsForOrder()` → Automatically releases reservations +- `issueMaterials()` → Calls `releaseReservation()` → Releases reservation before issuing stock + +#### 6.5.7 Benefits + +1. **Flexible Policies**: Different products can have different reservation behaviors +2. **Automatic Management**: Reservations created/released automatically based on order status +3. **Stock Availability**: `quantity_available` automatically calculated (on_hand - reserved) +4. **Partial Reservations**: Support for partial fulfillment when policy allows +5. **Future-Ready**: WAIT policy placeholder for future queue/retry mechanism + +#### 6.5.8 Use Cases + +**High-Value Items (FULL policy):** +- Electronics, precision instruments +- Require exact quantities, no partial reservations + +**Bulk Materials (PARTIAL policy):** +- Raw materials, chemicals +- Accept partial fulfillment, reserve what's available + +**Strict Control (REJECT policy):** +- Critical inventory items +- No reservations if insufficient stock + +**Future: Automated Retry (WAIT policy):** +- Queue reservation requests +- Auto-retry when stock arrives +- Requires queue system implementation + --- -### 6.5 Procurement +### 6.7 Procurement #### Suppliers ```sql @@ -909,12 +1288,14 @@ purchase_order_items ├── unit_price (decimal(15,4)) ├── tax_percentage (decimal(5,2), default: 0) ├── line_total (decimal(15,2)) +├── over_delivery_tolerance_percentage (decimal(5,2), nullable) +│ -- Over-delivery tolerance for this specific order item (most specific level) └── created_at (timestamp) ``` --- -### 6.6 Sales Management (External Integration) +### 6.8 Sales Management (Optional Module) #### Customers ```sql @@ -967,44 +1348,713 @@ INDEX idx_so_customer ON sales_orders(customer_id, order_date DESC) INDEX idx_so_status ON sales_orders(status) ``` +#### Sales Order Items +```sql +sales_order_items +├── id (bigint, PK) +├── sales_order_id (bigint, FK) +├── product_id (bigint, FK) +├── quantity_ordered (decimal(15,3)) +├── quantity_shipped (decimal(15,3), default: 0) +├── uom_id (bigint, FK) +├── unit_price (decimal(15,4)) +├── tax_percentage (decimal(5,2), default: 0) +├── line_total (decimal(15,2)) +├── over_delivery_tolerance_percentage (decimal(5,2), nullable) +│ -- Over-delivery tolerance for this specific order item (most specific level) +└── created_at (timestamp) +``` + +--- + +### 6.9 Over-Delivery Tolerance System + +The system implements a flexible over-delivery tolerance mechanism for both **Sales Orders → Delivery Notes** and **Purchase Orders → Goods Received Notes (GRN)**. This allows partial deliveries while preventing excessive over-delivery through a hierarchical fallback system. + +#### 6.9.1 Tolerance Levels (Fallback Logic) + +The system uses a **4-level fallback hierarchy** (most specific to least specific, SaaS application - no system level): + +``` +1. Order Item Level (Most Specific) + ├── sales_order_items.over_delivery_tolerance_percentage + └── purchase_order_items.over_delivery_tolerance_percentage + +2. Product Level + └── products.over_delivery_tolerance_percentage + +3. Category Level + └── categories.over_delivery_tolerance_percentage (primary category) + +4. Company Level (Company-specific default - Final Fallback) + └── settings.delivery.default_over_delivery_tolerance.{company_id} + +Note: No system-level tolerance as this is a SaaS application where each company +manages its own tolerance settings. Company-level is the final fallback. +``` + +**Decision Logic:** +```php +$tolerance = $orderItem->over_delivery_tolerance_percentage + ?? $product->over_delivery_tolerance_percentage + ?? $category->over_delivery_tolerance_percentage + ?? Setting::get("delivery.default_over_delivery_tolerance.{$companyId}", 0); + +// Note: Company-level is the final fallback (no system-level in SaaS architecture) +``` + +**API Endpoints for Company-Level Tolerance:** +- `GET /api/over-delivery-tolerance` - Get current company's default tolerance +- `PUT /api/over-delivery-tolerance` - Update current company's default tolerance (Admin only) +- `GET /api/over-delivery-tolerance/levels` - Get all tolerance levels (Item, Product, Category, Company) + +#### 6.9.2 Database Schema + +**Products Table:** +```sql +products +├── ... +├── over_delivery_tolerance_percentage (decimal(5,2), nullable) +│ -- Product-specific tolerance (e.g., 5.0 for 5%) +└── ... +``` + +**Categories Table:** +```sql +categories +├── ... +├── over_delivery_tolerance_percentage (decimal(5,2), nullable) +│ -- Category-specific tolerance (e.g., 2.0 for bulk items) +└── ... +``` + +**Sales Order Items Table:** +```sql +sales_order_items +├── ... +├── over_delivery_tolerance_percentage (decimal(5,2), nullable) +│ -- Item-specific override (most specific) +└── ... +``` + +**Purchase Order Items Table:** +```sql +purchase_order_items +├── ... +├── over_delivery_tolerance_percentage (decimal(5,2), nullable) +│ -- Item-specific override (most specific) +└── ... +``` + +**Company Settings (in Settings table):** +```sql +settings +├── group = 'delivery' +├── key = 'default_over_delivery_tolerance.{company_id}' +├── value = '0' (default: no tolerance, company-specific) +└── ... + +Note: Each company has its own default tolerance setting. No global system-level tolerance. +``` + +#### 6.9.3 Sales Order → Delivery Note Flow + +**Quantity Control Logic:** +1. Calculate total quantity already in delivery notes (including DRAFTs): + ```php + $totalInDeliveryNotes = DeliveryNoteItem::where('sales_order_item_id', $item->id) + ->sum('quantity_shipped'); + ``` + +2. Calculate remaining quantity: + ```php + $remainingQty = $salesOrderItem->quantity_ordered - $totalInDeliveryNotes; + ``` + +3. Get tolerance using fallback logic: + ```php + $tolerancePercentage = $this->getOverDeliveryTolerance($salesOrderItem); + ``` + +4. Calculate maximum allowed quantity: + ```php + $maxAllowedQty = $salesOrderItem->quantity_ordered * (1 + $tolerancePercentage / 100); + $maxAllowedQtyInDeliveryNotes = $maxAllowedQty - $totalInDeliveryNotes; + ``` + +5. Validate: + - If `quantity_requested > remainingQty`: + - If `quantity_requested <= maxAllowedQtyInDeliveryNotes`: ✅ **Allow with warning log** + - If `quantity_requested > maxAllowedQtyInDeliveryNotes`: ❌ **Reject with error** + +**Example Scenario:** +- Sales Order Item: 1000 units ordered +- Company default tolerance: 5% +- Max allowed: 1000 × 1.05 = 1050 units + +| Delivery Note | Quantity | Result | Reason | +|--------------|----------|--------|--------| +| DN-001 | 1000 | ✅ Success | Normal delivery | +| DN-002 | 50 | ✅ Success (Warning) | Within tolerance (1050 total) | +| DN-003 | 1 | ❌ Error | Exceeds tolerance (1051 > 1050) | + +#### 6.9.4 Purchase Order → GRN Flow + +**Quantity Control Logic:** +1. Calculate total quantity already in GRNs (including DRAFTs): + ```php + $totalInGrns = GoodsReceivedNoteItem::where('purchase_order_item_id', $item->id) + ->sum('quantity_received'); + ``` + +2. Calculate remaining quantity: + ```php + $remainingQty = $purchaseOrderItem->quantity_ordered - $totalInGrns; + ``` + +3. Get tolerance using fallback logic (same as Sales Orders): + ```php + $tolerancePercentage = $this->getOverDeliveryTolerance($purchaseOrderItem); + ``` + +4. Calculate maximum allowed quantity: + ```php + $maxAllowedQty = $purchaseOrderItem->quantity_ordered * (1 + $tolerancePercentage / 100); + $maxAllowedQtyInGrns = $maxAllowedQty - $totalInGrns; + ``` + +5. Validate (same logic as Delivery Notes) + +**Example Scenario:** +- Purchase Order Item: 500 units ordered +- Product tolerance: 3% +- Max allowed: 500 × 1.03 = 515 units + +| GRN | Quantity | Result | Reason | +|-----|----------|--------|--------| +| GRN-001 | 500 | ✅ Success | Normal receipt | +| GRN-002 | 15 | ✅ Success (Warning) | Within tolerance (515 total) | +| GRN-003 | 1 | ❌ Error | Exceeds tolerance (516 > 515) | + +#### 6.9.5 Partial Delivery Support + +Both systems support **multiple partial deliveries**: + +- **Sales Orders**: Multiple delivery notes can be created for the same sales order item +- **Purchase Orders**: Multiple GRNs can be created for the same purchase order item +- **Total Control**: System tracks total quantity across all delivery notes/GRNs (including DRAFTs) +- **Tolerance Applied**: Tolerance is applied to the **total delivered/received quantity**, not per delivery + +#### 6.9.6 Service Implementation + +**DeliveryNoteService:** +```php +protected function getOverDeliveryTolerance(SalesOrderItem $salesOrderItem): float +{ + // 1. Check SalesOrderItem level (most specific) + if ($salesOrderItem->over_delivery_tolerance_percentage !== null) { + return (float) $salesOrderItem->over_delivery_tolerance_percentage; + } + + // 2. Check Product level + $product = $salesOrderItem->product; + if ($product && $product->over_delivery_tolerance_percentage !== null) { + return (float) $product->over_delivery_tolerance_percentage; + } + + // 3. Check Category level (primary category) + if ($product) { + $primaryCategory = $product->primaryCategory; + if ($primaryCategory && $primaryCategory->over_delivery_tolerance_percentage !== null) { + return (float) $primaryCategory->over_delivery_tolerance_percentage; + } + } + + // 4. Company default (company-specific, final fallback) + $companyId = Auth::user()->company_id; + $companyKey = "delivery.default_over_delivery_tolerance.{$companyId}"; + $companyDefault = Setting::get($companyKey, 0); + + $tolerance = is_array($companyDefault) ? (float) ($companyDefault[0] ?? 0) : (float) $companyDefault; + return $tolerance; + + // Note: No system-level tolerance as this is a SaaS application. + // Company-level is the final fallback. +} +``` + +**GoodsReceivedNoteService:** +- Same `getOverDeliveryTolerance()` method, but accepts `PurchaseOrderItem` instead + +#### 6.9.7 Benefits + +1. **Flexibility**: Different tolerance levels for different products/categories +2. **Control**: Prevents excessive over-delivery while allowing reasonable variations +3. **Hierarchy**: Most specific setting wins (item > product > category > system) +4. **Partial Delivery**: Supports multiple deliveries/receipts per order +5. **Audit Trail**: Warning logs when tolerance is used + +#### 6.9.8 Use Cases + +**High-Value Items (0% tolerance):** +- Electronics, precision instruments +- Set at Product or Category level + +**Bulk Materials (2-5% tolerance):** +- Raw materials, chemicals, grains +- Set at Category level + +**Special Orders (Item-level override):** +- Customer-specific tolerance for specific order +- Set at Sales Order Item level + +**Company-Wide Default:** +- Company-specific tolerance for all items (e.g., 0% = strict, 5% = flexible) +- Set via API: `PUT /api/over-delivery-tolerance` (Admin only) +- Managed per company independently + +**System-Wide Default:** +- Global fallback tolerance for all companies +- Set in System Settings (for companies without company-specific setting) + --- -### 6.7 Manufacturing +### 6.10 Manufacturing (Phase 5) - MRP II Core + +Manufacturing modülü **MRP II (Manufacturing Resource Planning)** sisteminin çekirdeğidir. MRP II, klasik MRP'nin (Material Requirements Planning) gelişmiş versiyonudur ve aşağıdaki bileşenleri içerir: + +#### 6.10.1 MRP II Components Overview + +**Implemented Components:** + +**1. Material Requirements Planning (MRP)** +- Multi-level BOM explosion +- Net requirement calculations +- Purchase order and work order recommendations +- Safety stock considerations +- Lead time respect + +**2. Capacity Requirements Planning (CRP)** +- Work center capacity analysis +- Capacity load reports +- Bottleneck identification +- Calendar-based capacity planning +- Work center availability tracking + +**3. Master Production Schedule (MPS)** +- Work Orders as production schedule +- Production planning horizon +- Production order prioritization +- Material availability checks + +**4. Shop Floor Control (Atölye Kontrolü)** +- **Definition**: Shop Floor Control refers to the real-time monitoring and management of production activities on the manufacturing floor (shop floor). It bridges the gap between production planning (MPS/MRP) and actual execution. + +- **Key Features**: + - **Work Order Management**: Create, release, start, complete, cancel, and hold work orders + - **Operation Tracking**: Track individual operations within a work order (start, complete, status) + - **Material Consumption**: Issue materials from stock when production starts + - **Finished Goods Receipt**: Receive completed products back into inventory + - **Production Progress Tracking**: Monitor quantity completed vs. ordered + - **Status Management**: Track work order and operation statuses (draft, released, in_progress, completed, cancelled, on_hold) + - **Real-time Updates**: Actual start/end dates, actual quantities, scrap tracking + - **Capacity Integration**: Check work center availability before starting operations + +- **Workflow**: + 1. Work Order created (draft) → Material requirements calculated from BOM + 2. Work Order released → Materials automatically reserved + 3. Work Order started → Status changes to "in_progress", actual start date recorded + 4. Operations started/completed → Individual operation tracking + 5. Materials issued → Stock consumed for production + 6. Finished goods received → Completed products added to inventory + 7. Work Order completed → Status finalized, actual end date recorded + +- **Integration Points**: + - Links to MRP recommendations (work orders can be created from MRP) + - Uses BOM for material requirements + - Uses Routing for operation sequences + - Integrates with Stock Service for material issuance and finished goods receipt + - Capacity planning checks work center availability + +**5. Bill of Materials (BOM) Management** +- Multi-level product structures +- BOM versioning and status management +- Component quantity calculations +- BOM explosion for MRP + +**6. Routing Management** +- Manufacturing process definitions +- Operation sequences +- Work center assignments +- Setup and run times +- Lead time calculations + +**7. Work Center Management** +- Production resource definitions +- Capacity definitions (hours per day) +- Efficiency tracking +- Basic cost per hour tracking (for operational planning) +- Availability calendars + +**Not Implemented (Future Roadmap):** + +**8. Financial Planning & Cost Management** +- **Status**: Not implemented - conscious design decision +- **Current Focus**: Planning and inventory management +- **Basic Cost Tracking Available**: + - Standard cost per product + - Average cost calculation + - Work center cost per hour (for operational planning) + - Estimated vs actual cost in Work Orders (basic tracking) +- **Future Plans**: + - Comprehensive cost accounting + - Financial planning and budgeting + - Cost center management + - Financial reporting and analysis + - Integration with external accounting systems + +**Design Philosophy:** +The system prioritizes **planning and inventory management** over financial integration. While basic cost tracking exists for operational purposes (e.g., standard costs, work center costs), comprehensive financial planning is deferred to future releases. This allows the system to focus on its core strengths: material planning, capacity planning, and inventory control. #### Work Centers ```sql work_centers ├── id (bigint, PK) ├── company_id (bigint, FK) -├── code (varchar(50)) -├── name (varchar(255)) -- Single language -├── work_center_type (enum: machine, manual, assembly, quality) +├── code (varchar(50), unique per company) +├── name (varchar(255)) +├── description (text, nullable) +├── work_center_type (enum: machine, labor, subcontract, tool) ├── cost_per_hour (decimal(15,4), default: 0) +├── cost_currency (varchar(3), default: 'USD') +├── capacity_per_day (decimal(15,3), default: 8) -- Hours per day +├── efficiency_percentage (decimal(5,2), default: 100.00) ├── is_active (boolean, default: true) +├── settings (jsonb, nullable) +├── created_by (bigint, FK) ├── created_at (timestamp) -└── updated_at (timestamp) +├── updated_at (timestamp) +└── deleted_at (timestamp, nullable) + +INDEX idx_work_centers_active ON work_centers(company_id, is_active) +INDEX idx_work_centers_type ON work_centers(company_id, work_center_type) ``` -#### Production Orders +#### BOMs (Bill of Materials Header) ```sql -production_orders +boms ├── id (bigint, PK) ├── company_id (bigint, FK) -├── order_number (varchar(50), unique) ├── product_id (bigint, FK) +├── bom_number (varchar(50), unique per company) +├── version (integer, default: 1) +├── name (varchar(255)) +├── description (text, nullable) +├── bom_type (enum: manufacturing, engineering, phantom) +├── status (enum: draft, active, obsolete) +├── quantity (decimal(15,4), default: 1) -- Base quantity +├── uom_id (bigint, FK) +├── is_default (boolean, default: false) +├── effective_date (date, nullable) +├── expiry_date (date, nullable) +├── notes (text, nullable) +├── meta_data (jsonb, nullable) +├── created_by (bigint, FK) +├── created_at (timestamp) +├── updated_at (timestamp) +└── deleted_at (timestamp, nullable) + +INDEX idx_boms_product ON boms(company_id, product_id) +INDEX idx_boms_status ON boms(company_id, status) +INDEX idx_boms_default ON boms(product_id, is_default) +``` + +#### BOM Items (Components) +```sql +bom_items +├── id (bigint, PK) ├── bom_id (bigint, FK) -├── warehouse_id (bigint, FK) -├── quantity_to_produce (decimal(15,3)) -├── quantity_produced (decimal(15,3), default: 0) -├── status (enum: draft, released, in_progress, completed, cancelled) -├── scheduled_start_date (date) -├── scheduled_end_date (date) -├── actual_start_date (date, nullable) -├── actual_end_date (date, nullable) +├── component_id (bigint, FK to products) +├── line_number (integer, default: 1) +├── quantity (decimal(15,4)) +├── uom_id (bigint, FK) +├── scrap_percentage (decimal(5,2), default: 0) +├── is_optional (boolean, default: false) +├── is_phantom (boolean, default: false) -- Pass-through item +├── notes (text, nullable) +├── created_at (timestamp) +└── updated_at (timestamp) + +UNIQUE idx_bom_component ON bom_items(bom_id, component_id) +INDEX idx_bom_items_line ON bom_items(bom_id, line_number) +``` + +#### Routings (Header) +```sql +routings +├── id (bigint, PK) +├── company_id (bigint, FK) +├── product_id (bigint, FK) +├── routing_number (varchar(50), unique per company) +├── version (integer, default: 1) +├── name (varchar(255)) +├── description (text, nullable) +├── status (enum: draft, active, obsolete) +├── is_default (boolean, default: false) +├── effective_date (date, nullable) +├── expiry_date (date, nullable) +├── notes (text, nullable) +├── meta_data (jsonb, nullable) ├── created_by (bigint, FK) ├── created_at (timestamp) ├── updated_at (timestamp) └── deleted_at (timestamp, nullable) + +INDEX idx_routings_product ON routings(company_id, product_id) +INDEX idx_routings_status ON routings(company_id, status) +``` + +#### Routing Operations +```sql +routing_operations +├── id (bigint, PK) +├── routing_id (bigint, FK) +├── work_center_id (bigint, FK) +├── operation_number (integer) +├── name (varchar(255)) +├── description (text, nullable) +├── setup_time (decimal(10,2), default: 0) -- Minutes +├── run_time_per_unit (decimal(10,4), default: 0) -- Minutes +├── queue_time (decimal(10,2), default: 0) -- Wait before operation +├── move_time (decimal(10,2), default: 0) -- Move to next operation +├── is_subcontracted (boolean, default: false) +├── subcontractor_id (bigint, FK to suppliers, nullable) +├── subcontract_cost (decimal(15,4), nullable) +├── instructions (text, nullable) +├── settings (jsonb, nullable) +├── created_at (timestamp) +└── updated_at (timestamp) + +UNIQUE idx_routing_op ON routing_operations(routing_id, operation_number) +INDEX idx_routing_ops_wc ON routing_operations(work_center_id) +``` + +#### Work Orders (Production Orders) +```sql +work_orders +├── id (bigint, PK) +├── company_id (bigint, FK) +├── work_order_number (varchar(50), unique per company) +├── product_id (bigint, FK) +├── bom_id (bigint, FK, nullable) +├── routing_id (bigint, FK, nullable) +├── quantity_ordered (decimal(15,3)) +├── quantity_completed (decimal(15,3), default: 0) +├── quantity_scrapped (decimal(15,3), default: 0) +├── uom_id (bigint, FK) +├── warehouse_id (bigint, FK) -- Finished goods destination +├── status (enum: draft, released, in_progress, completed, cancelled, on_hold) +├── priority (enum: low, normal, high, urgent) +├── planned_start_date (datetime, nullable) +├── planned_end_date (datetime, nullable) +├── actual_start_date (datetime, nullable) +├── actual_end_date (datetime, nullable) +├── estimated_cost (decimal(15,4), default: 0) +├── actual_cost (decimal(15,4), default: 0) +├── notes (text, nullable) +├── internal_notes (text, nullable) +├── meta_data (jsonb, nullable) +├── created_by (bigint, FK) +├── approved_by (bigint, FK, nullable) +├── approved_at (timestamp, nullable) +├── released_by (bigint, FK, nullable) +├── released_at (timestamp, nullable) +├── completed_by (bigint, FK, nullable) +├── completed_at (timestamp, nullable) +├── created_at (timestamp) +├── updated_at (timestamp) +└── deleted_at (timestamp, nullable) + +INDEX idx_wo_status ON work_orders(company_id, status) +INDEX idx_wo_product ON work_orders(company_id, product_id) +INDEX idx_wo_priority ON work_orders(company_id, priority, status) +INDEX idx_wo_dates ON work_orders(planned_start_date, planned_end_date) +``` + +#### Work Order Operations +```sql +work_order_operations +├── id (bigint, PK) +├── work_order_id (bigint, FK) +├── routing_operation_id (bigint, FK, nullable) +├── work_center_id (bigint, FK) +├── operation_number (integer) +├── name (varchar(255)) +├── description (text, nullable) +├── status (enum: pending, in_progress, completed, skipped) +├── quantity_completed (decimal(15,3), default: 0) +├── quantity_scrapped (decimal(15,3), default: 0) +├── planned_start (datetime, nullable) +├── planned_end (datetime, nullable) +├── actual_start (datetime, nullable) +├── actual_end (datetime, nullable) +├── actual_setup_time (decimal(10,2), default: 0) -- Minutes +├── actual_run_time (decimal(10,2), default: 0) -- Minutes +├── actual_cost (decimal(15,4), default: 0) +├── notes (text, nullable) +├── started_by (bigint, FK, nullable) +├── completed_by (bigint, FK, nullable) +├── created_at (timestamp) +└── updated_at (timestamp) + +UNIQUE idx_wo_op ON work_order_operations(work_order_id, operation_number) +INDEX idx_wo_ops_status ON work_order_operations(work_order_id, status) +INDEX idx_wo_ops_wc ON work_order_operations(work_center_id) +``` + +#### Work Order Materials (Material Consumption) +```sql +work_order_materials +├── id (bigint, PK) +├── work_order_id (bigint, FK) +├── product_id (bigint, FK) +├── bom_item_id (bigint, FK, nullable) +├── quantity_required (decimal(15,4)) +├── quantity_issued (decimal(15,4), default: 0) +├── quantity_returned (decimal(15,4), default: 0) +├── uom_id (bigint, FK) +├── warehouse_id (bigint, FK) +├── unit_cost (decimal(15,4), default: 0) +├── total_cost (decimal(15,4), default: 0) +├── notes (text, nullable) +├── created_at (timestamp) +└── updated_at (timestamp) + +INDEX idx_wo_materials ON work_order_materials(work_order_id, product_id) +``` + +### 6.10 Manufacturing Enums + +#### WorkCenterType +``` +machine - Machine-based (CNC, lathe, etc.) +labor - Labor-intensive (assembly, inspection) +subcontract - Outsourced operations +tool - Tool or equipment based +``` + +#### BomStatus / RoutingStatus +``` +draft - Can be edited +active - Can be used for production +obsolete - No longer in use +``` + +#### WorkOrderStatus +``` +draft → released → in_progress → completed + ↘ on_hold ↗ + → cancelled +``` + +#### WorkOrderPriority +``` +low, normal, high, urgent +``` + +#### OperationStatus +``` +pending → in_progress → completed + → skipped +``` + +### 6.11 Manufacturing Services + +#### WorkCenterService +- CRUD operations +- Capacity calculation +- Availability check + +#### BomService +- CRUD for BOM and items +- Version management +- Copy/clone BOM +- **explodeBom()** - Multi-level BOM explosion +- **calculateMaterialRequirements()** - Material calculation +- **validateBomItems()** - Circular reference check + +```php +// BOM Explosion Algorithm +public function explodeBom(Bom $bom, float $quantity = 1, int $level = 0): array +{ + $materials = []; + foreach ($bom->items as $item) { + $requiredQty = $item->quantity * $quantity * (1 + $item->scrap_percentage/100); + + if ($item->is_phantom && $item->component->defaultBom) { + // Recursive explosion for phantom items + $childBom = $item->component->defaultBom; + $childMaterials = $this->explodeBom($childBom, $requiredQty, $level + 1); + $materials = array_merge($materials, $childMaterials); + } else { + $materials[] = [ + 'product_id' => $item->component_id, + 'quantity' => $requiredQty, + 'level' => $level, + ]; + } + } + return $materials; +} +``` + +#### RoutingService +- CRUD for Routing and operations +- Calculate total lead time +- Clone routing + +#### WorkOrderService +- **createFromBom()** - Create from BOM + Routing +- **release()** - Release for production +- **start() / complete()** - Status transitions +- **issueMaterials()** - Material consumption (stock issue) +- **receiveFinishedGoods()** - Finished goods receipt (stock receive) +- **calculateCosts()** - Cost calculation +- **getProgress()** - Progress tracking + +```php +// Material Issuance Flow +public function issueMaterials(WorkOrder $workOrder): void +{ + // 1. Get required materials from work_order_materials + // 2. Check stock availability (quality_status = 'available') + // 3. Issue stock (create stock movement: issue) + // 4. Update work_order_materials.quantity_issued +} + +// Finished Goods Receipt Flow +public function receiveFinishedGoods(WorkOrder $workOrder, float $quantity): void +{ + // 1. Validate quantity <= quantity_ordered - quantity_completed + // 2. Receive stock (create stock movement: production_output) + // 3. Update work_order.quantity_completed + // 4. If complete, update status to 'completed' +} +``` + +### 6.12 Manufacturing API Routes + +See Section 10.8 for complete Manufacturing module endpoint documentation. + +### 6.13 Manufacturing Permissions + +``` +manufacturing.view - View work centers, BOMs, routings, work orders +manufacturing.create - Create new records +manufacturing.edit - Edit existing records +manufacturing.delete - Delete records +manufacturing.release - Release work orders for production +manufacturing.complete - Complete operations and work orders ``` --- @@ -1313,31 +2363,545 @@ CREATE INDEX idx_active_products ON products(id) WHERE status = 'active' AND del ## 10. API Structure -### 10.1 API Versioning +### 10.1 API Base URL +``` +/api/... +``` + +**Note:** API versioning (`/api/v1/`) is not currently implemented. All endpoints use `/api/` prefix. + +### 10.2 Authentication Endpoints + +``` +POST /api/auth/register +POST /api/auth/login +POST /api/auth/logout +GET /api/auth/me +POST /api/auth/refresh +POST /api/auth/forgot-password +POST /api/auth/reset-password +``` + +### 10.3 Core System Endpoints + +**Users:** +``` +GET /api/users +POST /api/users +GET /api/users/{id} +PUT /api/users/{id} +DELETE /api/users/{id} +POST /api/users/{id}/restore +DELETE /api/users/{id}/force +``` + +**Roles & Permissions:** ``` -/api/v1/... +GET /api/roles +POST /api/roles +GET /api/roles/{id} +PUT /api/roles/{id} +DELETE /api/roles/{id} +POST /api/roles/{id}/permissions/assign +POST /api/roles/{id}/permissions/revoke + +GET /api/permissions +POST /api/permissions +GET /api/permissions/{id} +PUT /api/permissions/{id} +DELETE /api/permissions/{id} +GET /api/permissions/modules/list ``` -### 10.2 Core Endpoints +**Settings:** +``` +GET /api/settings +POST /api/settings +GET /api/settings/groups +GET /api/settings/group/{group} +GET /api/settings/{group}/{key} +PUT /api/settings/{group}/{key} +DELETE /api/settings/{group}/{key} +``` + +**Over-Delivery Tolerance:** +``` +GET /api/over-delivery-tolerance +PUT /api/over-delivery-tolerance +GET /api/over-delivery-tolerance/levels +``` -**Authentication:** +**Company Calendar:** ``` -POST /api/v1/auth/login -POST /api/v1/auth/logout -POST /api/v1/auth/refresh -GET /api/v1/auth/me +GET /api/company-calendar +POST /api/company-calendar +POST /api/company-calendar/bulk +GET /api/company-calendar/date-range +GET /api/company-calendar/{id} +PUT /api/company-calendar/{id} +DELETE /api/company-calendar/{id} +``` + +**Currencies:** +``` +GET /api/currencies +POST /api/currencies +GET /api/currencies/active +GET /api/currencies/{id} +PUT /api/currencies/{id} +DELETE /api/currencies/{id} +POST /api/currencies/{id}/toggle-active +GET /api/currencies/exchange-rate/get +GET /api/currencies/exchange-rate/history +POST /api/currencies/exchange-rate/set +POST /api/currencies/convert +``` + +**Units of Measure:** +``` +GET /api/units-of-measure +POST /api/units-of-measure +GET /api/units-of-measure/list +GET /api/units-of-measure/types +GET /api/units-of-measure/{id} +PUT /api/units-of-measure/{id} +DELETE /api/units-of-measure/{id} +``` + +### 10.4 Product Management Endpoints + +**Categories:** +``` +GET /api/categories +POST /api/categories +GET /api/categories/{id} +PUT /api/categories/{id} +DELETE /api/categories/{id} +GET /api/categories/{id}/attributes +POST /api/categories/{id}/attributes +PUT /api/categories/{id}/attributes/{attributeId} +DELETE /api/categories/{id}/attributes/{attributeId} +``` + +**Attributes:** +``` +GET /api/attributes +POST /api/attributes +GET /api/attributes/{id} +PUT /api/attributes/{id} +DELETE /api/attributes/{id} +POST /api/attributes/{id}/values +PUT /api/attributes/{id}/values/{valueId} +DELETE /api/attributes/{id}/values/{valueId} +POST /api/variants/bulk-generate +``` + +**Product Types:** +``` +GET /api/producttypes +POST /api/producttypes +GET /api/producttypes/{id} +PUT /api/producttypes/{id} +DELETE /api/producttypes/{id} ``` **Products:** ``` -GET /api/v1/products -POST /api/v1/products -GET /api/v1/products/{id} -PUT /api/v1/products/{id} -DELETE /api/v1/products/{id} -GET /api/v1/products/{id}/stock -GET /api/v1/products/{id}/bom -POST /api/v1/products/search (Elasticsearch) +GET /api/products +POST /api/products +GET /api/products/search +GET /api/products/{id} +PUT /api/products/{id} +DELETE /api/products/{id} +POST /api/products/{id}/restore + +# Product Attributes +GET /api/products/{id}/attributes +POST /api/products/{id}/attributes +PUT /api/products/{id}/attributes/{attributeId} +DELETE /api/products/{id}/attributes/{attributeId} + +# Product Images +POST /api/products/{id}/images +PUT /api/products/{id}/images/{imageId} +DELETE /api/products/{id}/images/{imageId} +POST /api/products/{id}/images/reorder + +# Product Variants +GET /api/products/{id}/variants +POST /api/products/{id}/variants +POST /api/products/{id}/variants/generate +POST /api/products/{id}/variants/expand +PUT /api/products/{id}/variants/{variantId} +DELETE /api/products/{id}/variants/{variantId} +DELETE /api/products/{id}/variants/clear +DELETE /api/products/{id}/variants/{variantId}/force +DELETE /api/products/{id}/variants/force-clear + +# Product UOM Conversions +GET /api/products/{id}/uom-conversions +POST /api/products/{id}/uom-conversions +POST /api/products/{id}/uom-conversions/bulk +POST /api/products/{id}/uom-conversions/copy-from +GET /api/products/{id}/uom-conversions/{conversionId} +PUT /api/products/{id}/uom-conversions/{conversionId} +DELETE /api/products/{id}/uom-conversions/{conversionId} +POST /api/products/{id}/uom-conversions/{conversionId}/toggle-active +POST /api/products/{id}/uom-conversions/convert +``` + +### 10.5 Inventory Management Endpoints + +**Warehouses:** +``` +GET /api/warehouses +POST /api/warehouses +GET /api/warehouses/list +GET /api/warehouses/quarantine-zones +GET /api/warehouses/rejection-zones +GET /api/warehouses/qc-zones +GET /api/warehouses/{id} +PUT /api/warehouses/{id} +DELETE /api/warehouses/{id} +POST /api/warehouses/{id}/toggle-active +POST /api/warehouses/{id}/set-default +GET /api/warehouses/{id}/stock-summary +``` + +**Stock:** +``` +GET /api/stock +GET /api/stock/low-stock +GET /api/stock/expiring +GET /api/stock/product/{productId} +GET /api/stock/warehouse/{warehouseId} +POST /api/stock/receive +POST /api/stock/issue +POST /api/stock/transfer +POST /api/stock/adjust +POST /api/stock/reserve +POST /api/stock/release-reservation +``` + +**Stock Movements:** +``` +GET /api/stock-movements +GET /api/stock-movements/summary +GET /api/stock-movements/daily-report +GET /api/stock-movements/audit-trail +GET /api/stock-movements/product/{productId} +GET /api/stock-movements/warehouse/{warehouseId} +GET /api/stock-movements/types/movement +GET /api/stock-movements/types/transaction +``` + +**Stock Debts (Negative Stock):** +``` +GET /api/stock-debts +GET /api/stock-debts/{id} +GET /api/stock-debts/alerts +GET /api/stock-debts/weekly-report +GET /api/stock-debts/long-term +``` + +### 10.6 Procurement Module Endpoints + +**Suppliers:** +``` +GET /api/suppliers +POST /api/suppliers +GET /api/suppliers/list +GET /api/suppliers/{id} +PUT /api/suppliers/{id} +DELETE /api/suppliers/{id} +POST /api/suppliers/{id}/toggle-active +GET /api/suppliers/{id}/statistics +GET /api/suppliers/for-product/{productId} +POST /api/suppliers/{id}/products +PUT /api/suppliers/{id}/products/{productId} +DELETE /api/suppliers/{id}/products/{productId} + +# Supplier Quality (requires QC module) +GET /api/suppliers/quality-ranking +GET /api/suppliers/{id}/quality-score +GET /api/suppliers/{id}/quality-statistics +``` + +**Purchase Orders:** +``` +GET /api/purchase-orders +POST /api/purchase-orders +GET /api/purchase-orders/statistics +GET /api/purchase-orders/overdue +GET /api/purchase-orders/{id} +PUT /api/purchase-orders/{id} +DELETE /api/purchase-orders/{id} +POST /api/purchase-orders/{id}/items +PUT /api/purchase-orders/{id}/items/{itemId} +DELETE /api/purchase-orders/{id}/items/{itemId} +POST /api/purchase-orders/{id}/submit +POST /api/purchase-orders/{id}/approve +POST /api/purchase-orders/{id}/reject +POST /api/purchase-orders/{id}/send +POST /api/purchase-orders/{id}/cancel +POST /api/purchase-orders/{id}/close +``` + +**Goods Received Notes (GRN):** +``` +GET /api/goods-received-notes +POST /api/goods-received-notes +GET /api/goods-received-notes/pending-inspection +GET /api/goods-received-notes/for-purchase-order/{purchaseOrderId} +GET /api/goods-received-notes/{id} +PUT /api/goods-received-notes/{id} +DELETE /api/goods-received-notes/{id} +POST /api/goods-received-notes/{id}/submit-inspection +POST /api/goods-received-notes/{id}/record-inspection +POST /api/goods-received-notes/{id}/complete +POST /api/goods-received-notes/{id}/cancel +``` + +### 10.7 Quality Control Module Endpoints + +**Acceptance Rules:** +``` +GET /api/acceptance-rules +POST /api/acceptance-rules +GET /api/acceptance-rules/list +GET /api/acceptance-rules/inspection-types +GET /api/acceptance-rules/sampling-methods +POST /api/acceptance-rules/find-applicable +GET /api/acceptance-rules/{id} +PUT /api/acceptance-rules/{id} +DELETE /api/acceptance-rules/{id} +``` + +**Receiving Inspections:** +``` +GET /api/receiving-inspections +GET /api/receiving-inspections/statistics +GET /api/receiving-inspections/results +GET /api/receiving-inspections/dispositions +GET /api/receiving-inspections/for-grn/{grnId} +GET /api/receiving-inspections/{id} +POST /api/receiving-inspections/create-for-grn/{grnId} +POST /api/receiving-inspections/{id}/record-result +POST /api/receiving-inspections/{id}/approve +PUT /api/receiving-inspections/{id}/disposition +POST /api/receiving-inspections/{id}/transfer-to-qc +``` + +**Non-Conformance Reports (NCR):** +``` +GET /api/ncrs +POST /api/ncrs +GET /api/ncrs/statistics +GET /api/ncrs/statuses +GET /api/ncrs/severities +GET /api/ncrs/defect-types +GET /api/ncrs/dispositions +GET /api/ncrs/supplier/{supplierId}/summary +GET /api/ncrs/{id} +PUT /api/ncrs/{id} +DELETE /api/ncrs/{id} +POST /api/ncrs/from-inspection/{inspectionId} +POST /api/ncrs/{id}/submit-review +POST /api/ncrs/{id}/complete-review +POST /api/ncrs/{id}/start-progress +POST /api/ncrs/{id}/set-disposition +POST /api/ncrs/{id}/close +POST /api/ncrs/{id}/cancel +``` + +### 10.8 Manufacturing Module Endpoints + +**Work Centers:** +``` +GET /api/work-centers +POST /api/work-centers +GET /api/work-centers/list +GET /api/work-centers/types +GET /api/work-centers/{id} +GET /api/work-centers/{id}/availability +PUT /api/work-centers/{id} +DELETE /api/work-centers/{id} +POST /api/work-centers/{id}/toggle-active +``` + +**BOMs (Bill of Materials):** +``` +GET /api/boms +POST /api/boms +GET /api/boms/list +GET /api/boms/types +GET /api/boms/statuses +GET /api/boms/for-product/{productId} +GET /api/boms/{id} +PUT /api/boms/{id} +DELETE /api/boms/{id} +POST /api/boms/{id}/items +PUT /api/boms/{id}/items/{itemId} +DELETE /api/boms/{id}/items/{itemId} +POST /api/boms/{id}/activate +POST /api/boms/{id}/obsolete +POST /api/boms/{id}/set-default +POST /api/boms/{id}/copy +GET /api/boms/{id}/explode +``` + +**Routings:** +``` +GET /api/routings +POST /api/routings +GET /api/routings/list +GET /api/routings/statuses +GET /api/routings/for-product/{productId} +GET /api/routings/{id} +PUT /api/routings/{id} +DELETE /api/routings/{id} +POST /api/routings/{id}/operations +PUT /api/routings/{id}/operations/{operationId} +DELETE /api/routings/{id}/operations/{operationId} +POST /api/routings/{id}/operations/reorder +POST /api/routings/{id}/activate +POST /api/routings/{id}/obsolete +POST /api/routings/{id}/set-default +POST /api/routings/{id}/copy +POST /api/routings/{id}/calculate-lead-time +``` + +**Work Orders:** +``` +GET /api/work-orders +POST /api/work-orders +GET /api/work-orders/statistics +GET /api/work-orders/statuses +GET /api/work-orders/priorities +GET /api/work-orders/{id} +PUT /api/work-orders/{id} +DELETE /api/work-orders/{id} +POST /api/work-orders/{id}/release +POST /api/work-orders/{id}/start +POST /api/work-orders/{id}/complete +POST /api/work-orders/{id}/cancel +POST /api/work-orders/{id}/hold +POST /api/work-orders/{id}/resume +GET /api/work-orders/{id}/material-requirements +POST /api/work-orders/{id}/issue-materials +POST /api/work-orders/{id}/receive-finished-goods +POST /api/work-orders/{id}/operations/{operationId}/start +POST /api/work-orders/{id}/operations/{operationId}/complete +GET /api/work-orders/{id}/check-capacity +``` + +**MRP (Material Requirements Planning) - MRP II Component:** +``` +GET /api/mrp +POST /api/mrp +GET /api/mrp/statistics +GET /api/mrp/statuses +GET /api/mrp/recommendation-types +GET /api/mrp/recommendation-statuses +GET /api/mrp/priorities +GET /api/mrp/products-needing-attention +GET /api/mrp/{id} +GET /api/mrp/{id}/progress +GET /api/mrp/{id}/recommendations +POST /api/mrp/{id}/cancel +POST /api/mrp/invalidate-cache +POST /api/mrp/recommendations/{id}/approve +POST /api/mrp/recommendations/{id}/reject +POST /api/mrp/recommendations/bulk-approve +POST /api/mrp/recommendations/bulk-reject +``` + +**Capacity Requirements Planning (CRP) - MRP II Component:** +``` +GET /api/capacity/overview +GET /api/capacity/load-report +GET /api/capacity/bottleneck-analysis +GET /api/capacity/day-types +GET /api/capacity/work-center/{id} +GET /api/capacity/work-center/{id}/daily +GET /api/capacity/work-center/{id}/find-slot +GET /api/capacity/work-center/{id}/calendar +POST /api/capacity/generate-calendar +POST /api/capacity/work-center/{id}/set-holiday +POST /api/capacity/work-center/{id}/set-maintenance +PUT /api/capacity/calendar/{id} +``` + +### 10.9 Sales Module Endpoints + +**Customer Groups:** +``` +GET /api/customer-groups +POST /api/customer-groups +GET /api/customer-groups/list +GET /api/customer-groups/{id} +PUT /api/customer-groups/{id} +DELETE /api/customer-groups/{id} +GET /api/customer-groups/{id}/prices +POST /api/customer-groups/{id}/prices +POST /api/customer-groups/{id}/prices/bulk +DELETE /api/customer-groups/{id}/prices/{priceId} +``` + +**Customers:** +``` +GET /api/customers +POST /api/customers +GET /api/customers/list +GET /api/customers/{id} +PUT /api/customers/{id} +DELETE /api/customers/{id} +GET /api/customers/{id}/statistics +``` + +**Sales Orders:** +``` +GET /api/sales-orders +POST /api/sales-orders +GET /api/sales-orders/statistics +GET /api/sales-orders/statuses +GET /api/sales-orders/{id} +PUT /api/sales-orders/{id} +DELETE /api/sales-orders/{id} +POST /api/sales-orders/{id}/submit-for-approval +POST /api/sales-orders/{id}/approve +POST /api/sales-orders/{id}/reject +POST /api/sales-orders/{id}/confirm +POST /api/sales-orders/{id}/cancel +POST /api/sales-orders/{id}/mark-as-shipped +POST /api/sales-orders/{id}/mark-as-delivered +``` + +**Delivery Notes:** +``` +GET /api/delivery-notes +POST /api/delivery-notes +GET /api/delivery-notes/statuses +GET /api/delivery-notes/for-sales-order/{salesOrderId} +GET /api/delivery-notes/{id} +PUT /api/delivery-notes/{id} +DELETE /api/delivery-notes/{id} +POST /api/delivery-notes/{id}/confirm +POST /api/delivery-notes/{id}/ship +POST /api/delivery-notes/{id}/mark-as-delivered +POST /api/delivery-notes/{id}/cancel +``` + +### 10.10 Module Status Endpoints + +**Public (No Authentication):** +``` +GET /api/modules +``` + +**Protected:** +``` +POST /api/modules/clear-cache ``` ### 10.3 Request Headers @@ -1541,19 +3105,19 @@ class ProductResource extends JsonResource ### Phase 1: Foundation & Architecture -**Week 1: Database & Core Setup** +**Database & Core Setup** - ✅ PostgreSQL setup - ✅ Core migrations (companies, users, roles/permissions) - ✅ User authentication (Sanctum) - ✅ Multi-tenant setup -**Week 2: Architecture Patterns** +**Architecture Patterns** - 🔴 Service Layer Pattern - 🔴 Laravel Policies - 🔴 API Resources - 🔴 Polymorphic Media -**Week 3: Product Catalog (Simplified)** +**Product Catalog (Simplified)** - ✅ Product types - ✅ Categories (no translation tables) - ✅ Products (no translation tables) @@ -1570,39 +3134,37 @@ class ProductResource extends JsonResource - Polymorphic media system - Frontend i18n (UI translations) -### Phase 2: Inventory (Weeks 4-5) +### Phase 2: Inventory - ✅ Warehouses - ✅ Stock tracking - ✅ Stock movements - ✅ Elasticsearch setup -### Phase 3: Procurement (Weeks 6-7) +### Phase 3: Procurement - ✅ Suppliers - ✅ Purchase orders - ✅ GRN -### Phase 4: Sales (Weeks 8-9) +### Phase 4: Sales - ✅ Customers - ✅ Sales orders - ✅ Stock reservation -### Phase 5: Manufacturing (Weeks 10-11) +### Phase 5: Manufacturing - ✅ BOM management - ✅ Production orders -### Phase 6: Support & Reporting (Weeks 12-13) +### Phase 6: Support & Reporting - ✅ Activity logs - ✅ Notifications - ✅ Dashboard - ✅ Reports -### Phase 7: Testing & Deployment (Weeks 14-15) +### Phase 7: Testing & Deployment - ✅ Unit tests - ✅ Feature tests - ✅ Production deployment -**Total Timeline: 15 weeks (vs. 18 weeks with translation tables)** - --- ## Appendix A: Database Changes Summary @@ -1669,6 +3231,58 @@ function ProductForm() { ## Document History +**Version 5.9** - 2026-01-08 +- ✅ **Negative Stock Policy System**: Comprehensive negative stock management with product-level policies +- ✅ Added Section 6.5: Negative Stock Policy System with NEVER, ALLOWED, LIMITED policies +- ✅ Added Stock Debt tracking system with automatic reconciliation +- ✅ Documented automatic debt creation and reconciliation flow +- ✅ Added `negative_stock_policy` and `negative_stock_limit` fields to Products table schema +- ✅ Added Stock Debts table schema (Section 6.4.3) +- ✅ Documented MRP integration with negative stock as priority requirement +- ✅ Documented Stock Alert Service for negative stock monitoring +- ✅ Renumbered sections: Stock Reservation (6.6), Procurement (6.7), Sales (6.8), Over-Delivery Tolerance (6.9), Manufacturing (6.10) + +**Version 5.8** - 2026-01-08 +- ✅ **Stock Reservation Policy System**: Comprehensive reservation management with configurable policies +- ✅ Added Section 6.6: Stock Reservation System with ReservationPolicy enum +- ✅ Documented automatic reservation flow for Sales Orders and Work Orders +- ✅ Documented reservation policies: FULL, PARTIAL, REJECT, WAIT +- ✅ Documented automatic reservation creation/release based on order status +- ✅ Renumbered sections: Procurement (6.7), Sales (6.8), Over-Delivery Tolerance (6.9), Manufacturing (6.10) + +**Version 5.7** - 2026-01-08 +- ✅ **Over-Delivery Tolerance System**: Comprehensive tolerance management for Sales Orders and Purchase Orders +- ✅ Added Section 6.7: Over-Delivery Tolerance System with hierarchical fallback logic +- ✅ Added `over_delivery_tolerance_percentage` to `purchase_order_items` table +- ✅ Added `over_delivery_tolerance_percentage` to `sales_order_items` table +- ✅ Added `over_delivery_tolerance_percentage` to `products` table +- ✅ Added `over_delivery_tolerance_percentage` to `categories` table +- ✅ Implemented 4-level fallback hierarchy: Order Item → Product → Category → Company Default (SaaS - no system level) +- ✅ Delivery Note quantity control with tolerance validation +- ✅ GRN quantity control with tolerance validation +- ✅ Support for multiple partial deliveries per order +- ✅ Warning logs when tolerance is used +- ✅ Renumbered Manufacturing section from 6.7 to 6.8 + +**Version 5.6** - 2025-12-30 +- ✅ **Manufacturing Module (Phase 5)**: Complete Manufacturing documentation +- ✅ Added Section 6.7-6.11: Comprehensive Manufacturing module +- ✅ Work Centers with types (machine, labor, subcontract, tool) +- ✅ BOMs with multi-level explosion support (phantom items) +- ✅ Routings with operations and time estimates +- ✅ Work Orders with full lifecycle (draft → released → in_progress → completed) +- ✅ Work Order Operations tracking +- ✅ Work Order Materials for material consumption +- ✅ Manufacturing Enums (WorkCenterType, BomStatus, RoutingStatus, WorkOrderStatus, WorkOrderPriority, OperationStatus) +- ✅ Manufacturing Services documentation (BOM explosion algorithm) +- ✅ Manufacturing API Routes (40+ endpoints) +- ✅ Manufacturing Permissions + +**Version 5.5** - 2025-12-28 +- ✅ **QC Zones**: Added quarantine and rejection warehouse zones +- ✅ **Supplier Quality Scoring**: Quality score and grade calculation from inspection data +- ✅ **Stock Quality Status**: Comprehensive status tracking with operation restrictions + **Version 5.4** - 2025-12-26 - ✅ **Standard Quality Control**: Implemented QC module within Procurement - ✅ Added `acceptance_rules` table for inspection criteria (product/category/supplier-specific) @@ -1686,7 +3300,7 @@ function ProductForm() { - ✅ Added module configuration system (`config/modules.php`) - ✅ Added module middleware for route protection - ✅ Core module (mandatory), Procurement (optional), Manufacturing (optional) -- ✅ Sales/Finance as external integrations only (webhook API) +- ✅ Sales module as optional module (can be enabled via `MODULE_SALES_ENABLED=true`) with webhook API for external integrations - ✅ Python Prediction Service integration (sync HTTP, async future) - ✅ Renumbered all sections to accommodate new architecture section - ✅ Updated system type from MRP to MRP II @@ -1726,5 +3340,5 @@ function ProductForm() { --- -*Current Version: 5.4* -*Last Updated: 2025-12-26* +*Current Version: 5.9* +*Last Updated: 2026-01-08* diff --git a/SmartStockManagement.postman_environment.json b/SmartStockManagement.postman_environment.json index f37c90a..2fd2462 100644 --- a/SmartStockManagement.postman_environment.json +++ b/SmartStockManagement.postman_environment.json @@ -19,6 +19,25 @@ "value": "", "type": "default", "enabled": true + }, + { + "key": "company_id", + "value": "", + "type": "default", + "enabled": true + }, + { + "key": "is_platform_admin", + "value": "false", + "type": "default", + "enabled": true + }, + { + "key": "invitation_token", + "value": "", + "type": "secret", + "enabled": true, + "description": "Invitation token from email. Used for testing accept invitation endpoints." } ], "_postman_variable_scope": "environment" diff --git a/backend/app/Console/Commands/SeedDatabaseCommand.php b/backend/app/Console/Commands/SeedDatabaseCommand.php new file mode 100644 index 0000000..391d09b --- /dev/null +++ b/backend/app/Console/Commands/SeedDatabaseCommand.php @@ -0,0 +1,64 @@ +option('demo') ? 'demo' : 'minimal'; + + $this->components->info("Running database fresh with seeding in {$mode} mode..."); + + // Set the environment variable for seeder mode + putenv("SEED_MODE={$mode}"); + $_ENV['SEED_MODE'] = $mode; + + // Build migrate:fresh command options + $options = ['--seed' => true]; + + if ($this->option('force')) { + $options['--force'] = true; + } + + // Run migrate:fresh with seed + $exitCode = Artisan::call('migrate:fresh', $options, $this->output); + + if ($exitCode === 0) { + $this->newLine(); + $this->components->info('Database seeded successfully!'); + + if ($mode === 'minimal') { + $this->components->info('Minimal mode: Only system essentials were seeded.'); + $this->components->info('To include demo data, use: php artisan db:seed-fresh --demo'); + } else { + $this->components->info('Demo mode: All sample data has been seeded.'); + } + } + + return $exitCode; + } +} diff --git a/backend/app/Enums/BomStatus.php b/backend/app/Enums/BomStatus.php new file mode 100644 index 0000000..94d4a03 --- /dev/null +++ b/backend/app/Enums/BomStatus.php @@ -0,0 +1,82 @@ + 'Draft', + self::ACTIVE => 'Active', + self::OBSOLETE => 'Obsolete', + }; + } + + /** + * Get allowed status transitions + */ + public function allowedTransitions(): array + { + return match ($this) { + self::DRAFT => [self::ACTIVE], + self::ACTIVE => [self::OBSOLETE, self::DRAFT], + self::OBSOLETE => [self::DRAFT], // Can reactivate by going back to draft + }; + } + + /** + * Check if transition to target status is allowed + */ + public function canTransitionTo(self $target): bool + { + return in_array($target, $this->allowedTransitions()); + } + + /** + * Check if BOM can be edited + */ + public function canEdit(): bool + { + return $this === self::DRAFT; + } + + /** + * Check if BOM can be used for production + */ + public function canUseForProduction(): bool + { + return $this === self::ACTIVE; + } + + /** + * Get all values as array + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** + * Get all as options for dropdown + */ + public static function options(): array + { + return array_map( + fn(self $case) => ['value' => $case->value, 'label' => $case->label()], + self::cases() + ); + } +} diff --git a/backend/app/Enums/BomType.php b/backend/app/Enums/BomType.php new file mode 100644 index 0000000..5d7b224 --- /dev/null +++ b/backend/app/Enums/BomType.php @@ -0,0 +1,66 @@ + 'Manufacturing', + self::ENGINEERING => 'Engineering', + self::PHANTOM => 'Phantom', + }; + } + + /** + * Get description + */ + public function description(): string + { + return match ($this) { + self::MANUFACTURING => 'Standard production BOM used for manufacturing', + self::ENGINEERING => 'Engineering BOM for design/development purposes', + self::PHANTOM => 'Phantom BOM - components pass through to parent', + }; + } + + /** + * Check if BOM is used for production + */ + public function isProduction(): bool + { + return $this === self::MANUFACTURING; + } + + /** + * Get all values as array + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** + * Get all as options for dropdown + */ + public static function options(): array + { + return array_map( + fn(self $case) => ['value' => $case->value, 'label' => $case->label()], + self::cases() + ); + } +} diff --git a/backend/app/Enums/CalendarDayType.php b/backend/app/Enums/CalendarDayType.php new file mode 100644 index 0000000..bbf9d3a --- /dev/null +++ b/backend/app/Enums/CalendarDayType.php @@ -0,0 +1,52 @@ + 'Working Day', + self::HOLIDAY => 'Holiday', + self::MAINTENANCE => 'Maintenance', + self::SHUTDOWN => 'Shutdown', + }; + } + + public function color(): string + { + return match ($this) { + self::WORKING => 'green', + self::HOLIDAY => 'blue', + self::MAINTENANCE => 'orange', + self::SHUTDOWN => 'red', + }; + } + + public function isAvailable(): bool + { + return $this === self::WORKING; + } + + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + public static function options(): array + { + return array_map( + fn(self $case) => ['value' => $case->value, 'label' => $case->label()], + self::cases() + ); + } +} diff --git a/backend/app/Enums/DefectType.php b/backend/app/Enums/DefectType.php new file mode 100644 index 0000000..35230c8 --- /dev/null +++ b/backend/app/Enums/DefectType.php @@ -0,0 +1,103 @@ + 'Dimensional', + self::VISUAL => 'Visual', + self::FUNCTIONAL => 'Functional', + self::DOCUMENTATION => 'Documentation', + self::PACKAGING => 'Packaging', + self::CONTAMINATION => 'Contamination', + self::WRONG_ITEM => 'Wrong Item', + self::QUANTITY_SHORT => 'Quantity Short', + self::QUANTITY_OVER => 'Quantity Over', + self::DAMAGE => 'Damage', + self::OTHER => 'Other', + }; + } +} diff --git a/backend/app/Enums/DeliveryNoteStatus.php b/backend/app/Enums/DeliveryNoteStatus.php new file mode 100644 index 0000000..75f5123 --- /dev/null +++ b/backend/app/Enums/DeliveryNoteStatus.php @@ -0,0 +1,105 @@ + [self::CONFIRMED, self::SHIPPED, self::CANCELLED], + self::CONFIRMED => [self::SHIPPED, self::CANCELLED], + self::SHIPPED => [self::DELIVERED, self::CANCELLED], + self::DELIVERED => [], + self::CANCELLED => [], + }; + } + + /** + * Check if transition to target status is allowed + */ + public function canTransitionTo(self $target): bool + { + return in_array($target, $this->allowedTransitions(), true); + } + + /** + * Check if can be edited + */ + public function canEdit(): bool + { + return $this === self::DRAFT || $this === self::CONFIRMED; + } + + /** + * Check if can be cancelled + */ + public function canCancel(): bool + { + return match ($this) { + self::DRAFT, self::SHIPPED => true, + default => false, + }; + } + + /** + * Check if is final state + */ + public function isFinal(): bool + { + return match ($this) { + self::DELIVERED, self::CANCELLED => true, + default => false, + }; + } + + /** + * Get all values as array + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** + * Get label + */ + public function label(): string + { + return match ($this) { + self::DRAFT => 'Draft', + self::CONFIRMED => 'Confirmed', + self::SHIPPED => 'Shipped', + self::DELIVERED => 'Delivered', + self::CANCELLED => 'Cancelled', + }; + } + + /** + * Get color for UI display + */ + public function color(): string + { + return match ($this) { + self::DRAFT => 'gray', + self::CONFIRMED => 'blue', + self::SHIPPED => 'cyan', + self::DELIVERED => 'green', + self::CANCELLED => 'red', + }; + } +} diff --git a/backend/app/Enums/GrnStatus.php b/backend/app/Enums/GrnStatus.php new file mode 100644 index 0000000..4abe92a --- /dev/null +++ b/backend/app/Enums/GrnStatus.php @@ -0,0 +1,121 @@ + [self::PENDING_INSPECTION, self::COMPLETED, self::CANCELLED], + self::PENDING_INSPECTION => [self::INSPECTED, self::CANCELLED], + self::INSPECTED => [self::COMPLETED, self::CANCELLED], + self::COMPLETED => [], + self::CANCELLED => [], + }; + } + + /** + * Check if GRN can be edited + */ + public function canEdit(): bool + { + return $this === self::DRAFT; + } + + /** + * Check if GRN can be cancelled + */ + public function canCancel(): bool + { + return match ($this) { + self::DRAFT, self::PENDING_INSPECTION, self::INSPECTED => true, + default => false, + }; + } + + /** + * Check if GRN can be deleted + */ + public function canDelete(): bool + { + return match ($this) { + self::DRAFT, self::CANCELLED => true, + default => false, + }; + } + + /** + * Check if GRN requires inspection + */ + public function requiresInspection(): bool + { + return $this === self::PENDING_INSPECTION; + } + + /** + * Check if GRN can add stock + */ + public function canAddStock(): bool + { + return match ($this) { + self::INSPECTED, self::COMPLETED => true, + default => false, + }; + } + + /** + * Check if GRN is in final state + */ + public function isFinal(): bool + { + return match ($this) { + self::COMPLETED, self::CANCELLED => true, + default => false, + }; + } + + /** + * Check if GRN is active + */ + public function isActive(): bool + { + return !$this->isFinal(); + } + + /** + * Get all values as array + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** + * Get fallback label + */ + public function fallbackLabel(): string + { + return match ($this) { + self::DRAFT => 'Draft', + self::PENDING_INSPECTION => 'Pending Inspection', + self::INSPECTED => 'Inspected', + self::COMPLETED => 'Completed', + self::CANCELLED => 'Cancelled', + }; + } +} diff --git a/backend/app/Enums/InspectionDisposition.php b/backend/app/Enums/InspectionDisposition.php new file mode 100644 index 0000000..3f01300 --- /dev/null +++ b/backend/app/Enums/InspectionDisposition.php @@ -0,0 +1,83 @@ + true, + default => false, + }; + } + + /** + * Check if requires manager approval + */ + public function requiresApproval(): bool + { + return match ($this) { + self::USE_AS_IS, self::REWORK => true, + default => false, + }; + } + + /** + * Check if triggers supplier notification + */ + public function notifySupplier(): bool + { + return match ($this) { + self::REJECT, self::RETURN_TO_SUPPLIER => true, + default => false, + }; + } + + /** + * Check if disposition is final + */ + public function isFinal(): bool + { + return $this !== self::PENDING; + } + + /** + * Get all values as array + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** + * Get default fallback label + */ + public function fallbackLabel(): string + { + return match ($this) { + self::PENDING => 'Pending Decision', + self::ACCEPT => 'Accept', + self::REJECT => 'Reject', + self::REWORK => 'Rework', + self::RETURN_TO_SUPPLIER => 'Return to Supplier', + self::USE_AS_IS => 'Use As Is', + }; + } +} diff --git a/backend/app/Enums/InspectionResult.php b/backend/app/Enums/InspectionResult.php new file mode 100644 index 0000000..b9ebab0 --- /dev/null +++ b/backend/app/Enums/InspectionResult.php @@ -0,0 +1,78 @@ + true, + default => false, + }; + } + + /** + * Check if inspection is complete + */ + public function isComplete(): bool + { + return $this !== self::PENDING; + } + + /** + * Check if stock can be released + */ + public function canReleaseStock(): bool + { + return $this === self::PASSED; + } + + /** + * Check if requires quarantine + */ + public function requiresQuarantine(): bool + { + return match ($this) { + self::FAILED, self::ON_HOLD => true, + default => false, + }; + } + + /** + * Get all values as array + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** + * Get default fallback label (used if DB lookup fails) + */ + public function fallbackLabel(): string + { + return match ($this) { + self::PENDING => 'Pending', + self::PASSED => 'Passed', + self::FAILED => 'Failed', + self::PARTIAL => 'Partial', + self::ON_HOLD => 'On Hold', + }; + } +} diff --git a/backend/app/Enums/MrpPriority.php b/backend/app/Enums/MrpPriority.php new file mode 100644 index 0000000..3d96691 --- /dev/null +++ b/backend/app/Enums/MrpPriority.php @@ -0,0 +1,57 @@ + 'Low', + self::MEDIUM => 'Medium', + self::HIGH => 'High', + self::CRITICAL => 'Critical', + }; + } + + public function color(): string + { + return match ($this) { + self::LOW => 'gray', + self::MEDIUM => 'blue', + self::HIGH => 'orange', + self::CRITICAL => 'red', + }; + } + + public function sortOrder(): int + { + return match ($this) { + self::CRITICAL => 1, + self::HIGH => 2, + self::MEDIUM => 3, + self::LOW => 4, + }; + } + + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + public static function options(): array + { + return array_map( + fn(self $case) => ['value' => $case->value, 'label' => $case->label()], + self::cases() + ); + } +} diff --git a/backend/app/Enums/MrpRecommendationStatus.php b/backend/app/Enums/MrpRecommendationStatus.php new file mode 100644 index 0000000..28b3712 --- /dev/null +++ b/backend/app/Enums/MrpRecommendationStatus.php @@ -0,0 +1,70 @@ + 'Pending Review', + self::APPROVED => 'Approved', + self::REJECTED => 'Rejected', + self::ACTIONED => 'Actioned', + self::EXPIRED => 'Expired', + }; + } + + public function color(): string + { + return match ($this) { + self::PENDING => 'yellow', + self::APPROVED => 'blue', + self::REJECTED => 'red', + self::ACTIONED => 'green', + self::EXPIRED => 'gray', + }; + } + + public function canAction(): bool + { + return in_array($this, [self::PENDING, self::APPROVED]); + } + + public function canApprove(): bool + { + return $this === self::PENDING; + } + + public function canReject(): bool + { + return $this === self::PENDING; + } + + public function isFinal(): bool + { + return in_array($this, [self::ACTIONED, self::REJECTED, self::EXPIRED]); + } + + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + public static function options(): array + { + return array_map( + fn(self $case) => ['value' => $case->value, 'label' => $case->label()], + self::cases() + ); + } +} diff --git a/backend/app/Enums/MrpRecommendationType.php b/backend/app/Enums/MrpRecommendationType.php new file mode 100644 index 0000000..5f0e0aa --- /dev/null +++ b/backend/app/Enums/MrpRecommendationType.php @@ -0,0 +1,86 @@ + 'Purchase Order', + self::WORK_ORDER => 'Work Order', + self::TRANSFER => 'Transfer', + self::RESCHEDULE_IN => 'Reschedule In', + self::RESCHEDULE_OUT => 'Reschedule Out', + self::CANCEL => 'Cancel', + self::EXPEDITE => 'Expedite', + }; + } + + public function color(): string + { + return match ($this) { + self::PURCHASE_ORDER => 'blue', + self::WORK_ORDER => 'green', + self::TRANSFER => 'purple', + self::RESCHEDULE_IN => 'orange', + self::RESCHEDULE_OUT => 'yellow', + self::CANCEL => 'red', + self::EXPEDITE => 'pink', + }; + } + + public function icon(): string + { + return match ($this) { + self::PURCHASE_ORDER => 'shopping-cart', + self::WORK_ORDER => 'cog', + self::TRANSFER => 'arrows-alt', + self::RESCHEDULE_IN => 'arrow-left', + self::RESCHEDULE_OUT => 'arrow-right', + self::CANCEL => 'times-circle', + self::EXPEDITE => 'bolt', + }; + } + + public function description(): string + { + return match ($this) { + self::PURCHASE_ORDER => 'Create a new purchase order from supplier', + self::WORK_ORDER => 'Create a new work order for manufacturing', + self::TRANSFER => 'Transfer stock between warehouses', + self::RESCHEDULE_IN => 'Move existing order date earlier', + self::RESCHEDULE_OUT => 'Move existing order date later', + self::CANCEL => 'Cancel existing order (no longer needed)', + self::EXPEDITE => 'Expedite existing order (urgent)', + }; + } + + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + public static function options(): array + { + return array_map( + fn(self $case) => [ + 'value' => $case->value, + 'label' => $case->label(), + 'description' => $case->description(), + ], + self::cases() + ); + } +} diff --git a/backend/app/Enums/MrpRunStatus.php b/backend/app/Enums/MrpRunStatus.php new file mode 100644 index 0000000..b5db6d6 --- /dev/null +++ b/backend/app/Enums/MrpRunStatus.php @@ -0,0 +1,60 @@ + 'Pending', + self::RUNNING => 'Running', + self::COMPLETED => 'Completed', + self::FAILED => 'Failed', + self::CANCELLED => 'Cancelled', + }; + } + + public function color(): string + { + return match ($this) { + self::PENDING => 'gray', + self::RUNNING => 'blue', + self::COMPLETED => 'green', + self::FAILED => 'red', + self::CANCELLED => 'orange', + }; + } + + public function canCancel(): bool + { + return in_array($this, [self::PENDING, self::RUNNING]); + } + + public function isFinal(): bool + { + return in_array($this, [self::COMPLETED, self::FAILED, self::CANCELLED]); + } + + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + public static function options(): array + { + return array_map( + fn(self $case) => ['value' => $case->value, 'label' => $case->label()], + self::cases() + ); + } +} diff --git a/backend/app/Enums/NcrDisposition.php b/backend/app/Enums/NcrDisposition.php new file mode 100644 index 0000000..fc2e3cb --- /dev/null +++ b/backend/app/Enums/NcrDisposition.php @@ -0,0 +1,112 @@ + 'Pending Decision', + self::USE_AS_IS => 'Use As Is', + self::REWORK => 'Rework', + self::SCRAP => 'Scrap', + self::RETURN_TO_SUPPLIER => 'Return to Supplier', + self::SORT_AND_USE => 'Sort and Use', + self::REJECT => 'Reject', + }; + } +} diff --git a/backend/app/Enums/NcrSeverity.php b/backend/app/Enums/NcrSeverity.php new file mode 100644 index 0000000..7530b26 --- /dev/null +++ b/backend/app/Enums/NcrSeverity.php @@ -0,0 +1,84 @@ + 4, + self::MAJOR => 24, + self::MINOR => 72, + }; + } + + /** + * Get priority color for UI + */ + public function color(): string + { + return match ($this) { + self::CRITICAL => 'red', + self::MAJOR => 'orange', + self::MINOR => 'yellow', + }; + } + + /** + * Get all values as array + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** + * Get default fallback label + */ + public function fallbackLabel(): string + { + return match ($this) { + self::MINOR => 'Minor', + self::MAJOR => 'Major', + self::CRITICAL => 'Critical', + }; + } +} diff --git a/backend/app/Enums/NcrStatus.php b/backend/app/Enums/NcrStatus.php new file mode 100644 index 0000000..e2dc20c --- /dev/null +++ b/backend/app/Enums/NcrStatus.php @@ -0,0 +1,102 @@ + [self::UNDER_REVIEW, self::CANCELLED], + self::UNDER_REVIEW => [self::PENDING_DISPOSITION, self::CANCELLED], + self::PENDING_DISPOSITION => [self::DISPOSITION_APPROVED, self::CANCELLED], + self::DISPOSITION_APPROVED => [self::IN_PROGRESS, self::CLOSED], + self::IN_PROGRESS => [self::CLOSED], + self::CLOSED, self::CANCELLED => [], + }; + } + + /** + * Get all values as array + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** + * Get default fallback label + */ + public function fallbackLabel(): string + { + return match ($this) { + self::OPEN => 'Open', + self::UNDER_REVIEW => 'Under Review', + self::PENDING_DISPOSITION => 'Pending Disposition', + self::DISPOSITION_APPROVED => 'Disposition Approved', + self::IN_PROGRESS => 'In Progress', + self::CLOSED => 'Closed', + self::CANCELLED => 'Cancelled', + }; + } +} diff --git a/backend/app/Enums/OperationStatus.php b/backend/app/Enums/OperationStatus.php new file mode 100644 index 0000000..9ddc007 --- /dev/null +++ b/backend/app/Enums/OperationStatus.php @@ -0,0 +1,93 @@ + 'Pending', + self::IN_PROGRESS => 'In Progress', + self::COMPLETED => 'Completed', + self::SKIPPED => 'Skipped', + }; + } + + /** + * Get allowed status transitions + */ + public function allowedTransitions(): array + { + return match ($this) { + self::PENDING => [self::IN_PROGRESS, self::SKIPPED], + self::IN_PROGRESS => [self::COMPLETED, self::SKIPPED], + self::COMPLETED => [], // Final state + self::SKIPPED => [], // Final state + }; + } + + /** + * Check if transition to target status is allowed + */ + public function canTransitionTo(self $target): bool + { + return in_array($target, $this->allowedTransitions()); + } + + /** + * Check if operation can be started + */ + public function canStart(): bool + { + return $this === self::PENDING; + } + + /** + * Check if operation can be completed + */ + public function canComplete(): bool + { + return $this === self::IN_PROGRESS; + } + + /** + * Check if operation is in final state + */ + public function isFinal(): bool + { + return in_array($this, [self::COMPLETED, self::SKIPPED]); + } + + /** + * Get all values as array + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** + * Get all as options for dropdown + */ + public static function options(): array + { + return array_map( + fn(self $case) => ['value' => $case->value, 'label' => $case->label()], + self::cases() + ); + } +} diff --git a/backend/app/Enums/PoStatus.php b/backend/app/Enums/PoStatus.php new file mode 100644 index 0000000..7a2ce23 --- /dev/null +++ b/backend/app/Enums/PoStatus.php @@ -0,0 +1,122 @@ + [self::PENDING_APPROVAL, self::CANCELLED], + self::PENDING_APPROVAL => [self::APPROVED, self::DRAFT, self::CANCELLED], + self::APPROVED => [self::SENT, self::CANCELLED], + self::SENT => [self::PARTIALLY_RECEIVED, self::RECEIVED, self::CANCELLED], + self::PARTIALLY_RECEIVED => [self::RECEIVED, self::CANCELLED], + self::RECEIVED => [self::CLOSED], + self::CANCELLED => [], + self::CLOSED => [], + }; + } + + /** + * Check if PO can be edited + */ + public function canEdit(): bool + { + return match ($this) { + self::DRAFT, self::PENDING_APPROVAL => true, + default => false, + }; + } + + /** + * Check if PO can be cancelled + */ + public function canCancel(): bool + { + return match ($this) { + self::DRAFT, self::PENDING_APPROVAL, self::APPROVED, self::SENT, self::PARTIALLY_RECEIVED => true, + default => false, + }; + } + + /** + * Check if PO can receive goods + */ + public function canReceive(): bool + { + return match ($this) { + self::SENT, self::PARTIALLY_RECEIVED => true, + default => false, + }; + } + + /** + * Check if PO requires approval + */ + public function requiresApproval(): bool + { + return $this === self::PENDING_APPROVAL; + } + + /** + * Check if PO is in final state + */ + public function isFinal(): bool + { + return match ($this) { + self::CANCELLED, self::CLOSED => true, + default => false, + }; + } + + /** + * Check if PO is active (not cancelled/closed) + */ + public function isActive(): bool + { + return !$this->isFinal(); + } + + /** + * Get all values as array + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** + * Get fallback label + */ + public function fallbackLabel(): string + { + return match ($this) { + self::DRAFT => 'Draft', + self::PENDING_APPROVAL => 'Pending Approval', + self::APPROVED => 'Approved', + self::SENT => 'Sent to Supplier', + self::PARTIALLY_RECEIVED => 'Partially Received', + self::RECEIVED => 'Received', + self::CANCELLED => 'Cancelled', + self::CLOSED => 'Closed', + }; + } +} diff --git a/backend/app/Enums/ReservationPolicy.php b/backend/app/Enums/ReservationPolicy.php new file mode 100644 index 0000000..f8dca55 --- /dev/null +++ b/backend/app/Enums/ReservationPolicy.php @@ -0,0 +1,88 @@ + 'Full Reservation Only', + self::PARTIAL => 'Allow Partial Reservation', + self::REJECT => 'Reject if Insufficient', + self::WAIT => 'Wait for Full Stock', + }; + } + + /** + * Get description + */ + public function description(): string + { + return match ($this) { + self::FULL => 'Only reserve if full quantity is available. Reject if insufficient.', + self::PARTIAL => 'Reserve available quantity even if less than requested.', + self::REJECT => 'Reject the reservation request if insufficient stock.', + self::WAIT => 'Wait and retry when full stock becomes available. ⚠️ NOT YET IMPLEMENTED - Currently throws error. Future: Will queue and auto-retry when stock arrives.', + }; + } + + /** + * Check if partial reservation is allowed + */ + public function allowsPartial(): bool + { + return $this === self::PARTIAL; + } + + /** + * Check if should reject on insufficient stock + */ + public function shouldReject(): bool + { + return in_array($this, [self::FULL, self::REJECT]); + } + + /** + * Get all values as array + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** + * Get all as options for dropdown + */ + public static function options(): array + { + return array_map( + fn(self $case) => [ + 'value' => $case->value, + 'label' => $case->label(), + 'description' => $case->description(), + ], + self::cases() + ); + } +} diff --git a/backend/app/Enums/RoutingStatus.php b/backend/app/Enums/RoutingStatus.php new file mode 100644 index 0000000..d45b391 --- /dev/null +++ b/backend/app/Enums/RoutingStatus.php @@ -0,0 +1,82 @@ + 'Draft', + self::ACTIVE => 'Active', + self::OBSOLETE => 'Obsolete', + }; + } + + /** + * Get allowed status transitions + */ + public function allowedTransitions(): array + { + return match ($this) { + self::DRAFT => [self::ACTIVE], + self::ACTIVE => [self::OBSOLETE, self::DRAFT], + self::OBSOLETE => [self::DRAFT], + }; + } + + /** + * Check if transition to target status is allowed + */ + public function canTransitionTo(self $target): bool + { + return in_array($target, $this->allowedTransitions()); + } + + /** + * Check if routing can be edited + */ + public function canEdit(): bool + { + return $this === self::DRAFT; + } + + /** + * Check if routing can be used for work orders + */ + public function canUseForProduction(): bool + { + return $this === self::ACTIVE; + } + + /** + * Get all values as array + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** + * Get all as options for dropdown + */ + public static function options(): array + { + return array_map( + fn(self $case) => ['value' => $case->value, 'label' => $case->label()], + self::cases() + ); + } +} diff --git a/backend/app/Enums/SalesOrderStatus.php b/backend/app/Enums/SalesOrderStatus.php new file mode 100644 index 0000000..26d211a --- /dev/null +++ b/backend/app/Enums/SalesOrderStatus.php @@ -0,0 +1,155 @@ + [self::PENDING_APPROVAL, self::CANCELLED], + self::PENDING_APPROVAL => [self::APPROVED, self::REJECTED, self::DRAFT, self::CANCELLED], + self::APPROVED => [self::CONFIRMED, self::CANCELLED], + self::REJECTED => [self::DRAFT], // Can be revised and resubmitted + self::CONFIRMED => [self::PROCESSING, self::CANCELLED], + self::PROCESSING => [self::PARTIALLY_SHIPPED, self::SHIPPED, self::CANCELLED], + self::PARTIALLY_SHIPPED => [self::SHIPPED, self::CANCELLED], + self::SHIPPED => [self::DELIVERED], + self::DELIVERED => [], + self::CANCELLED => [], + }; + } + + /** + * Check if transition to target status is allowed + */ + public function canTransitionTo(self $target): bool + { + return in_array($target, $this->allowedTransitions(), true); + } + + /** + * Check if SO can be edited + */ + public function canEdit(): bool + { + return match ($this) { + self::DRAFT, self::PENDING_APPROVAL, self::REJECTED => true, + default => false, + }; + } + + /** + * Check if SO can be cancelled + */ + public function canCancel(): bool + { + return match ($this) { + self::DRAFT, self::PENDING_APPROVAL, self::APPROVED, self::CONFIRMED, self::PROCESSING, self::PARTIALLY_SHIPPED => true, + default => false, + }; + } + + /** + * Check if SO can be shipped + */ + public function canShip(): bool + { + return match ($this) { + self::CONFIRMED, self::PROCESSING, self::PARTIALLY_SHIPPED => true, + default => false, + }; + } + + /** + * Check if SO requires approval + */ + public function requiresApproval(): bool + { + return $this === self::PENDING_APPROVAL; + } + + /** + * Check if SO is in final state + */ + public function isFinal(): bool + { + return match ($this) { + self::CANCELLED, self::DELIVERED => true, + default => false, + }; + } + + /** + * Check if SO is active + */ + public function isActive(): bool + { + return !$this->isFinal(); + } + + /** + * Get all values as array + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** + * Get label + */ + public function label(): string + { + return match ($this) { + self::DRAFT => 'Draft', + self::PENDING_APPROVAL => 'Pending Approval', + self::APPROVED => 'Approved', + self::REJECTED => 'Rejected', + self::CONFIRMED => 'Confirmed', + self::PROCESSING => 'Processing', + self::PARTIALLY_SHIPPED => 'Partially Shipped', + self::SHIPPED => 'Shipped', + self::DELIVERED => 'Delivered', + self::CANCELLED => 'Cancelled', + }; + } + + /** + * Get color for UI display + */ + public function color(): string + { + return match ($this) { + self::DRAFT => 'gray', + self::PENDING_APPROVAL => 'yellow', + self::APPROVED => 'blue', + self::REJECTED => 'red', + self::CONFIRMED => 'indigo', + self::PROCESSING => 'purple', + self::PARTIALLY_SHIPPED => 'orange', + self::SHIPPED => 'cyan', + self::DELIVERED => 'green', + self::CANCELLED => 'red', + }; + } +} diff --git a/backend/app/Enums/UomType.php b/backend/app/Enums/UomType.php new file mode 100644 index 0000000..19ddb3b --- /dev/null +++ b/backend/app/Enums/UomType.php @@ -0,0 +1,206 @@ + 'Weight', + self::VOLUME => 'Volume', + self::LENGTH => 'Length', + self::AREA => 'Area', + self::QUANTITY => 'Quantity', + self::TIME => 'Time', + self::POWER => 'Power', + self::SPEED => 'Speed', + self::FLOW => 'Flow Rate', + self::PRESSURE => 'Pressure', + self::FORCE => 'Force/Torque', + self::TEMPERATURE => 'Temperature', + self::ENERGY => 'Energy', + self::ELECTRICITY => 'Electrical', + self::DENSITY => 'Density', + self::CONCENTRATION => 'Concentration', + }; + } + + /** + * Get icon for UI display (Heroicons names) + */ + public function icon(): string + { + return match ($this) { + self::WEIGHT => 'scale', + self::VOLUME => 'beaker', + self::LENGTH => 'ruler', + self::AREA => 'square', + self::QUANTITY => 'hashtag', + self::TIME => 'clock', + self::POWER => 'bolt', + self::SPEED => 'gauge', + self::FLOW => 'droplet', + self::PRESSURE => 'gauge', + self::FORCE => 'wrench', + self::TEMPERATURE => 'thermometer', + self::ENERGY => 'battery', + self::ELECTRICITY => 'bolt', + self::DENSITY => 'cube', + self::CONCENTRATION => 'flask', + }; + } + + /** + * Get example units for this type + */ + public function exampleUnits(): array + { + return match ($this) { + self::WEIGHT => ['kg', 'g', 'lb', 'oz', 't', 'mg'], + self::VOLUME => ['L', 'mL', 'gal', 'm³', 'cm³'], + self::LENGTH => ['m', 'cm', 'mm', 'ft', 'in', 'km'], + self::AREA => ['m²', 'ft²', 'ha', 'cm²', 'acre'], + self::QUANTITY => ['pcs', 'box', 'set', 'pair', 'dozen', 'pack'], + self::TIME => ['hr', 'min', 'sec', 'day', 'week', 'month'], + self::POWER => ['hp', 'kW', 'W', 'MW', 'BTU/h'], + self::SPEED => ['rpm', 'km/h', 'm/s', 'ft/min', 'mph'], + self::FLOW => ['L/min', 'L/h', 'm³/h', 'gal/min', 'cfm'], + self::PRESSURE => ['bar', 'psi', 'kPa', 'MPa', 'atm', 'mmHg'], + self::FORCE => ['N', 'kN', 'Nm', 'lbf', 'kgf', 'ft-lb'], + self::TEMPERATURE => ['°C', '°F', 'K'], + self::ENERGY => ['kWh', 'J', 'kJ', 'MJ', 'cal', 'BTU'], + self::ELECTRICITY => ['A', 'V', 'Ω', 'W', 'Ah', 'mA'], + self::DENSITY => ['kg/m³', 'g/cm³', 'lb/ft³', 'kg/L'], + self::CONCENTRATION => ['%', 'ppm', 'g/L', 'mol/L', 'mg/kg'], + }; + } + + /** + * Get sectors that commonly use this type + */ + public function commonSectors(): array + { + return match ($this) { + self::WEIGHT, self::VOLUME, self::LENGTH, self::AREA, self::QUANTITY, self::TIME + => ['All sectors'], + self::POWER, self::SPEED, self::FORCE + => ['Machinery', 'Automotive', 'Manufacturing'], + self::FLOW, self::PRESSURE + => ['Hydraulics', 'Chemical', 'Food & Beverage', 'Pharmaceutical'], + self::TEMPERATURE + => ['Food & Beverage', 'Chemical', 'Pharmaceutical', 'HVAC'], + self::ENERGY, self::ELECTRICITY + => ['Energy', 'Electronics', 'Utilities'], + self::DENSITY, self::CONCENTRATION + => ['Chemical', 'Pharmaceutical', 'Food & Beverage'], + }; + } + + /** + * Check if this type can be converted to another + */ + public function canConvertTo(self $target): bool + { + // Can only convert within same type + return $this === $target; + } + + /** + * Get all values as array + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** + * Get options for select dropdowns + */ + public static function options(): array + { + return array_map( + fn(self $type) => [ + 'value' => $type->value, + 'label' => $type->label(), + 'icon' => $type->icon(), + ], + self::cases() + ); + } + + /** + * Get grouped options by category + */ + public static function groupedOptions(): array + { + return [ + 'Basic' => [ + self::WEIGHT, + self::VOLUME, + self::LENGTH, + self::AREA, + self::QUANTITY, + self::TIME, + ], + 'Mechanical' => [ + self::POWER, + self::SPEED, + self::FLOW, + self::PRESSURE, + self::FORCE, + ], + 'Thermal & Energy' => [ + self::TEMPERATURE, + self::ENERGY, + ], + 'Electrical' => [ + self::ELECTRICITY, + ], + 'Material Properties' => [ + self::DENSITY, + self::CONCENTRATION, + ], + ]; + } +} diff --git a/backend/app/Enums/WorkCenterType.php b/backend/app/Enums/WorkCenterType.php new file mode 100644 index 0000000..acfdfc0 --- /dev/null +++ b/backend/app/Enums/WorkCenterType.php @@ -0,0 +1,61 @@ + 'Machine', + self::LABOR => 'Labor', + self::SUBCONTRACT => 'Subcontract', + self::TOOL => 'Tool', + }; + } + + /** + * Get description + */ + public function description(): string + { + return match ($this) { + self::MACHINE => 'Machine-based work center (CNC, lathe, etc.)', + self::LABOR => 'Labor-intensive work center (assembly, inspection)', + self::SUBCONTRACT => 'Outsourced operations to third party', + self::TOOL => 'Tool or equipment based operations', + }; + } + + /** + * Get all values as array + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** + * Get all as options for dropdown + */ + public static function options(): array + { + return array_map( + fn(self $case) => ['value' => $case->value, 'label' => $case->label()], + self::cases() + ); + } +} diff --git a/backend/app/Enums/WorkOrderPriority.php b/backend/app/Enums/WorkOrderPriority.php new file mode 100644 index 0000000..6830c48 --- /dev/null +++ b/backend/app/Enums/WorkOrderPriority.php @@ -0,0 +1,74 @@ + 'Low', + self::NORMAL => 'Normal', + self::HIGH => 'High', + self::URGENT => 'Urgent', + }; + } + + /** + * Get numeric value for sorting + */ + public function sortOrder(): int + { + return match ($this) { + self::LOW => 1, + self::NORMAL => 2, + self::HIGH => 3, + self::URGENT => 4, + }; + } + + /** + * Get color for UI + */ + public function color(): string + { + return match ($this) { + self::LOW => 'gray', + self::NORMAL => 'blue', + self::HIGH => 'orange', + self::URGENT => 'red', + }; + } + + /** + * Get all values as array + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** + * Get all as options for dropdown + */ + public static function options(): array + { + return array_map( + fn(self $case) => ['value' => $case->value, 'label' => $case->label()], + self::cases() + ); + } +} diff --git a/backend/app/Enums/WorkOrderStatus.php b/backend/app/Enums/WorkOrderStatus.php new file mode 100644 index 0000000..d1edaac --- /dev/null +++ b/backend/app/Enums/WorkOrderStatus.php @@ -0,0 +1,155 @@ + 'Draft', + self::RELEASED => 'Released', + self::IN_PROGRESS => 'In Progress', + self::COMPLETED => 'Completed', + self::CANCELLED => 'Cancelled', + self::ON_HOLD => 'On Hold', + }; + } + + /** + * Get allowed status transitions + */ + public function allowedTransitions(): array + { + return match ($this) { + self::DRAFT => [self::RELEASED, self::CANCELLED], + self::RELEASED => [self::IN_PROGRESS, self::ON_HOLD, self::CANCELLED], + self::IN_PROGRESS => [self::COMPLETED, self::ON_HOLD, self::CANCELLED], + self::ON_HOLD => [self::RELEASED, self::IN_PROGRESS, self::CANCELLED], + self::COMPLETED => [], // Final state + self::CANCELLED => [], // Final state + }; + } + + /** + * Check if transition to target status is allowed + */ + public function canTransitionTo(self $target): bool + { + return in_array($target, $this->allowedTransitions()); + } + + /** + * Check if work order can be edited + */ + public function canEdit(): bool + { + return $this === self::DRAFT; + } + + /** + * Check if work order can be released + */ + public function canRelease(): bool + { + return $this === self::DRAFT; + } + + /** + * Check if work order can be started + */ + public function canStart(): bool + { + return in_array($this, [self::RELEASED, self::ON_HOLD]); + } + + /** + * Check if work order can be completed + */ + public function canComplete(): bool + { + return $this === self::IN_PROGRESS; + } + + /** + * Check if work order can be cancelled + */ + public function canCancel(): bool + { + return !$this->isFinal(); + } + + /** + * Check if work order can be put on hold + */ + public function canHold(): bool + { + return in_array($this, [self::RELEASED, self::IN_PROGRESS]); + } + + /** + * Check if work order can issue materials + */ + public function canIssueMaterials(): bool + { + return in_array($this, [self::RELEASED, self::IN_PROGRESS]); + } + + /** + * Check if work order can receive finished goods + */ + public function canReceiveFinishedGoods(): bool + { + return $this === self::IN_PROGRESS; + } + + /** + * Check if work order is in final state + */ + public function isFinal(): bool + { + return in_array($this, [self::COMPLETED, self::CANCELLED]); + } + + /** + * Check if work order is active + */ + public function isActive(): bool + { + return in_array($this, [self::RELEASED, self::IN_PROGRESS]); + } + + /** + * Get all values as array + */ + public static function values(): array + { + return array_column(self::cases(), 'value'); + } + + /** + * Get all as options for dropdown + */ + public static function options(): array + { + return array_map( + fn(self $case) => ['value' => $case->value, 'label' => $case->label()], + self::cases() + ); + } +} diff --git a/backend/app/Http/Controllers/AttributeController.php b/backend/app/Http/Controllers/AttributeController.php index 285483d..63c8b00 100644 --- a/backend/app/Http/Controllers/AttributeController.php +++ b/backend/app/Http/Controllers/AttributeController.php @@ -80,8 +80,14 @@ public function show(Attribute $attribute): JsonResponse */ public function update(Request $request, Attribute $attribute): JsonResponse { + $companyId = $request->user()->company_id; + $validated = $request->validate([ - 'name' => ['string', 'max:255', Rule::unique('attributes')->ignore($attribute->id)], + 'name' => [ + 'string', + 'max:255', + Rule::unique('attributes')->where('company_id', $companyId)->ignore($attribute->id), + ], 'display_name' => 'string|max:255', 'type' => 'in:select,text,number,boolean', 'order' => 'integer|min:0', diff --git a/backend/app/Http/Controllers/AuthController.php b/backend/app/Http/Controllers/AuthController.php index 7caa729..b78a500 100644 --- a/backend/app/Http/Controllers/AuthController.php +++ b/backend/app/Http/Controllers/AuthController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Http\Resources\UserResource; use App\Models\User; use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; @@ -16,6 +17,12 @@ class AuthController extends Controller { /** * Register a new user + * + * NOTE: For SaaS applications, public registration is typically disabled. + * Users should be created by company administrators via UserController. + * This endpoint may be kept for initial company setup or removed entirely. + * + * If kept, it requires company_id to be provided (for initial setup only). */ public function register(Request $request): JsonResponse { @@ -24,13 +31,24 @@ public function register(Request $request): JsonResponse 'last_name' => 'required|string|max:255', 'email' => 'required|string|email|max:255|unique:users', 'password' => 'required|string|min:8|confirmed', + 'company_id' => 'required|integer|exists:companies,id', ]); - $user = User::create([ + // Validate company is active + $company = \App\Models\Company::findOrFail($validated['company_id']); + if (!$company->is_active) { + throw ValidationException::withMessages([ + 'company_id' => ['The selected company is not active.'], + ]); + } + + // Use forLogin scope to bypass company filter for creation + $user = User::forLogin()->create([ 'first_name' => $validated['first_name'], 'last_name' => $validated['last_name'], 'email' => $validated['email'], 'password' => Hash::make($validated['password']), + 'company_id' => $validated['company_id'], ]); $token = $user->createToken('auth_token')->plainTextToken; @@ -53,7 +71,8 @@ public function login(Request $request): JsonResponse 'password' => 'required', ]); - $user = User::where('email', $validated['email'])->first(); + // Use forLogin scope to bypass company filter (email is unique globally) + $user = User::forLogin()->where('email', $validated['email'])->first(); if (!$user || !Hash::check($validated['password'], $user->password)) { throw ValidationException::withMessages([ @@ -61,6 +80,31 @@ public function login(Request $request): JsonResponse ]); } + // Validate company (skip for platform admins) + if ($user->company_id === null) { + // Platform admin - no company validation needed + if (!$user->hasRole('platform_admin')) { + throw ValidationException::withMessages([ + 'email' => ['User does not belong to a company. Please contact support.'], + ]); + } + } else { + // Regular user - validate company + $company = $user->company; + if (!$company || !$company->is_active) { + throw ValidationException::withMessages([ + 'email' => ['Company account is not active. Please contact support.'], + ]); + } + } + + // Check if user is active + if (!$user->is_active) { + throw ValidationException::withMessages([ + 'email' => ['User account is inactive. Please contact administrator.'], + ]); + } + $token = $user->createToken('auth_token')->plainTextToken; return response()->json([ @@ -88,8 +132,20 @@ public function logout(Request $request): JsonResponse */ public function me(Request $request): JsonResponse { + $user = $request->user(); + + if (!$user) { + return response()->json([ + 'message' => 'User not authenticated', + 'user' => null, + ], 401); + } + + // Load relationships + $user->load(['company', 'roles']); + return response()->json([ - 'user' => $request->user(), + 'user' => UserResource::make($user), ]); } diff --git a/backend/app/Http/Controllers/BomController.php b/backend/app/Http/Controllers/BomController.php new file mode 100644 index 0000000..9161b6d --- /dev/null +++ b/backend/app/Http/Controllers/BomController.php @@ -0,0 +1,417 @@ +only([ + 'search', + 'product_id', + 'status', + 'bom_type', + 'is_default', + 'active_only', + ]); + $perPage = $request->get('per_page', 15); + + $boms = $this->bomService->getBoms($filters, $perPage); + + return BomListResource::collection($boms); + } + + /** + * Get all active BOMs for dropdowns + */ + public function list(): JsonResponse + { + $boms = $this->bomService->getActiveBoms(); + + return response()->json([ + 'data' => BomListResource::collection($boms), + ]); + } + + /** + * Get BOMs for a specific product + */ + public function forProduct(int $productId): JsonResponse + { + $boms = $this->bomService->getBomsForProduct($productId); + + return response()->json([ + 'data' => BomListResource::collection($boms), + ]); + } + + /** + * Store a newly created BOM + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'product_id' => 'required|exists:products,id', + 'bom_number' => 'nullable|string|max:50', + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'bom_type' => ['required', Rule::enum(BomType::class)], + 'quantity' => 'required|numeric|min:0.0001', + 'uom_id' => 'required|exists:units_of_measure,id', + 'is_default' => 'boolean', + 'effective_date' => 'nullable|date', + 'expiry_date' => 'nullable|date|after_or_equal:effective_date', + 'notes' => 'nullable|string', + 'meta_data' => 'nullable|array', + 'items' => 'nullable|array', + 'items.*.component_id' => 'required|exists:products,id', + 'items.*.quantity' => 'required|numeric|min:0.0001', + 'items.*.uom_id' => 'required|exists:units_of_measure,id', + 'items.*.scrap_percentage' => 'nullable|numeric|min:0|max:100', + 'items.*.is_optional' => 'boolean', + 'items.*.is_phantom' => 'boolean', + 'items.*.notes' => 'nullable|string', + ]); + + $bom = $this->bomService->create($validated); + + return response()->json([ + 'message' => 'BOM created successfully', + 'data' => BomResource::make($bom), + ], 201); + } + + /** + * Display the specified BOM + */ + public function show(Bom $bom): JsonResource + { + return BomResource::make( + $this->bomService->getBom($bom) + ); + } + + /** + * Update the specified BOM + */ + public function update(Request $request, Bom $bom): JsonResource + { + $validated = $request->validate([ + 'name' => 'sometimes|required|string|max:255', + 'description' => 'nullable|string', + 'bom_type' => ['sometimes', Rule::enum(BomType::class)], + 'quantity' => 'sometimes|numeric|min:0.0001', + 'uom_id' => 'sometimes|exists:units_of_measure,id', + 'effective_date' => 'nullable|date', + 'expiry_date' => 'nullable|date|after_or_equal:effective_date', + 'notes' => 'nullable|string', + 'meta_data' => 'nullable|array', + ]); + + $bom = $this->bomService->update($bom, $validated); + + return BomResource::make($bom) + ->additional(['message' => 'BOM updated successfully']); + } + + /** + * Remove the specified BOM + */ + public function destroy(Bom $bom): JsonResponse + { + $this->bomService->delete($bom); + + return response()->json([ + 'message' => 'BOM deleted successfully', + ]); + } + + /** + * Add item to BOM + */ + public function addItem(Request $request, Bom $bom): JsonResponse + { + $validated = $request->validate([ + 'component_id' => 'required|exists:products,id', + 'quantity' => 'required|numeric|min:0.0001', + 'uom_id' => 'required|exists:units_of_measure,id', + 'scrap_percentage' => 'nullable|numeric|min:0|max:100', + 'is_optional' => 'boolean', + 'is_phantom' => 'boolean', + 'line_number' => 'nullable|integer|min:1', + 'notes' => 'nullable|string', + ]); + + $item = $this->bomService->addItem($bom, $validated); + + return response()->json([ + 'message' => 'Item added to BOM successfully', + 'data' => BomItemResource::make($item->load(['component', 'uom'])), + ], 201); + } + + /** + * Update BOM item + */ + public function updateItem(Request $request, Bom $bom, int $itemId): JsonResponse + { + $validated = $request->validate([ + 'component_id' => 'sometimes|exists:products,id', + 'quantity' => 'sometimes|numeric|min:0.0001', + 'uom_id' => 'sometimes|exists:units_of_measure,id', + 'scrap_percentage' => 'nullable|numeric|min:0|max:100', + 'is_optional' => 'boolean', + 'is_phantom' => 'boolean', + 'line_number' => 'nullable|integer|min:1', + 'notes' => 'nullable|string', + ]); + + $item = $this->bomService->updateItem($bom, $itemId, $validated); + + return response()->json([ + 'message' => 'BOM item updated successfully', + 'data' => BomItemResource::make($item->load(['component', 'uom'])), + ]); + } + + /** + * Remove item from BOM + */ + public function removeItem(Bom $bom, int $itemId): JsonResponse + { + $this->bomService->removeItem($bom, $itemId); + + return response()->json([ + 'message' => 'Item removed from BOM successfully', + ]); + } + + /** + * Activate BOM + */ + public function activate(Bom $bom): JsonResponse + { + $bom = $this->bomService->activate($bom); + + return response()->json([ + 'message' => 'BOM activated successfully', + 'data' => BomResource::make($bom), + ]); + } + + /** + * Mark BOM as obsolete + */ + public function obsolete(Bom $bom): JsonResponse + { + $bom = $this->bomService->obsolete($bom); + + return response()->json([ + 'message' => 'BOM marked as obsolete successfully', + 'data' => BomResource::make($bom), + ]); + } + + /** + * Set BOM as default + */ + public function setDefault(Bom $bom): JsonResponse + { + $bom = $this->bomService->setAsDefault($bom); + + return response()->json([ + 'message' => 'BOM set as default successfully', + 'data' => BomResource::make($bom), + ]); + } + + /** + * Copy BOM to new version + */ + public function copy(Request $request, Bom $bom): JsonResponse + { + $validated = $request->validate([ + 'name' => 'nullable|string|max:255', + ]); + + $newBom = $this->bomService->copy($bom, $validated['name'] ?? null); + + return response()->json([ + 'message' => 'BOM copied successfully', + 'data' => BomResource::make($newBom), + ], 201); + } + + /** + * Explode BOM (multi-level only - all levels exploded) + * + * Note: For single-level BOMs, use GET /api/boms/{bom} instead. + * This endpoint always explodes all sub-BOMs recursively (phantom + regular items). + */ + public function explode(Request $request, Bom $bom): JsonResponse + { + // Normalize boolean query parameters + $request->merge([ + 'include_optional' => $request->has('include_optional') + ? filter_var($request->input('include_optional'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false + : null, + 'aggregate_by_product' => $request->has('aggregate_by_product') + ? filter_var($request->input('aggregate_by_product'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false + : null, + ]); + + $validated = $request->validate([ + 'quantity' => 'nullable|numeric|min:0.0001', + 'include_optional' => 'nullable|boolean', + 'aggregate_by_product' => 'nullable|boolean', + ]); + + $quantity = $validated['quantity'] ?? 1; + $includeOptional = $validated['include_optional'] ?? false; + $explodeAllLevels = true; // Always explode all levels for this endpoint + + // Auto-detect aggregate_by_product based on BOM levels + $aggregateByProduct = $validated['aggregate_by_product'] ?? null; + + // Check if BOM structure is cached (for determining single vs multi-level) + // We use a simple flag to check structure without full explosion + $structureCacheKey = "mrp:bom_structure:{$bom->id}"; + $hasMultiLevel = null; + $cachedStructure = Redis::get($structureCacheKey); + + if ($cachedStructure !== null) { + $hasMultiLevel = json_decode($cachedStructure, true)['has_multi_level'] ?? null; + } + + // If structure not cached, do a quick explosion to determine it + if ($hasMultiLevel === null) { + $tempMaterials = $this->bomService->explodeBom($bom, 1, 10, false, false, false, false); + $hasMultiLevel = false; + foreach ($tempMaterials as $material) { + if ($material['level'] > 0) { + $hasMultiLevel = true; + break; + } + } + // Cache structure info for 1 hour + Redis::setex($structureCacheKey, 3600, json_encode(['has_multi_level' => $hasMultiLevel])); + } + + // Auto-detect aggregate_by_product based on BOM structure + if ($aggregateByProduct === null) { + // Single-level: default to aggregate for cleaner output + // Multi-level: default to false for detailed tree + $aggregateByProduct = !$hasMultiLevel; + } + + $asTree = $hasMultiLevel; // Tree structure for multi-level + + // Check cache (quantity-independent, base structure for quantity=1) + $baseMaterials = $this->cacheService->getCachedBomExplode( + $bom->id, + $includeOptional, + $aggregateByProduct, + $asTree + ); + + if ($baseMaterials === null) { + // Cache miss - explode BOM with quantity=1 (base structure) + $baseMaterials = $this->bomService->explodeBom( + $bom, + 1, // Base quantity = 1 for caching + 10, + $includeOptional, + $explodeAllLevels, // Always true + $aggregateByProduct, + $asTree // asTree: true for multi-level, false for single-level + ); + + // Cache the base structure (quantity=1) + $this->cacheService->cacheBomExplode( + $bom->id, + $includeOptional, + $aggregateByProduct, + $asTree, + $baseMaterials + ); + } + + // Scale quantities if needed (quantity != 1) + if (abs($quantity - 1.0) > 0.0001) { + $materials = $this->cacheService->scaleExplosionQuantities($baseMaterials, $quantity); + } else { + $materials = $baseMaterials; + } + + return response()->json([ + 'data' => [ + 'bom' => BomListResource::make($bom), + 'quantity' => $quantity, + 'include_optional' => $includeOptional, + 'aggregate_by_product' => $aggregateByProduct, + 'is_single_level' => !$hasMultiLevel, + 'structure' => $hasMultiLevel ? 'tree' : 'flat', + 'materials' => $materials, + 'total_materials' => $hasMultiLevel ? $this->countTreeItems($materials) : count($materials), + ], + ]); + } + + /** + * Count total items in tree structure + */ + protected function countTreeItems(array $tree): int + { + $count = 0; + foreach ($tree as $item) { + $count++; + if (isset($item['children']) && is_array($item['children'])) { + $count += $this->countTreeItems($item['children']); + } + } + return $count; + } + + /** + * Get BOM types + */ + public function types(): JsonResponse + { + return response()->json([ + 'data' => BomType::options(), + ]); + } + + /** + * Get BOM statuses + */ + public function statuses(): JsonResponse + { + return response()->json([ + 'data' => BomStatus::options(), + ]); + } +} diff --git a/backend/app/Http/Controllers/CapacityController.php b/backend/app/Http/Controllers/CapacityController.php new file mode 100644 index 0000000..c585f9d --- /dev/null +++ b/backend/app/Http/Controllers/CapacityController.php @@ -0,0 +1,324 @@ +validate([ + 'start_date' => 'required|date', + 'end_date' => 'required|date|after_or_equal:start_date', + ]); + + $entries = WorkCenterCalendar::where('work_center_id', $workCenter->id) + ->dateRange($validated['start_date'], $validated['end_date']) + ->orderBy('calendar_date') + ->get(); + + return WorkCenterCalendarResource::collection($entries); + } + + /** + * Generate calendar entries + */ + public function generateCalendar(Request $request): JsonResponse + { + $validated = $request->validate([ + 'work_center_id' => 'nullable|exists:work_centers,id', + 'start_date' => 'required|date', + 'end_date' => 'required|date|after_or_equal:start_date', + 'holidays' => 'nullable|array', + 'holidays.*.date' => 'required|date', + 'holidays.*.reason' => 'required|string|max:255', + ]); + + $holidays = []; + if (!empty($validated['holidays'])) { + foreach ($validated['holidays'] as $holiday) { + $holidays[$holiday['date']] = $holiday['reason']; + } + } + + if (!empty($validated['work_center_id'])) { + $workCenter = WorkCenter::findOrFail($validated['work_center_id']); + $count = $this->capacityService->generateCalendar( + $workCenter, + Carbon::parse($validated['start_date']), + Carbon::parse($validated['end_date']), + $holidays + ); + + return response()->json([ + 'message' => "Generated {$count} calendar entries", + 'entries_created' => $count, + ]); + } + + $results = $this->capacityService->generateAllCalendars( + Carbon::parse($validated['start_date']), + Carbon::parse($validated['end_date']), + $holidays + ); + + return response()->json([ + 'message' => 'Calendar entries generated', + 'data' => $results, + ]); + } + + /** + * Update a calendar entry + */ + public function updateCalendarEntry(Request $request, WorkCenterCalendar $calendar): JsonResponse + { + $validated = $request->validate([ + 'shift_start' => 'nullable|date_format:H:i:s', + 'shift_end' => 'nullable|date_format:H:i:s', + 'break_hours' => 'nullable|numeric|min:0|max:12', + 'available_hours' => 'nullable|numeric|min:0|max:24', + 'efficiency_override' => 'nullable|numeric|min:0|max:200', + 'capacity_override' => 'nullable|numeric|min:0|max:24', + 'day_type' => ['nullable', Rule::enum(CalendarDayType::class)], + 'notes' => 'nullable|string|max:500', + ]); + + $entry = $this->capacityService->updateCalendarEntry($calendar, $validated); + + return response()->json([ + 'message' => 'Calendar entry updated', + 'data' => WorkCenterCalendarResource::make($entry), + ]); + } + + /** + * Set holiday for a date range + */ + public function setHoliday(Request $request, WorkCenter $workCenter): JsonResponse + { + $validated = $request->validate([ + 'start_date' => 'required|date', + 'end_date' => 'required|date|after_or_equal:start_date', + 'reason' => 'required|string|max:255', + ]); + + $count = $this->capacityService->setHoliday( + $workCenter->id, + Carbon::parse($validated['start_date']), + Carbon::parse($validated['end_date']), + $validated['reason'] + ); + + return response()->json([ + 'message' => "{$count} days marked as holiday", + 'updated_count' => $count, + ]); + } + + /** + * Set maintenance for a date + */ + public function setMaintenance(Request $request, WorkCenter $workCenter): JsonResponse + { + $validated = $request->validate([ + 'date' => 'required|date', + 'reduced_hours' => 'required|numeric|min:0|max:24', + 'reason' => 'required|string|max:255', + ]); + + $entry = $this->capacityService->setMaintenance( + $workCenter->id, + Carbon::parse($validated['date']), + $validated['reduced_hours'], + $validated['reason'] + ); + + return response()->json([ + 'message' => 'Maintenance scheduled', + 'data' => WorkCenterCalendarResource::make($entry), + ]); + } + + // ========================================= + // Capacity Analysis + // ========================================= + + /** + * Get capacity overview for all work centers + */ + public function overview(Request $request): JsonResponse + { + $validated = $request->validate([ + 'start_date' => 'required|date', + 'end_date' => 'required|date|after_or_equal:start_date', + ]); + + $overview = $this->capacityService->getCapacityOverview( + Carbon::parse($validated['start_date']), + Carbon::parse($validated['end_date']) + ); + + return response()->json([ + 'data' => $overview, + ]); + } + + /** + * Get capacity for a specific work center + */ + public function workCenterCapacity(Request $request, WorkCenter $workCenter): JsonResponse + { + $validated = $request->validate([ + 'start_date' => 'required|date', + 'end_date' => 'required|date|after_or_equal:start_date', + ]); + + $capacity = $this->capacityService->getAvailableCapacity( + $workCenter, + Carbon::parse($validated['start_date']), + Carbon::parse($validated['end_date']) + ); + + return response()->json([ + 'data' => $capacity, + ]); + } + + /** + * Get daily capacity breakdown for a work center + */ + public function dailyCapacity(Request $request, WorkCenter $workCenter): JsonResponse + { + $validated = $request->validate([ + 'start_date' => 'required|date', + 'end_date' => 'required|date|after_or_equal:start_date', + ]); + + $daily = $this->capacityService->getDailyCapacity( + $workCenter, + Carbon::parse($validated['start_date']), + Carbon::parse($validated['end_date']) + ); + + return response()->json([ + 'data' => $daily, + ]); + } + + /** + * Check capacity for a work order + */ + public function checkWorkOrderCapacity(WorkOrder $workOrder): JsonResponse + { + $result = $this->capacityService->checkCapacityForWorkOrder($workOrder); + + return response()->json([ + 'data' => $result, + ]); + } + + /** + * Find next available slot + */ + public function findSlot(Request $request, WorkCenter $workCenter): JsonResponse + { + $validated = $request->validate([ + 'required_hours' => 'required|numeric|min:0.1', + 'start_from' => 'nullable|date', + ]); + + $slot = $this->capacityService->findNextAvailableSlot( + $workCenter, + $validated['required_hours'], + isset($validated['start_from']) ? Carbon::parse($validated['start_from']) : null + ); + + if (!$slot) { + return response()->json([ + 'message' => 'No available slot found within 90 days', + 'data' => null, + ], 404); + } + + return response()->json([ + 'data' => $slot, + ]); + } + + // ========================================= + // Reports + // ========================================= + + /** + * Get load report + */ + public function loadReport(Request $request): JsonResponse + { + $validated = $request->validate([ + 'start_date' => 'required|date', + 'end_date' => 'required|date|after_or_equal:start_date', + ]); + + $report = $this->capacityService->getLoadReport( + Carbon::parse($validated['start_date']), + Carbon::parse($validated['end_date']) + ); + + return response()->json([ + 'data' => $report, + ]); + } + + /** + * Get bottleneck analysis + */ + public function bottleneckAnalysis(Request $request): JsonResponse + { + $validated = $request->validate([ + 'start_date' => 'required|date', + 'end_date' => 'required|date|after_or_equal:start_date', + ]); + + $analysis = $this->capacityService->getBottleneckAnalysis( + Carbon::parse($validated['start_date']), + Carbon::parse($validated['end_date']) + ); + + return response()->json([ + 'data' => $analysis, + ]); + } + + /** + * Get day types + */ + public function dayTypes(): JsonResponse + { + return response()->json([ + 'data' => CalendarDayType::options(), + ]); + } +} diff --git a/backend/app/Http/Controllers/CategoryController.php b/backend/app/Http/Controllers/CategoryController.php index 0e480f3..3e037e8 100644 --- a/backend/app/Http/Controllers/CategoryController.php +++ b/backend/app/Http/Controllers/CategoryController.php @@ -71,9 +71,16 @@ public function show(Category $category): JsonResource */ public function update(Request $request, Category $category): JsonResource { + $companyId = $request->user()->company_id; + $validated = $request->validate([ 'name' => 'sometimes|required|string|max:255', - 'slug' => ['sometimes', 'required', 'string', Rule::unique('categories')->ignore($category->id)], + 'slug' => [ + 'sometimes', + 'required', + 'string', + Rule::unique('categories')->where('company_id', $companyId)->ignore($category->id), + ], 'description' => 'nullable|string', 'parent_id' => 'nullable|exists:categories,id', ]); diff --git a/backend/app/Http/Controllers/CompanyCalendarController.php b/backend/app/Http/Controllers/CompanyCalendarController.php new file mode 100644 index 0000000..1abc7d2 --- /dev/null +++ b/backend/app/Http/Controllers/CompanyCalendarController.php @@ -0,0 +1,259 @@ +company_id; + + $query = CompanyCalendar::where('company_id', $companyId) + ->orderBy('calendar_date', 'desc'); + + // Filter by date range + if ($request->has('start_date')) { + $query->where('calendar_date', '>=', $request->start_date); + } + + if ($request->has('end_date')) { + $query->where('calendar_date', '<=', $request->end_date); + } + + // Filter by day type + if ($request->has('day_type')) { + $query->where('day_type', $request->day_type); + } + + // Filter by recurring + if ($request->has('recurring')) { + $query->where('is_recurring', filter_var($request->recurring, FILTER_VALIDATE_BOOLEAN)); + } + + $calendars = $query->paginate($request->get('per_page', 25)); + + return response()->json([ + 'success' => true, + 'data' => $calendars, + ]); + } + + /** + * Get a specific calendar entry + */ + public function show(CompanyCalendar $calendar): JsonResponse + { + // Ensure user can only access their company's calendar + if ($calendar->company_id !== Auth::user()->company_id) { + return response()->json([ + 'success' => false, + 'message' => 'Calendar entry not found', + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => $calendar, + ]); + } + + /** + * Create a new calendar entry + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'calendar_date' => 'required|date', + 'day_type' => 'required|in:working,holiday', + 'shift_name' => 'nullable|string|max:50', + 'shift_start' => 'nullable|date_format:H:i:s', + 'shift_end' => 'nullable|date_format:H:i:s', + 'break_hours' => 'nullable|numeric|min:0|max:24', + 'working_hours' => 'nullable|numeric|min:0|max:24', + 'reason' => 'nullable|string|max:255', + 'notes' => 'nullable|string', + 'is_recurring' => 'nullable|boolean', + 'recurrence_type' => 'nullable|in:yearly,monthly,weekly', + 'recurrence_pattern' => 'nullable|array', + ]); + + $companyId = Auth::user()->company_id; + + // Check if entry already exists for this date + $existing = CompanyCalendar::where('company_id', $companyId) + ->forDate($validated['calendar_date']) + ->first(); + + if ($existing) { + return response()->json([ + 'success' => false, + 'message' => 'Calendar entry already exists for this date. Use update instead.', + ], 409); + } + + $calendar = CompanyCalendar::create([ + 'company_id' => $companyId, + ...$validated, + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Calendar entry created successfully', + 'data' => $calendar, + ], 201); + } + + /** + * Update a calendar entry + */ + public function update(Request $request, CompanyCalendar $calendar): JsonResponse + { + // Ensure user can only update their company's calendar + if ($calendar->company_id !== Auth::user()->company_id) { + return response()->json([ + 'success' => false, + 'message' => 'Calendar entry not found', + ], 404); + } + + $validated = $request->validate([ + 'day_type' => 'sometimes|in:working,holiday', + 'shift_name' => 'nullable|string|max:50', + 'shift_start' => 'nullable|date_format:H:i:s', + 'shift_end' => 'nullable|date_format:H:i:s', + 'break_hours' => 'nullable|numeric|min:0|max:24', + 'working_hours' => 'nullable|numeric|min:0|max:24', + 'reason' => 'nullable|string|max:255', + 'notes' => 'nullable|string', + 'is_recurring' => 'nullable|boolean', + 'recurrence_type' => 'nullable|in:yearly,monthly,weekly', + 'recurrence_pattern' => 'nullable|array', + ]); + + $calendar->update($validated); + + return response()->json([ + 'success' => true, + 'message' => 'Calendar entry updated successfully', + 'data' => $calendar->fresh(), + ]); + } + + /** + * Delete a calendar entry + */ + public function destroy(CompanyCalendar $calendar): JsonResponse + { + // Ensure user can only delete their company's calendar + if ($calendar->company_id !== Auth::user()->company_id) { + return response()->json([ + 'success' => false, + 'message' => 'Calendar entry not found', + ], 404); + } + + $calendar->delete(); + + return response()->json([ + 'success' => true, + 'message' => 'Calendar entry deleted successfully', + ]); + } + + /** + * Bulk create calendar entries (for holidays, etc.) + */ + public function bulkStore(Request $request): JsonResponse + { + $validated = $request->validate([ + 'entries' => 'required|array|min:1', + 'entries.*.calendar_date' => 'required|date', + 'entries.*.day_type' => 'required|in:working,holiday', + 'entries.*.shift_name' => 'nullable|string|max:50', + 'entries.*.shift_start' => 'nullable|date_format:H:i:s', + 'entries.*.shift_end' => 'nullable|date_format:H:i:s', + 'entries.*.break_hours' => 'nullable|numeric|min:0|max:24', + 'entries.*.working_hours' => 'nullable|numeric|min:0|max:24', + 'entries.*.reason' => 'nullable|string|max:255', + 'entries.*.notes' => 'nullable|string', + ]); + + $companyId = Auth::user()->company_id; + $created = []; + $skipped = []; + + DB::beginTransaction(); + try { + foreach ($validated['entries'] as $entry) { + // Check if entry already exists + $existing = CompanyCalendar::where('company_id', $companyId) + ->forDate($entry['calendar_date']) + ->first(); + + if ($existing) { + $skipped[] = $entry['calendar_date']; + continue; + } + + $calendar = CompanyCalendar::create([ + 'company_id' => $companyId, + ...$entry, + ]); + + $created[] = $calendar; + } + + DB::commit(); + + return response()->json([ + 'success' => true, + 'message' => 'Calendar entries created successfully', + 'data' => [ + 'created' => count($created), + 'skipped' => count($skipped), + 'entries' => $created, + 'skipped_dates' => $skipped, + ], + ], 201); + } catch (\Exception $e) { + DB::rollBack(); + return response()->json([ + 'success' => false, + 'message' => 'Failed to create calendar entries: ' . $e->getMessage(), + ], 500); + } + } + + /** + * Get calendar entries for a specific date range (for MRP planning) + */ + public function getDateRange(Request $request): JsonResponse + { + $validated = $request->validate([ + 'start_date' => 'required|date', + 'end_date' => 'required|date|after_or_equal:start_date', + ]); + + $companyId = Auth::user()->company_id; + + $entries = CompanyCalendar::where('company_id', $companyId) + ->dateRange($validated['start_date'], $validated['end_date']) + ->orderBy('calendar_date') + ->get(); + + return response()->json([ + 'success' => true, + 'data' => $entries, + ]); + } +} diff --git a/backend/app/Http/Controllers/CustomerController.php b/backend/app/Http/Controllers/CustomerController.php new file mode 100644 index 0000000..ef0c1c6 --- /dev/null +++ b/backend/app/Http/Controllers/CustomerController.php @@ -0,0 +1,151 @@ +only(['search', 'customer_group_id', 'is_active']); + $perPage = $request->get('per_page', 15); + + $customers = $this->customerService->getCustomers($filters, $perPage); + + return CustomerListResource::collection($customers); + } + + /** + * Get all active customers for dropdowns + */ + public function list(): JsonResponse + { + $customers = $this->customerService->getActiveCustomers(); + + return response()->json([ + 'data' => $customers, + ]); + } + + /** + * Store a newly created customer + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'customer_group_id' => 'nullable|exists:customer_groups,id', + 'code' => 'nullable|string|max:50', + 'name' => 'required|string|max:255', + 'email' => 'nullable|email|max:255', + 'phone' => 'nullable|string|max:50', + 'tax_number' => 'nullable|string|max:50', + 'billing_address' => 'nullable|string', + 'shipping_address' => 'nullable|string', + 'city' => 'nullable|string|max:100', + 'state' => 'nullable|string|max:100', + 'postal_code' => 'nullable|string|max:20', + 'country' => 'nullable|string|max:100', + 'contact_person' => 'nullable|string|max:255', + 'payment_terms_days' => 'nullable|integer|min:0|max:365', + 'credit_limit' => 'nullable|numeric|min:0', + 'notes' => 'nullable|string', + 'is_active' => 'boolean', + ]); + + $customer = $this->customerService->create($validated); + + return response()->json([ + 'message' => 'Customer created successfully', + 'data' => CustomerResource::make($customer), + ], 201); + } + + /** + * Display the specified customer + */ + public function show(Customer $customer): JsonResource + { + return CustomerResource::make( + $this->customerService->getCustomer($customer) + ); + } + + /** + * Update the specified customer + */ + public function update(Request $request, Customer $customer): JsonResource + { + $validated = $request->validate([ + 'customer_group_id' => 'nullable|exists:customer_groups,id', + 'code' => [ + 'sometimes', + 'required', + 'string', + 'max:50', + Rule::unique('customers')->where(function ($query) use ($customer) { + return $query->where('company_id', $customer->company_id); + })->ignore($customer->id), + ], + 'name' => 'sometimes|required|string|max:255', + 'email' => 'nullable|email|max:255', + 'phone' => 'nullable|string|max:50', + 'tax_number' => 'nullable|string|max:50', + 'billing_address' => 'nullable|string', + 'shipping_address' => 'nullable|string', + 'city' => 'nullable|string|max:100', + 'state' => 'nullable|string|max:100', + 'postal_code' => 'nullable|string|max:20', + 'country' => 'nullable|string|max:100', + 'contact_person' => 'nullable|string|max:255', + 'payment_terms_days' => 'nullable|integer|min:0|max:365', + 'credit_limit' => 'nullable|numeric|min:0', + 'notes' => 'nullable|string', + 'is_active' => 'boolean', + ]); + + $customer = $this->customerService->update($customer, $validated); + + return CustomerResource::make($customer) + ->additional(['message' => 'Customer updated successfully']); + } + + /** + * Remove the specified customer + */ + public function destroy(Customer $customer): JsonResponse + { + $this->customerService->delete($customer); + + return response()->json([ + 'message' => 'Customer deleted successfully', + ]); + } + + /** + * Get customer statistics + */ + public function statistics(Customer $customer): JsonResponse + { + $stats = $this->customerService->getStatistics($customer); + + return response()->json([ + 'data' => $stats, + ]); + } +} diff --git a/backend/app/Http/Controllers/CustomerGroupController.php b/backend/app/Http/Controllers/CustomerGroupController.php new file mode 100644 index 0000000..95367b8 --- /dev/null +++ b/backend/app/Http/Controllers/CustomerGroupController.php @@ -0,0 +1,194 @@ +only(['search', 'is_active']); + $perPage = $request->get('per_page', 15); + + $customerGroups = $this->customerGroupService->getCustomerGroups($filters, $perPage); + + return CustomerGroupListResource::collection($customerGroups); + } + + /** + * Get all active customer groups for dropdowns + */ + public function list(): JsonResponse + { + $groups = $this->customerGroupService->getActiveGroups(); + + return response()->json([ + 'data' => $groups, + ]); + } + + /** + * Store a newly created customer group + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'code' => 'nullable|string|max:50', + 'description' => 'nullable|string', + 'discount_percentage' => 'nullable|numeric|min:0|max:100', + 'payment_terms_days' => 'nullable|integer|min:0|max:365', + 'credit_limit' => 'nullable|numeric|min:0', + 'is_active' => 'boolean', + ]); + + $customerGroup = $this->customerGroupService->create($validated); + + return response()->json([ + 'message' => 'Customer group created successfully', + 'data' => CustomerGroupResource::make($customerGroup), + ], 201); + } + + /** + * Display the specified customer group + */ + public function show(CustomerGroup $customerGroup): JsonResource + { + return CustomerGroupResource::make( + $this->customerGroupService->getCustomerGroup($customerGroup) + ); + } + + /** + * Update the specified customer group + */ + public function update(Request $request, CustomerGroup $customerGroup): JsonResource + { + $validated = $request->validate([ + 'name' => 'sometimes|required|string|max:255', + 'code' => [ + 'nullable', + 'string', + 'max:50', + Rule::unique('customer_groups')->where(function ($query) use ($customerGroup) { + return $query->where('company_id', $customerGroup->company_id); + })->ignore($customerGroup->id), + ], + 'description' => 'nullable|string', + 'discount_percentage' => 'nullable|numeric|min:0|max:100', + 'payment_terms_days' => 'nullable|integer|min:0|max:365', + 'credit_limit' => 'nullable|numeric|min:0', + 'is_active' => 'boolean', + ]); + + $customerGroup = $this->customerGroupService->update($customerGroup, $validated); + + return CustomerGroupResource::make($customerGroup) + ->additional(['message' => 'Customer group updated successfully']); + } + + /** + * Remove the specified customer group + */ + public function destroy(CustomerGroup $customerGroup): JsonResponse + { + $this->customerGroupService->delete($customerGroup); + + return response()->json([ + 'message' => 'Customer group deleted successfully', + ]); + } + + /** + * Get prices for a customer group + */ + public function prices(Request $request, CustomerGroup $customerGroup): AnonymousResourceCollection + { + $filters = $request->only(['search', 'active_only']); + $perPage = $request->get('per_page', 15); + + $prices = $this->priceService->getPricesForGroup($customerGroup, $filters, $perPage); + + return CustomerGroupPriceResource::collection($prices); + } + + /** + * Set price for a product in customer group + */ + public function setPrice(Request $request, CustomerGroup $customerGroup): JsonResponse + { + $validated = $request->validate([ + 'product_id' => 'required|exists:products,id', + 'price' => 'required|numeric|min:0', + 'currency_id' => 'nullable|exists:currencies,id', + 'min_quantity' => 'nullable|numeric|min:1', + 'valid_from' => 'nullable|date', + 'valid_until' => 'nullable|date|after:valid_from', + 'is_active' => 'boolean', + ]); + + $validated['customer_group_id'] = $customerGroup->id; + $price = $this->priceService->setPrice($validated); + + return response()->json([ + 'message' => 'Price set successfully', + 'data' => CustomerGroupPriceResource::make($price), + ]); + } + + /** + * Bulk set prices for customer group + */ + public function bulkSetPrices(Request $request, CustomerGroup $customerGroup): JsonResponse + { + $validated = $request->validate([ + 'prices' => 'required|array|min:1', + 'prices.*.product_id' => 'required|exists:products,id', + 'prices.*.price' => 'required|numeric|min:0', + 'prices.*.currency_id' => 'nullable|exists:currencies,id', + 'prices.*.min_quantity' => 'nullable|numeric|min:1', + 'prices.*.valid_from' => 'nullable|date', + 'prices.*.valid_until' => 'nullable|date', + 'prices.*.is_active' => 'boolean', + ]); + + $prices = $this->priceService->bulkSetPrices($customerGroup, $validated['prices']); + + return response()->json([ + 'message' => count($prices) . ' prices set successfully', + ]); + } + + /** + * Delete a group price + */ + public function deletePrice(CustomerGroup $customerGroup, int $priceId): JsonResponse + { + $price = $customerGroup->groupPrices()->findOrFail($priceId); + $this->priceService->delete($price); + + return response()->json([ + 'message' => 'Price deleted successfully', + ]); + } +} diff --git a/backend/app/Http/Controllers/DeliveryNoteController.php b/backend/app/Http/Controllers/DeliveryNoteController.php new file mode 100644 index 0000000..1b4bc2f --- /dev/null +++ b/backend/app/Http/Controllers/DeliveryNoteController.php @@ -0,0 +1,192 @@ +only([ + 'search', + 'status', + 'sales_order_id', + 'warehouse_id', + 'from_date', + 'to_date', + ]); + $perPage = $request->get('per_page', 15); + + $deliveryNotes = $this->deliveryNoteService->getDeliveryNotes($filters, $perPage); + + return DeliveryNoteListResource::collection($deliveryNotes); + } + + /** + * Store a newly created delivery note + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'sales_order_id' => 'required|exists:sales_orders,id', + 'warehouse_id' => 'required|exists:warehouses,id', + 'delivery_date' => 'nullable|date', + 'shipping_address' => 'nullable|string', + 'carrier' => 'nullable|string|max:255', + 'tracking_number' => 'nullable|string|max:255', + 'notes' => 'nullable|string', + 'items' => 'required|array|min:1', + 'items.*.sales_order_item_id' => 'required|exists:sales_order_items,id', + 'items.*.quantity' => 'required|numeric|min:0.01', + 'items.*.lot_number' => 'nullable|string|max:100', + 'items.*.serial_numbers' => 'nullable|array', + 'items.*.notes' => 'nullable|string', + ]); + + $salesOrder = SalesOrder::findOrFail($validated['sales_order_id']); + $deliveryNote = $this->deliveryNoteService->createFromSalesOrder($salesOrder, $validated); + + return response()->json([ + 'message' => 'Delivery note created successfully', + 'data' => DeliveryNoteResource::make($deliveryNote), + ], 201); + } + + /** + * Display the specified delivery note + */ + public function show(DeliveryNote $deliveryNote): JsonResource + { + return DeliveryNoteResource::make( + $this->deliveryNoteService->getDeliveryNote($deliveryNote) + ); + } + + /** + * Update the specified delivery note + */ + public function update(Request $request, DeliveryNote $deliveryNote): JsonResource + { + $validated = $request->validate([ + 'delivery_date' => 'nullable|date', + 'shipping_address' => 'nullable|string', + 'carrier' => 'nullable|string|max:255', + 'tracking_number' => 'nullable|string|max:255', + 'notes' => 'nullable|string', + ]); + + $deliveryNote = $this->deliveryNoteService->update($deliveryNote, $validated); + + return DeliveryNoteResource::make($deliveryNote) + ->additional(['message' => 'Delivery note updated successfully']); + } + + /** + * Remove the specified delivery note + */ + public function destroy(DeliveryNote $deliveryNote): JsonResponse + { + $this->deliveryNoteService->delete($deliveryNote); + + return response()->json([ + 'message' => 'Delivery note deleted successfully', + ]); + } + + /** + * Confirm delivery note (ready for shipping) + */ + public function confirm(DeliveryNote $deliveryNote): JsonResponse + { + $deliveryNote = $this->deliveryNoteService->confirm($deliveryNote); + + return response()->json([ + 'message' => 'Delivery note confirmed', + 'data' => DeliveryNoteResource::make($deliveryNote), + ]); + } + + /** + * Ship delivery note (deduct stock) + */ + public function ship(DeliveryNote $deliveryNote): JsonResponse + { + $deliveryNote = $this->deliveryNoteService->ship($deliveryNote); + + return response()->json([ + 'message' => 'Delivery note shipped. Stock has been deducted.', + 'data' => DeliveryNoteResource::make($deliveryNote), + ]); + } + + /** + * Mark delivery note as delivered + */ + public function markAsDelivered(DeliveryNote $deliveryNote): JsonResponse + { + $deliveryNote = $this->deliveryNoteService->markAsDelivered($deliveryNote); + + return response()->json([ + 'message' => 'Delivery note marked as delivered', + 'data' => DeliveryNoteResource::make($deliveryNote), + ]); + } + + /** + * Cancel delivery note + */ + public function cancel(Request $request, DeliveryNote $deliveryNote): JsonResponse + { + $validated = $request->validate([ + 'reason' => 'nullable|string|max:1000', + ]); + + $deliveryNote = $this->deliveryNoteService->cancel($deliveryNote, $validated['reason'] ?? null); + + return response()->json([ + 'message' => 'Delivery note cancelled', + ]); + } + + /** + * Get available statuses + */ + public function statuses(): JsonResponse + { + $statuses = $this->deliveryNoteService->getStatuses(); + + return response()->json([ + 'data' => $statuses, + ]); + } + + /** + * Get delivery notes for a sales order + */ + public function forSalesOrder(SalesOrder $salesOrder): JsonResponse + { + $deliveryNotes = $salesOrder->deliveryNotes() + ->with(['warehouse', 'items.product']) + ->get(); + + return response()->json([ + 'data' => DeliveryNoteListResource::collection($deliveryNotes), + ]); + } +} diff --git a/backend/app/Http/Controllers/InvitationController.php b/backend/app/Http/Controllers/InvitationController.php new file mode 100644 index 0000000..c0c6e83 --- /dev/null +++ b/backend/app/Http/Controllers/InvitationController.php @@ -0,0 +1,250 @@ +invitationService = $invitationService; + } + + /** + * Send invitation to a user + * Required permission: users.create (checked by middleware) + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'email' => 'required|email|max:255', + 'role_ids' => 'required|array|min:1', + 'role_ids.*' => 'required|integer|exists:roles,id', + 'expiration_days' => 'sometimes|integer|min:1|max:30', + ]); + + try { + $invitation = $this->invitationService->sendInvitation($validated); + + return response()->json([ + 'message' => 'Invitation sent successfully', + 'data' => [ + 'id' => $invitation->id, + 'email' => $invitation->email, + 'token' => $invitation->token, // Include token for testing + 'expires_at' => $invitation->expires_at->toISOString(), + 'invited_by' => [ + 'id' => $invitation->inviter->id, + 'name' => $invitation->inviter->full_name, + ], + ], + ], 201); + + } catch (\App\Exceptions\BusinessException $e) { + return response()->json([ + 'message' => $e->getMessage(), + ], 422); + } + } + + /** + * Get invitation details by token (public endpoint) + * Supports both path parameter and query parameter: /accept/{token} or /accept?token=... + */ + public function show(Request $request, ?string $token = null): JsonResponse + { + // Get token from path parameter or query parameter + $token = $token ?? $request->query('token'); + + if (!$token) { + return response()->json([ + 'message' => 'Invitation token is required.', + ], 400); + } + + $invitation = $this->invitationService->getInvitationByToken($token); + + if (!$invitation) { + return response()->json([ + 'message' => 'Invalid invitation token.', + ], 404); + } + + if ($invitation->isExpired()) { + return response()->json([ + 'message' => 'This invitation has expired.', + 'error' => 'invitation_expired', + ], 410); + } + + if ($invitation->isAccepted()) { + return response()->json([ + 'message' => 'This invitation has already been accepted.', + 'error' => 'invitation_accepted', + ], 410); + } + + return response()->json([ + 'data' => [ + 'email' => $invitation->email, + 'company' => [ + 'id' => $invitation->company->id, + 'name' => $invitation->company->name, + ], + 'invited_by' => [ + 'name' => $invitation->inviter->full_name, + ], + 'expires_at' => $invitation->expires_at->toISOString(), + ], + ]); + } + + /** + * Accept invitation and create user account (public endpoint) + * Supports both path parameter and query parameter: /accept/{token} or /accept?token=... + */ + public function accept(Request $request, ?string $token = null): JsonResponse + { + // Get token from path parameter or query parameter + $token = $token ?? $request->query('token'); + + if (!$token) { + return response()->json([ + 'message' => 'Invitation token is required.', + ], 400); + } + + $validated = $request->validate([ + 'email' => 'required|email', + 'first_name' => 'required|string|max:255', + 'last_name' => 'required|string|max:255', + 'password' => 'required|string|min:8|confirmed', + ]); + + try { + $user = $this->invitationService->acceptInvitation($token, $validated); + + // Create token for immediate login + $authToken = $user->createToken('auth_token')->plainTextToken; + + return response()->json([ + 'message' => 'Account created successfully', + 'user' => UserResource::make($user), + 'access_token' => $authToken, + 'token_type' => 'Bearer', + ], 201); + + } catch (\App\Exceptions\BusinessException $e) { + return response()->json([ + 'message' => $e->getMessage(), + ], 422); + } + } + + /** + * Resend invitation + * Permission: users.create OR user is the inviter + */ + public function resend(Request $request, int $id): JsonResponse + { + // Permission check is done in service (allows inviter to resend their own invitations) + // No need to check here, service will handle it + + try { + $invitation = $this->invitationService->resendInvitation($id); + + return response()->json([ + 'message' => 'Invitation resent successfully', + 'data' => [ + 'id' => $invitation->id, + 'email' => $invitation->email, + 'expires_at' => $invitation->expires_at->toISOString(), + ], + ]); + + } catch (\App\Exceptions\BusinessException $e) { + return response()->json([ + 'message' => $e->getMessage(), + ], 422); + } + } + + /** + * Cancel invitation + * Required permission: users.delete + */ + public function destroy(Request $request, int $id): JsonResponse + { + if (!$request->user()->hasPermission('users.delete')) { + return response()->json([ + 'message' => 'Forbidden. You do not have permission to cancel invitations.', + ], 403); + } + + try { + $this->invitationService->cancelInvitation($id); + + return response()->json([ + 'message' => 'Invitation cancelled successfully', + ]); + + } catch (\App\Exceptions\BusinessException $e) { + return response()->json([ + 'message' => $e->getMessage(), + ], 422); + } + } + + /** + * List invitations (for company admin) + * Required permission: users.view + */ + public function index(Request $request): JsonResponse + { + if (!$request->user()->hasPermission('users.view')) { + return response()->json([ + 'message' => 'Forbidden. You do not have permission to view invitations.', + ], 403); + } + + $companyId = $request->user()->company_id; + $perPage = $request->input('per_page', 15); + + $invitations = \App\Models\UserInvitation::where('company_id', $companyId) + ->with(['inviter', 'company']) + ->latest() + ->paginate($perPage); + + return response()->json([ + 'data' => $invitations->map(function ($invitation) { + return [ + 'id' => $invitation->id, + 'email' => $invitation->email, + 'invited_by' => [ + 'id' => $invitation->inviter->id, + 'name' => $invitation->inviter->full_name, + ], + 'expires_at' => $invitation->expires_at->toISOString(), + 'accepted_at' => $invitation->accepted_at?->toISOString(), + 'is_expired' => $invitation->isExpired(), + 'is_accepted' => $invitation->isAccepted(), + 'is_valid' => $invitation->isValid(), + 'created_at' => $invitation->created_at->toISOString(), + ]; + }), + 'meta' => [ + 'current_page' => $invitations->currentPage(), + 'last_page' => $invitations->lastPage(), + 'per_page' => $invitations->perPage(), + 'total' => $invitations->total(), + ], + ]); + } +} diff --git a/backend/app/Http/Controllers/MrpController.php b/backend/app/Http/Controllers/MrpController.php new file mode 100644 index 0000000..12dbe67 --- /dev/null +++ b/backend/app/Http/Controllers/MrpController.php @@ -0,0 +1,303 @@ +only(['status', 'from_date', 'to_date']); + $perPage = $request->get('per_page', 15); + + $runs = $this->mrpService->getRuns($filters, $perPage); + + return MrpRunListResource::collection($runs); + } + + /** + * Get MRP statistics + */ + public function statistics(): JsonResponse + { + $stats = $this->mrpService->getStatistics(); + + return response()->json([ + 'data' => $stats, + ]); + } + + /** + * Create and execute a new MRP run + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'name' => 'nullable|string|max:255', + 'planning_horizon_start' => 'nullable|date', + 'planning_horizon_end' => 'nullable|date|after_or_equal:planning_horizon_start', + 'include_safety_stock' => 'nullable|boolean', + 'respect_lead_times' => 'nullable|boolean', + 'consider_wip' => 'nullable|boolean', + 'net_change' => 'nullable|boolean', + 'async' => 'nullable|boolean', // Force async mode + 'product_filters' => 'nullable|array', + 'product_filters.product_ids' => 'nullable|array', + 'product_filters.product_ids.*' => 'integer|exists:products,id', + 'product_filters.category_ids' => 'nullable|array', + 'product_filters.category_ids.*' => 'integer|exists:categories,id', + 'product_filters.make_or_buy' => 'nullable|string|in:make,buy', + 'warehouse_filters' => 'nullable|array', + 'warehouse_filters.include' => 'nullable|array', + 'warehouse_filters.include.*' => 'integer|exists:warehouses,id', + 'warehouse_filters.exclude' => 'nullable|array', + 'warehouse_filters.exclude.*' => 'integer|exists:warehouses,id', + ]); + + $async = $validated['async'] ?? null; + unset($validated['async']); + + $run = $this->mrpService->runMrp($validated, $async); + + $message = $run->status->value === 'running' + ? 'MRP run started and is processing in the background' + : 'MRP run completed successfully'; + + return response()->json([ + 'message' => $message, + 'data' => MrpRunResource::make($run), + ], 201); + } + + /** + * Get a specific MRP run + */ + public function show(MrpRun $mrpRun): JsonResponse + { + $run = $this->mrpService->getRun($mrpRun); + + return response()->json([ + 'data' => MrpRunResource::make($run), + ]); + } + + /** + * Get MRP run progress (for long-running calculations) + */ + public function progress(MrpRun $mrpRun): JsonResponse + { + $progress = $this->mrpService->getRunProgress($mrpRun); + + if ($progress === null) { + return response()->json([ + 'message' => 'No progress information available', + 'data' => null, + ], 404); + } + + return response()->json([ + 'data' => $progress, + ]); + } + + /** + * Invalidate MRP cache (call when BOMs or product structures change) + */ + public function invalidateCache(): JsonResponse + { + $this->mrpService->invalidateCache(); + + return response()->json([ + 'message' => 'MRP cache invalidated successfully', + ]); + } + + /** + * Cancel an MRP run + */ + public function cancel(MrpRun $mrpRun): JsonResponse + { + $run = $this->mrpService->cancelRun($mrpRun); + + return response()->json([ + 'message' => 'MRP run cancelled', + 'data' => MrpRunResource::make($run), + ]); + } + + // ========================================= + // MRP Recommendations + // ========================================= + + /** + * Get recommendations for an MRP run + */ + public function recommendations(Request $request, MrpRun $mrpRun): AnonymousResourceCollection + { + $filters = $request->only([ + 'status', + 'type', + 'priority', + 'product_id', + 'urgent_only', + ]); + $perPage = $request->get('per_page', 25); + + $recommendations = $this->mrpService->getRecommendations($mrpRun, $filters, $perPage); + + return MrpRecommendationResource::collection($recommendations); + } + + /** + * Approve a recommendation + */ + public function approveRecommendation(MrpRecommendation $recommendation): JsonResponse + { + $recommendation = $this->mrpService->approveRecommendation($recommendation); + + return response()->json([ + 'message' => 'Recommendation approved', + 'data' => MrpRecommendationResource::make($recommendation), + ]); + } + + /** + * Reject a recommendation + */ + public function rejectRecommendation(Request $request, MrpRecommendation $recommendation): JsonResponse + { + $validated = $request->validate([ + 'notes' => 'nullable|string|max:1000', + ]); + + $recommendation = $this->mrpService->rejectRecommendation( + $recommendation, + $validated['notes'] ?? null + ); + + return response()->json([ + 'message' => 'Recommendation rejected', + 'data' => MrpRecommendationResource::make($recommendation), + ]); + } + + /** + * Bulk approve recommendations + */ + public function bulkApprove(Request $request): JsonResponse + { + $validated = $request->validate([ + 'ids' => 'required|array|min:1', + 'ids.*' => 'integer|exists:mrp_recommendations,id', + ]); + + $count = $this->mrpService->bulkApprove($validated['ids']); + + return response()->json([ + 'message' => "{$count} recommendations approved", + 'approved_count' => $count, + ]); + } + + /** + * Bulk reject recommendations + */ + public function bulkReject(Request $request): JsonResponse + { + $validated = $request->validate([ + 'ids' => 'required|array|min:1', + 'ids.*' => 'integer|exists:mrp_recommendations,id', + 'notes' => 'nullable|string|max:1000', + ]); + + $count = $this->mrpService->bulkReject($validated['ids'], $validated['notes'] ?? null); + + return response()->json([ + 'message' => "{$count} recommendations rejected", + 'rejected_count' => $count, + ]); + } + + // ========================================= + // Helper Endpoints + // ========================================= + + /** + * Get products needing attention + */ + public function productsNeedingAttention(Request $request): JsonResponse + { + $limit = $request->get('limit', 10); + + $products = $this->mrpService->getProductsNeedingAttention($limit); + + return response()->json([ + 'data' => $products, + ]); + } + + /** + * Get MRP run statuses + */ + public function statuses(): JsonResponse + { + return response()->json([ + 'data' => MrpRunStatus::options(), + ]); + } + + /** + * Get recommendation types + */ + public function recommendationTypes(): JsonResponse + { + return response()->json([ + 'data' => MrpRecommendationType::options(), + ]); + } + + /** + * Get recommendation statuses + */ + public function recommendationStatuses(): JsonResponse + { + return response()->json([ + 'data' => MrpRecommendationStatus::options(), + ]); + } + + /** + * Get MRP priorities + */ + public function priorities(): JsonResponse + { + return response()->json([ + 'data' => MrpPriority::options(), + ]); + } +} diff --git a/backend/app/Http/Controllers/NonConformanceReportController.php b/backend/app/Http/Controllers/NonConformanceReportController.php index 210f7f9..95339d6 100644 --- a/backend/app/Http/Controllers/NonConformanceReportController.php +++ b/backend/app/Http/Controllers/NonConformanceReportController.php @@ -6,6 +6,8 @@ use App\Models\ReceivingInspection; use App\Services\NonConformanceReportService; use App\Http\Resources\NonConformanceReportResource; +use App\Enums\NcrSeverity; +use App\Enums\NcrDisposition; use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; @@ -50,20 +52,15 @@ public function store(Request $request): JsonResponse $validated = $request->validate([ 'title' => 'required|string|max:255', 'description' => 'required|string', - 'source_type' => ['nullable', Rule::in(array_keys([ - 'receiving' => 'Receiving', - 'production' => 'Production', - 'internal' => 'Internal', - 'customer' => 'Customer', - ]))], + 'source_type' => ['nullable', Rule::in(array_keys(NonConformanceReport::SOURCES))], 'product_id' => 'nullable|exists:products,id', 'supplier_id' => 'nullable|exists:suppliers,id', 'lot_number' => 'nullable|string|max:100', 'batch_number' => 'nullable|string|max:100', 'quantity_affected' => 'nullable|numeric|min:0', 'unit_of_measure' => 'nullable|string|max:20', - 'severity' => ['required', Rule::in(array_keys(NonConformanceReport::SEVERITIES))], - 'priority' => ['nullable', Rule::in(['low', 'medium', 'high', 'urgent'])], + 'severity' => ['required', Rule::in(NcrSeverity::values())], + 'priority' => ['nullable', Rule::in(array_keys(NonConformanceReport::PRIORITIES))], 'defect_type' => ['required', Rule::in(array_keys(NonConformanceReport::DEFECT_TYPES))], 'root_cause' => 'nullable|string', 'attachments' => 'nullable|array', @@ -86,8 +83,8 @@ public function createFromInspection(Request $request, ReceivingInspection $rece 'title' => 'required|string|max:255', 'description' => 'required|string', 'quantity_affected' => 'nullable|numeric|min:0', - 'severity' => ['required', Rule::in(array_keys(NonConformanceReport::SEVERITIES))], - 'priority' => ['nullable', Rule::in(['low', 'medium', 'high', 'urgent'])], + 'severity' => ['required', Rule::in(NcrSeverity::values())], + 'priority' => ['nullable', Rule::in(array_keys(NonConformanceReport::PRIORITIES))], 'defect_type' => ['required', Rule::in(array_keys(NonConformanceReport::DEFECT_TYPES))], 'root_cause' => 'nullable|string', 'attachments' => 'nullable|array', @@ -125,8 +122,8 @@ public function update(Request $request, NonConformanceReport $nonConformanceRep 'batch_number' => 'nullable|string|max:100', 'quantity_affected' => 'nullable|numeric|min:0', 'unit_of_measure' => 'nullable|string|max:20', - 'severity' => ['sometimes', 'required', Rule::in(array_keys(NonConformanceReport::SEVERITIES))], - 'priority' => ['nullable', Rule::in(['low', 'medium', 'high', 'urgent'])], + 'severity' => ['sometimes', 'required', Rule::in(NcrSeverity::values())], + 'priority' => ['nullable', Rule::in(array_keys(NonConformanceReport::PRIORITIES))], 'defect_type' => ['sometimes', 'required', Rule::in(array_keys(NonConformanceReport::DEFECT_TYPES))], 'root_cause' => 'nullable|string', 'attachments' => 'nullable|array', @@ -170,7 +167,7 @@ public function completeReview(Request $request, NonConformanceReport $nonConfor public function setDisposition(Request $request, NonConformanceReport $nonConformanceReport): JsonResource { $validated = $request->validate([ - 'disposition' => ['required', Rule::in(array_keys(NonConformanceReport::DISPOSITIONS))], + 'disposition' => ['required', Rule::in(NcrDisposition::values())], 'disposition_reason' => 'nullable|string', 'cost_impact' => 'nullable|numeric|min:0', 'cost_currency' => 'nullable|string|size:3', diff --git a/backend/app/Http/Controllers/OverDeliveryToleranceController.php b/backend/app/Http/Controllers/OverDeliveryToleranceController.php new file mode 100644 index 0000000..faabb75 --- /dev/null +++ b/backend/app/Http/Controllers/OverDeliveryToleranceController.php @@ -0,0 +1,129 @@ +company_id; + $key = $this->getSettingKey($companyId); + + $value = Setting::get($key, 0); + + // Convert array to float if needed (Setting model casts value as array) + $tolerance = is_array($value) ? (float) ($value[0] ?? 0) : (float) $value; + + return response()->json([ + 'success' => true, + 'data' => [ + 'company_id' => $companyId, + 'tolerance_percentage' => $tolerance, + 'description' => 'Default over-delivery tolerance percentage for this company', + ], + ]); + } + + /** + * Update default over-delivery tolerance for current company + */ + public function update(Request $request): JsonResponse + { + $validated = $request->validate([ + 'tolerance_percentage' => 'required|numeric|min:0|max:100', + 'description' => 'nullable|string|max:255', + ]); + + $companyId = Auth::user()->company_id; + $key = $this->getSettingKey($companyId); + + // Setting model casts value as array, so we need to store as array + // Store as [value] to work with array cast + $setting = Setting::where('group', 'delivery') + ->where('key', "default_over_delivery_tolerance.{$companyId}") + ->first(); + + if ($setting) { + $setting->update([ + 'value' => $validated['tolerance_percentage'], // Laravel will auto-cast to array + 'description' => $validated['description'] ?? $setting->description, + ]); + } else { + $setting = Setting::create([ + 'group' => 'delivery', + 'key' => "default_over_delivery_tolerance.{$companyId}", + 'value' => $validated['tolerance_percentage'], // Laravel will auto-cast to array + 'description' => $validated['description'] ?? 'Default over-delivery tolerance percentage for this company', + 'is_system' => false, + ]); + } + + // Clear cache + Cache::tags(['settings'])->forget("settings.delivery.default_over_delivery_tolerance.{$companyId}"); + Cache::tags(['settings'])->forget("settings.delivery"); + + return response()->json([ + 'success' => true, + 'message' => 'Over-delivery tolerance updated successfully', + 'data' => [ + 'company_id' => $companyId, + 'tolerance_percentage' => (float) $validated['tolerance_percentage'], + 'description' => $setting->description, + ], + ]); + } + + /** + * Get tolerance settings for all levels (Company, Product, Category) + * + * Note: System-level removed as this is a SaaS application where each company + * manages its own tolerance settings. Company-level is the final fallback. + */ + public function levels(Request $request): JsonResponse + { + $companyId = Auth::user()->company_id; + + // Get company-level setting + $companyKey = $this->getSettingKey($companyId); + $companyValue = Setting::get($companyKey, null); + $companyTolerance = is_array($companyValue) ? (float) ($companyValue[0] ?? null) : (float) $companyValue; + if ($companyTolerance == 0 && $companyValue !== 0) { + $companyTolerance = null; + } + + return response()->json([ + 'success' => true, + 'data' => [ + 'company' => [ + 'tolerance_percentage' => $companyTolerance, + 'level' => 'company', + 'description' => 'Company-specific default tolerance (final fallback)', + ], + 'fallback_order' => [ + '1' => 'Order Item Level (most specific)', + '2' => 'Product Level', + '3' => 'Category Level', + '4' => 'Company Level (least specific, final fallback)', + ], + 'note' => 'This is a SaaS application. Each company manages its own tolerance settings. Company-level is the final fallback (no system-level tolerance).', + ], + ]); + } + + /** + * Get setting key for company-specific tolerance + */ + protected function getSettingKey(int $companyId): string + { + return "delivery.default_over_delivery_tolerance.{$companyId}"; + } +} diff --git a/backend/app/Http/Controllers/ProductController.php b/backend/app/Http/Controllers/ProductController.php index 7c7f546..bcaaf3e 100644 --- a/backend/app/Http/Controllers/ProductController.php +++ b/backend/app/Http/Controllers/ProductController.php @@ -60,10 +60,20 @@ public function index(Request $request): AnonymousResourceCollection */ public function store(Request $request): JsonResponse { + $companyId = $request->user()->company_id; + $validated = $request->validate([ 'name' => 'required|string|max:255', - 'slug' => 'nullable|string|unique:products,slug', - 'sku' => 'required|string|unique:products,sku', + 'slug' => [ + 'nullable', + 'string', + Rule::unique('products')->where('company_id', $companyId), + ], + 'sku' => [ + 'required', + 'string', + Rule::unique('products')->where('company_id', $companyId), + ], 'description' => 'nullable|string', 'short_description' => 'nullable|string', 'price' => 'required|numeric|min:0', @@ -77,6 +87,19 @@ public function store(Request $request): JsonResponse 'category_ids' => 'nullable|array', 'category_ids.*' => 'exists:categories,id', 'primary_category_id' => 'nullable|exists:categories,id', + // MRP Planning fields + 'lead_time_days' => 'nullable|integer|min:0', + 'safety_stock' => 'nullable|numeric|min:0', + 'reorder_point' => 'nullable|numeric|min:0', + 'make_or_buy' => 'nullable|string|in:make,buy', + 'minimum_order_qty' => 'nullable|numeric|min:0', + 'order_multiple' => 'nullable|numeric|min:1', + 'maximum_stock' => 'nullable|numeric|min:0', + // Negative stock policy + 'negative_stock_policy' => 'nullable|string|in:NEVER,ALLOWED,LIMITED', + 'negative_stock_limit' => 'nullable|numeric|min:0', + // Reservation policy + 'reservation_policy' => 'nullable|string|in:full,partial,reject,wait', ]); $product = $this->productService->create($validated); @@ -110,10 +133,22 @@ public function show(Product $product): JsonResource */ public function update(Request $request, Product $product): JsonResource { + $companyId = $request->user()->company_id; + $validated = $request->validate([ 'name' => 'sometimes|required|string|max:255', - 'slug' => ['sometimes', 'required', 'string', Rule::unique('products')->ignore($product->id)], - 'sku' => ['sometimes', 'required', 'string', Rule::unique('products')->ignore($product->id)], + 'slug' => [ + 'sometimes', + 'required', + 'string', + Rule::unique('products')->where('company_id', $companyId)->ignore($product->id), + ], + 'sku' => [ + 'sometimes', + 'required', + 'string', + Rule::unique('products')->where('company_id', $companyId)->ignore($product->id), + ], 'description' => 'nullable|string', 'short_description' => 'nullable|string', 'price' => 'sometimes|required|numeric|min:0', @@ -127,6 +162,19 @@ public function update(Request $request, Product $product): JsonResource 'category_ids' => 'nullable|array', 'category_ids.*' => 'exists:categories,id', 'primary_category_id' => 'nullable|exists:categories,id', + // MRP Planning fields + 'lead_time_days' => 'nullable|integer|min:0', + 'safety_stock' => 'nullable|numeric|min:0', + 'reorder_point' => 'nullable|numeric|min:0', + 'make_or_buy' => 'nullable|string|in:make,buy', + 'minimum_order_qty' => 'nullable|numeric|min:0', + 'order_multiple' => 'nullable|numeric|min:1', + 'maximum_stock' => 'nullable|numeric|min:0', + // Negative stock policy + 'negative_stock_policy' => 'nullable|string|in:NEVER,ALLOWED,LIMITED', + 'negative_stock_limit' => 'nullable|numeric|min:0', + // Reservation policy + 'reservation_policy' => 'nullable|string|in:full,partial,reject,wait', ]); $product = $this->productService->update($product, $validated); diff --git a/backend/app/Http/Controllers/ProductUomConversionController.php b/backend/app/Http/Controllers/ProductUomConversionController.php new file mode 100644 index 0000000..dab20a9 --- /dev/null +++ b/backend/app/Http/Controllers/ProductUomConversionController.php @@ -0,0 +1,241 @@ +conversionService = $conversionService; + } + + /** + * Get all conversions for a product + */ + public function index(Product $product): JsonResponse + { + $data = $this->conversionService->getConversionsForProduct($product); + + return response()->json([ + 'data' => $data, + ]); + } + + /** + * Store a new conversion for a product + */ + public function store(Request $request, Product $product): JsonResponse + { + $validated = $request->validate([ + 'from_uom_id' => 'required|exists:units_of_measure,id', + 'to_uom_id' => 'required|exists:units_of_measure,id|different:from_uom_id', + 'conversion_factor' => 'required|numeric|gt:0', + 'is_default' => 'boolean', + 'is_active' => 'boolean', + ]); + + // Check if conversion already exists + $exists = $product->uomConversions() + ->where('from_uom_id', $validated['from_uom_id']) + ->where('to_uom_id', $validated['to_uom_id']) + ->exists(); + + if ($exists) { + return response()->json([ + 'message' => 'A conversion between these units already exists for this product.', + ], 422); + } + + $conversion = $this->conversionService->create($product, $validated); + + return response()->json([ + 'message' => 'Product conversion created successfully', + 'data' => $conversion->load(['fromUom', 'toUom']), + ], 201); + } + + /** + * Show a specific conversion + */ + public function show(Product $product, ProductUomConversion $conversion): JsonResponse + { + // Ensure conversion belongs to product + if ($conversion->product_id !== $product->id) { + return response()->json([ + 'message' => 'Conversion not found for this product.', + ], 404); + } + + return response()->json([ + 'data' => $conversion->load(['fromUom', 'toUom', 'product']), + ]); + } + + /** + * Update a conversion + */ + public function update(Request $request, Product $product, ProductUomConversion $conversion): JsonResponse + { + // Ensure conversion belongs to product + if ($conversion->product_id !== $product->id) { + return response()->json([ + 'message' => 'Conversion not found for this product.', + ], 404); + } + + $validated = $request->validate([ + 'from_uom_id' => 'sometimes|required|exists:units_of_measure,id', + 'to_uom_id' => 'sometimes|required|exists:units_of_measure,id', + 'conversion_factor' => 'sometimes|required|numeric|gt:0', + 'is_default' => 'boolean', + 'is_active' => 'boolean', + ]); + + // Check for duplicate if changing units + if (isset($validated['from_uom_id']) || isset($validated['to_uom_id'])) { + $fromId = $validated['from_uom_id'] ?? $conversion->from_uom_id; + $toId = $validated['to_uom_id'] ?? $conversion->to_uom_id; + + if ($fromId === $toId) { + return response()->json([ + 'message' => 'From and To units must be different.', + ], 422); + } + + $exists = $product->uomConversions() + ->where('id', '!=', $conversion->id) + ->where('from_uom_id', $fromId) + ->where('to_uom_id', $toId) + ->exists(); + + if ($exists) { + return response()->json([ + 'message' => 'A conversion between these units already exists for this product.', + ], 422); + } + } + + $conversion = $this->conversionService->update($conversion, $validated); + + return response()->json([ + 'message' => 'Product conversion updated successfully', + 'data' => $conversion, + ]); + } + + /** + * Delete a conversion + */ + public function destroy(Product $product, ProductUomConversion $conversion): JsonResponse + { + // Ensure conversion belongs to product + if ($conversion->product_id !== $product->id) { + return response()->json([ + 'message' => 'Conversion not found for this product.', + ], 404); + } + + $this->conversionService->delete($conversion); + + return response()->json([ + 'message' => 'Product conversion deleted successfully', + ]); + } + + /** + * Toggle conversion active status + */ + public function toggleActive(Product $product, ProductUomConversion $conversion): JsonResponse + { + // Ensure conversion belongs to product + if ($conversion->product_id !== $product->id) { + return response()->json([ + 'message' => 'Conversion not found for this product.', + ], 404); + } + + $conversion = $this->conversionService->toggleActive($conversion); + + return response()->json([ + 'message' => 'Product conversion status updated successfully', + 'data' => $conversion, + ]); + } + + /** + * Convert quantity using product-specific or standard conversion + */ + public function convert(Request $request, Product $product): JsonResponse + { + $validated = $request->validate([ + 'quantity' => 'required|numeric|gt:0', + 'from_uom_id' => 'required|exists:units_of_measure,id', + 'to_uom_id' => 'required|exists:units_of_measure,id', + ]); + + $result = $this->conversionService->convert( + $product, + $validated['quantity'], + $validated['from_uom_id'], + $validated['to_uom_id'] + ); + + if (!$result['success']) { + return response()->json([ + 'message' => $result['error'], + ], 422); + } + + return response()->json([ + 'data' => $result, + ]); + } + + /** + * Bulk create conversions + */ + public function bulkStore(Request $request, Product $product): JsonResponse + { + $validated = $request->validate([ + 'conversions' => 'required|array|min:1', + 'conversions.*.from_uom_id' => 'required|exists:units_of_measure,id', + 'conversions.*.to_uom_id' => 'required|exists:units_of_measure,id', + 'conversions.*.conversion_factor' => 'required|numeric|gt:0', + 'conversions.*.is_default' => 'boolean', + 'conversions.*.is_active' => 'boolean', + ]); + + $created = $this->conversionService->bulkCreate($product, $validated['conversions']); + + return response()->json([ + 'message' => count($created) . ' conversions created successfully', + 'data' => $created, + ], 201); + } + + /** + * Copy conversions from another product + */ + public function copyFrom(Request $request, Product $product): JsonResponse + { + $validated = $request->validate([ + 'source_product_id' => 'required|exists:products,id', + ]); + + $sourceProduct = Product::findOrFail($validated['source_product_id']); + $copied = $this->conversionService->copyFromProduct($sourceProduct, $product); + + return response()->json([ + 'message' => count($copied) . ' conversions copied successfully', + 'data' => $copied, + ], 201); + } +} diff --git a/backend/app/Http/Controllers/PurchaseOrderController.php b/backend/app/Http/Controllers/PurchaseOrderController.php index 8f205b5..abf7675 100644 --- a/backend/app/Http/Controllers/PurchaseOrderController.php +++ b/backend/app/Http/Controllers/PurchaseOrderController.php @@ -46,7 +46,7 @@ public function store(Request $request): JsonResponse { $validated = $request->validate([ 'order_number' => 'nullable|string|max:50', - 'supplier_id' => 'required|exists:suppliers,id', + 'supplier_id' => 'nullable|exists:suppliers,id', 'warehouse_id' => 'required|exists:warehouses,id', 'order_date' => 'nullable|date', 'expected_delivery_date' => 'nullable|date|after_or_equal:order_date', @@ -98,7 +98,7 @@ public function show(PurchaseOrder $purchaseOrder): JsonResource public function update(Request $request, PurchaseOrder $purchaseOrder): JsonResource { $validated = $request->validate([ - 'supplier_id' => 'sometimes|required|exists:suppliers,id', + 'supplier_id' => 'sometimes|nullable|exists:suppliers,id', 'warehouse_id' => 'sometimes|required|exists:warehouses,id', 'order_date' => 'nullable|date', 'expected_delivery_date' => 'nullable|date', diff --git a/backend/app/Http/Controllers/ReceivingInspectionController.php b/backend/app/Http/Controllers/ReceivingInspectionController.php index d4e9983..afeb60b 100644 --- a/backend/app/Http/Controllers/ReceivingInspectionController.php +++ b/backend/app/Http/Controllers/ReceivingInspectionController.php @@ -164,4 +164,22 @@ public function dispositions(): JsonResponse 'data' => $this->inspectionService->getDispositions(), ]); } + + /** + * Transfer inspection items to QC zone (quarantine or rejection warehouse) + */ + public function transferToQcZone(Request $request, ReceivingInspection $receivingInspection): JsonResource + { + $validated = $request->validate([ + 'target_warehouse_id' => 'required|exists:warehouses,id', + ]); + + $inspection = $this->inspectionService->transferToQcZone( + $receivingInspection, + $validated['target_warehouse_id'] + ); + + return ReceivingInspectionResource::make($inspection) + ->additional(['message' => 'Items transferred to QC zone successfully']); + } } diff --git a/backend/app/Http/Controllers/RoutingController.php b/backend/app/Http/Controllers/RoutingController.php new file mode 100644 index 0000000..53f3b68 --- /dev/null +++ b/backend/app/Http/Controllers/RoutingController.php @@ -0,0 +1,319 @@ +only([ + 'search', + 'product_id', + 'status', + 'is_default', + 'active_only', + ]); + $perPage = $request->get('per_page', 15); + + $routings = $this->routingService->getRoutings($filters, $perPage); + + return RoutingListResource::collection($routings); + } + + /** + * Get all active routings for dropdowns + */ + public function list(): JsonResponse + { + $routings = $this->routingService->getActiveRoutings(); + + return response()->json([ + 'data' => RoutingListResource::collection($routings), + ]); + } + + /** + * Get routings for a specific product + */ + public function forProduct(int $productId): JsonResponse + { + $routings = $this->routingService->getRoutingsForProduct($productId); + + return response()->json([ + 'data' => RoutingListResource::collection($routings), + ]); + } + + /** + * Store a newly created routing + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'product_id' => 'required|exists:products,id', + 'routing_number' => 'nullable|string|max:50', + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'is_default' => 'boolean', + 'effective_date' => 'nullable|date', + 'expiry_date' => 'nullable|date|after_or_equal:effective_date', + 'notes' => 'nullable|string', + 'meta_data' => 'nullable|array', + 'operations' => 'nullable|array', + 'operations.*.work_center_id' => 'required|exists:work_centers,id', + 'operations.*.operation_number' => 'nullable|integer|min:1', + 'operations.*.name' => 'required|string|max:255', + 'operations.*.description' => 'nullable|string', + 'operations.*.setup_time' => 'nullable|numeric|min:0', + 'operations.*.run_time_per_unit' => 'nullable|numeric|min:0', + 'operations.*.queue_time' => 'nullable|numeric|min:0', + 'operations.*.move_time' => 'nullable|numeric|min:0', + 'operations.*.is_subcontracted' => 'boolean', + 'operations.*.subcontractor_id' => 'nullable|exists:suppliers,id', + 'operations.*.subcontract_cost' => 'nullable|numeric|min:0', + 'operations.*.instructions' => 'nullable|string', + ]); + + $routing = $this->routingService->create($validated); + + return response()->json([ + 'message' => 'Routing created successfully', + 'data' => RoutingResource::make($routing), + ], 201); + } + + /** + * Display the specified routing + */ + public function show(Routing $routing): JsonResource + { + return RoutingResource::make( + $this->routingService->getRouting($routing) + ); + } + + /** + * Update the specified routing + */ + public function update(Request $request, Routing $routing): JsonResource + { + $validated = $request->validate([ + 'name' => 'sometimes|required|string|max:255', + 'description' => 'nullable|string', + 'effective_date' => 'nullable|date', + 'expiry_date' => 'nullable|date|after_or_equal:effective_date', + 'notes' => 'nullable|string', + 'meta_data' => 'nullable|array', + ]); + + $routing = $this->routingService->update($routing, $validated); + + return RoutingResource::make($routing) + ->additional(['message' => 'Routing updated successfully']); + } + + /** + * Remove the specified routing + */ + public function destroy(Routing $routing): JsonResponse + { + $this->routingService->delete($routing); + + return response()->json([ + 'message' => 'Routing deleted successfully', + ]); + } + + /** + * Add operation to routing + */ + public function addOperation(Request $request, Routing $routing): JsonResponse + { + $validated = $request->validate([ + 'work_center_id' => 'required|exists:work_centers,id', + 'operation_number' => 'nullable|integer|min:1', + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'setup_time' => 'nullable|numeric|min:0', + 'run_time_per_unit' => 'nullable|numeric|min:0', + 'queue_time' => 'nullable|numeric|min:0', + 'move_time' => 'nullable|numeric|min:0', + 'is_subcontracted' => 'boolean', + 'subcontractor_id' => 'nullable|exists:suppliers,id', + 'subcontract_cost' => 'nullable|numeric|min:0', + 'instructions' => 'nullable|string', + 'settings' => 'nullable|array', + ]); + + $operation = $this->routingService->addOperation($routing, $validated); + + return response()->json([ + 'message' => 'Operation added to routing successfully', + 'data' => RoutingOperationResource::make($operation->load('workCenter')), + ], 201); + } + + /** + * Update routing operation + */ + public function updateOperation(Request $request, Routing $routing, int $operationId): JsonResponse + { + $validated = $request->validate([ + 'work_center_id' => 'sometimes|exists:work_centers,id', + 'operation_number' => 'nullable|integer|min:1', + 'name' => 'sometimes|required|string|max:255', + 'description' => 'nullable|string', + 'setup_time' => 'nullable|numeric|min:0', + 'run_time_per_unit' => 'nullable|numeric|min:0', + 'queue_time' => 'nullable|numeric|min:0', + 'move_time' => 'nullable|numeric|min:0', + 'is_subcontracted' => 'boolean', + 'subcontractor_id' => 'nullable|exists:suppliers,id', + 'subcontract_cost' => 'nullable|numeric|min:0', + 'instructions' => 'nullable|string', + 'settings' => 'nullable|array', + ]); + + $operation = $this->routingService->updateOperation($routing, $operationId, $validated); + + return response()->json([ + 'message' => 'Routing operation updated successfully', + 'data' => RoutingOperationResource::make($operation->load('workCenter')), + ]); + } + + /** + * Remove operation from routing + */ + public function removeOperation(Routing $routing, int $operationId): JsonResponse + { + $this->routingService->removeOperation($routing, $operationId); + + return response()->json([ + 'message' => 'Operation removed from routing successfully', + ]); + } + + /** + * Reorder operations + */ + public function reorderOperations(Request $request, Routing $routing): JsonResponse + { + $validated = $request->validate([ + 'operation_ids' => 'required|array', + 'operation_ids.*' => 'required|integer|exists:routing_operations,id', + ]); + + $this->routingService->reorderOperations($routing, $validated['operation_ids']); + + return response()->json([ + 'message' => 'Operations reordered successfully', + 'data' => RoutingResource::make($routing->fresh('operations')), + ]); + } + + /** + * Activate routing + */ + public function activate(Routing $routing): JsonResponse + { + $routing = $this->routingService->activate($routing); + + return response()->json([ + 'message' => 'Routing activated successfully', + 'data' => RoutingResource::make($routing), + ]); + } + + /** + * Mark routing as obsolete + */ + public function obsolete(Routing $routing): JsonResponse + { + $routing = $this->routingService->obsolete($routing); + + return response()->json([ + 'message' => 'Routing marked as obsolete successfully', + 'data' => RoutingResource::make($routing), + ]); + } + + /** + * Set routing as default + */ + public function setDefault(Routing $routing): JsonResponse + { + $routing = $this->routingService->setAsDefault($routing); + + return response()->json([ + 'message' => 'Routing set as default successfully', + 'data' => RoutingResource::make($routing), + ]); + } + + /** + * Copy routing to new version + */ + public function copy(Request $request, Routing $routing): JsonResponse + { + $validated = $request->validate([ + 'name' => 'nullable|string|max:255', + ]); + + $newRouting = $this->routingService->copy($routing, $validated['name'] ?? null); + + return response()->json([ + 'message' => 'Routing copied successfully', + 'data' => RoutingResource::make($newRouting), + ], 201); + } + + /** + * Calculate lead time + */ + public function calculateLeadTime(Request $request, Routing $routing): JsonResponse + { + $validated = $request->validate([ + 'quantity' => 'required|numeric|min:0.0001', + ]); + + $leadTime = $this->routingService->calculateLeadTime($routing, $validated['quantity']); + + return response()->json([ + 'data' => [ + 'routing' => RoutingListResource::make($routing), + 'quantity' => $validated['quantity'], + 'lead_time' => $leadTime, + ], + ]); + } + + /** + * Get routing statuses + */ + public function statuses(): JsonResponse + { + return response()->json([ + 'data' => RoutingStatus::options(), + ]); + } +} diff --git a/backend/app/Http/Controllers/SalesOrderController.php b/backend/app/Http/Controllers/SalesOrderController.php new file mode 100644 index 0000000..63781a2 --- /dev/null +++ b/backend/app/Http/Controllers/SalesOrderController.php @@ -0,0 +1,242 @@ +only([ + 'search', + 'status', + 'customer_id', + 'from_date', + 'to_date', + 'pending_approval', + ]); + $perPage = $request->get('per_page', 15); + + $salesOrders = $this->salesOrderService->getSalesOrders($filters, $perPage); + + return SalesOrderListResource::collection($salesOrders); + } + + /** + * Store a newly created sales order + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'customer_id' => 'required|exists:customers,id', + 'warehouse_id' => 'required|exists:warehouses,id', + 'order_date' => 'nullable|date', + 'expected_delivery_date' => 'nullable|date|after_or_equal:order_date', + 'shipping_address' => 'nullable|string', + 'notes' => 'nullable|string', + 'internal_notes' => 'nullable|string', + 'items' => 'required|array|min:1', + 'items.*.product_id' => 'required|exists:products,id', + 'items.*.quantity' => 'required|numeric|min:0.01', + 'items.*.unit_price' => 'nullable|numeric|min:0', + 'items.*.discount_amount' => 'nullable|numeric|min:0', + 'items.*.tax_amount' => 'nullable|numeric|min:0', + 'items.*.notes' => 'nullable|string', + ]); + + $salesOrder = $this->salesOrderService->create($validated); + + return response()->json([ + 'message' => 'Sales order created successfully', + 'data' => SalesOrderResource::make($salesOrder), + ], 201); + } + + /** + * Display the specified sales order + */ + public function show(SalesOrder $salesOrder): JsonResource + { + return SalesOrderResource::make( + $this->salesOrderService->getSalesOrder($salesOrder) + ); + } + + /** + * Update the specified sales order + */ + public function update(Request $request, SalesOrder $salesOrder): JsonResource + { + $validated = $request->validate([ + 'expected_delivery_date' => 'nullable|date', + 'shipping_address' => 'nullable|string', + 'notes' => 'nullable|string', + 'internal_notes' => 'nullable|string', + 'discount_amount' => 'nullable|numeric|min:0', + 'tax_amount' => 'nullable|numeric|min:0', + 'items' => 'nullable|array|min:1', + 'items.*.product_id' => 'required|exists:products,id', + 'items.*.quantity' => 'required|numeric|min:0.01', + 'items.*.unit_price' => 'nullable|numeric|min:0', + 'items.*.discount_amount' => 'nullable|numeric|min:0', + 'items.*.tax_amount' => 'nullable|numeric|min:0', + 'items.*.notes' => 'nullable|string', + ]); + + $salesOrder = $this->salesOrderService->update($salesOrder, $validated); + + return SalesOrderResource::make($salesOrder) + ->additional(['message' => 'Sales order updated successfully']); + } + + /** + * Remove the specified sales order + */ + public function destroy(SalesOrder $salesOrder): JsonResponse + { + $this->salesOrderService->delete($salesOrder); + + return response()->json([ + 'message' => 'Sales order deleted successfully', + ]); + } + + /** + * Submit order for approval + */ + public function submitForApproval(SalesOrder $salesOrder): JsonResponse + { + $salesOrder = $this->salesOrderService->submitForApproval($salesOrder); + + return response()->json([ + 'message' => 'Sales order submitted for approval', + 'data' => SalesOrderResource::make($salesOrder), + ]); + } + + /** + * Approve sales order + */ + public function approve(SalesOrder $salesOrder): JsonResponse + { + $salesOrder = $this->salesOrderService->approve($salesOrder); + + return response()->json([ + 'message' => 'Sales order approved', + 'data' => SalesOrderResource::make($salesOrder), + ]); + } + + /** + * Reject sales order + */ + public function reject(Request $request, SalesOrder $salesOrder): JsonResponse + { + $validated = $request->validate([ + 'reason' => 'nullable|string|max:1000', + ]); + + $salesOrder = $this->salesOrderService->reject($salesOrder, $validated['reason'] ?? null); + + return response()->json([ + 'message' => 'Sales order rejected', + 'data' => SalesOrderResource::make($salesOrder), + ]); + } + + /** + * Confirm sales order + */ + public function confirm(SalesOrder $salesOrder): JsonResponse + { + $salesOrder = $this->salesOrderService->confirm($salesOrder); + + return response()->json([ + 'message' => 'Sales order confirmed', + 'data' => SalesOrderResource::make($salesOrder), + ]); + } + + /** + * Mark order as shipped + */ + public function markAsShipped(SalesOrder $salesOrder): JsonResponse + { + $salesOrder = $this->salesOrderService->markAsShipped($salesOrder); + + return response()->json([ + 'message' => 'Sales order marked as shipped', + 'data' => SalesOrderResource::make($salesOrder), + ]); + } + + /** + * Mark order as delivered + */ + public function markAsDelivered(SalesOrder $salesOrder): JsonResponse + { + $salesOrder = $this->salesOrderService->markAsDelivered($salesOrder); + + return response()->json([ + 'message' => 'Sales order marked as delivered', + 'data' => SalesOrderResource::make($salesOrder), + ]); + } + + /** + * Cancel sales order + */ + public function cancel(Request $request, SalesOrder $salesOrder): JsonResponse + { + $validated = $request->validate([ + 'reason' => 'nullable|string|max:1000', + ]); + + $salesOrder = $this->salesOrderService->cancel($salesOrder, $validated['reason'] ?? null); + + return response()->json([ + 'message' => 'Sales order cancelled', + 'data' => SalesOrderResource::make($salesOrder), + ]); + } + + /** + * Get sales order statistics + */ + public function statistics(Request $request): JsonResponse + { + $filters = $request->only(['from_date', 'to_date']); + $stats = $this->salesOrderService->getStatistics($filters); + + return response()->json([ + 'data' => $stats, + ]); + } + + /** + * Get available statuses + */ + public function statuses(): JsonResponse + { + $statuses = $this->salesOrderService->getStatuses(); + + return response()->json([ + 'data' => $statuses, + ]); + } +} diff --git a/backend/app/Http/Controllers/SettingController.php b/backend/app/Http/Controllers/SettingController.php index fe3e111..198d442 100644 --- a/backend/app/Http/Controllers/SettingController.php +++ b/backend/app/Http/Controllers/SettingController.php @@ -99,6 +99,15 @@ public function update(Request $request, string $group, string $key): JsonRespon ], 404); } + // System settings can only be modified by admin + // Route already has permission:settings.edit middleware, but adding extra check for security + if ($setting->is_system && !$request->user()->hasRole('admin')) { + return response()->json([ + 'success' => false, + 'message' => 'System settings can only be modified by administrators', + ], 403); + } + $validated = $request->validate([ 'value' => 'required', 'description' => 'nullable|string|max:255', diff --git a/backend/app/Http/Controllers/StockDebtController.php b/backend/app/Http/Controllers/StockDebtController.php new file mode 100644 index 0000000..3e5562f --- /dev/null +++ b/backend/app/Http/Controllers/StockDebtController.php @@ -0,0 +1,106 @@ +where('company_id', Auth::user()->company_id); + + // Filters + if ($request->has('product_id')) { + $query->where('product_id', $request->product_id); + } + + if ($request->has('warehouse_id')) { + $query->where('warehouse_id', $request->warehouse_id); + } + + if ($request->has('outstanding_only') && $request->boolean('outstanding_only')) { + $query->outstanding(); + } + + if ($request->has('reconciled_only') && $request->boolean('reconciled_only')) { + $query->reconciled(); + } + + // Sorting + $sortBy = $request->get('sort_by', 'created_at'); + $sortOrder = $request->get('sort_order', 'desc'); + $query->orderBy($sortBy, $sortOrder); + + $perPage = $request->get('per_page', 15); + $debts = $query->paginate($perPage); + + return StockDebtResource::collection($debts); + } + + /** + * Display the specified stock debt + */ + public function show(StockDebt $stockDebt): JsonResponse + { + $stockDebt->load(['product', 'warehouse', 'stockMovement', 'reference']); + + return response()->json([ + 'data' => StockDebtResource::make($stockDebt), + ]); + } + + /** + * Get negative stock alerts + */ + public function alerts(): JsonResponse + { + $alerts = $this->alertService->getNegativeStockAlerts(); + + return response()->json([ + 'data' => $alerts, + 'count' => $alerts->count(), + ]); + } + + /** + * Get weekly negative stock report + */ + public function weeklyReport(): JsonResponse + { + $report = $this->alertService->getWeeklyNegativeStockReport(); + + return response()->json([ + 'data' => $report, + ]); + } + + /** + * Get long-term negative stock (outstanding for more than threshold days) + */ + public function longTerm(Request $request): JsonResponse + { + $thresholdDays = $request->get('threshold_days', 7); + $longTermDebts = $this->alertService->checkLongTermNegativeStock($thresholdDays); + + return response()->json([ + 'data' => $longTermDebts, + 'count' => $longTermDebts->count(), + 'threshold_days' => $thresholdDays, + ]); + } +} diff --git a/backend/app/Http/Controllers/SupplierController.php b/backend/app/Http/Controllers/SupplierController.php index 80847d3..11ed749 100644 --- a/backend/app/Http/Controllers/SupplierController.php +++ b/backend/app/Http/Controllers/SupplierController.php @@ -269,4 +269,50 @@ public function forProduct(int $productId): JsonResponse 'data' => SupplierListResource::collection($suppliers), ]); } + + /** + * Get supplier quality score + */ + public function qualityScore(Supplier $supplier): JsonResponse + { + $score = $this->supplierService->getQualityScore($supplier); + + return response()->json([ + 'data' => $score, + ]); + } + + /** + * Get supplier quality statistics + */ + public function qualityStatistics(Request $request, Supplier $supplier): JsonResponse + { + $dateRange = null; + if ($request->filled('from_date') && $request->filled('to_date')) { + $dateRange = [ + 'from' => $request->get('from_date'), + 'to' => $request->get('to_date'), + ]; + } + + $stats = $this->supplierService->getQualityStatistics($supplier, $dateRange); + + return response()->json([ + 'data' => $stats, + ]); + } + + /** + * Get quality score ranking for all suppliers + */ + public function qualityRanking(Request $request): JsonResponse + { + $limit = $request->get('limit', 10); + + $ranking = $this->supplierService->getQualityScoreRanking($limit); + + return response()->json([ + 'data' => $ranking, + ]); + } } diff --git a/backend/app/Http/Controllers/UnitOfMeasureController.php b/backend/app/Http/Controllers/UnitOfMeasureController.php index 63ae664..a618e74 100644 --- a/backend/app/Http/Controllers/UnitOfMeasureController.php +++ b/backend/app/Http/Controllers/UnitOfMeasureController.php @@ -2,10 +2,12 @@ namespace App\Http\Controllers; +use App\Enums\UomType; use App\Models\UnitOfMeasure; use App\Services\UnitOfMeasureService; use Illuminate\Http\Request; use Illuminate\Http\JsonResponse; +use Illuminate\Validation\Rule; class UnitOfMeasureController extends Controller { @@ -43,7 +45,7 @@ public function grouped(): JsonResponse return response()->json([ 'data' => $grouped, - 'types' => UnitOfMeasure::TYPES, + 'types' => UomType::options(), ]); } @@ -55,7 +57,7 @@ public function store(Request $request): JsonResponse $validated = $request->validate([ 'code' => 'required|string|max:20', 'name' => 'required|string|max:100', - 'uom_type' => 'required|in:weight,volume,length,area,quantity,time', + 'uom_type' => ['required', Rule::enum(UomType::class)], 'base_unit_id' => 'nullable|exists:units_of_measure,id', 'conversion_factor' => 'nullable|numeric|min:0', 'precision' => 'integer|min:0|max:6', @@ -88,7 +90,7 @@ public function update(Request $request, UnitOfMeasure $unitOfMeasure): JsonResp $validated = $request->validate([ 'code' => 'sometimes|required|string|max:20', 'name' => 'sometimes|required|string|max:100', - 'uom_type' => 'sometimes|required|in:weight,volume,length,area,quantity,time', + 'uom_type' => ['sometimes', 'required', Rule::enum(UomType::class)], 'base_unit_id' => 'nullable|exists:units_of_measure,id', 'conversion_factor' => 'nullable|numeric|min:0', 'precision' => 'integer|min:0|max:6', diff --git a/backend/app/Http/Controllers/UserController.php b/backend/app/Http/Controllers/UserController.php index 4dcc332..03a2c58 100644 --- a/backend/app/Http/Controllers/UserController.php +++ b/backend/app/Http/Controllers/UserController.php @@ -58,7 +58,7 @@ public function store(Request $request): JsonResponse 'email' => 'required|string|email|max:255|unique:users', 'password' => 'required|string|min:8|confirmed', 'role_ids' => 'sometimes|array', - 'role_ids.*' => 'exists:roles,id', + 'role_ids.*' => 'required|integer|exists:roles,id', ]); $user = $this->userService->createUser($validated); @@ -81,6 +81,13 @@ public function show(Request $request, User $user): JsonResource|JsonResponse ], 403); } + // Company ownership check (defense in depth - BelongsToCompany scope already filters) + if ($user->company_id !== $request->user()->company_id) { + return response()->json([ + 'message' => 'Forbidden. You can only view users from your company.', + ], 403); + } + $user->load(['roles', 'company']); return UserResource::make($user); @@ -98,6 +105,13 @@ public function update(Request $request, User $user): JsonResource|JsonResponse ], 403); } + // Company ownership check (defense in depth - BelongsToCompany scope already filters) + if ($user->company_id !== $request->user()->company_id) { + return response()->json([ + 'message' => 'Forbidden. You can only update users from your company.', + ], 403); + } + $validated = $request->validate([ 'first_name' => 'required|string|max:255', 'last_name' => 'sometimes|required|string|max:255', @@ -114,6 +128,9 @@ public function update(Request $request, User $user): JsonResource|JsonResponse 'role_ids.*' => 'exists:roles,id', ]); + // Security: Prevent company_id from being changed + unset($validated['company_id']); + $user = $this->userService->updateUser($user, $validated); return UserResource::make($user) @@ -132,6 +149,13 @@ public function destroy(Request $request, User $user): JsonResponse ], 403); } + // Company ownership check (defense in depth - BelongsToCompany scope already filters) + if ($user->company_id !== $request->user()->company_id) { + return response()->json([ + 'message' => 'Forbidden. You can only delete users from your company.', + ], 403); + } + if ($user->id === $request->user()->id) { return response()->json([ 'message' => 'You cannot delete yourself.', @@ -165,6 +189,13 @@ public function restore(Request $request, int $id): JsonResource|JsonResponse ], 404); } + // Company ownership check + if ($user->company_id !== $request->user()->company_id) { + return response()->json([ + 'message' => 'Forbidden. You can only restore users from your company.', + ], 403); + } + $user->load(['roles', 'company']); return UserResource::make($user) @@ -191,6 +222,13 @@ public function forceDelete(Request $request, int $id): JsonResponse ], 404); } + // Company ownership check + if ($user->company_id !== $request->user()->company_id) { + return response()->json([ + 'message' => 'Forbidden. You can only permanently delete users from your company.', + ], 403); + } + if ($user->id === $request->user()->id) { return response()->json([ 'message' => 'You cannot delete yourself.', diff --git a/backend/app/Http/Controllers/WarehouseController.php b/backend/app/Http/Controllers/WarehouseController.php index 21aa99e..a3798fb 100644 --- a/backend/app/Http/Controllers/WarehouseController.php +++ b/backend/app/Http/Controllers/WarehouseController.php @@ -165,4 +165,49 @@ public function stockSummary(Warehouse $warehouse): JsonResponse 'data' => $summary, ]); } + + /** + * Get all quarantine zone warehouses + */ + public function quarantineZones(): JsonResponse + { + $warehouses = Warehouse::quarantineZones() + ->where('is_active', true) + ->get(['id', 'code', 'name']); + + return response()->json([ + 'data' => $warehouses, + ]); + } + + /** + * Get all rejection zone warehouses + */ + public function rejectionZones(): JsonResponse + { + $warehouses = Warehouse::rejectionZones() + ->where('is_active', true) + ->get(['id', 'code', 'name']); + + return response()->json([ + 'data' => $warehouses, + ]); + } + + /** + * Get all QC zones (quarantine + rejection) + */ + public function qcZones(): JsonResponse + { + $warehouses = Warehouse::where('is_active', true) + ->where(function ($query) { + $query->where('is_quarantine_zone', true) + ->orWhere('is_rejection_zone', true); + }) + ->get(['id', 'code', 'name', 'is_quarantine_zone', 'is_rejection_zone']); + + return response()->json([ + 'data' => $warehouses, + ]); + } } diff --git a/backend/app/Http/Controllers/WorkCenterController.php b/backend/app/Http/Controllers/WorkCenterController.php new file mode 100644 index 0000000..1c657d1 --- /dev/null +++ b/backend/app/Http/Controllers/WorkCenterController.php @@ -0,0 +1,174 @@ +only([ + 'search', + 'is_active', + 'work_center_type', + ]); + $perPage = $request->get('per_page', 15); + + $workCenters = $this->workCenterService->getWorkCenters($filters, $perPage); + + return WorkCenterListResource::collection($workCenters); + } + + /** + * Get all active work centers for dropdowns + */ + public function list(): JsonResponse + { + $workCenters = $this->workCenterService->getActiveWorkCenters(); + + return response()->json([ + 'data' => WorkCenterListResource::collection($workCenters), + ]); + } + + /** + * Store a newly created work center + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'code' => 'required|string|max:50', + 'name' => 'required|string|max:255', + 'description' => 'nullable|string', + 'work_center_type' => ['required', Rule::enum(WorkCenterType::class)], + 'cost_per_hour' => 'nullable|numeric|min:0', + 'cost_currency' => 'nullable|string|size:3', + 'capacity_per_day' => 'nullable|numeric|min:0', + 'efficiency_percentage' => 'nullable|numeric|min:0|max:200', + 'is_active' => 'boolean', + 'settings' => 'nullable|array', + ]); + + $workCenter = $this->workCenterService->create($validated); + + return response()->json([ + 'message' => 'Work center created successfully', + 'data' => WorkCenterResource::make($workCenter), + ], 201); + } + + /** + * Display the specified work center + */ + public function show(WorkCenter $workCenter): JsonResource + { + return WorkCenterResource::make( + $this->workCenterService->getWorkCenter($workCenter) + ); + } + + /** + * Update the specified work center + */ + public function update(Request $request, WorkCenter $workCenter): JsonResource + { + $validated = $request->validate([ + 'code' => [ + 'sometimes', + 'required', + 'string', + 'max:50', + Rule::unique('work_centers')->where(function ($query) use ($workCenter) { + return $query->where('company_id', $workCenter->company_id); + })->ignore($workCenter->id), + ], + 'name' => 'sometimes|required|string|max:255', + 'description' => 'nullable|string', + 'work_center_type' => ['sometimes', Rule::enum(WorkCenterType::class)], + 'cost_per_hour' => 'nullable|numeric|min:0', + 'cost_currency' => 'nullable|string|size:3', + 'capacity_per_day' => 'nullable|numeric|min:0', + 'efficiency_percentage' => 'nullable|numeric|min:0|max:200', + 'is_active' => 'boolean', + 'settings' => 'nullable|array', + ]); + + $workCenter = $this->workCenterService->update($workCenter, $validated); + + return WorkCenterResource::make($workCenter) + ->additional(['message' => 'Work center updated successfully']); + } + + /** + * Remove the specified work center + */ + public function destroy(WorkCenter $workCenter): JsonResponse + { + $this->workCenterService->delete($workCenter); + + return response()->json([ + 'message' => 'Work center deleted successfully', + ]); + } + + /** + * Toggle work center active status + */ + public function toggleActive(WorkCenter $workCenter): JsonResponse + { + $workCenter = $this->workCenterService->toggleActive($workCenter); + + return response()->json([ + 'message' => 'Work center status updated successfully', + 'data' => WorkCenterResource::make($workCenter), + ]); + } + + /** + * Get work center availability + */ + public function availability(Request $request, WorkCenter $workCenter): JsonResponse + { + $validated = $request->validate([ + 'start_date' => 'required|date', + 'end_date' => 'required|date|after_or_equal:start_date', + ]); + + $availability = $this->workCenterService->getAvailability( + $workCenter, + new \DateTime($validated['start_date']), + new \DateTime($validated['end_date']) + ); + + return response()->json([ + 'data' => $availability, + ]); + } + + /** + * Get work center types + */ + public function types(): JsonResponse + { + return response()->json([ + 'data' => WorkCenterType::options(), + ]); + } +} diff --git a/backend/app/Http/Controllers/WorkOrderController.php b/backend/app/Http/Controllers/WorkOrderController.php new file mode 100644 index 0000000..8dcdd5f --- /dev/null +++ b/backend/app/Http/Controllers/WorkOrderController.php @@ -0,0 +1,334 @@ +only([ + 'search', + 'product_id', + 'status', + 'priority', + 'active_only', + 'from_date', + 'to_date', + 'order_by_priority', + ]); + $perPage = $request->get('per_page', 15); + + $workOrders = $this->workOrderService->getWorkOrders($filters, $perPage); + + return WorkOrderListResource::collection($workOrders); + } + + /** + * Get work order statistics + */ + public function statistics(): JsonResponse + { + $stats = $this->workOrderService->getStatistics(); + + return response()->json([ + 'data' => $stats, + ]); + } + + /** + * Store a newly created work order + */ + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'product_id' => 'required|exists:products,id', + 'bom_id' => 'nullable|exists:boms,id', + 'routing_id' => 'nullable|exists:routings,id', + 'work_order_number' => 'nullable|string|max:50', + 'quantity_ordered' => 'required|numeric|min:0.001', + 'uom_id' => 'required|exists:units_of_measure,id', + 'warehouse_id' => 'required|exists:warehouses,id', + 'priority' => ['nullable', Rule::enum(WorkOrderPriority::class)], + 'planned_start_date' => 'nullable|date', + 'planned_end_date' => 'nullable|date|after_or_equal:planned_start_date', + 'notes' => 'nullable|string', + 'internal_notes' => 'nullable|string', + 'meta_data' => 'nullable|array', + ]); + + $workOrder = $this->workOrderService->create($validated); + + return response()->json([ + 'message' => 'Work order created successfully', + 'data' => WorkOrderResource::make($workOrder), + ], 201); + } + + /** + * Display the specified work order + */ + public function show(WorkOrder $workOrder): JsonResource + { + return WorkOrderResource::make( + $this->workOrderService->getWorkOrder($workOrder) + ); + } + + /** + * Update the specified work order + */ + public function update(Request $request, WorkOrder $workOrder): JsonResource + { + $validated = $request->validate([ + 'quantity_ordered' => 'sometimes|numeric|min:0.001', + 'priority' => ['nullable', Rule::enum(WorkOrderPriority::class)], + 'planned_start_date' => 'nullable|date', + 'planned_end_date' => 'nullable|date|after_or_equal:planned_start_date', + 'notes' => 'nullable|string', + 'internal_notes' => 'nullable|string', + 'meta_data' => 'nullable|array', + ]); + + $workOrder = $this->workOrderService->update($workOrder, $validated); + + return WorkOrderResource::make($workOrder) + ->additional(['message' => 'Work order updated successfully']); + } + + /** + * Remove the specified work order + */ + public function destroy(WorkOrder $workOrder): JsonResponse + { + $this->workOrderService->delete($workOrder); + + return response()->json([ + 'message' => 'Work order deleted successfully', + ]); + } + + /** + * Release work order for production + */ + public function release(WorkOrder $workOrder): JsonResponse + { + $workOrder = $this->workOrderService->release($workOrder); + + return response()->json([ + 'message' => 'Work order released successfully', + 'data' => WorkOrderResource::make($workOrder), + ]); + } + + /** + * Start work order + */ + public function start(WorkOrder $workOrder): JsonResponse + { + $workOrder = $this->workOrderService->start($workOrder); + + return response()->json([ + 'message' => 'Work order started successfully', + 'data' => WorkOrderResource::make($workOrder), + ]); + } + + /** + * Complete work order + */ + public function complete(WorkOrder $workOrder): JsonResponse + { + $workOrder = $this->workOrderService->complete($workOrder); + + return response()->json([ + 'message' => 'Work order completed successfully', + 'data' => WorkOrderResource::make($workOrder), + ]); + } + + /** + * Cancel work order + */ + public function cancel(Request $request, WorkOrder $workOrder): JsonResponse + { + $validated = $request->validate([ + 'reason' => 'nullable|string|max:1000', + ]); + + $workOrder = $this->workOrderService->cancel($workOrder, $validated['reason'] ?? null); + + return response()->json([ + 'message' => 'Work order cancelled successfully', + 'data' => WorkOrderResource::make($workOrder), + ]); + } + + /** + * Put work order on hold + */ + public function hold(Request $request, WorkOrder $workOrder): JsonResponse + { + $validated = $request->validate([ + 'reason' => 'nullable|string|max:1000', + ]); + + $workOrder = $this->workOrderService->hold($workOrder, $validated['reason'] ?? null); + + return response()->json([ + 'message' => 'Work order put on hold successfully', + 'data' => WorkOrderResource::make($workOrder), + ]); + } + + /** + * Resume work order from hold + */ + public function resume(WorkOrder $workOrder): JsonResponse + { + $workOrder = $this->workOrderService->resume($workOrder); + + return response()->json([ + 'message' => 'Work order resumed successfully', + 'data' => WorkOrderResource::make($workOrder), + ]); + } + + /** + * Start an operation + */ + public function startOperation(WorkOrder $workOrder, int $operationId): JsonResponse + { + $operation = $this->workOrderService->startOperation($workOrder, $operationId); + + return response()->json([ + 'message' => 'Operation started successfully', + 'data' => WorkOrderOperationResource::make($operation), + ]); + } + + /** + * Complete an operation + */ + public function completeOperation(Request $request, WorkOrder $workOrder, int $operationId): JsonResponse + { + $validated = $request->validate([ + 'quantity_completed' => 'required|numeric|min:0', + 'quantity_scrapped' => 'nullable|numeric|min:0', + 'actual_setup_time' => 'nullable|numeric|min:0', + 'actual_run_time' => 'nullable|numeric|min:0', + 'notes' => 'nullable|string', + ]); + + $operation = $this->workOrderService->completeOperation( + $workOrder, + $operationId, + $validated['quantity_completed'], + $validated['quantity_scrapped'] ?? 0, + $validated['actual_setup_time'] ?? null, + $validated['actual_run_time'] ?? null, + $validated['notes'] ?? null + ); + + return response()->json([ + 'message' => 'Operation completed successfully', + 'data' => WorkOrderOperationResource::make($operation), + ]); + } + + /** + * Get material requirements + */ + public function materialRequirements(WorkOrder $workOrder): JsonResponse + { + $requirements = $this->workOrderService->getMaterialRequirements($workOrder); + + return response()->json([ + 'data' => $requirements, + ]); + } + + /** + * Issue materials + */ + public function issueMaterials(Request $request, WorkOrder $workOrder): JsonResponse + { + $validated = $request->validate([ + 'material_ids' => 'nullable|array', + 'material_ids.*' => 'integer|exists:work_order_materials,id', + ]); + + $workOrder = $this->workOrderService->issueMaterials( + $workOrder, + $validated['material_ids'] ?? null + ); + + return response()->json([ + 'message' => 'Materials issued successfully', + 'data' => WorkOrderResource::make($workOrder), + ]); + } + + /** + * Receive finished goods + */ + public function receiveFinishedGoods(Request $request, WorkOrder $workOrder): JsonResponse + { + $validated = $request->validate([ + 'quantity' => 'required|numeric|min:0.001', + 'lot_number' => 'nullable|string|max:100', + 'unit_cost' => 'nullable|numeric|min:0', + ]); + + $workOrder = $this->workOrderService->receiveFinishedGoods( + $workOrder, + $validated['quantity'], + $validated['lot_number'] ?? null, + $validated['unit_cost'] ?? null + ); + + return response()->json([ + 'message' => 'Finished goods received successfully', + 'data' => WorkOrderResource::make($workOrder), + ]); + } + + /** + * Get work order statuses + */ + public function statuses(): JsonResponse + { + return response()->json([ + 'data' => WorkOrderStatus::options(), + ]); + } + + /** + * Get work order priorities + */ + public function priorities(): JsonResponse + { + return response()->json([ + 'data' => WorkOrderPriority::options(), + ]); + } +} diff --git a/backend/app/Http/Resources/BomItemResource.php b/backend/app/Http/Resources/BomItemResource.php new file mode 100644 index 0000000..46d1fa0 --- /dev/null +++ b/backend/app/Http/Resources/BomItemResource.php @@ -0,0 +1,33 @@ + $this->id, + 'line_number' => $this->line_number, + 'quantity' => (float) $this->quantity, + 'scrap_percentage' => (float) $this->scrap_percentage, + 'is_optional' => $this->is_optional, + 'is_phantom' => $this->is_phantom, + 'notes' => $this->notes, + + // Relationships + 'component' => new ProductListResource($this->whenLoaded('component')), + 'uom' => new UnitOfMeasureResource($this->whenLoaded('uom')), + + // Timestamps + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } +} diff --git a/backend/app/Http/Resources/BomListResource.php b/backend/app/Http/Resources/BomListResource.php new file mode 100644 index 0000000..932443d --- /dev/null +++ b/backend/app/Http/Resources/BomListResource.php @@ -0,0 +1,30 @@ + $this->id, + 'bom_number' => $this->bom_number, + 'version' => $this->version, + 'name' => $this->name, + 'bom_type' => $this->bom_type?->value, + 'bom_type_label' => $this->bom_type?->label(), + 'status' => $this->status?->value, + 'status_label' => $this->status?->label(), + 'is_default' => $this->is_default, + 'product' => new ProductListResource($this->whenLoaded('product')), + 'items_count' => $this->when(isset($this->items_count), $this->items_count), + 'effective_date' => $this->effective_date?->toDateString(), + ]; + } +} diff --git a/backend/app/Http/Resources/BomResource.php b/backend/app/Http/Resources/BomResource.php new file mode 100644 index 0000000..087160a --- /dev/null +++ b/backend/app/Http/Resources/BomResource.php @@ -0,0 +1,59 @@ + $this->id, + 'bom_number' => $this->bom_number, + 'version' => $this->version, + 'name' => $this->name, + 'description' => $this->description, + + // Type and Status + 'bom_type' => $this->bom_type?->value, + 'bom_type_label' => $this->bom_type?->label(), + 'status' => $this->status?->value, + 'status_label' => $this->status?->label(), + + // Quantity + 'quantity' => (float) $this->quantity, + 'uom' => new UnitOfMeasureResource($this->whenLoaded('uom')), + + // Flags + 'is_default' => $this->is_default, + 'can_edit' => $this->canEdit(), + 'can_use_for_production' => $this->canUseForProduction(), + + // Dates + 'effective_date' => $this->effective_date?->toDateString(), + 'expiry_date' => $this->expiry_date?->toDateString(), + + // Notes + 'notes' => $this->notes, + 'meta_data' => $this->meta_data, + + // Relationships + 'product' => new ProductListResource($this->whenLoaded('product')), + 'items' => BomItemResource::collection($this->whenLoaded('items')), + 'creator' => new UserResource($this->whenLoaded('creator')), + + // Computed + 'items_count' => $this->when(isset($this->items_count), $this->items_count), + 'component_count' => $this->whenLoaded('items', fn() => $this->component_count), + + // Timestamps + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } +} diff --git a/backend/app/Http/Resources/CustomerGroupListResource.php b/backend/app/Http/Resources/CustomerGroupListResource.php new file mode 100644 index 0000000..1c9477e --- /dev/null +++ b/backend/app/Http/Resources/CustomerGroupListResource.php @@ -0,0 +1,27 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'code' => $this->code, + 'discount_percentage' => $this->discount_percentage, + 'is_active' => $this->is_active, + 'customers_count' => $this->when(isset($this->customers_count), $this->customers_count), + 'created_at' => $this->created_at?->toISOString(), + ]; + } +} diff --git a/backend/app/Http/Resources/CustomerGroupPriceResource.php b/backend/app/Http/Resources/CustomerGroupPriceResource.php new file mode 100644 index 0000000..adeb54f --- /dev/null +++ b/backend/app/Http/Resources/CustomerGroupPriceResource.php @@ -0,0 +1,38 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'customer_group_id' => $this->customer_group_id, + 'product_id' => $this->product_id, + 'price' => $this->price, + 'currency_id' => $this->currency_id, + 'min_quantity' => $this->min_quantity, + 'valid_from' => $this->valid_from?->toDateString(), + 'valid_until' => $this->valid_until?->toDateString(), + 'is_active' => $this->is_active, + + // Relationships + 'product' => new ProductListResource($this->whenLoaded('product')), + 'customer_group' => new CustomerGroupListResource($this->whenLoaded('customerGroup')), + 'currency' => new CurrencyResource($this->whenLoaded('currency')), + + // Timestamps + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } +} diff --git a/backend/app/Http/Resources/CustomerGroupResource.php b/backend/app/Http/Resources/CustomerGroupResource.php new file mode 100644 index 0000000..a378e73 --- /dev/null +++ b/backend/app/Http/Resources/CustomerGroupResource.php @@ -0,0 +1,39 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'code' => $this->code, + 'description' => $this->description, + 'discount_percentage' => $this->discount_percentage, + 'payment_terms_days' => $this->payment_terms_days, + 'credit_limit' => $this->credit_limit, + 'is_active' => $this->is_active, + + // Relationships + 'customers' => CustomerListResource::collection($this->whenLoaded('customers')), + 'group_prices' => CustomerGroupPriceResource::collection($this->whenLoaded('groupPrices')), + + // Counts + 'customers_count' => $this->when(isset($this->customers_count), $this->customers_count), + + // Timestamps + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } +} diff --git a/backend/app/Http/Resources/CustomerListResource.php b/backend/app/Http/Resources/CustomerListResource.php new file mode 100644 index 0000000..edd5d1f --- /dev/null +++ b/backend/app/Http/Resources/CustomerListResource.php @@ -0,0 +1,32 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'code' => $this->customer_code, + 'customer_code' => $this->customer_code, + 'name' => $this->name, + 'email' => $this->email, + 'phone' => $this->phone, + 'city' => $this->city, + 'country' => $this->country, + 'is_active' => $this->is_active, + 'customer_group_id' => $this->customer_group_id, + 'customer_group' => new CustomerGroupListResource($this->whenLoaded('customerGroup')), + 'created_at' => $this->created_at?->toISOString(), + ]; + } +} diff --git a/backend/app/Http/Resources/CustomerResource.php b/backend/app/Http/Resources/CustomerResource.php new file mode 100644 index 0000000..2faa4ea --- /dev/null +++ b/backend/app/Http/Resources/CustomerResource.php @@ -0,0 +1,61 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'code' => $this->customer_code, + 'customer_code' => $this->customer_code, + 'name' => $this->name, + 'email' => $this->email, + 'phone' => $this->phone, + 'tax_number' => $this->tax_id, + 'tax_id' => $this->tax_id, + + // Address + 'address' => $this->address, + 'billing_address' => $this->address, + 'shipping_address' => $this->address, + 'city' => $this->city, + 'state' => $this->state, + 'postal_code' => $this->postal_code, + 'country' => $this->country, + + // Contact + 'contact_person' => $this->contact_person, + + // Financial + 'payment_terms_days' => $this->payment_terms_days, + 'credit_limit' => $this->credit_limit, + + // Notes + 'notes' => $this->notes, + + // Status + 'is_active' => $this->is_active, + + // Customer group + 'customer_group_id' => $this->customer_group_id, + 'customer_group' => new CustomerGroupListResource($this->whenLoaded('customerGroup')), + + // Recent orders + 'sales_orders' => SalesOrderListResource::collection($this->whenLoaded('salesOrders')), + + // Timestamps + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } +} diff --git a/backend/app/Http/Resources/DeliveryNoteItemResource.php b/backend/app/Http/Resources/DeliveryNoteItemResource.php new file mode 100644 index 0000000..7c5a4f2 --- /dev/null +++ b/backend/app/Http/Resources/DeliveryNoteItemResource.php @@ -0,0 +1,38 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'delivery_note_id' => $this->delivery_note_id, + 'sales_order_item_id' => $this->sales_order_item_id, + 'product_id' => $this->product_id, + 'quantity' => $this->quantity, + 'lot_number' => $this->lot_number, + 'serial_numbers' => $this->serial_numbers, + 'notes' => $this->notes, + + // Product + 'product' => new ProductListResource($this->whenLoaded('product')), + + // Sales order item + 'sales_order_item' => new SalesOrderItemResource($this->whenLoaded('salesOrderItem')), + + // Timestamps + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } +} diff --git a/backend/app/Http/Resources/DeliveryNoteListResource.php b/backend/app/Http/Resources/DeliveryNoteListResource.php new file mode 100644 index 0000000..fdb16de --- /dev/null +++ b/backend/app/Http/Resources/DeliveryNoteListResource.php @@ -0,0 +1,32 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'delivery_number' => $this->delivery_number, + 'delivery_date' => $this->delivery_date?->toDateString(), + 'status' => $this->status->value, + 'status_label' => $this->status->label(), + 'status_color' => $this->status->color(), + 'carrier' => $this->carrier, + 'tracking_number' => $this->tracking_number, + 'sales_order' => new SalesOrderListResource($this->whenLoaded('salesOrder')), + 'warehouse' => new WarehouseListResource($this->whenLoaded('warehouse')), + 'created_by' => new UserResource($this->whenLoaded('createdBy')), + 'created_at' => $this->created_at?->toISOString(), + ]; + } +} diff --git a/backend/app/Http/Resources/DeliveryNoteResource.php b/backend/app/Http/Resources/DeliveryNoteResource.php new file mode 100644 index 0000000..3ca542f --- /dev/null +++ b/backend/app/Http/Resources/DeliveryNoteResource.php @@ -0,0 +1,57 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'delivery_number' => $this->delivery_number, + 'delivery_date' => $this->delivery_date?->toDateString(), + + // Status + 'status' => $this->status->value, + 'status_label' => $this->status->label(), + 'status_color' => $this->status->color(), + 'can_be_edited' => $this->canBeEdited(), + + // Sales Order + 'sales_order_id' => $this->sales_order_id, + 'sales_order' => new SalesOrderListResource($this->whenLoaded('salesOrder')), + + // Warehouse + 'warehouse_id' => $this->warehouse_id, + 'warehouse' => new WarehouseListResource($this->whenLoaded('warehouse')), + + // Shipping + 'shipping_address' => $this->shipping_address, + 'carrier' => $this->carrier, + 'tracking_number' => $this->tracking_number, + + // Notes + 'notes' => $this->notes, + + // Items + 'items' => DeliveryNoteItemResource::collection($this->whenLoaded('items')), + + // Audit + 'created_by' => new UserResource($this->whenLoaded('createdBy')), + 'shipped_at' => $this->shipped_at?->toISOString(), + 'delivered_at' => $this->delivered_at?->toISOString(), + + // Timestamps + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } +} diff --git a/backend/app/Http/Resources/MrpRecommendationResource.php b/backend/app/Http/Resources/MrpRecommendationResource.php new file mode 100644 index 0000000..208ebed --- /dev/null +++ b/backend/app/Http/Resources/MrpRecommendationResource.php @@ -0,0 +1,77 @@ + $this->id, + + // Type and status + 'recommendation_type' => $this->recommendation_type?->value, + 'recommendation_type_label' => $this->recommendation_type?->label(), + 'recommendation_type_color' => $this->recommendation_type?->color(), + 'recommendation_type_icon' => $this->recommendation_type?->icon(), + 'status' => $this->status?->value, + 'status_label' => $this->status?->label(), + 'status_color' => $this->status?->color(), + + // Dates + 'required_date' => $this->required_date?->toDateString(), + 'suggested_date' => $this->suggested_date?->toDateString(), + 'due_date' => $this->due_date?->toDateString(), + 'days_until_required' => $this->days_until_required, + + // Quantities + 'gross_requirement' => (float) $this->gross_requirement, + 'net_requirement' => (float) $this->net_requirement, + 'suggested_quantity' => (float) $this->suggested_quantity, + 'current_stock' => (float) $this->current_stock, + 'projected_stock' => (float) $this->projected_stock, + + // Demand source + 'demand_source_type' => $this->demand_source_type, + 'demand_source_id' => $this->demand_source_id, + + // Priority and urgency + 'priority' => $this->priority?->value, + 'priority_label' => $this->priority?->label(), + 'priority_color' => $this->priority?->color(), + 'is_urgent' => $this->is_urgent, + 'is_overdue' => $this->is_overdue, + 'urgency_reason' => $this->urgency_reason, + + // Actions + 'can_action' => $this->status?->canAction(), + 'can_approve' => $this->status?->canApprove(), + 'can_reject' => $this->status?->canReject(), + 'actioned_at' => $this->actioned_at?->toISOString(), + 'action_reference_type' => $this->action_reference_type, + 'action_reference_id' => $this->action_reference_id, + 'action_notes' => $this->action_notes, + + // Calculation details (for debugging/audit) + 'calculation_details' => $this->calculation_details, + + // Summary + 'summary' => $this->getSummary(), + + // Relationships + 'product' => new ProductListResource($this->whenLoaded('product')), + 'warehouse' => new WarehouseListResource($this->whenLoaded('warehouse')), + 'actioned_by' => new UserResource($this->whenLoaded('actionedByUser')), + + // Timestamps + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } +} diff --git a/backend/app/Http/Resources/MrpRunListResource.php b/backend/app/Http/Resources/MrpRunListResource.php new file mode 100644 index 0000000..5c2af99 --- /dev/null +++ b/backend/app/Http/Resources/MrpRunListResource.php @@ -0,0 +1,31 @@ + $this->id, + 'run_number' => $this->run_number, + 'name' => $this->name, + 'planning_horizon_start' => $this->planning_horizon_start?->toDateString(), + 'planning_horizon_end' => $this->planning_horizon_end?->toDateString(), + 'status' => $this->status?->value, + 'status_label' => $this->status?->label(), + 'status_color' => $this->status?->color(), + 'products_processed' => $this->products_processed, + 'recommendations_generated' => $this->recommendations_generated, + 'completed_at' => $this->completed_at?->toISOString(), + 'creator' => new UserResource($this->whenLoaded('creator')), + 'created_at' => $this->created_at?->toISOString(), + ]; + } +} diff --git a/backend/app/Http/Resources/MrpRunResource.php b/backend/app/Http/Resources/MrpRunResource.php new file mode 100644 index 0000000..5a835e2 --- /dev/null +++ b/backend/app/Http/Resources/MrpRunResource.php @@ -0,0 +1,70 @@ + $this->id, + 'run_number' => $this->run_number, + 'name' => $this->name, + + // Planning parameters + 'planning_horizon_start' => $this->planning_horizon_start?->toDateString(), + 'planning_horizon_end' => $this->planning_horizon_end?->toDateString(), + 'planning_horizon_days' => $this->planning_horizon_days, + 'include_safety_stock' => $this->include_safety_stock, + 'respect_lead_times' => $this->respect_lead_times, + 'consider_wip' => $this->consider_wip, + 'net_change' => $this->net_change, + + // Filters + 'product_filters' => $this->product_filters, + 'warehouse_filters' => $this->warehouse_filters, + + // Status + 'status' => $this->status?->value, + 'status_label' => $this->status?->label(), + 'status_color' => $this->status?->color(), + + // Timing + 'started_at' => $this->started_at?->toISOString(), + 'completed_at' => $this->completed_at?->toISOString(), + 'duration' => $this->duration, + 'duration_formatted' => $this->duration_formatted, + + // Statistics + 'products_processed' => $this->products_processed, + 'recommendations_generated' => $this->recommendations_generated, + 'warnings_count' => $this->warnings_count, + 'warnings_summary' => $this->warnings_summary, + 'pending_recommendations_count' => $this->when( + $this->relationLoaded('recommendations'), + fn() => $this->getPendingRecommendationsCount() + ), + 'actioned_recommendations_count' => $this->when( + $this->relationLoaded('recommendations'), + fn() => $this->getActionedRecommendationsCount() + ), + + // Error + 'error_message' => $this->error_message, + + // Relationships + 'creator' => new UserResource($this->whenLoaded('creator')), + 'recommendations' => MrpRecommendationResource::collection($this->whenLoaded('recommendations')), + + // Timestamps + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } +} diff --git a/backend/app/Http/Resources/ProductListResource.php b/backend/app/Http/Resources/ProductListResource.php index 365ff95..11d3630 100644 --- a/backend/app/Http/Resources/ProductListResource.php +++ b/backend/app/Http/Resources/ProductListResource.php @@ -25,6 +25,10 @@ public function toArray(Request $request): array 'is_active' => $this->is_active, 'is_featured' => $this->is_featured, 'is_low_stock' => $this->isLowStock(), + // Negative stock policy (for quick overview in lists) + 'negative_stock_policy' => $this->negative_stock_policy, + // Reservation policy (for quick overview in lists) + 'reservation_policy' => $this->reservation_policy, // Minimal relations 'primary_category' => $this->whenLoaded('categories', function () { diff --git a/backend/app/Http/Resources/ProductResource.php b/backend/app/Http/Resources/ProductResource.php index 9a710cd..5b064df 100644 --- a/backend/app/Http/Resources/ProductResource.php +++ b/backend/app/Http/Resources/ProductResource.php @@ -27,6 +27,11 @@ public function toArray(Request $request): array 'is_featured' => $this->is_featured, 'is_low_stock' => $this->isLowStock(), 'is_out_of_stock' => $this->isOutOfStock(), + // Negative stock policy + 'negative_stock_policy' => $this->negative_stock_policy, + 'negative_stock_limit' => $this->negative_stock_limit, + // Reservation policy + 'reservation_policy' => $this->reservation_policy, 'meta_data' => $this->meta_data, // Relations (only when loaded) diff --git a/backend/app/Http/Resources/RoutingListResource.php b/backend/app/Http/Resources/RoutingListResource.php new file mode 100644 index 0000000..4f4bb04 --- /dev/null +++ b/backend/app/Http/Resources/RoutingListResource.php @@ -0,0 +1,28 @@ + $this->id, + 'routing_number' => $this->routing_number, + 'version' => $this->version, + 'name' => $this->name, + 'status' => $this->status?->value, + 'status_label' => $this->status?->label(), + 'is_default' => $this->is_default, + 'product' => new ProductListResource($this->whenLoaded('product')), + 'operations_count' => $this->when(isset($this->operations_count), $this->operations_count), + 'effective_date' => $this->effective_date?->toDateString(), + ]; + } +} diff --git a/backend/app/Http/Resources/RoutingOperationResource.php b/backend/app/Http/Resources/RoutingOperationResource.php new file mode 100644 index 0000000..b481575 --- /dev/null +++ b/backend/app/Http/Resources/RoutingOperationResource.php @@ -0,0 +1,45 @@ + $this->id, + 'operation_number' => $this->operation_number, + 'name' => $this->name, + 'description' => $this->description, + + // Time estimates (in minutes) + 'setup_time' => (float) $this->setup_time, + 'run_time_per_unit' => (float) $this->run_time_per_unit, + 'queue_time' => (float) $this->queue_time, + 'move_time' => (float) $this->move_time, + 'total_time_per_unit' => (float) $this->total_time_per_unit, + + // Subcontracting + 'is_subcontracted' => $this->is_subcontracted, + 'subcontract_cost' => $this->subcontract_cost ? (float) $this->subcontract_cost : null, + 'subcontractor' => new SupplierListResource($this->whenLoaded('subcontractor')), + + // Instructions + 'instructions' => $this->instructions, + 'settings' => $this->settings, + + // Relationships + 'work_center' => new WorkCenterListResource($this->whenLoaded('workCenter')), + + // Timestamps + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } +} diff --git a/backend/app/Http/Resources/RoutingResource.php b/backend/app/Http/Resources/RoutingResource.php new file mode 100644 index 0000000..d0ab83b --- /dev/null +++ b/backend/app/Http/Resources/RoutingResource.php @@ -0,0 +1,54 @@ + $this->id, + 'routing_number' => $this->routing_number, + 'version' => $this->version, + 'name' => $this->name, + 'description' => $this->description, + + // Status + 'status' => $this->status?->value, + 'status_label' => $this->status?->label(), + + // Flags + 'is_default' => $this->is_default, + 'can_edit' => $this->canEdit(), + 'can_use_for_production' => $this->canUseForProduction(), + + // Dates + 'effective_date' => $this->effective_date?->toDateString(), + 'expiry_date' => $this->expiry_date?->toDateString(), + + // Notes + 'notes' => $this->notes, + 'meta_data' => $this->meta_data, + + // Relationships + 'product' => new ProductListResource($this->whenLoaded('product')), + 'operations' => RoutingOperationResource::collection($this->whenLoaded('operations')), + 'creator' => new UserResource($this->whenLoaded('creator')), + + // Computed + 'operations_count' => $this->when(isset($this->operations_count), $this->operations_count), + 'operation_count' => $this->whenLoaded('operations', fn() => $this->operation_count), + 'total_lead_time' => $this->whenLoaded('operations', fn() => $this->total_lead_time), + + // Timestamps + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } +} diff --git a/backend/app/Http/Resources/SalesOrderItemResource.php b/backend/app/Http/Resources/SalesOrderItemResource.php new file mode 100644 index 0000000..614bb84 --- /dev/null +++ b/backend/app/Http/Resources/SalesOrderItemResource.php @@ -0,0 +1,39 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'sales_order_id' => $this->sales_order_id, + 'product_id' => $this->product_id, + 'quantity' => $this->quantity_ordered, + 'quantity_ordered' => $this->quantity_ordered, + 'unit_price' => $this->unit_price, + 'discount_amount' => $this->discount_amount, + 'tax_amount' => $this->tax_amount, + 'line_total' => $this->line_total, + 'quantity_shipped' => $this->quantity_shipped, + 'quantity_remaining' => $this->quantity_ordered - $this->quantity_shipped, + 'notes' => $this->notes, + + // Product + 'product' => new ProductListResource($this->whenLoaded('product')), + + // Timestamps + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } +} diff --git a/backend/app/Http/Resources/SalesOrderListResource.php b/backend/app/Http/Resources/SalesOrderListResource.php new file mode 100644 index 0000000..9fc55b3 --- /dev/null +++ b/backend/app/Http/Resources/SalesOrderListResource.php @@ -0,0 +1,32 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'order_number' => $this->order_number, + 'order_date' => $this->order_date?->toDateString(), + 'expected_delivery_date' => $this->requested_delivery_date?->toDateString(), + 'requested_delivery_date' => $this->requested_delivery_date?->toDateString(), + 'status' => $this->status->value, + 'status_label' => $this->status->label(), + 'status_color' => $this->status->color(), + 'total_amount' => $this->total_amount, + 'customer' => new CustomerListResource($this->whenLoaded('customer')), + 'created_by' => new UserResource($this->whenLoaded('createdBy')), + 'created_at' => $this->created_at?->toISOString(), + ]; + } +} diff --git a/backend/app/Http/Resources/SalesOrderResource.php b/backend/app/Http/Resources/SalesOrderResource.php new file mode 100644 index 0000000..52ac2ff --- /dev/null +++ b/backend/app/Http/Resources/SalesOrderResource.php @@ -0,0 +1,63 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'order_number' => $this->order_number, + 'order_date' => $this->order_date?->toDateString(), + 'expected_delivery_date' => $this->requested_delivery_date?->toDateString(), + 'requested_delivery_date' => $this->requested_delivery_date?->toDateString(), + + // Status + 'status' => $this->status->value, + 'status_label' => $this->status->label(), + 'status_color' => $this->status->color(), + 'can_be_edited' => $this->canBeEdited(), + + // Customer + 'customer_id' => $this->customer_id, + 'customer' => new CustomerListResource($this->whenLoaded('customer')), + + // Addresses + 'shipping_address' => $this->shipping_address, + + // Amounts + 'subtotal' => $this->subtotal, + 'tax_amount' => $this->tax_amount, + 'discount_amount' => $this->discount_amount, + 'total_amount' => $this->total_amount, + + // Notes + 'notes' => $this->notes, + 'internal_notes' => $this->internal_notes, + + // Items + 'items' => SalesOrderItemResource::collection($this->whenLoaded('items')), + + // Delivery notes + 'delivery_notes' => DeliveryNoteListResource::collection($this->whenLoaded('deliveryNotes')), + + // Audit + 'created_by' => new UserResource($this->whenLoaded('createdBy')), + 'approved_by' => new UserResource($this->whenLoaded('approvedBy')), + 'approved_at' => $this->approved_at?->toISOString(), + + // Timestamps + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } +} diff --git a/backend/app/Http/Resources/StockDebtResource.php b/backend/app/Http/Resources/StockDebtResource.php new file mode 100644 index 0000000..f63d9ae --- /dev/null +++ b/backend/app/Http/Resources/StockDebtResource.php @@ -0,0 +1,45 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'company_id' => $this->company_id, + 'product_id' => $this->product_id, + 'product' => [ + 'id' => $this->product->id ?? null, + 'name' => $this->product->name ?? null, + 'sku' => $this->product->sku ?? null, + ], + 'warehouse_id' => $this->warehouse_id, + 'warehouse' => [ + 'id' => $this->warehouse->id ?? null, + 'name' => $this->warehouse->name ?? null, + 'code' => $this->warehouse->code ?? null, + ], + 'stock_movement_id' => $this->stock_movement_id, + 'quantity' => (float) $this->quantity, + 'reconciled_quantity' => (float) $this->reconciled_quantity, + 'outstanding_quantity' => (float) $this->outstanding_quantity, + 'reference_type' => $this->reference_type, + 'reference_id' => $this->reference_id, + 'reference' => $this->when($this->reference, $this->reference), + 'is_fully_reconciled' => $this->isFullyReconciled(), + 'reconciled_at' => $this->reconciled_at?->toISOString(), + 'created_at' => $this->created_at->toISOString(), + 'updated_at' => $this->updated_at->toISOString(), + ]; + } +} diff --git a/backend/app/Http/Resources/WorkCenterCalendarResource.php b/backend/app/Http/Resources/WorkCenterCalendarResource.php new file mode 100644 index 0000000..fcfbff4 --- /dev/null +++ b/backend/app/Http/Resources/WorkCenterCalendarResource.php @@ -0,0 +1,57 @@ + $this->id, + 'work_center_id' => $this->work_center_id, + 'calendar_date' => $this->calendar_date?->toDateString(), + 'day_name' => $this->calendar_date?->format('l'), + + // Shift times + 'shift_start' => $this->shift_start, + 'shift_end' => $this->shift_end, + 'break_hours' => (float) $this->break_hours, + + // Capacity + 'available_hours' => (float) $this->available_hours, + 'effective_hours' => (float) $this->effective_hours, + 'efficiency_override' => $this->efficiency_override ? (float) $this->efficiency_override : null, + 'capacity_override' => $this->capacity_override ? (float) $this->capacity_override : null, + + // Day type + 'day_type' => $this->day_type?->value, + 'day_type_label' => $this->day_type?->label(), + 'day_type_color' => $this->day_type?->color(), + 'is_available' => $this->isAvailable(), + 'has_reduced_capacity' => $this->hasReducedCapacity(), + + // Notes + 'notes' => $this->notes, + + // Relationships + 'work_center' => $this->when( + $this->relationLoaded('workCenter'), + fn() => [ + 'id' => $this->workCenter->id, + 'code' => $this->workCenter->code, + 'name' => $this->workCenter->name, + ] + ), + + // Timestamps + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } +} diff --git a/backend/app/Http/Resources/WorkCenterListResource.php b/backend/app/Http/Resources/WorkCenterListResource.php new file mode 100644 index 0000000..8c9c6ef --- /dev/null +++ b/backend/app/Http/Resources/WorkCenterListResource.php @@ -0,0 +1,27 @@ + $this->id, + 'code' => $this->code, + 'name' => $this->name, + 'work_center_type' => $this->work_center_type?->value, + 'work_center_type_label' => $this->work_center_type?->label(), + 'cost_per_hour' => (float) $this->cost_per_hour, + 'capacity_per_day' => (float) $this->capacity_per_day, + 'efficiency_percentage' => (float) $this->efficiency_percentage, + 'is_active' => $this->is_active, + ]; + } +} diff --git a/backend/app/Http/Resources/WorkCenterResource.php b/backend/app/Http/Resources/WorkCenterResource.php new file mode 100644 index 0000000..2266b32 --- /dev/null +++ b/backend/app/Http/Resources/WorkCenterResource.php @@ -0,0 +1,48 @@ + $this->id, + 'code' => $this->code, + 'name' => $this->name, + 'description' => $this->description, + + // Type + 'work_center_type' => $this->work_center_type?->value, + 'work_center_type_label' => $this->work_center_type?->label(), + + // Costing + 'cost_per_hour' => (float) $this->cost_per_hour, + 'cost_currency' => $this->cost_currency, + + // Capacity + 'capacity_per_day' => (float) $this->capacity_per_day, + 'efficiency_percentage' => (float) $this->efficiency_percentage, + 'effective_capacity' => (float) $this->effective_capacity, + + // Status + 'is_active' => $this->is_active, + + // Settings + 'settings' => $this->settings, + + // Relationships + 'creator' => new UserResource($this->whenLoaded('creator')), + + // Timestamps + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } +} diff --git a/backend/app/Http/Resources/WorkOrderListResource.php b/backend/app/Http/Resources/WorkOrderListResource.php new file mode 100644 index 0000000..9bf18b0 --- /dev/null +++ b/backend/app/Http/Resources/WorkOrderListResource.php @@ -0,0 +1,32 @@ + $this->id, + 'work_order_number' => $this->work_order_number, + 'quantity_ordered' => (float) $this->quantity_ordered, + 'quantity_completed' => (float) $this->quantity_completed, + 'completion_percentage' => (float) $this->completion_percentage, + 'status' => $this->status?->value, + 'status_label' => $this->status?->label(), + 'priority' => $this->priority?->value, + 'priority_label' => $this->priority?->label(), + 'planned_start_date' => $this->planned_start_date?->toISOString(), + 'planned_end_date' => $this->planned_end_date?->toISOString(), + 'product' => new ProductListResource($this->whenLoaded('product')), + 'warehouse' => new WarehouseListResource($this->whenLoaded('warehouse')), + 'created_at' => $this->created_at?->toISOString(), + ]; + } +} diff --git a/backend/app/Http/Resources/WorkOrderMaterialResource.php b/backend/app/Http/Resources/WorkOrderMaterialResource.php new file mode 100644 index 0000000..2a689d8 --- /dev/null +++ b/backend/app/Http/Resources/WorkOrderMaterialResource.php @@ -0,0 +1,46 @@ + $this->id, + + // Quantities + 'quantity_required' => (float) $this->quantity_required, + 'quantity_issued' => (float) $this->quantity_issued, + 'quantity_returned' => (float) $this->quantity_returned, + 'outstanding_quantity' => (float) $this->outstanding_quantity, + 'net_issued_quantity' => (float) $this->net_issued_quantity, + + // Status + 'is_fully_issued' => $this->isFullyIssued(), + 'has_shortage' => $this->hasShortage(), + + // Cost + 'unit_cost' => (float) $this->unit_cost, + 'total_cost' => (float) $this->total_cost, + + // Notes + 'notes' => $this->notes, + + // Relationships + 'product' => new ProductListResource($this->whenLoaded('product')), + 'uom' => new UnitOfMeasureResource($this->whenLoaded('uom')), + 'warehouse' => new WarehouseListResource($this->whenLoaded('warehouse')), + + // Timestamps + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } +} diff --git a/backend/app/Http/Resources/WorkOrderOperationResource.php b/backend/app/Http/Resources/WorkOrderOperationResource.php new file mode 100644 index 0000000..11342ce --- /dev/null +++ b/backend/app/Http/Resources/WorkOrderOperationResource.php @@ -0,0 +1,63 @@ + $this->id, + 'operation_number' => $this->operation_number, + 'name' => $this->name, + 'description' => $this->description, + + // Status + 'status' => $this->status?->value, + 'status_label' => $this->status?->label(), + 'can_start' => $this->canStart(), + 'can_complete' => $this->canComplete(), + + // Quantities + 'quantity_completed' => (float) $this->quantity_completed, + 'quantity_scrapped' => (float) $this->quantity_scrapped, + + // Planned times + 'planned_start' => $this->planned_start?->toISOString(), + 'planned_end' => $this->planned_end?->toISOString(), + + // Actual times + 'actual_start' => $this->actual_start?->toISOString(), + 'actual_end' => $this->actual_end?->toISOString(), + + // Time spent (in minutes) + 'actual_setup_time' => (float) $this->actual_setup_time, + 'actual_run_time' => (float) $this->actual_run_time, + 'total_actual_time' => (float) $this->total_actual_time, + + // Cost + 'actual_cost' => (float) $this->actual_cost, + + // Efficiency + 'efficiency' => $this->efficiency, + + // Notes + 'notes' => $this->notes, + + // Relationships + 'work_center' => new WorkCenterListResource($this->whenLoaded('workCenter')), + 'starter' => new UserResource($this->whenLoaded('starter')), + 'completer' => new UserResource($this->whenLoaded('completer')), + + // Timestamps + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } +} diff --git a/backend/app/Http/Resources/WorkOrderResource.php b/backend/app/Http/Resources/WorkOrderResource.php new file mode 100644 index 0000000..e5fc117 --- /dev/null +++ b/backend/app/Http/Resources/WorkOrderResource.php @@ -0,0 +1,83 @@ + $this->id, + 'work_order_number' => $this->work_order_number, + + // Quantities + 'quantity_ordered' => (float) $this->quantity_ordered, + 'quantity_completed' => (float) $this->quantity_completed, + 'quantity_scrapped' => (float) $this->quantity_scrapped, + 'remaining_quantity' => (float) $this->remaining_quantity, + 'completion_percentage' => (float) $this->completion_percentage, + + // Status + 'status' => $this->status?->value, + 'status_label' => $this->status?->label(), + 'priority' => $this->priority?->value, + 'priority_label' => $this->priority?->label(), + + // Capabilities + 'can_edit' => $this->canEdit(), + 'can_release' => $this->canRelease(), + 'can_start' => $this->canStart(), + 'can_complete' => $this->canComplete(), + 'can_cancel' => $this->canCancel(), + 'can_issue_materials' => $this->canIssueMaterials(), + 'can_receive_finished_goods' => $this->canReceiveFinishedGoods(), + + // Dates + 'planned_start_date' => $this->planned_start_date?->toISOString(), + 'planned_end_date' => $this->planned_end_date?->toISOString(), + 'actual_start_date' => $this->actual_start_date?->toISOString(), + 'actual_end_date' => $this->actual_end_date?->toISOString(), + + // Cost + 'estimated_cost' => (float) $this->estimated_cost, + 'actual_cost' => (float) $this->actual_cost, + + // Notes + 'notes' => $this->notes, + 'internal_notes' => $this->internal_notes, + 'meta_data' => $this->meta_data, + + // Relationships + 'product' => new ProductListResource($this->whenLoaded('product')), + 'bom' => new BomListResource($this->whenLoaded('bom')), + 'routing' => new RoutingListResource($this->whenLoaded('routing')), + 'warehouse' => new WarehouseListResource($this->whenLoaded('warehouse')), + 'uom' => new UnitOfMeasureResource($this->whenLoaded('uom')), + 'operations' => WorkOrderOperationResource::collection($this->whenLoaded('operations')), + 'materials' => WorkOrderMaterialResource::collection($this->whenLoaded('materials')), + + // Users + 'creator' => new UserResource($this->whenLoaded('creator')), + 'approver' => new UserResource($this->whenLoaded('approver')), + 'releaser' => new UserResource($this->whenLoaded('releaser')), + + // Approval dates + 'approved_at' => $this->approved_at?->toISOString(), + 'released_at' => $this->released_at?->toISOString(), + 'completed_at' => $this->completed_at?->toISOString(), + + // Progress + 'operations_progress' => $this->whenLoaded('operations', fn() => $this->operations_progress), + + // Timestamps + 'created_at' => $this->created_at?->toISOString(), + 'updated_at' => $this->updated_at?->toISOString(), + ]; + } +} diff --git a/backend/app/Jobs/LogAuditEvent.php b/backend/app/Jobs/LogAuditEvent.php new file mode 100644 index 0000000..7bb581c --- /dev/null +++ b/backend/app/Jobs/LogAuditEvent.php @@ -0,0 +1,57 @@ +auditData); + } catch (\Exception $e) { + Log::error('Failed to create audit log', [ + 'audit_data' => $this->auditData, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + // Re-throw to trigger retry mechanism + throw $e; + } + } + + /** + * Handle a job failure. + */ + public function failed(\Throwable $exception): void + { + Log::error('Audit log job failed after all retries', [ + 'audit_data' => $this->auditData, + 'error' => $exception->getMessage(), + 'trace' => $exception->getTraceAsString(), + ]); + } +} diff --git a/backend/app/Jobs/ProcessMrpChunkJob.php b/backend/app/Jobs/ProcessMrpChunkJob.php new file mode 100644 index 0000000..1052154 --- /dev/null +++ b/backend/app/Jobs/ProcessMrpChunkJob.php @@ -0,0 +1,110 @@ +runId = $runId; + $this->productIds = $productIds; + $this->params = $params; + } + + /** + * Execute the job. + */ + public function handle(MrpService $mrpService): void + { + $run = MrpRun::find($this->runId); + + if (!$run) { + Log::error('ProcessMrpChunkJob: MRP run not found', ['run_id' => $this->runId]); + return; + } + + // Check if run was cancelled + if ($run->status->value === 'cancelled') { + Log::info('ProcessMrpChunkJob: MRP run was cancelled', ['run_id' => $this->runId]); + return; + } + + try { + // Get products for this chunk + $products = Product::whereIn('id', $this->productIds) + ->where('company_id', $run->company_id) + ->where('is_active', true) + ->orderBy('low_level_code', 'asc') + ->get(); + + if ($products->isEmpty()) { + Log::warning('ProcessMrpChunkJob: No products found for chunk', [ + 'run_id' => $this->runId, + 'product_ids' => $this->productIds, + ]); + return; + } + + // Process chunk + $processed = $mrpService->processProductChunk($run, $products, $this->params); + + Log::info('ProcessMrpChunkJob: Chunk processed successfully', [ + 'run_id' => $this->runId, + 'chunk_size' => count($this->productIds), + 'processed' => $processed, + ]); + + } catch (\Exception $e) { + Log::error('ProcessMrpChunkJob: Chunk processing failed', [ + 'run_id' => $this->runId, + 'product_ids' => $this->productIds, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + throw $e; + } + } + + /** + * Handle a job failure. + */ + public function failed(\Throwable $exception): void + { + Log::error('ProcessMrpChunkJob: Job failed permanently', [ + 'run_id' => $this->runId, + 'product_ids' => $this->productIds, + 'error' => $exception->getMessage(), + ]); + } +} diff --git a/backend/app/Jobs/ProcessMrpRunJob.php b/backend/app/Jobs/ProcessMrpRunJob.php new file mode 100644 index 0000000..774a574 --- /dev/null +++ b/backend/app/Jobs/ProcessMrpRunJob.php @@ -0,0 +1,97 @@ +runId = $runId; + $this->params = $params; + } + + /** + * Execute the job. + */ + public function handle(MrpService $mrpService): void + { + $run = MrpRun::find($this->runId); + + if (!$run) { + Log::error('ProcessMrpRunJob: MRP run not found', ['run_id' => $this->runId]); + return; + } + + // Check if run was cancelled + if ($run->status->value === 'cancelled') { + Log::info('ProcessMrpRunJob: MRP run was cancelled', ['run_id' => $this->runId]); + return; + } + + try { + // Set the run context for the service + // The service will use the existing run record + $mrpService->processExistingRun($run, $this->params); + + Log::info('ProcessMrpRunJob: MRP run completed successfully', [ + 'run_id' => $this->runId, + ]); + } catch (\Exception $e) { + Log::error('ProcessMrpRunJob: MRP run failed', [ + 'run_id' => $this->runId, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + // Mark run as failed + $run->markAsFailed($e->getMessage()); + + // Re-throw to trigger failed job handling + throw $e; + } + } + + /** + * Handle a job failure. + */ + public function failed(\Throwable $exception): void + { + $run = MrpRun::find($this->runId); + + if ($run && $run->status->value !== 'completed') { + $run->markAsFailed('Job failed: ' . $exception->getMessage()); + } + + Log::error('ProcessMrpRunJob: Job failed permanently', [ + 'run_id' => $this->runId, + 'error' => $exception->getMessage(), + ]); + } +} diff --git a/backend/app/Mail/UserInvitationMail.php b/backend/app/Mail/UserInvitationMail.php new file mode 100644 index 0000000..7820e3d --- /dev/null +++ b/backend/app/Mail/UserInvitationMail.php @@ -0,0 +1,66 @@ +invitation->company->name; + + return new Envelope( + subject: "You're invited to join {$companyName}", + ); + } + + /** + * Get the message content definition. + */ + public function content(): Content + { + $acceptUrl = config('app.frontend_url') . '/accept-invitation?token=' . $this->invitation->token; + $expiresAt = $this->invitation->expires_at->format('F j, Y \a\t g:i A'); + $inviterName = $this->invitation->inviter->full_name; + $companyName = $this->invitation->company->name; + + return new Content( + view: 'emails.user-invitation', + with: [ + 'acceptUrl' => $acceptUrl, + 'expiresAt' => $expiresAt, + 'inviterName' => $inviterName, + 'companyName' => $companyName, + 'email' => $this->invitation->email, + ], + ); + } + + /** + * Get the attachments for the message. + * + * @return array + */ + public function attachments(): array + { + return []; + } +} diff --git a/backend/app/Models/AuditLog.php b/backend/app/Models/AuditLog.php new file mode 100644 index 0000000..c8d9d8b --- /dev/null +++ b/backend/app/Models/AuditLog.php @@ -0,0 +1,77 @@ + 'datetime', + 'changes' => 'array', + 'metadata' => 'array', + ]; + + /** + * Get the user who performed the action + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Get the entity that was audited (polymorphic) + */ + public function entity(): MorphTo + { + return $this->morphTo('entity', 'entity_type', 'entity_id'); + } + + /** + * Scope to filter by entity + */ + public function scopeForEntity($query, string $entityType, int $entityId) + { + return $query->where('entity_type', $entityType) + ->where('entity_id', $entityId); + } + + /** + * Scope to filter by user + */ + public function scopeByUser($query, int $userId) + { + return $query->where('user_id', $userId); + } + + /** + * Scope to filter by event type + */ + public function scopeByEventType($query, string $eventType) + { + return $query->where('event_type', $eventType); + } +} diff --git a/backend/app/Models/Bom.php b/backend/app/Models/Bom.php new file mode 100644 index 0000000..b1c83cd --- /dev/null +++ b/backend/app/Models/Bom.php @@ -0,0 +1,195 @@ + BomType::class, + 'status' => BomStatus::class, + 'quantity' => 'decimal:4', + 'is_default' => 'boolean', + 'effective_date' => 'date', + 'expiry_date' => 'date', + 'meta_data' => 'array', + ]; + + /** + * Company relationship + */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + /** + * Product being manufactured + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * Unit of measure + */ + public function uom(): BelongsTo + { + return $this->belongsTo(UnitOfMeasure::class, 'uom_id'); + } + + /** + * Creator relationship + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * BOM items (components) + */ + public function items(): HasMany + { + return $this->hasMany(BomItem::class)->orderBy('line_number'); + } + + /** + * Work orders using this BOM + */ + public function workOrders(): HasMany + { + return $this->hasMany(WorkOrder::class); + } + + /** + * Scope: Active BOMs + */ + public function scopeActive($query) + { + return $query->where('status', BomStatus::ACTIVE); + } + + /** + * Scope: Default BOMs + */ + public function scopeDefault($query) + { + return $query->where('is_default', true); + } + + /** + * Scope: For a specific product + */ + public function scopeForProduct($query, int $productId) + { + return $query->where('product_id', $productId); + } + + /** + * Scope: Currently effective BOMs + */ + public function scopeEffective($query, ?\DateTimeInterface $date = null) + { + $date = $date ?? now(); + + return $query->where(function ($q) use ($date) { + $q->where(function ($q2) use ($date) { + $q2->whereNull('effective_date') + ->orWhere('effective_date', '<=', $date); + })->where(function ($q2) use ($date) { + $q2->whereNull('expiry_date') + ->orWhere('expiry_date', '>=', $date); + }); + }); + } + + /** + * Scope: Filter by status + */ + public function scopeByStatus($query, BomStatus $status) + { + return $query->where('status', $status); + } + + /** + * Scope: Filter by type + */ + public function scopeOfType($query, BomType $type) + { + return $query->where('bom_type', $type); + } + + /** + * Scope: Search by number or name + */ + public function scopeSearch($query, string $term) + { + return $query->where(function ($q) use ($term) { + $q->where('bom_number', 'ilike', "%{$term}%") + ->orWhere('name', 'ilike', "%{$term}%"); + }); + } + + /** + * Check if BOM can be edited + */ + public function canEdit(): bool + { + return $this->status->canEdit(); + } + + /** + * Check if BOM can be used for production + */ + public function canUseForProduction(): bool + { + return $this->status->canUseForProduction(); + } + + /** + * Get total component count + */ + public function getComponentCountAttribute(): int + { + return $this->items()->count(); + } + + /** + * Check if BOM has any phantom items + */ + public function hasPhantomItems(): bool + { + return $this->items()->where('is_phantom', true)->exists(); + } +} diff --git a/backend/app/Models/BomItem.php b/backend/app/Models/BomItem.php new file mode 100644 index 0000000..01717bc --- /dev/null +++ b/backend/app/Models/BomItem.php @@ -0,0 +1,103 @@ + 'decimal:4', + 'scrap_percentage' => 'decimal:2', + 'is_optional' => 'boolean', + 'is_phantom' => 'boolean', + ]; + + /** + * Parent BOM + */ + public function bom(): BelongsTo + { + return $this->belongsTo(Bom::class); + } + + /** + * Component product + */ + public function component(): BelongsTo + { + return $this->belongsTo(Product::class, 'component_id'); + } + + /** + * Unit of measure + */ + public function uom(): BelongsTo + { + return $this->belongsTo(UnitOfMeasure::class, 'uom_id'); + } + + /** + * Calculate required quantity including scrap + */ + public function getRequiredQuantity(float $parentQuantity = 1): float + { + $scrapFactor = 1 + ($this->scrap_percentage / 100); + return $this->quantity * $parentQuantity * $scrapFactor; + } + + /** + * Get the component's default BOM (for phantom explosion) + */ + public function getComponentDefaultBom(): ?Bom + { + if (!$this->is_phantom) { + return null; + } + + return Bom::where('product_id', $this->component_id) + ->where('is_default', true) + ->active() + ->first(); + } + + /** + * Check if this component can be exploded (has its own BOM) + */ + public function canExplode(): bool + { + return $this->is_phantom && $this->getComponentDefaultBom() !== null; + } + + /** + * Scope: Required items only (non-optional) + */ + public function scopeRequired($query) + { + return $query->where('is_optional', false); + } + + /** + * Scope: Phantom items only + */ + public function scopePhantom($query) + { + return $query->where('is_phantom', true); + } +} diff --git a/backend/app/Models/Category.php b/backend/app/Models/Category.php index ca9bc09..0aa7533 100644 --- a/backend/app/Models/Category.php +++ b/backend/app/Models/Category.php @@ -19,12 +19,14 @@ class Category extends Model 'parent_id', 'is_active', 'sort_order', + 'over_delivery_tolerance_percentage', 'created_by', ]; protected $casts = [ 'is_active' => 'boolean', 'sort_order' => 'integer', + 'over_delivery_tolerance_percentage' => 'decimal:2', ]; /** diff --git a/backend/app/Models/Company.php b/backend/app/Models/Company.php index 7428620..2448445 100644 --- a/backend/app/Models/Company.php +++ b/backend/app/Models/Company.php @@ -99,4 +99,12 @@ public function getDefaultCurrency(): string { return $this->base_currency ?? 'USD'; } + + /** + * Get calendar entries for this company + */ + public function calendars(): HasMany + { + return $this->hasMany(CompanyCalendar::class); + } } diff --git a/backend/app/Models/CompanyCalendar.php b/backend/app/Models/CompanyCalendar.php new file mode 100644 index 0000000..1073718 --- /dev/null +++ b/backend/app/Models/CompanyCalendar.php @@ -0,0 +1,113 @@ + 'date', + 'break_hours' => 'decimal:2', + 'working_hours' => 'decimal:2', + 'is_recurring' => 'boolean', + 'recurrence_pattern' => 'array', + ]; + + // ========================================= + // Relationships + // ========================================= + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + // ========================================= + // Scopes + // ========================================= + + public function scopeForDate($query, $date) + { + return $query->where('calendar_date', $date); + } + + public function scopeDateRange($query, $startDate, $endDate) + { + return $query->whereBetween('calendar_date', [$startDate, $endDate]); + } + + public function scopeWorking($query) + { + return $query->where('day_type', 'working'); + } + + public function scopeHoliday($query) + { + return $query->where('day_type', 'holiday'); + } + + public function scopeRecurring($query) + { + return $query->where('is_recurring', true); + } + + // ========================================= + // Helpers + // ========================================= + + /** + * Check if this is a working day + */ + public function isWorkingDay(): bool + { + return $this->day_type === 'working'; + } + + /** + * Check if this is a holiday + */ + public function isHoliday(): bool + { + return $this->day_type === 'holiday'; + } + + /** + * Get effective working hours for this day + */ + public function getEffectiveWorkingHours(): ?float + { + if ($this->working_hours !== null) { + return (float) $this->working_hours; + } + + if ($this->shift_start && $this->shift_end) { + $start = \Carbon\Carbon::parse($this->shift_start); + $end = \Carbon\Carbon::parse($this->shift_end); + $totalMinutes = $start->diffInMinutes($end); + $breakMinutes = ($this->break_hours ?? 0) * 60; + return max(0, ($totalMinutes - $breakMinutes) / 60); + } + + return null; // Use default from settings + } +} diff --git a/backend/app/Models/Customer.php b/backend/app/Models/Customer.php new file mode 100644 index 0000000..b9a667e --- /dev/null +++ b/backend/app/Models/Customer.php @@ -0,0 +1,133 @@ + 'decimal:2', + 'is_active' => 'boolean', + 'meta_data' => 'array', + ]; + + /** + * Company relationship + */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + /** + * Customer group relationship + */ + public function customerGroup(): BelongsTo + { + return $this->belongsTo(CustomerGroup::class); + } + + /** + * Creator relationship + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * Sales orders + */ + public function salesOrders(): HasMany + { + return $this->hasMany(SalesOrder::class); + } + + /** + * Delivery notes + */ + public function deliveryNotes(): HasMany + { + return $this->hasMany(DeliveryNote::class); + } + + /** + * Get full address + */ + public function getFullAddressAttribute(): string + { + $parts = array_filter([ + $this->address, + $this->city, + $this->state, + $this->postal_code, + $this->country, + ]); + + return implode(', ', $parts); + } + + /** + * Scope: Active customers + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope: Search by name, code, or email + */ + public function scopeSearch($query, string $term) + { + return $query->where(function ($q) use ($term) { + $q->where('name', 'ilike', "%{$term}%") + ->orWhere('customer_code', 'ilike', "%{$term}%") + ->orWhere('email', 'ilike', "%{$term}%"); + }); + } + + /** + * Scope: By customer group + */ + public function scopeInGroup($query, int $groupId) + { + return $query->where('customer_group_id', $groupId); + } +} diff --git a/backend/app/Models/CustomerGroup.php b/backend/app/Models/CustomerGroup.php new file mode 100644 index 0000000..112f579 --- /dev/null +++ b/backend/app/Models/CustomerGroup.php @@ -0,0 +1,79 @@ + 'decimal:2', + 'is_active' => 'boolean', + ]; + + /** + * Company relationship + */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + /** + * Customers in this group + */ + public function customers(): HasMany + { + return $this->hasMany(Customer::class); + } + + /** + * Group-specific prices + */ + public function prices(): HasMany + { + return $this->hasMany(CustomerGroupPrice::class); + } + + /** + * Group prices (alias for prices) + */ + public function groupPrices(): HasMany + { + return $this->hasMany(CustomerGroupPrice::class); + } + + /** + * Scope: Active groups + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope: Search by name or code + */ + public function scopeSearch($query, string $term) + { + return $query->where(function ($q) use ($term) { + $q->where('name', 'ilike', "%{$term}%") + ->orWhere('code', 'ilike', "%{$term}%"); + }); + } +} diff --git a/backend/app/Models/CustomerGroupPrice.php b/backend/app/Models/CustomerGroupPrice.php new file mode 100644 index 0000000..b212a02 --- /dev/null +++ b/backend/app/Models/CustomerGroupPrice.php @@ -0,0 +1,97 @@ + 'decimal:4', + 'min_quantity' => 'decimal:4', + 'valid_from' => 'date', + 'valid_to' => 'date', + 'is_active' => 'boolean', + ]; + + /** + * Customer group relationship + */ + public function customerGroup(): BelongsTo + { + return $this->belongsTo(CustomerGroup::class); + } + + /** + * Product relationship + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * Currency relationship + */ + public function currency(): BelongsTo + { + return $this->belongsTo(Currency::class); + } + + /** + * Scope: Valid prices (within date range) + */ + public function scopeValid($query, $date = null) + { + $date = $date ?? now()->toDateString(); + + return $query->where(function ($q) use ($date) { + $q->whereNull('valid_from') + ->orWhere('valid_from', '<=', $date); + })->where(function ($q) use ($date) { + $q->whereNull('valid_to') + ->orWhere('valid_to', '>=', $date); + }); + } + + /** + * Scope: For specific product + */ + public function scopeForProduct($query, int $productId) + { + return $query->where('product_id', $productId); + } + + /** + * Scope: For minimum quantity + */ + public function scopeForQuantity($query, float $quantity) + { + return $query->where('min_quantity', '<=', $quantity) + ->orderByDesc('min_quantity'); + } + + /** + * Scope: Active prices + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } +} diff --git a/backend/app/Models/DeliveryNote.php b/backend/app/Models/DeliveryNote.php new file mode 100644 index 0000000..f7bf8d4 --- /dev/null +++ b/backend/app/Models/DeliveryNote.php @@ -0,0 +1,175 @@ + 'date', + 'status' => DeliveryNoteStatus::class, + 'delivered_at' => 'datetime', + ]; + + /** + * Company relationship + */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + /** + * Sales order relationship + */ + public function salesOrder(): BelongsTo + { + return $this->belongsTo(SalesOrder::class); + } + + /** + * Customer relationship + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + /** + * Warehouse relationship + */ + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + /** + * Delivery note items + */ + public function items(): HasMany + { + return $this->hasMany(DeliveryNoteItem::class); + } + + /** + * Creator relationship + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * Created by relationship (alias for creator) + */ + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * Delivered by relationship + */ + public function deliveredByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'delivered_by'); + } + + /** + * Check if can be edited + */ + public function canBeEdited(): bool + { + return $this->status->canEdit(); + } + + /** + * Check if can be cancelled + */ + public function canBeCancelled(): bool + { + return $this->status->canCancel(); + } + + /** + * Get total quantity shipped + */ + public function getTotalQuantityShippedAttribute(): float + { + return $this->items->sum('quantity_shipped'); + } + + /** + * Scope: By status + */ + public function scopeStatus($query, $status) + { + if ($status instanceof DeliveryNoteStatus) { + $status = $status->value; + } + return $query->where('status', $status); + } + + /** + * Scope: For sales order + */ + public function scopeForSalesOrder($query, int $salesOrderId) + { + return $query->where('sales_order_id', $salesOrderId); + } + + /** + * Scope: For customer + */ + public function scopeForCustomer($query, int $customerId) + { + return $query->where('customer_id', $customerId); + } + + /** + * Scope: Date range + */ + public function scopeDateRange($query, string $from, string $to) + { + return $query->whereBetween('delivery_date', [$from, $to]); + } + + /** + * Scope: Search + */ + public function scopeSearch($query, string $term) + { + return $query->where(function ($q) use ($term) { + $q->where('delivery_number', 'ilike', "%{$term}%") + ->orWhere('tracking_number', 'ilike', "%{$term}%") + ->orWhereHas('customer', function ($cq) use ($term) { + $cq->where('name', 'ilike', "%{$term}%"); + }); + }); + } +} diff --git a/backend/app/Models/DeliveryNoteItem.php b/backend/app/Models/DeliveryNoteItem.php new file mode 100644 index 0000000..83bcdec --- /dev/null +++ b/backend/app/Models/DeliveryNoteItem.php @@ -0,0 +1,50 @@ + 'decimal:4', + ]; + + /** + * Delivery note relationship + */ + public function deliveryNote(): BelongsTo + { + return $this->belongsTo(DeliveryNote::class); + } + + /** + * Sales order item relationship + */ + public function salesOrderItem(): BelongsTo + { + return $this->belongsTo(SalesOrderItem::class); + } + + /** + * Product relationship + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } +} diff --git a/backend/app/Models/GoodsReceivedNote.php b/backend/app/Models/GoodsReceivedNote.php index 1c310aa..6acecc2 100644 --- a/backend/app/Models/GoodsReceivedNote.php +++ b/backend/app/Models/GoodsReceivedNote.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\GrnStatus; use App\Traits\BelongsToCompany; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -115,15 +116,36 @@ public function creator(): BelongsTo return $this->belongsTo(User::class, 'created_by'); } + /** + * Get status as Enum + */ + public function getStatusEnumAttribute(): ?GrnStatus + { + return $this->status ? GrnStatus::tryFrom($this->status) : null; + } + /** * Check if can be edited */ public function canBeEdited(): bool { - return in_array($this->status, [ - self::STATUS_DRAFT, - self::STATUS_PENDING_INSPECTION, - ]); + return $this->status_enum?->canEdit() ?? false; + } + + /** + * Check if can be cancelled + */ + public function canBeCancelled(): bool + { + return $this->status_enum?->canCancel() ?? false; + } + + /** + * Check if can be deleted + */ + public function canBeDeleted(): bool + { + return $this->status_enum?->canDelete() ?? false; } /** diff --git a/backend/app/Models/MrpRecommendation.php b/backend/app/Models/MrpRecommendation.php new file mode 100644 index 0000000..815ca22 --- /dev/null +++ b/backend/app/Models/MrpRecommendation.php @@ -0,0 +1,290 @@ + MrpRecommendationType::class, + 'required_date' => 'date', + 'suggested_date' => 'date', + 'due_date' => 'date', + 'gross_requirement' => 'decimal:4', + 'net_requirement' => 'decimal:4', + 'suggested_quantity' => 'decimal:4', + 'current_stock' => 'decimal:4', + 'projected_stock' => 'decimal:4', + 'priority' => MrpPriority::class, + 'is_urgent' => 'boolean', + 'status' => MrpRecommendationStatus::class, + 'actioned_at' => 'datetime', + 'calculation_details' => 'array', + ]; + + // ========================================= + // Relationships + // ========================================= + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + public function mrpRun(): BelongsTo + { + return $this->belongsTo(MrpRun::class); + } + + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + public function actionedByUser(): BelongsTo + { + return $this->belongsTo(User::class, 'actioned_by'); + } + + // ========================================= + // Scopes + // ========================================= + + public function scopePending($query) + { + return $query->where('status', MrpRecommendationStatus::PENDING); + } + + public function scopeApproved($query) + { + return $query->where('status', MrpRecommendationStatus::APPROVED); + } + + public function scopeActioned($query) + { + return $query->where('status', MrpRecommendationStatus::ACTIONED); + } + + public function scopeActionable($query) + { + return $query->whereIn('status', [ + MrpRecommendationStatus::PENDING, + MrpRecommendationStatus::APPROVED, + ]); + } + + public function scopeUrgent($query) + { + return $query->where('is_urgent', true); + } + + public function scopeOfType($query, MrpRecommendationType $type) + { + return $query->where('recommendation_type', $type); + } + + public function scopePurchaseOrders($query) + { + return $query->where('recommendation_type', MrpRecommendationType::PURCHASE_ORDER); + } + + public function scopeWorkOrders($query) + { + return $query->where('recommendation_type', MrpRecommendationType::WORK_ORDER); + } + + public function scopeByPriority($query) + { + return $query->orderByRaw(" + CASE priority + WHEN 'critical' THEN 1 + WHEN 'high' THEN 2 + WHEN 'medium' THEN 3 + WHEN 'low' THEN 4 + ELSE 5 + END + "); + } + + public function scopeDueSoon($query, int $days = 7) + { + return $query->where('required_date', '<=', now()->addDays($days)); + } + + public function scopeOverdue($query) + { + return $query->where('required_date', '<', today()) + ->whereIn('status', [ + MrpRecommendationStatus::PENDING, + MrpRecommendationStatus::APPROVED, + ]); + } + + // ========================================= + // Status Management + // ========================================= + + public function approve(): bool + { + if (!$this->status->canApprove()) { + return false; + } + + return $this->update([ + 'status' => MrpRecommendationStatus::APPROVED, + ]); + } + + public function reject(?string $notes = null, ?int $userId = null): bool + { + if (!$this->status->canReject()) { + return false; + } + + return $this->update([ + 'status' => MrpRecommendationStatus::REJECTED, + 'action_notes' => $notes, + 'actioned_at' => now(), + 'actioned_by' => $userId, + ]); + } + + public function markAsActioned( + string $referenceType, + int $referenceId, + ?string $notes = null, + ?int $userId = null + ): bool { + if (!$this->status->canAction()) { + return false; + } + + return $this->update([ + 'status' => MrpRecommendationStatus::ACTIONED, + 'action_reference_type' => $referenceType, + 'action_reference_id' => $referenceId, + 'action_notes' => $notes, + 'actioned_at' => now(), + 'actioned_by' => $userId, + ]); + } + + public function expire(): bool + { + if ($this->status->isFinal()) { + return false; + } + + return $this->update([ + 'status' => MrpRecommendationStatus::EXPIRED, + ]); + } + + // ========================================= + // Computed Properties + // ========================================= + + public function getDaysUntilRequiredAttribute(): int + { + return today()->diffInDays($this->required_date, false); + } + + public function getIsOverdueAttribute(): bool + { + return $this->required_date < today() && !$this->status->isFinal(); + } + + public function getActionReferenceAttribute(): ?Model + { + if (!$this->action_reference_type || !$this->action_reference_id) { + return null; + } + + return match ($this->action_reference_type) { + 'purchase_order' => PurchaseOrder::find($this->action_reference_id), + 'work_order' => WorkOrder::find($this->action_reference_id), + default => null, + }; + } + + public function getDemandSourceAttribute(): ?Model + { + if (!$this->demand_source_type || !$this->demand_source_id) { + return null; + } + + return match ($this->demand_source_type) { + 'work_order' => WorkOrder::find($this->demand_source_id), + 'sales_order' => SalesOrder::find($this->demand_source_id), + default => null, + }; + } + + // ========================================= + // Helpers + // ========================================= + + public function getSummary(): string + { + $typeLabel = $this->recommendation_type->label(); + $quantity = number_format($this->suggested_quantity, 2); + $productName = $this->product?->name ?? 'Unknown'; + $date = $this->suggested_date->format('Y-m-d'); + + return "{$typeLabel}: {$quantity} {$productName} by {$date}"; + } + + public function toActionArray(): array + { + return [ + 'recommendation_id' => $this->id, + 'type' => $this->recommendation_type->value, + 'product_id' => $this->product_id, + 'warehouse_id' => $this->warehouse_id, + 'quantity' => $this->suggested_quantity, + 'required_date' => $this->required_date->toDateString(), + 'suggested_date' => $this->suggested_date->toDateString(), + ]; + } +} diff --git a/backend/app/Models/MrpRun.php b/backend/app/Models/MrpRun.php new file mode 100644 index 0000000..dd43259 --- /dev/null +++ b/backend/app/Models/MrpRun.php @@ -0,0 +1,219 @@ + 'date', + 'planning_horizon_end' => 'date', + 'include_safety_stock' => 'boolean', + 'respect_lead_times' => 'boolean', + 'consider_wip' => 'boolean', + 'net_change' => 'boolean', + 'product_filters' => 'array', + 'warehouse_filters' => 'array', + 'status' => MrpRunStatus::class, + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + 'products_processed' => 'integer', + 'recommendations_generated' => 'integer', + 'warnings_count' => 'integer', + 'warnings_summary' => 'array', + ]; + + // ========================================= + // Relationships + // ========================================= + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function recommendations(): HasMany + { + return $this->hasMany(MrpRecommendation::class); + } + + // ========================================= + // Scopes + // ========================================= + + public function scopeStatus($query, MrpRunStatus $status) + { + return $query->where('status', $status); + } + + public function scopeCompleted($query) + { + return $query->where('status', MrpRunStatus::COMPLETED); + } + + public function scopeLatest($query) + { + return $query->orderByDesc('created_at'); + } + + // ========================================= + // Status Management + // ========================================= + + public function markAsRunning(): bool + { + if ($this->status !== MrpRunStatus::PENDING) { + return false; + } + + return $this->update([ + 'status' => MrpRunStatus::RUNNING, + 'started_at' => now(), + ]); + } + + public function markAsCompleted(int $productsProcessed, int $recommendationsGenerated, int $warnings = 0, ?array $warningsSummary = null): bool + { + if ($this->status !== MrpRunStatus::RUNNING) { + return false; + } + + return $this->update([ + 'status' => MrpRunStatus::COMPLETED, + 'completed_at' => now(), + 'products_processed' => $productsProcessed, + 'recommendations_generated' => $recommendationsGenerated, + 'warnings_count' => $warnings, + 'warnings_summary' => $warningsSummary, + ]); + } + + public function markAsFailed(string $errorMessage): bool + { + return $this->update([ + 'status' => MrpRunStatus::FAILED, + 'completed_at' => now(), + 'error_message' => $errorMessage, + ]); + } + + public function markAsCancelled(): bool + { + if (!$this->status->canCancel()) { + return false; + } + + return $this->update([ + 'status' => MrpRunStatus::CANCELLED, + 'completed_at' => now(), + ]); + } + + // ========================================= + // Computed Properties + // ========================================= + + public function getDurationAttribute(): ?int + { + if (!$this->started_at) { + return null; + } + + $end = $this->completed_at ?? now(); + return $this->started_at->diffInSeconds($end); + } + + public function getDurationFormattedAttribute(): ?string + { + if (!$this->duration) { + return null; + } + + $seconds = $this->duration; + if ($seconds < 60) { + return "{$seconds}s"; + } + + $minutes = floor($seconds / 60); + $remainingSeconds = $seconds % 60; + return "{$minutes}m {$remainingSeconds}s"; + } + + public function getPlanningHorizonDaysAttribute(): int + { + return $this->planning_horizon_start->diffInDays($this->planning_horizon_end); + } + + // ========================================= + // Helpers + // ========================================= + + public static function generateRunNumber(int $companyId): string + { + $date = now()->format('Ymd'); + $companyIdPadded = str_pad($companyId, 3, '0', STR_PAD_LEFT); + $prefix = "MRP-{$date}-{$companyIdPadded}-"; + + $lastRun = static::where('company_id', $companyId) + ->where('run_number', 'like', "{$prefix}%") + ->whereDate('created_at', today()) + ->orderByRaw("CAST(SUBSTRING(run_number FROM '[0-9]+$') AS INTEGER) DESC") + ->first(); + + $sequence = 1; + if ($lastRun && preg_match('/(\d+)$/', $lastRun->run_number, $matches)) { + $sequence = (int) $matches[1] + 1; + } + + return $prefix . str_pad($sequence, 3, '0', STR_PAD_LEFT); + } + + public function getPendingRecommendationsCount(): int + { + return $this->recommendations() + ->where('status', 'pending') + ->count(); + } + + public function getActionedRecommendationsCount(): int + { + return $this->recommendations() + ->where('status', 'actioned') + ->count(); + } +} diff --git a/backend/app/Models/NonConformanceReport.php b/backend/app/Models/NonConformanceReport.php index 9475a76..befb43b 100644 --- a/backend/app/Models/NonConformanceReport.php +++ b/backend/app/Models/NonConformanceReport.php @@ -2,6 +2,10 @@ namespace App\Models; +use App\Enums\DefectType; +use App\Enums\NcrDisposition; +use App\Enums\NcrSeverity; +use App\Enums\NcrStatus; use App\Traits\BelongsToCompany; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -12,34 +16,17 @@ class NonConformanceReport extends Model { use HasFactory, SoftDeletes, BelongsToCompany; - // Status constants - public const STATUS_OPEN = 'open'; - public const STATUS_UNDER_REVIEW = 'under_review'; - public const STATUS_PENDING_DISPOSITION = 'pending_disposition'; - public const STATUS_DISPOSITION_APPROVED = 'disposition_approved'; - public const STATUS_IN_PROGRESS = 'in_progress'; - public const STATUS_CLOSED = 'closed'; - public const STATUS_CANCELLED = 'cancelled'; - - // Severity constants - public const SEVERITY_MINOR = 'minor'; - public const SEVERITY_MAJOR = 'major'; - public const SEVERITY_CRITICAL = 'critical'; - - // Source type constants + // Source type constants (no enum needed - simple category) public const SOURCE_RECEIVING = 'receiving'; public const SOURCE_PRODUCTION = 'production'; public const SOURCE_INTERNAL = 'internal'; public const SOURCE_CUSTOMER = 'customer'; - // Disposition constants - public const DISPOSITION_PENDING = 'pending'; - public const DISPOSITION_USE_AS_IS = 'use_as_is'; - public const DISPOSITION_REWORK = 'rework'; - public const DISPOSITION_SCRAP = 'scrap'; - public const DISPOSITION_RETURN = 'return_to_supplier'; - public const DISPOSITION_SORT = 'sort_and_use'; - public const DISPOSITION_REJECT = 'reject'; + // Priority constants + public const PRIORITY_LOW = 'low'; + public const PRIORITY_MEDIUM = 'medium'; + public const PRIORITY_HIGH = 'high'; + public const PRIORITY_URGENT = 'urgent'; protected $fillable = [ 'company_id', @@ -86,29 +73,7 @@ class NonConformanceReport extends Model ]; /** - * Status labels - */ - public const STATUSES = [ - self::STATUS_OPEN => 'Open', - self::STATUS_UNDER_REVIEW => 'Under Review', - self::STATUS_PENDING_DISPOSITION => 'Pending Disposition', - self::STATUS_DISPOSITION_APPROVED => 'Disposition Approved', - self::STATUS_IN_PROGRESS => 'In Progress', - self::STATUS_CLOSED => 'Closed', - self::STATUS_CANCELLED => 'Cancelled', - ]; - - /** - * Severity labels - */ - public const SEVERITIES = [ - self::SEVERITY_MINOR => 'Minor', - self::SEVERITY_MAJOR => 'Major', - self::SEVERITY_CRITICAL => 'Critical', - ]; - - /** - * Defect type labels + * Defect type labels for UI */ public const DEFECT_TYPES = [ 'dimensional' => 'Dimensional', @@ -125,18 +90,57 @@ class NonConformanceReport extends Model ]; /** - * Disposition labels + * Source type labels for UI + */ + public const SOURCES = [ + self::SOURCE_RECEIVING => 'Receiving', + self::SOURCE_PRODUCTION => 'Production', + self::SOURCE_INTERNAL => 'Internal', + self::SOURCE_CUSTOMER => 'Customer', + ]; + + /** + * Priority labels for UI */ - public const DISPOSITIONS = [ - self::DISPOSITION_PENDING => 'Pending Decision', - self::DISPOSITION_USE_AS_IS => 'Use As Is', - self::DISPOSITION_REWORK => 'Rework', - self::DISPOSITION_SCRAP => 'Scrap', - self::DISPOSITION_RETURN => 'Return to Supplier', - self::DISPOSITION_SORT => 'Sort and Use', - self::DISPOSITION_REJECT => 'Reject', + public const PRIORITIES = [ + self::PRIORITY_LOW => 'Low', + self::PRIORITY_MEDIUM => 'Medium', + self::PRIORITY_HIGH => 'High', + self::PRIORITY_URGENT => 'Urgent', ]; + /** + * Get status as Enum + */ + public function getStatusEnumAttribute(): ?NcrStatus + { + return $this->status ? NcrStatus::tryFrom($this->status) : null; + } + + /** + * Get severity as Enum + */ + public function getSeverityEnumAttribute(): ?NcrSeverity + { + return $this->severity ? NcrSeverity::tryFrom($this->severity) : null; + } + + /** + * Get disposition as Enum + */ + public function getDispositionEnumAttribute(): ?NcrDisposition + { + return $this->disposition ? NcrDisposition::tryFrom($this->disposition) : null; + } + + /** + * Get defect type as Enum + */ + public function getDefectTypeEnumAttribute(): ?DefectType + { + return $this->defect_type ? DefectType::tryFrom($this->defect_type) : null; + } + /** * Company relationship */ @@ -206,7 +210,8 @@ public function closer(): BelongsTo */ public function getStatusLabelAttribute(): string { - return self::STATUSES[$this->status] ?? $this->status; + $status = $this->status_enum; + return $status ? $status->fallbackLabel() : ucfirst($this->status ?? 'Unknown'); } /** @@ -214,7 +219,8 @@ public function getStatusLabelAttribute(): string */ public function getSeverityLabelAttribute(): string { - return self::SEVERITIES[$this->severity] ?? $this->severity; + $severity = $this->severity_enum; + return $severity ? $severity->fallbackLabel() : ucfirst($this->severity ?? 'Unknown'); } /** @@ -222,7 +228,7 @@ public function getSeverityLabelAttribute(): string */ public function getDefectTypeLabelAttribute(): string { - return self::DEFECT_TYPES[$this->defect_type] ?? $this->defect_type; + return self::DEFECT_TYPES[$this->defect_type] ?? ucfirst($this->defect_type ?? 'Unknown'); } /** @@ -230,27 +236,53 @@ public function getDefectTypeLabelAttribute(): string */ public function getDispositionLabelAttribute(): string { - return self::DISPOSITIONS[$this->disposition] ?? $this->disposition; + $disposition = $this->disposition_enum; + return $disposition ? $disposition->fallbackLabel() : ucfirst($this->disposition ?? 'Unknown'); } /** - * Check if NCR is open + * Check if NCR is open (using Enum for type-safe logic) */ public function isOpen(): bool { - return !in_array($this->status, [self::STATUS_CLOSED, self::STATUS_CANCELLED]); + $status = $this->status_enum; + return $status ? $status->isActive() : true; } /** - * Check if can be edited + * Check if can be edited (using Enum) */ public function canBeEdited(): bool { - return in_array($this->status, [ - self::STATUS_OPEN, - self::STATUS_UNDER_REVIEW, - self::STATUS_PENDING_DISPOSITION, - ]); + $status = $this->status_enum; + return $status ? $status->canEdit() : false; + } + + /** + * Check if requires quarantine based on severity (using Enum) + */ + public function requiresQuarantine(): bool + { + $severity = $this->severity_enum; + return $severity ? $severity->requiresQuarantine() : false; + } + + /** + * Get response time in hours based on severity (using Enum) + */ + public function getResponseTimeHours(): int + { + $severity = $this->severity_enum; + return $severity ? $severity->responseTimeHours() : 72; + } + + /** + * Check if should notify supplier (using Enum) + */ + public function shouldNotifySupplier(): bool + { + $disposition = $this->disposition_enum; + return $disposition ? $disposition->notifySupplier() : false; } /** @@ -267,7 +299,7 @@ public function getDaysOpenAttribute(): int */ public function scopeOpen($query) { - return $query->whereNotIn('status', [self::STATUS_CLOSED, self::STATUS_CANCELLED]); + return $query->whereNotIn('status', [NcrStatus::CLOSED->value, NcrStatus::CANCELLED->value]); } /** @@ -275,7 +307,7 @@ public function scopeOpen($query) */ public function scopeClosed($query) { - return $query->where('status', self::STATUS_CLOSED); + return $query->where('status', NcrStatus::CLOSED->value); } /** @@ -323,6 +355,6 @@ public function scopeDateRange($query, string $from, string $to) */ public function scopeCriticalOrMajor($query) { - return $query->whereIn('severity', [self::SEVERITY_CRITICAL, self::SEVERITY_MAJOR]); + return $query->whereIn('severity', [NcrSeverity::CRITICAL->value, NcrSeverity::MAJOR->value]); } } diff --git a/backend/app/Models/Product.php b/backend/app/Models/Product.php index 4318e44..54ff007 100644 --- a/backend/app/Models/Product.php +++ b/backend/app/Models/Product.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\BelongsToCompany; +use App\Traits\Blameable; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -10,7 +11,7 @@ class Product extends Model { - use SoftDeletes, Searchable, BelongsToCompany; + use SoftDeletes, Searchable, BelongsToCompany, Blameable; protected $fillable = [ 'company_id', @@ -30,6 +31,24 @@ class Product extends Model 'is_featured', 'meta_data', 'created_by', + 'updated_by', + 'deleted_by', + // MRP Planning fields + 'lead_time_days', + 'safety_stock', + 'reorder_point', + 'make_or_buy', + 'low_level_code', + 'minimum_order_qty', + 'order_multiple', + 'maximum_stock', + // Negative stock policy + 'negative_stock_policy', + 'negative_stock_limit', + // Reservation policy + 'reservation_policy', + // Over-delivery tolerance + 'over_delivery_tolerance_percentage', ]; protected $casts = [ @@ -41,6 +60,17 @@ class Product extends Model 'is_active' => 'boolean', 'is_featured' => 'boolean', 'meta_data' => 'array', + // MRP Planning casts + 'lead_time_days' => 'integer', + 'safety_stock' => 'decimal:4', + 'reorder_point' => 'decimal:4', + 'low_level_code' => 'integer', + 'minimum_order_qty' => 'decimal:4', + 'order_multiple' => 'decimal:4', + 'maximum_stock' => 'decimal:4', + 'negative_stock_limit' => 'decimal:3', + 'reservation_policy' => 'string', + 'over_delivery_tolerance_percentage' => 'decimal:2', ]; /** @@ -183,15 +213,6 @@ public function getPrimaryCategoryAttribute() return $this->categories()->wherePivot('is_primary', true)->first(); } - /** - * Alias for backwards compatibility - * @deprecated Use categories() or primaryCategory instead - */ - public function category() - { - return $this->primaryCategory(); - } - /** * Get the variants for the product */ @@ -241,4 +262,331 @@ public function isOutOfStock(): bool { return $this->stock <= 0; } + + // ========================================= + // Manufacturing Module Relationships + // ========================================= + + /** + * Get all BOMs for this product + */ + public function boms() + { + return $this->hasMany(Bom::class); + } + + /** + * Get the default BOM for this product + */ + public function defaultBom() + { + return $this->hasOne(Bom::class)->where('is_default', true)->where('status', 'active'); + } + + /** + * Get all routings for this product + */ + public function routings() + { + return $this->hasMany(Routing::class); + } + + /** + * Get the default routing for this product + */ + public function defaultRouting() + { + return $this->hasOne(Routing::class)->where('is_default', true)->where('status', 'active'); + } + + /** + * Get all work orders for this product + */ + public function workOrders() + { + return $this->hasMany(WorkOrder::class); + } + + /** + * Check if product has an active BOM + */ + public function hasActiveBom(): bool + { + return $this->boms()->where('status', 'active')->exists(); + } + + /** + * Check if product has an active routing + */ + public function hasActiveRouting(): bool + { + return $this->routings()->where('status', 'active')->exists(); + } + + /** + * Check if product can be manufactured (has both BOM and routing) + */ + public function canBeManufactured(): bool + { + return $this->hasActiveBom() && $this->hasActiveRouting(); + } + + /** + * Check if product type allows manufacturing (BOM/Routing) + */ + public function isManufacturable(): bool + { + return $this->productType?->can_be_manufactured ?? false; + } + + /** + * Check if product can have a BOM attached + */ + public function canHaveBom(): bool + { + return $this->isManufacturable(); + } + + /** + * Check if product can be used as a component in BOMs + * (All products can be components, but optionally restrict) + */ + public function canBeComponent(): bool + { + // All inventory-tracked products can be components + return $this->productType?->track_inventory ?? true; + } + + /** + * Get BOMs where this product is used as a component + * (Where Used / Reverse BOM lookup) + */ + public function usedInBoms() + { + return $this->hasManyThrough( + Bom::class, + BomItem::class, + 'component_id', // Foreign key on bom_items + 'id', // Foreign key on boms + 'id', // Local key on products + 'bom_id' // Local key on bom_items + ); + } + + /** + * Get BOM items where this product is used as a component + */ + public function bomItemsAsComponent() + { + return $this->hasMany(BomItem::class, 'component_id'); + } + + /** + * Check if product is used as component in any BOM + */ + public function isUsedAsComponent(): bool + { + return $this->bomItemsAsComponent()->exists(); + } + + // ========================================= + // UOM Conversion Relationships + // ========================================= + + /** + * Get all product-specific UOM conversions + */ + public function uomConversions() + { + return $this->hasMany(ProductUomConversion::class); + } + + /** + * Get active UOM conversions + */ + public function activeUomConversions() + { + return $this->uomConversions()->active(); + } + + /** + * Convert quantity between units for this product + * + * First checks product-specific conversions, then falls back to standard conversions. + * + * @param float $quantity Quantity to convert + * @param UnitOfMeasure $fromUnit Source unit + * @param UnitOfMeasure $toUnit Target unit + * @return float|null Converted quantity, null if conversion not possible + */ + public function convertQuantity(float $quantity, UnitOfMeasure $fromUnit, UnitOfMeasure $toUnit): ?float + { + // Same unit, no conversion needed + if ($fromUnit->id === $toUnit->id) { + return $quantity; + } + + // Try product-specific conversion first + $productConversion = $this->uomConversions() + ->active() + ->fromUnit($fromUnit->id) + ->toUnit($toUnit->id) + ->first(); + + if ($productConversion) { + return $productConversion->convert($quantity); + } + + // Try reverse product-specific conversion + $reverseConversion = $this->uomConversions() + ->active() + ->fromUnit($toUnit->id) + ->toUnit($fromUnit->id) + ->first(); + + if ($reverseConversion) { + return $reverseConversion->reverseConvert($quantity); + } + + // Fall back to standard conversion + return $fromUnit->convertTo($quantity, $toUnit); + } + + /** + * Get all available units for this product + * (base unit + all units with conversions) + */ + public function getAvailableUnits() + { + $unitIds = collect([$this->uom_id]); + + // Add units from product-specific conversions + $conversionUnits = $this->uomConversions() + ->active() + ->get() + ->flatMap(fn($c) => [$c->from_uom_id, $c->to_uom_id]); + + $unitIds = $unitIds->merge($conversionUnits)->unique()->filter(); + + return UnitOfMeasure::whereIn('id', $unitIds)->active()->get(); + } + + // ========================================= + // MRP Planning Methods + // ========================================= + + /** + * Get MRP recommendations for this product + */ + public function mrpRecommendations() + { + return $this->hasMany(MrpRecommendation::class); + } + + /** + * Check if product should be manufactured (vs purchased) + */ + public function shouldManufacture(): bool + { + return $this->make_or_buy === 'make'; + } + + /** + * Check if product should be purchased + */ + public function shouldPurchase(): bool + { + return $this->make_or_buy === 'buy'; + } + + /** + * Calculate order quantity respecting order multiple and minimum + */ + public function calculateOrderQuantity(float $netRequirement): float + { + // Apply minimum order quantity + $quantity = max($netRequirement, $this->minimum_order_qty ?? 1); + + // Apply order multiple (lot sizing) + $multiple = $this->order_multiple ?? 1; + if ($multiple > 1) { + $quantity = ceil($quantity / $multiple) * $multiple; + } + + // Check against maximum stock if set + if ($this->maximum_stock !== null) { + $currentStock = $this->getTotalStock(); + $maxOrderQty = $this->maximum_stock - $currentStock; + if ($maxOrderQty > 0) { + $quantity = min($quantity, $maxOrderQty); + } + } + + return $quantity; + } + + /** + * Get total stock across all warehouses + */ + public function getTotalStock(): float + { + return $this->stocks()->sum('quantity_available'); + } + + /** + * Get stock levels per warehouse + */ + public function stocks() + { + return $this->hasMany(Stock::class); + } + + /** + * Check if product is below reorder point + */ + public function isBelowReorderPoint(): bool + { + $totalStock = $this->getTotalStock(); + return $totalStock < ($this->reorder_point ?? 0); + } + + /** + * Check if product is below safety stock + */ + public function isBelowSafetyStock(): bool + { + $totalStock = $this->getTotalStock(); + return $totalStock < ($this->safety_stock ?? 0); + } + + /** + * Calculate when order should be placed (considering lead time) + */ + public function calculateOrderDate(\DateTimeInterface $requiredDate): \DateTimeInterface + { + $orderDate = \Carbon\Carbon::parse($requiredDate); + return $orderDate->subDays($this->lead_time_days ?? 0); + } + + /** + * Get negative stock limit based on policy + */ + public function getNegativeStockLimit(): float + { + return match($this->negative_stock_policy ?? 'NEVER') { + 'NEVER' => 0, + 'ALLOWED' => PHP_FLOAT_MAX, + 'LIMITED' => $this->negative_stock_limit ?? 0, + default => 0, + }; + } + + /** + * Check if product can go negative + */ + public function canGoNegative(): bool + { + $policy = $this->negative_stock_policy ?? 'NEVER'; + return $policy !== 'NEVER'; + } } diff --git a/backend/app/Models/ProductUomConversion.php b/backend/app/Models/ProductUomConversion.php new file mode 100644 index 0000000..4d1a122 --- /dev/null +++ b/backend/app/Models/ProductUomConversion.php @@ -0,0 +1,141 @@ + 'decimal:6', + 'is_default' => 'boolean', + 'is_active' => 'boolean', + ]; + + /** + * Get the product + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * Get the source unit of measure + */ + public function fromUom(): BelongsTo + { + return $this->belongsTo(UnitOfMeasure::class, 'from_uom_id'); + } + + /** + * Get the target unit of measure + */ + public function toUom(): BelongsTo + { + return $this->belongsTo(UnitOfMeasure::class, 'to_uom_id'); + } + + /** + * Convert quantity from source to target unit + * + * @param float $quantity Quantity in source unit + * @return float Quantity in target unit + */ + public function convert(float $quantity): float + { + return $quantity * $this->conversion_factor; + } + + /** + * Convert quantity from target back to source unit (reverse conversion) + * + * @param float $quantity Quantity in target unit + * @return float|null Quantity in source unit, null if conversion factor is zero + */ + public function reverseConvert(float $quantity): ?float + { + if ($this->conversion_factor == 0) { + return null; + } + + return $quantity / $this->conversion_factor; + } + + /** + * Get formatted conversion string for display + * Example: "1 box = 500 pcs" + */ + public function getDisplayString(): string + { + $fromCode = $this->fromUom?->code ?? '?'; + $toCode = $this->toUom?->code ?? '?'; + $factor = number_format($this->conversion_factor, $this->toUom?->precision ?? 2); + + return "1 {$fromCode} = {$factor} {$toCode}"; + } + + /** + * Scope: Get only active conversions + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope: Get default conversions + */ + public function scopeDefault($query) + { + return $query->where('is_default', true); + } + + /** + * Scope: Get conversions for a specific product + */ + public function scopeForProduct($query, int $productId) + { + return $query->where('product_id', $productId); + } + + /** + * Scope: Get conversions from a specific unit + */ + public function scopeFromUnit($query, int $uomId) + { + return $query->where('from_uom_id', $uomId); + } + + /** + * Scope: Get conversions to a specific unit + */ + public function scopeToUnit($query, int $uomId) + { + return $query->where('to_uom_id', $uomId); + } +} diff --git a/backend/app/Models/PurchaseOrder.php b/backend/app/Models/PurchaseOrder.php index d6d1476..eeca361 100644 --- a/backend/app/Models/PurchaseOrder.php +++ b/backend/app/Models/PurchaseOrder.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\PoStatus; use App\Traits\BelongsToCompany; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -27,6 +28,7 @@ class PurchaseOrder extends Model 'company_id', 'order_number', 'supplier_id', + 'mrp_recommendation_id', 'warehouse_id', 'order_date', 'expected_delivery_date', @@ -133,15 +135,28 @@ public function approver(): BelongsTo return $this->belongsTo(User::class, 'approved_by'); } + /** + * MRP Recommendation that generated this purchase order + */ + public function mrpRecommendation(): BelongsTo + { + return $this->belongsTo(MrpRecommendation::class); + } + + /** + * Get status as Enum + */ + public function getStatusEnumAttribute(): ?PoStatus + { + return $this->status ? PoStatus::tryFrom($this->status) : null; + } + /** * Check if order can be edited */ public function canBeEdited(): bool { - return in_array($this->status, [ - self::STATUS_DRAFT, - self::STATUS_PENDING_APPROVAL, - ]); + return $this->status_enum?->canEdit() ?? false; } /** @@ -149,7 +164,7 @@ public function canBeEdited(): bool */ public function canBeApproved(): bool { - return $this->status === self::STATUS_PENDING_APPROVAL; + return $this->status_enum?->requiresApproval() ?? false; } /** @@ -165,10 +180,7 @@ public function canBeSent(): bool */ public function canReceiveGoods(): bool { - return in_array($this->status, [ - self::STATUS_SENT, - self::STATUS_PARTIALLY_RECEIVED, - ]); + return $this->status_enum?->canReceive() ?? false; } /** @@ -176,11 +188,7 @@ public function canReceiveGoods(): bool */ public function canBeCancelled(): bool { - return in_array($this->status, [ - self::STATUS_DRAFT, - self::STATUS_PENDING_APPROVAL, - self::STATUS_APPROVED, - ]); + return $this->status_enum?->canCancel() ?? false; } /** diff --git a/backend/app/Models/PurchaseOrderItem.php b/backend/app/Models/PurchaseOrderItem.php index a252aae..0f4c94d 100644 --- a/backend/app/Models/PurchaseOrderItem.php +++ b/backend/app/Models/PurchaseOrderItem.php @@ -29,6 +29,7 @@ class PurchaseOrderItem extends Model 'actual_delivery_date', 'lot_number', 'notes', + 'over_delivery_tolerance_percentage', ]; protected $casts = [ @@ -43,6 +44,7 @@ class PurchaseOrderItem extends Model 'line_total' => 'decimal:2', 'expected_delivery_date' => 'date', 'actual_delivery_date' => 'date', + 'over_delivery_tolerance_percentage' => 'decimal:2', ]; /** diff --git a/backend/app/Models/ReceivingInspection.php b/backend/app/Models/ReceivingInspection.php index 37f779e..a32f929 100644 --- a/backend/app/Models/ReceivingInspection.php +++ b/backend/app/Models/ReceivingInspection.php @@ -2,6 +2,8 @@ namespace App\Models; +use App\Enums\InspectionDisposition; +use App\Enums\InspectionResult; use App\Traits\BelongsToCompany; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -64,7 +66,7 @@ class ReceivingInspection extends Model ]; /** - * Result labels + * Result labels for UI */ public const RESULTS = [ self::RESULT_PENDING => 'Pending', @@ -75,7 +77,7 @@ class ReceivingInspection extends Model ]; /** - * Disposition labels + * Disposition labels for UI */ public const DISPOSITIONS = [ self::DISPOSITION_ACCEPT => 'Accept', @@ -86,6 +88,22 @@ class ReceivingInspection extends Model self::DISPOSITION_PENDING => 'Pending Decision', ]; + /** + * Get result as Enum + */ + public function getResultEnumAttribute(): ?InspectionResult + { + return $this->result ? InspectionResult::tryFrom($this->result) : null; + } + + /** + * Get disposition as Enum + */ + public function getDispositionEnumAttribute(): ?InspectionDisposition + { + return $this->disposition ? InspectionDisposition::tryFrom($this->disposition) : null; + } + /** * Company relationship */ @@ -167,7 +185,7 @@ public function getPassRateAttribute(): float */ public function getResultLabelAttribute(): string { - return self::RESULTS[$this->result] ?? $this->result; + return self::RESULTS[$this->result] ?? ucfirst($this->result ?? 'Unknown'); } /** @@ -175,23 +193,44 @@ public function getResultLabelAttribute(): string */ public function getDispositionLabelAttribute(): string { - return self::DISPOSITIONS[$this->disposition] ?? $this->disposition; + return self::DISPOSITIONS[$this->disposition] ?? ucfirst($this->disposition ?? 'Unknown'); } /** - * Check if inspection is complete + * Check if inspection is complete (using Enum for type-safe logic) */ public function isComplete(): bool { - return $this->result !== self::RESULT_PENDING; + $result = $this->result_enum; + return $result ? $result->isComplete() : false; } /** - * Check if requires NCR + * Check if requires NCR (using Enum for type-safe logic) */ public function requiresNcr(): bool { - return $this->quantity_failed > 0 || $this->result === self::RESULT_FAILED; + if ($this->quantity_failed > 0) { + return true; + } + + $result = $this->result_enum; + return $result ? $result->requiresNcr() : false; + } + + /** + * Check if stock can be released (using Enum) + */ + public function canReleaseStock(): bool + { + $result = $this->result_enum; + $disposition = $this->disposition_enum; + + if (!$result || !$disposition) { + return false; + } + + return $result->canReleaseStock() || $disposition->allowsStockEntry(); } /** diff --git a/backend/app/Models/Routing.php b/backend/app/Models/Routing.php new file mode 100644 index 0000000..02be3c5 --- /dev/null +++ b/backend/app/Models/Routing.php @@ -0,0 +1,201 @@ + RoutingStatus::class, + 'is_default' => 'boolean', + 'effective_date' => 'date', + 'expiry_date' => 'date', + 'meta_data' => 'array', + ]; + + /** + * Company relationship + */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + /** + * Product this routing is for + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * Creator relationship + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * Operations in this routing + */ + public function operations(): HasMany + { + return $this->hasMany(RoutingOperation::class)->orderBy('operation_number'); + } + + /** + * Work orders using this routing + */ + public function workOrders(): HasMany + { + return $this->hasMany(WorkOrder::class); + } + + /** + * Scope: Active routings + */ + public function scopeActive($query) + { + return $query->where('status', RoutingStatus::ACTIVE); + } + + /** + * Scope: Default routings + */ + public function scopeDefault($query) + { + return $query->where('is_default', true); + } + + /** + * Scope: For a specific product + */ + public function scopeForProduct($query, int $productId) + { + return $query->where('product_id', $productId); + } + + /** + * Scope: Currently effective + */ + public function scopeEffective($query, ?\DateTimeInterface $date = null) + { + $date = $date ?? now(); + + return $query->where(function ($q) use ($date) { + $q->where(function ($q2) use ($date) { + $q2->whereNull('effective_date') + ->orWhere('effective_date', '<=', $date); + })->where(function ($q2) use ($date) { + $q2->whereNull('expiry_date') + ->orWhere('expiry_date', '>=', $date); + }); + }); + } + + /** + * Scope: Filter by status + */ + public function scopeByStatus($query, RoutingStatus $status) + { + return $query->where('status', $status); + } + + /** + * Scope: Search by number or name + */ + public function scopeSearch($query, string $term) + { + return $query->where(function ($q) use ($term) { + $q->where('routing_number', 'ilike', "%{$term}%") + ->orWhere('name', 'ilike', "%{$term}%"); + }); + } + + /** + * Check if routing can be edited + */ + public function canEdit(): bool + { + return $this->status->canEdit(); + } + + /** + * Check if routing can be used for production + */ + public function canUseForProduction(): bool + { + return $this->status->canUseForProduction(); + } + + /** + * Get operation count + */ + public function getOperationCountAttribute(): int + { + return $this->operations()->count(); + } + + /** + * Calculate total lead time in minutes + */ + public function getTotalLeadTimeAttribute(): float + { + return $this->operations()->sum(\DB::raw('setup_time + run_time_per_unit + queue_time + move_time')); + } + + /** + * Calculate total setup time + */ + public function getTotalSetupTimeAttribute(): float + { + return $this->operations()->sum('setup_time'); + } + + /** + * Calculate total run time per unit + */ + public function getTotalRunTimePerUnitAttribute(): float + { + return $this->operations()->sum('run_time_per_unit'); + } + + /** + * Calculate estimated time for a quantity + */ + public function calculateEstimatedTime(float $quantity): float + { + $setupTime = $this->total_setup_time; + $runTime = $this->total_run_time_per_unit * $quantity; + $queueMoveTime = $this->operations()->sum(\DB::raw('queue_time + move_time')); + + return $setupTime + $runTime + $queueMoveTime; + } +} diff --git a/backend/app/Models/RoutingOperation.php b/backend/app/Models/RoutingOperation.php new file mode 100644 index 0000000..efc296f --- /dev/null +++ b/backend/app/Models/RoutingOperation.php @@ -0,0 +1,101 @@ + 'decimal:2', + 'run_time_per_unit' => 'decimal:4', + 'queue_time' => 'decimal:2', + 'move_time' => 'decimal:2', + 'is_subcontracted' => 'boolean', + 'subcontract_cost' => 'decimal:4', + 'settings' => 'array', + ]; + + /** + * Parent routing + */ + public function routing(): BelongsTo + { + return $this->belongsTo(Routing::class); + } + + /** + * Work center for this operation + */ + public function workCenter(): BelongsTo + { + return $this->belongsTo(WorkCenter::class); + } + + /** + * Subcontractor (if subcontracted) + */ + public function subcontractor(): BelongsTo + { + return $this->belongsTo(Supplier::class, 'subcontractor_id'); + } + + /** + * Work order operations created from this template + */ + public function workOrderOperations(): HasMany + { + return $this->hasMany(WorkOrderOperation::class); + } + + /** + * Get total time for this operation (per unit) + */ + public function getTotalTimePerUnitAttribute(): float + { + return $this->setup_time + $this->run_time_per_unit + $this->queue_time + $this->move_time; + } + + /** + * Calculate operation time for a given quantity + */ + public function calculateTime(float $quantity): float + { + return $this->setup_time + ($this->run_time_per_unit * $quantity) + $this->queue_time + $this->move_time; + } + + /** + * Calculate operation cost for a given quantity + */ + public function calculateCost(float $quantity): float + { + if ($this->is_subcontracted) { + return ($this->subcontract_cost ?? 0) * $quantity; + } + + $hours = $this->calculateTime($quantity) / 60; + return $hours * ($this->workCenter->cost_per_hour ?? 0); + } +} diff --git a/backend/app/Models/SalesOrder.php b/backend/app/Models/SalesOrder.php new file mode 100644 index 0000000..4581222 --- /dev/null +++ b/backend/app/Models/SalesOrder.php @@ -0,0 +1,266 @@ + 'date', + 'requested_delivery_date' => 'date', + 'promised_delivery_date' => 'date', + 'status' => SalesOrderStatus::class, + 'exchange_rate' => 'decimal:6', + 'subtotal' => 'decimal:2', + 'discount_amount' => 'decimal:2', + 'tax_amount' => 'decimal:2', + 'shipping_cost' => 'decimal:2', + 'total_amount' => 'decimal:2', + 'meta_data' => 'array', + 'approved_at' => 'datetime', + ]; + + /** + * Company relationship + */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + /** + * Customer relationship + */ + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + /** + * Warehouse relationship + */ + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + /** + * Order items + */ + public function items(): HasMany + { + return $this->hasMany(SalesOrderItem::class)->orderBy('line_number'); + } + + /** + * Delivery notes + */ + public function deliveryNotes(): HasMany + { + return $this->hasMany(DeliveryNote::class); + } + + /** + * Creator relationship + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * CreatedBy relationship (alias for creator) + */ + public function createdBy(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * Updater relationship + */ + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } + + /** + * ApprovedBy relationship + */ + public function approvedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by'); + } + + /** + * Check if order can be edited + */ + public function canBeEdited(): bool + { + return $this->status->canEdit(); + } + + /** + * Check if order can be approved + */ + public function canBeApproved(): bool + { + return $this->status->requiresApproval(); + } + + /** + * Check if order can be shipped + */ + public function canBeShipped(): bool + { + return $this->status->canShip(); + } + + /** + * Check if order can be cancelled + */ + public function canBeCancelled(): bool + { + return $this->status->canCancel(); + } + + /** + * Get total quantity ordered + */ + public function getTotalQuantityOrderedAttribute(): float + { + return $this->items->sum('quantity_ordered'); + } + + /** + * Get total quantity shipped + */ + public function getTotalQuantityShippedAttribute(): float + { + return $this->items->sum('quantity_shipped'); + } + + /** + * Get remaining quantity to ship + */ + public function getRemainingQuantityAttribute(): float + { + return $this->total_quantity_ordered - $this->total_quantity_shipped; + } + + /** + * Get shipping progress percentage + */ + public function getShippingProgressAttribute(): float + { + if ($this->total_quantity_ordered <= 0) { + return 0; + } + + return round(($this->total_quantity_shipped / $this->total_quantity_ordered) * 100, 2); + } + + /** + * Calculate and update totals from items + */ + public function calculateTotals(): void + { + $subtotal = $this->items()->sum('line_total'); + $taxAmount = $this->items()->sum('tax_amount'); + + $this->subtotal = $subtotal; + $this->tax_amount = $taxAmount; + $this->total_amount = $subtotal - $this->discount_amount + $taxAmount + $this->shipping_cost; + } + + /** + * Scope: By status + */ + public function scopeStatus($query, $status) + { + if ($status instanceof SalesOrderStatus) { + $status = $status->value; + } + return $query->where('status', $status); + } + + /** + * Scope: Pending orders + */ + public function scopePending($query) + { + return $query->whereIn('status', [ + SalesOrderStatus::PENDING_APPROVAL->value, + SalesOrderStatus::APPROVED->value, + SalesOrderStatus::CONFIRMED->value, + SalesOrderStatus::PROCESSING->value, + SalesOrderStatus::PARTIALLY_SHIPPED->value, + ]); + } + + /** + * Scope: For customer + */ + public function scopeForCustomer($query, int $customerId) + { + return $query->where('customer_id', $customerId); + } + + /** + * Scope: Date range + */ + public function scopeDateRange($query, string $from, string $to) + { + return $query->whereBetween('order_date', [$from, $to]); + } + + /** + * Scope: Search + */ + public function scopeSearch($query, string $term) + { + return $query->where(function ($q) use ($term) { + $q->where('order_number', 'ilike', "%{$term}%") + ->orWhereHas('customer', function ($cq) use ($term) { + $cq->where('name', 'ilike', "%{$term}%"); + }); + }); + } +} diff --git a/backend/app/Models/SalesOrderItem.php b/backend/app/Models/SalesOrderItem.php new file mode 100644 index 0000000..0c10a21 --- /dev/null +++ b/backend/app/Models/SalesOrderItem.php @@ -0,0 +1,148 @@ + 'decimal:4', + 'quantity_shipped' => 'decimal:4', + 'quantity_cancelled' => 'decimal:4', + 'unit_price' => 'decimal:4', + 'discount_percentage' => 'decimal:2', + 'discount_amount' => 'decimal:4', + 'tax_percentage' => 'decimal:2', + 'tax_amount' => 'decimal:4', + 'line_total' => 'decimal:2', + 'over_delivery_tolerance_percentage' => 'decimal:2', + ]; + + /** + * Boot the model + */ + protected static function boot() + { + parent::boot(); + + static::saving(function ($item) { + $item->calculateLineTotal(); + }); + + static::saved(function ($item) { + $item->salesOrder->calculateTotals(); + $item->salesOrder->save(); + }); + + static::deleted(function ($item) { + $item->salesOrder->calculateTotals(); + $item->salesOrder->save(); + }); + } + + /** + * Sales order relationship + */ + public function salesOrder(): BelongsTo + { + return $this->belongsTo(SalesOrder::class); + } + + /** + * Product relationship + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * Unit of measure relationship + */ + public function uom(): BelongsTo + { + return $this->belongsTo(UnitOfMeasure::class, 'uom_id'); + } + + /** + * Delivery note items for this SO item + */ + public function deliveryNoteItems(): HasMany + { + return $this->hasMany(DeliveryNoteItem::class); + } + + /** + * Get remaining quantity to ship + */ + public function getRemainingQuantityAttribute(): float + { + return $this->quantity_ordered - $this->quantity_shipped - $this->quantity_cancelled; + } + + /** + * Check if fully shipped + */ + public function getIsFullyShippedAttribute(): bool + { + return $this->remaining_quantity <= 0; + } + + /** + * Get shipping progress percentage + */ + public function getShippingProgressAttribute(): float + { + if ($this->quantity_ordered <= 0) { + return 0; + } + + return round(($this->quantity_shipped / $this->quantity_ordered) * 100, 2); + } + + /** + * Calculate line total + */ + public function calculateLineTotal(): void + { + $subtotal = $this->quantity_ordered * $this->unit_price; + + // Apply discount + if ($this->discount_percentage > 0) { + $this->discount_amount = $subtotal * ($this->discount_percentage / 100); + } + $subtotal -= $this->discount_amount; + + // Calculate tax + if ($this->tax_percentage > 0) { + $this->tax_amount = $subtotal * ($this->tax_percentage / 100); + } + + $this->line_total = $subtotal + $this->tax_amount; + } +} diff --git a/backend/app/Models/Stock.php b/backend/app/Models/Stock.php index 5c9f168..88aa501 100644 --- a/backend/app/Models/Stock.php +++ b/backend/app/Models/Stock.php @@ -464,7 +464,7 @@ public function placeQualityHold( 'hold_reason' => $reason, 'hold_until' => $holdUntil, 'quality_restrictions' => $restrictions, - 'quality_hold_by' => $holdBy ?? auth()->id(), + 'quality_hold_by' => $holdBy, 'quality_hold_at' => now(), 'quality_reference_type' => $referenceType, 'quality_reference_id' => $referenceId, @@ -495,13 +495,13 @@ public function releaseQualityHold(): self /** * Set conditional quality status with restrictions */ - public function setConditionalStatus(array $restrictions, ?string $reason = null): self + public function setConditionalStatus(array $restrictions, ?string $reason = null, ?int $userId = null): self { $this->update([ 'quality_status' => self::QUALITY_CONDITIONAL, 'hold_reason' => $reason, 'quality_restrictions' => $restrictions, - 'quality_hold_by' => auth()->id(), + 'quality_hold_by' => $userId, 'quality_hold_at' => now(), ]); diff --git a/backend/app/Models/StockDebt.php b/backend/app/Models/StockDebt.php new file mode 100644 index 0000000..112cb8b --- /dev/null +++ b/backend/app/Models/StockDebt.php @@ -0,0 +1,88 @@ + 'decimal:3', + 'reconciled_quantity' => 'decimal:3', + 'outstanding_quantity' => 'decimal:3', + 'reconciled_at' => 'datetime', + ]; + + /** + * Get the product + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * Get the warehouse + */ + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + /** + * Get the stock movement that created this debt + */ + public function stockMovement(): BelongsTo + { + return $this->belongsTo(StockMovement::class); + } + + /** + * Get the reference (polymorphic) + */ + public function reference(): MorphTo + { + return $this->morphTo(); + } + + /** + * Check if debt is fully reconciled + */ + public function isFullyReconciled(): bool + { + return $this->reconciled_quantity >= $this->quantity; + } + + /** + * Scope: Outstanding debts + */ + public function scopeOutstanding($query) + { + return $query->whereColumn('reconciled_quantity', '<', 'quantity'); + } + + /** + * Scope: Fully reconciled + */ + public function scopeReconciled($query) + { + return $query->whereColumn('reconciled_quantity', '>=', 'quantity'); + } +} diff --git a/backend/app/Models/UnitOfMeasure.php b/backend/app/Models/UnitOfMeasure.php index 8ec2ef3..fe8dbe3 100644 --- a/backend/app/Models/UnitOfMeasure.php +++ b/backend/app/Models/UnitOfMeasure.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\UomType; use App\Traits\BelongsToCompany; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -25,23 +26,12 @@ class UnitOfMeasure extends Model ]; protected $casts = [ + 'uom_type' => UomType::class, 'conversion_factor' => 'decimal:6', 'precision' => 'integer', 'is_active' => 'boolean', ]; - /** - * UOM types enum values - */ - public const TYPES = [ - 'weight' => 'Weight', - 'volume' => 'Volume', - 'length' => 'Length', - 'area' => 'Area', - 'quantity' => 'Quantity', - 'time' => 'Time', - ]; - /** * Get the base unit for conversion */ @@ -85,7 +75,11 @@ public function convertTo(float $quantity, UnitOfMeasure $targetUnit): ?float } // Different types cannot be converted - if ($this->uom_type !== $targetUnit->uom_type) { + // Compare enum values since uom_type is now cast to UomType enum + $thisType = $this->uom_type instanceof UomType ? $this->uom_type->value : $this->uom_type; + $targetType = $targetUnit->uom_type instanceof UomType ? $targetUnit->uom_type->value : $targetUnit->uom_type; + + if ($thisType !== $targetType) { return null; } diff --git a/backend/app/Models/User.php b/backend/app/Models/User.php index 749fdf9..849779e 100644 --- a/backend/app/Models/User.php +++ b/backend/app/Models/User.php @@ -3,6 +3,8 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; +use App\Scopes\CompanyScope; +use App\Traits\BelongsToCompany; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -14,7 +16,7 @@ class User extends Authenticatable { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasApiTokens, HasFactory, Notifiable, SoftDeletes; + use HasApiTokens, HasFactory, Notifiable, SoftDeletes, BelongsToCompany; /** * The attributes that are mass assignable. @@ -133,4 +135,13 @@ public function removeRole(Role|string|int $role): void $this->roles()->detach($role); } + + /** + * Scope to query without company filter (for login) + * Login requires checking email across all companies since email is unique globally + */ + public function scopeForLogin($query) + { + return $query->withoutGlobalScope(CompanyScope::class); + } } diff --git a/backend/app/Models/UserInvitation.php b/backend/app/Models/UserInvitation.php new file mode 100644 index 0000000..d72aed9 --- /dev/null +++ b/backend/app/Models/UserInvitation.php @@ -0,0 +1,117 @@ + 'array', + 'expires_at' => 'datetime', + 'accepted_at' => 'datetime', + ]; + + /** + * Boot the model. + */ + protected static function boot() + { + parent::boot(); + + static::creating(function ($invitation) { + if (empty($invitation->token)) { + $invitation->token = Str::random(64); + } + }); + } + + /** + * Get the company that this invitation belongs to + */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + /** + * Get the user who sent the invitation + */ + public function inviter(): BelongsTo + { + return $this->belongsTo(User::class, 'invited_by'); + } + + /** + * Check if invitation is expired + */ + public function isExpired(): bool + { + return $this->expires_at->isPast(); + } + + /** + * Check if invitation is already accepted + */ + public function isAccepted(): bool + { + return $this->accepted_at !== null; + } + + /** + * Check if invitation is valid (not expired and not accepted) + */ + public function isValid(): bool + { + return !$this->isExpired() && !$this->isAccepted(); + } + + /** + * Mark invitation as accepted + */ + public function markAsAccepted(): void + { + $this->update(['accepted_at' => now()]); + } + + /** + * Scope: Get valid invitations + */ + public function scopeValid($query) + { + return $query->whereNull('accepted_at') + ->where('expires_at', '>', now()); + } + + /** + * Scope: Get expired invitations + */ + public function scopeExpired($query) + { + return $query->where('expires_at', '<=', now()) + ->whereNull('accepted_at'); + } + + /** + * Scope: Get accepted invitations + */ + public function scopeAccepted($query) + { + return $query->whereNotNull('accepted_at'); + } +} diff --git a/backend/app/Models/Warehouse.php b/backend/app/Models/Warehouse.php index 182922e..9a81d55 100644 --- a/backend/app/Models/Warehouse.php +++ b/backend/app/Models/Warehouse.php @@ -207,7 +207,7 @@ public function getTypeLabelAttribute(): string self::TYPE_RAW_MATERIALS => 'Raw Materials', self::TYPE_WIP => 'Work in Progress', self::TYPE_RETURNS => 'Returns', - default => $this->warehouse_type, + default => $this->warehouse_type ?? 'General', }; } } diff --git a/backend/app/Models/WorkCenter.php b/backend/app/Models/WorkCenter.php new file mode 100644 index 0000000..4d777c4 --- /dev/null +++ b/backend/app/Models/WorkCenter.php @@ -0,0 +1,145 @@ + WorkCenterType::class, + 'cost_per_hour' => 'decimal:4', + 'capacity_per_day' => 'decimal:3', + 'efficiency_percentage' => 'decimal:2', + 'is_active' => 'boolean', + 'settings' => 'array', + ]; + + /** + * Company relationship + */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + /** + * Creator relationship + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * Routing operations using this work center + */ + public function routingOperations(): HasMany + { + return $this->hasMany(RoutingOperation::class); + } + + /** + * Work order operations using this work center + */ + public function workOrderOperations(): HasMany + { + return $this->hasMany(WorkOrderOperation::class); + } + + /** + * Calendar entries for this work center (CRP) + */ + public function calendars(): HasMany + { + return $this->hasMany(WorkCenterCalendar::class); + } + + /** + * Scope: Active work centers + */ + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + /** + * Scope: Filter by type + */ + public function scopeOfType($query, WorkCenterType $type) + { + return $query->where('work_center_type', $type); + } + + /** + * Scope: Search by name or code + */ + public function scopeSearch($query, string $term) + { + return $query->where(function ($q) use ($term) { + $q->where('name', 'ilike', "%{$term}%") + ->orWhere('code', 'ilike', "%{$term}%"); + }); + } + + /** + * Get effective capacity (considering efficiency) + */ + public function getEffectiveCapacityAttribute(): float + { + return $this->capacity_per_day * ($this->efficiency_percentage / 100); + } + + /** + * Calculate available hours for a date range + */ + public function calculateAvailableHours(\DateTimeInterface $startDate, \DateTimeInterface $endDate): float + { + $days = $startDate->diff($endDate)->days + 1; + return $days * $this->effective_capacity; + } + + /** + * Check if work center can handle the requested hours + */ + public function hasCapacity(float $requiredHours, \DateTimeInterface $startDate, \DateTimeInterface $endDate): bool + { + $availableHours = $this->calculateAvailableHours($startDate, $endDate); + + // Calculate already scheduled hours + $scheduledHours = $this->workOrderOperations() + ->whereHas('workOrder', function ($q) { + $q->whereNotIn('status', ['completed', 'cancelled']); + }) + ->where(function ($q) use ($startDate, $endDate) { + $q->whereBetween('planned_start', [$startDate, $endDate]) + ->orWhereBetween('planned_end', [$startDate, $endDate]); + }) + ->sum(\DB::raw('(COALESCE(actual_setup_time, planned_setup_time, 0) + COALESCE(actual_run_time, planned_run_time, 0)) / 60')); + + return ($availableHours - $scheduledHours) >= $requiredHours; + } +} diff --git a/backend/app/Models/WorkCenterCalendar.php b/backend/app/Models/WorkCenterCalendar.php new file mode 100644 index 0000000..2931566 --- /dev/null +++ b/backend/app/Models/WorkCenterCalendar.php @@ -0,0 +1,226 @@ + 'date', + 'break_hours' => 'decimal:2', + 'available_hours' => 'decimal:2', + 'efficiency_override' => 'decimal:2', + 'capacity_override' => 'decimal:2', + 'day_type' => CalendarDayType::class, + ]; + + // ========================================= + // Relationships + // ========================================= + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + public function workCenter(): BelongsTo + { + return $this->belongsTo(WorkCenter::class); + } + + // ========================================= + // Scopes + // ========================================= + + public function scopeForDate($query, $date) + { + return $query->where('calendar_date', $date); + } + + public function scopeDateRange($query, $startDate, $endDate) + { + return $query->whereBetween('calendar_date', [$startDate, $endDate]); + } + + public function scopeWorking($query) + { + return $query->where('day_type', CalendarDayType::WORKING); + } + + public function scopeNonWorking($query) + { + return $query->where('day_type', '!=', CalendarDayType::WORKING); + } + + // ========================================= + // Capacity Calculations + // ========================================= + + /** + * Get the effective available hours for this day + */ + public function getEffectiveHoursAttribute(): float + { + if (!$this->day_type->isAvailable()) { + return 0; + } + + // Use capacity override if set, otherwise use available_hours + $hours = $this->capacity_override ?? $this->available_hours; + + // Apply efficiency + $efficiency = $this->getEffectiveEfficiency() / 100; + + return $hours * $efficiency; + } + + /** + * Get the effective efficiency percentage + */ + public function getEffectiveEfficiency(): float + { + // Use override if set, otherwise get from work center + return $this->efficiency_override + ?? $this->workCenter?->efficiency_percentage + ?? 100; + } + + /** + * Calculate hours from shift times + */ + public function calculateShiftHours(): float + { + if (!$this->shift_start || !$this->shift_end) { + return 0; + } + + $start = \Carbon\Carbon::parse($this->shift_start); + $end = \Carbon\Carbon::parse($this->shift_end); + + $totalMinutes = $start->diffInMinutes($end); + $breakMinutes = ($this->break_hours ?? 0) * 60; + + return max(0, ($totalMinutes - $breakMinutes) / 60); + } + + // ========================================= + // Helpers + // ========================================= + + /** + * Check if this day is available for work + */ + public function isAvailable(): bool + { + return $this->day_type->isAvailable(); + } + + /** + * Check if this day has reduced capacity + */ + public function hasReducedCapacity(): bool + { + if (!$this->isAvailable()) { + return true; + } + + $normalHours = $this->workCenter?->capacity_per_day ?? 8; + return $this->effective_hours < $normalHours; + } + + // ========================================= + // Static Helpers + // ========================================= + + /** + * Generate default calendar entries for a work center + */ + public static function generateForWorkCenter( + WorkCenter $workCenter, + \DateTimeInterface $startDate, + \DateTimeInterface $endDate, + array $holidays = [] + ): int { + $count = 0; + $current = \Carbon\Carbon::parse($startDate); + $end = \Carbon\Carbon::parse($endDate); + + while ($current <= $end) { + // Check if entry already exists + $exists = static::where('work_center_id', $workCenter->id) + ->where('calendar_date', $current->toDateString()) + ->exists(); + + if (!$exists) { + $dayType = CalendarDayType::WORKING; + $notes = null; + + // Check if weekend + if ($current->isWeekend()) { + $dayType = CalendarDayType::HOLIDAY; + $notes = 'Weekend'; + } + + // Check if in holidays array + $dateString = $current->toDateString(); + if (isset($holidays[$dateString])) { + $dayType = CalendarDayType::HOLIDAY; + $notes = $holidays[$dateString]; + } + + static::create([ + 'company_id' => $workCenter->company_id, + 'work_center_id' => $workCenter->id, + 'calendar_date' => $dateString, + 'shift_start' => '08:00:00', + 'shift_end' => '17:00:00', + 'break_hours' => 1.00, + 'available_hours' => $dayType->isAvailable() ? $workCenter->capacity_per_day : 0, + 'day_type' => $dayType, + 'notes' => $notes, + ]); + + $count++; + } + + $current->addDay(); + } + + return $count; + } + + /** + * Get total available hours for a work center in date range + */ + public static function getTotalAvailableHours( + int $workCenterId, + \DateTimeInterface $startDate, + \DateTimeInterface $endDate + ): float { + return static::where('work_center_id', $workCenterId) + ->dateRange($startDate, $endDate) + ->working() + ->get() + ->sum(fn($cal) => $cal->effective_hours); + } +} diff --git a/backend/app/Models/WorkOrder.php b/backend/app/Models/WorkOrder.php new file mode 100644 index 0000000..7a068d0 --- /dev/null +++ b/backend/app/Models/WorkOrder.php @@ -0,0 +1,337 @@ + WorkOrderStatus::class, + 'priority' => WorkOrderPriority::class, + 'quantity_ordered' => 'decimal:3', + 'quantity_completed' => 'decimal:3', + 'quantity_scrapped' => 'decimal:3', + 'estimated_cost' => 'decimal:4', + 'actual_cost' => 'decimal:4', + 'planned_start_date' => 'datetime', + 'planned_end_date' => 'datetime', + 'actual_start_date' => 'datetime', + 'actual_end_date' => 'datetime', + 'approved_at' => 'datetime', + 'released_at' => 'datetime', + 'completed_at' => 'datetime', + 'meta_data' => 'array', + ]; + + /** + * Company relationship + */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class); + } + + /** + * Product being manufactured + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * BOM used for this work order + */ + public function bom(): BelongsTo + { + return $this->belongsTo(Bom::class); + } + + /** + * Routing used for this work order + */ + public function routing(): BelongsTo + { + return $this->belongsTo(Routing::class); + } + + /** + * Unit of measure + */ + public function uom(): BelongsTo + { + return $this->belongsTo(UnitOfMeasure::class, 'uom_id'); + } + + /** + * Destination warehouse for finished goods + */ + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + /** + * Creator + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + /** + * Approver + */ + public function approver(): BelongsTo + { + return $this->belongsTo(User::class, 'approved_by'); + } + + /** + * Releaser + */ + public function releaser(): BelongsTo + { + return $this->belongsTo(User::class, 'released_by'); + } + + /** + * Completer + */ + public function completer(): BelongsTo + { + return $this->belongsTo(User::class, 'completed_by'); + } + + /** + * MRP Recommendation that generated this work order + */ + public function mrpRecommendation(): BelongsTo + { + return $this->belongsTo(MrpRecommendation::class); + } + + /** + * Operations for this work order + */ + public function operations(): HasMany + { + return $this->hasMany(WorkOrderOperation::class)->orderBy('operation_number'); + } + + /** + * Materials required for this work order + */ + public function materials(): HasMany + { + return $this->hasMany(WorkOrderMaterial::class); + } + + /** + * Scope: Filter by status + */ + public function scopeByStatus($query, WorkOrderStatus $status) + { + return $query->where('status', $status); + } + + /** + * Scope: Active work orders (not completed or cancelled) + */ + public function scopeActive($query) + { + return $query->whereNotIn('status', [ + WorkOrderStatus::COMPLETED, + WorkOrderStatus::CANCELLED, + ]); + } + + /** + * Scope: In progress + */ + public function scopeInProgress($query) + { + return $query->where('status', WorkOrderStatus::IN_PROGRESS); + } + + /** + * Scope: Filter by priority + */ + public function scopeByPriority($query, WorkOrderPriority $priority) + { + return $query->where('priority', $priority); + } + + /** + * Scope: For a specific product + */ + public function scopeForProduct($query, int $productId) + { + return $query->where('product_id', $productId); + } + + /** + * Scope: Search by number + */ + public function scopeSearch($query, string $term) + { + return $query->where('work_order_number', 'ilike', "%{$term}%"); + } + + /** + * Scope: Planned for date range + */ + public function scopePlannedBetween($query, \DateTimeInterface $start, \DateTimeInterface $end) + { + return $query->where(function ($q) use ($start, $end) { + $q->whereBetween('planned_start_date', [$start, $end]) + ->orWhereBetween('planned_end_date', [$start, $end]); + }); + } + + /** + * Get remaining quantity to complete + */ + public function getRemainingQuantityAttribute(): float + { + return $this->quantity_ordered - $this->quantity_completed - $this->quantity_scrapped; + } + + /** + * Get completion percentage + */ + public function getCompletionPercentageAttribute(): float + { + if ($this->quantity_ordered == 0) { + return 0; + } + + return round(($this->quantity_completed / $this->quantity_ordered) * 100, 2); + } + + /** + * Get operations completion percentage + */ + public function getOperationsProgressAttribute(): float + { + $total = $this->operations()->count(); + + if ($total === 0) { + return 0; + } + + $completed = $this->operations() + ->whereIn('status', ['completed', 'skipped']) + ->count(); + + return round(($completed / $total) * 100, 2); + } + + /** + * Check if work order can be edited + */ + public function canEdit(): bool + { + return $this->status->canEdit(); + } + + /** + * Check if work order can be released + */ + public function canRelease(): bool + { + return $this->status->canRelease(); + } + + /** + * Check if work order can be started + */ + public function canStart(): bool + { + return $this->status->canStart(); + } + + /** + * Check if work order can be completed + */ + public function canComplete(): bool + { + return $this->status->canComplete(); + } + + /** + * Check if work order can be cancelled + */ + public function canCancel(): bool + { + return $this->status->canCancel(); + } + + /** + * Check if work order can issue materials + */ + public function canIssueMaterials(): bool + { + return $this->status->canIssueMaterials(); + } + + /** + * Check if work order can receive finished goods + */ + public function canReceiveFinishedGoods(): bool + { + return $this->status->canReceiveFinishedGoods(); + } + + /** + * Check if all operations are completed + */ + public function allOperationsCompleted(): bool + { + return $this->operations() + ->whereNotIn('status', ['completed', 'skipped']) + ->count() === 0; + } +} diff --git a/backend/app/Models/WorkOrderMaterial.php b/backend/app/Models/WorkOrderMaterial.php new file mode 100644 index 0000000..951a648 --- /dev/null +++ b/backend/app/Models/WorkOrderMaterial.php @@ -0,0 +1,122 @@ + 'decimal:4', + 'quantity_issued' => 'decimal:4', + 'quantity_returned' => 'decimal:4', + 'unit_cost' => 'decimal:4', + 'total_cost' => 'decimal:4', + ]; + + /** + * Parent work order + */ + public function workOrder(): BelongsTo + { + return $this->belongsTo(WorkOrder::class); + } + + /** + * Material product + */ + public function product(): BelongsTo + { + return $this->belongsTo(Product::class); + } + + /** + * Source BOM item (if from BOM) + */ + public function bomItem(): BelongsTo + { + return $this->belongsTo(BomItem::class); + } + + /** + * Unit of measure + */ + public function uom(): BelongsTo + { + return $this->belongsTo(UnitOfMeasure::class, 'uom_id'); + } + + /** + * Source warehouse + */ + public function warehouse(): BelongsTo + { + return $this->belongsTo(Warehouse::class); + } + + /** + * Get outstanding quantity to issue + */ + public function getOutstandingQuantityAttribute(): float + { + return max(0, $this->quantity_required - $this->quantity_issued + $this->quantity_returned); + } + + /** + * Get net issued quantity + */ + public function getNetIssuedQuantityAttribute(): float + { + return $this->quantity_issued - $this->quantity_returned; + } + + /** + * Check if material is fully issued + */ + public function isFullyIssued(): bool + { + return $this->outstanding_quantity <= 0; + } + + /** + * Check if there's a shortage + */ + public function hasShortage(): bool + { + return $this->outstanding_quantity > 0; + } + + /** + * Scope: With outstanding quantities + */ + public function scopeWithOutstanding($query) + { + return $query->whereRaw('quantity_required > (quantity_issued - quantity_returned)'); + } + + /** + * Scope: Fully issued + */ + public function scopeFullyIssued($query) + { + return $query->whereRaw('quantity_required <= (quantity_issued - quantity_returned)'); + } +} diff --git a/backend/app/Models/WorkOrderOperation.php b/backend/app/Models/WorkOrderOperation.php new file mode 100644 index 0000000..a8a2caf --- /dev/null +++ b/backend/app/Models/WorkOrderOperation.php @@ -0,0 +1,177 @@ + OperationStatus::class, + 'quantity_completed' => 'decimal:3', + 'quantity_scrapped' => 'decimal:3', + 'planned_start' => 'datetime', + 'planned_end' => 'datetime', + 'actual_start' => 'datetime', + 'actual_end' => 'datetime', + 'actual_setup_time' => 'decimal:2', + 'actual_run_time' => 'decimal:2', + 'actual_cost' => 'decimal:4', + ]; + + /** + * Parent work order + */ + public function workOrder(): BelongsTo + { + return $this->belongsTo(WorkOrder::class); + } + + /** + * Source routing operation (if created from routing) + */ + public function routingOperation(): BelongsTo + { + return $this->belongsTo(RoutingOperation::class); + } + + /** + * Work center for this operation + */ + public function workCenter(): BelongsTo + { + return $this->belongsTo(WorkCenter::class); + } + + /** + * User who started this operation + */ + public function starter(): BelongsTo + { + return $this->belongsTo(User::class, 'started_by'); + } + + /** + * User who completed this operation + */ + public function completer(): BelongsTo + { + return $this->belongsTo(User::class, 'completed_by'); + } + + /** + * Scope: Pending operations + */ + public function scopePending($query) + { + return $query->where('status', OperationStatus::PENDING); + } + + /** + * Scope: In progress operations + */ + public function scopeInProgress($query) + { + return $query->where('status', OperationStatus::IN_PROGRESS); + } + + /** + * Scope: Completed operations + */ + public function scopeCompleted($query) + { + return $query->where('status', OperationStatus::COMPLETED); + } + + /** + * Get total actual time in minutes + */ + public function getTotalActualTimeAttribute(): float + { + return $this->actual_setup_time + $this->actual_run_time; + } + + /** + * Get duration in minutes (from start to end) + */ + public function getDurationAttribute(): ?float + { + if (!$this->actual_start || !$this->actual_end) { + return null; + } + + return $this->actual_start->diffInMinutes($this->actual_end); + } + + /** + * Check if operation can be started + */ + public function canStart(): bool + { + return $this->status->canStart(); + } + + /** + * Check if operation can be completed + */ + public function canComplete(): bool + { + return $this->status->canComplete(); + } + + /** + * Check if operation is in final state + */ + public function isFinished(): bool + { + return $this->status->isFinal(); + } + + /** + * Get efficiency compared to planned time + */ + public function getEfficiencyAttribute(): ?float + { + $routingOp = $this->routingOperation; + + if (!$routingOp || $this->total_actual_time == 0) { + return null; + } + + $plannedTime = $routingOp->setup_time + + ($routingOp->run_time_per_unit * $this->quantity_completed); + + if ($plannedTime == 0) { + return null; + } + + return round(($plannedTime / $this->total_actual_time) * 100, 2); + } +} diff --git a/backend/app/Observers/BomItemObserver.php b/backend/app/Observers/BomItemObserver.php new file mode 100644 index 0000000..390bea0 --- /dev/null +++ b/backend/app/Observers/BomItemObserver.php @@ -0,0 +1,101 @@ +invalidateCache($bomItem); + + // Audit logging + $bomNumber = $bomItem->bom?->bom_number ?? 'N/A'; + $componentName = $bomItem->component?->name ?? 'N/A'; + $this->auditLogService->logCreation( + $bomItem, + "BOM Item created: Component '{$componentName}' added to BOM {$bomNumber}" + ); + } + + /** + * Handle the BomItem "updated" event. + */ + public function updated(BomItem $bomItem): void + { + // Invalidate if any structural field changed + if ($bomItem->wasChanged(['component_id', 'quantity', 'scrap_percentage', 'is_phantom', 'is_optional'])) { + $this->invalidateCache($bomItem); + } + + // Audit logging + $bomNumber = $bomItem->bom?->bom_number ?? 'N/A'; + $componentName = $bomItem->component?->name ?? 'N/A'; + $this->auditLogService->logUpdate( + $bomItem, + "BOM Item updated: Component '{$componentName}' in BOM {$bomNumber}" + ); + } + + /** + * Handle the BomItem "deleted" event. + */ + public function deleted(BomItem $bomItem): void + { + $this->invalidateCache($bomItem); + + // Audit logging + $bomNumber = $bomItem->bom?->bom_number ?? 'N/A'; + $componentName = $bomItem->component?->name ?? 'N/A'; + $this->auditLogService->logDeletion( + $bomItem, + "BOM Item deleted: Component '{$componentName}' removed from BOM {$bomNumber}" + ); + } + + /** + * Invalidate BOM explode cache + */ + protected function invalidateCache(BomItem $bomItem): void + { + try { + if ($bomItem->bom_id) { + // Invalidate BOM explode cache for this BOM + $this->cacheService->invalidateBomExplodeCache($bomItem->bom_id); + + // Also invalidate MRP cache (BOM structure changed) + if ($bomItem->bom) { + $this->cacheService->invalidateLowLevelCodes($bomItem->bom->company_id); + $this->cacheService->invalidateCompanyCache($bomItem->bom->company_id); + } + + Log::info('BOM explode cache invalidated due to BOM item change', [ + 'bom_item_id' => $bomItem->id, + 'bom_id' => $bomItem->bom_id, + ]); + } + } catch (\Exception $e) { + Log::error('Failed to invalidate BOM explode cache', [ + 'bom_item_id' => $bomItem->id, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/backend/app/Observers/BomObserver.php b/backend/app/Observers/BomObserver.php new file mode 100644 index 0000000..c089310 --- /dev/null +++ b/backend/app/Observers/BomObserver.php @@ -0,0 +1,113 @@ +invalidateCache($bom); + + // Audit logging + $this->auditLogService->logCreation( + $bom, + "BOM created: {$bom->bom_number} - {$bom->name}" + ); + } + + /** + * Handle the Bom "updated" event. + */ + public function updated(Bom $bom): void + { + // Invalidate if BOM structure or status changed + if ($bom->wasChanged(['status', 'product_id', 'bom_type', 'version'])) { + $this->invalidateCache($bom); + + // Mark parent product as dirty for incremental MRP + if ($bom->product_id) { + $this->markProductDirty($bom); + } + } + + // Audit logging + $this->auditLogService->logUpdate( + $bom, + "BOM updated: {$bom->bom_number} - {$bom->name}" + ); + } + + /** + * Handle the Bom "deleted" event. + */ + public function deleted(Bom $bom): void + { + $this->invalidateCache($bom); + + // Audit logging + $this->auditLogService->logDeletion( + $bom, + "BOM deleted: {$bom->bom_number} - {$bom->name}" + ); + } + + /** + * Invalidate MRP cache for the company + */ + protected function invalidateCache(Bom $bom): void + { + try { + // BOM changes always require LLC recalculation + $this->cacheService->invalidateLowLevelCodes($bom->company_id); + $this->cacheService->invalidateCompanyCache($bom->company_id); + + // Also invalidate BOM explode cache (for /api/boms/{bom}/explode endpoint) + $this->cacheService->invalidateBomExplodeCache($bom->id); + + Log::info('MRP cache invalidated due to BOM change', [ + 'bom_id' => $bom->id, + 'company_id' => $bom->company_id, + ]); + } catch (\Exception $e) { + Log::error('Failed to invalidate MRP cache', [ + 'bom_id' => $bom->id, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * Mark product as dirty for incremental MRP + */ + protected function markProductDirty(Bom $bom): void + { + try { + if ($bom->product_id) { + $this->cacheService->markProductDirty($bom->company_id, $bom->product_id); + } + } catch (\Exception $e) { + Log::error('Failed to mark product as dirty', [ + 'bom_id' => $bom->id, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/backend/app/Observers/CompanyCalendarObserver.php b/backend/app/Observers/CompanyCalendarObserver.php new file mode 100644 index 0000000..96dc930 --- /dev/null +++ b/backend/app/Observers/CompanyCalendarObserver.php @@ -0,0 +1,95 @@ +invalidateCache($calendar); + + // Audit logging + $this->auditLogService->logCreation( + $calendar, + "Company Calendar created: {$calendar->calendar_date} - {$calendar->day_type}" + ); + } + + /** + * Handle the CompanyCalendar "updated" event. + */ + public function updated(CompanyCalendar $calendar): void + { + // Invalidate if working day related fields changed + if ($calendar->wasChanged([ + 'day_type', + 'shift_start', + 'shift_end', + 'working_hours', + 'calendar_date', + ])) { + $this->invalidateCache($calendar); + } + + // Audit logging + $this->auditLogService->logUpdate( + $calendar, + "Company Calendar updated: {$calendar->calendar_date} - {$calendar->day_type}" + ); + } + + /** + * Handle the CompanyCalendar "deleted" event. + */ + public function deleted(CompanyCalendar $calendar): void + { + $this->invalidateCache($calendar); + + // Audit logging + $this->auditLogService->logDeletion( + $calendar, + "Company Calendar deleted: {$calendar->calendar_date} - {$calendar->day_type}" + ); + } + + /** + * Invalidate MRP cache for the company + */ + protected function invalidateCache(CompanyCalendar $calendar): void + { + try { + // Calendar changes affect working day calculations + // Invalidate all MRP caches for this company + $this->cacheService->invalidateCompanyCache($calendar->company_id); + + Log::info('MRP cache invalidated due to company calendar change', [ + 'calendar_id' => $calendar->id, + 'company_id' => $calendar->company_id, + 'date' => $calendar->calendar_date, + ]); + } catch (\Exception $e) { + Log::error('Failed to invalidate MRP cache', [ + 'calendar_id' => $calendar->id, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/backend/app/Observers/ProductObserver.php b/backend/app/Observers/ProductObserver.php new file mode 100644 index 0000000..099025b --- /dev/null +++ b/backend/app/Observers/ProductObserver.php @@ -0,0 +1,124 @@ +auditLogService->logCreation( + $product, + "Product created: {$product->name} (SKU: {$product->sku})" + ); + } + + /** + * Handle the Product "updated" event. + */ + public function updated(Product $product): void + { + // Only invalidate if MRP-related fields changed + $mrpFields = [ + 'lead_time_days', + 'safety_stock', + 'reorder_point', + 'make_or_buy', + 'minimum_order_qty', + 'order_multiple', + 'maximum_stock', + ]; + + if ($product->wasChanged($mrpFields)) { + $this->invalidateCache($product); + + // Mark product as dirty for incremental MRP + $this->markProductDirty($product); + } + + // Audit logging (log all changes, not just MRP fields) + $this->auditLogService->logUpdate( + $product, + "Product updated: {$product->name} (SKU: {$product->sku})" + ); + } + + /** + * Handle the Product "deleted" event. + */ + public function deleted(Product $product): void + { + // Audit logging + $this->auditLogService->logDeletion( + $product, + "Product deleted: {$product->name} (SKU: {$product->sku})" + ); + } + + /** + * Invalidate MRP cache for the company + */ + protected function invalidateCache(Product $product): void + { + try { + // Invalidate LLC cache (product structure might have changed) + $this->cacheService->invalidateLowLevelCodes($product->company_id); + + // Invalidate product-specific caches + $this->cacheService->invalidateCompanyCache($product->company_id); + + Log::info('MRP cache invalidated due to product MRP field change', [ + 'product_id' => $product->id, + 'company_id' => $product->company_id, + 'changed_fields' => array_intersect( + ['lead_time_days', 'safety_stock', 'reorder_point', 'make_or_buy'], + array_keys($product->getChanges()) + ), + ]); + } catch (\Exception $e) { + Log::error('Failed to invalidate MRP cache', [ + 'product_id' => $product->id, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * Mark product as dirty for incremental MRP + */ + protected function markProductDirty(Product $product): void + { + try { + $this->cacheService->markProductDirty($product->company_id, $product->id); + } catch (\Exception $e) { + Log::error('Failed to mark product as dirty', [ + 'product_id' => $product->id, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 1c90836..18214a8 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -3,6 +3,12 @@ namespace App\Providers; use App\Scout\ElasticsearchEngine; +use App\Models\Bom; +use App\Models\Product; +use App\Models\CompanyCalendar; +use App\Observers\BomObserver; +use App\Observers\ProductObserver; +use App\Observers\CompanyCalendarObserver; use Elastic\Elasticsearch\Client; use Elastic\Elasticsearch\ClientBuilder; use Illuminate\Cache\RateLimiting\Limit; @@ -76,5 +82,11 @@ public function boot(): void RateLimiter::for('bulk-variant-generate', function (Request $request) { return Limit::perMinute(5)->by($request->user()?->id ?: $request->ip()); }); + + // Register Observers for automatic MRP cache invalidation + Bom::observe(BomObserver::class); + \App\Models\BomItem::observe(\App\Observers\BomItemObserver::class); + Product::observe(ProductObserver::class); + CompanyCalendar::observe(CompanyCalendarObserver::class); } } diff --git a/backend/app/Rules/AllowNegativeStock.php b/backend/app/Rules/AllowNegativeStock.php new file mode 100644 index 0000000..a7076ed --- /dev/null +++ b/backend/app/Rules/AllowNegativeStock.php @@ -0,0 +1,76 @@ +productId = $productId; + $this->stockService = $stockService ?? app(StockService::class); + } + + /** + * Determine if the validation rule passes. + */ + public function passes($attribute, $value): bool + { + $product = Product::find($this->productId); + + if (!$product) { + return false; + } + + $policy = $product->negative_stock_policy ?? 'NEVER'; + + if ($policy === 'NEVER') { + return $value >= 0; + } + + if ($policy === 'LIMITED') { + $currentStock = $this->getCurrentStock($product); + $newStock = $currentStock - $value; + $limit = $product->negative_stock_limit ?? 0; + + return $newStock >= -$limit; + } + + return true; // ALLOWED + } + + /** + * Get the validation error message. + */ + public function message(): string + { + $product = Product::find($this->productId); + $policy = $product->negative_stock_policy ?? 'NEVER'; + + if ($policy === 'NEVER') { + return 'This product cannot go negative.'; + } + + if ($policy === 'LIMITED') { + $limit = $product->negative_stock_limit ?? 0; + return "This product can only go negative up to {$limit} units."; + } + + return 'Invalid stock quantity.'; + } + + /** + * Get current stock for product + */ + protected function getCurrentStock(Product $product): float + { + $stock = $this->stockService->getProductStock($product->id, null); + return $stock['quantity_available'] ?? 0; + } +} diff --git a/backend/app/Scopes/CompanyScope.php b/backend/app/Scopes/CompanyScope.php index eea43db..8775aa6 100644 --- a/backend/app/Scopes/CompanyScope.php +++ b/backend/app/Scopes/CompanyScope.php @@ -18,8 +18,19 @@ class CompanyScope implements Scope public function apply(Builder $builder, Model $model): void { // Only apply if user is authenticated and has a company - if (Auth::check() && Auth::user()->company_id) { - $builder->where($model->getTable() . '.company_id', Auth::user()->company_id); + // Platform admins (company_id null) can see all companies + if (Auth::check()) { + $user = Auth::user(); + + // Skip scope for platform admins (company_id null) + if ($user->company_id === null) { + return; // Platform admin can see all companies + } + + // Apply company scope for regular users + if ($user->company_id) { + $builder->where($model->getTable() . '.company_id', $user->company_id); + } } } } diff --git a/backend/app/Services/AcceptanceRuleService.php b/backend/app/Services/AcceptanceRuleService.php index c3b1f54..c96f027 100644 --- a/backend/app/Services/AcceptanceRuleService.php +++ b/backend/app/Services/AcceptanceRuleService.php @@ -319,7 +319,8 @@ public function getDefaultRule(): ?AcceptanceRule public function generateRuleCode(): string { $companyId = Auth::user()->company_id; - $prefix = "AR-"; + $companyIdPadded = str_pad($companyId, 3, '0', STR_PAD_LEFT); + $prefix = "AR-{$companyIdPadded}-"; $lastRule = AcceptanceRule::withTrashed() ->where('company_id', $companyId) diff --git a/backend/app/Services/AuditLogService.php b/backend/app/Services/AuditLogService.php new file mode 100644 index 0000000..82394f9 --- /dev/null +++ b/backend/app/Services/AuditLogService.php @@ -0,0 +1,230 @@ +company_id ?? $entity->company_id ?? null; + + if (!$companyId) { + Log::warning('Cannot create audit log: no company_id available', [ + 'event_type' => $eventType, + 'entity_type' => get_class($entity), + 'entity_id' => $entity->id ?? null, + ]); + return null; + } + + $auditData = [ + 'company_id' => $companyId, + 'event_type' => $eventType, + 'entity_type' => get_class($entity), + 'entity_id' => $entity->id, + 'user_id' => $user->id ?? null, + 'user_name' => $user->full_name ?? null, + 'user_email' => $user->email ?? null, + 'occurred_at' => now(), + 'changes' => $changes, + 'ip_address' => request()->ip(), + 'user_agent' => request()->userAgent(), + 'request_id' => request()->header('X-Request-ID'), + 'description' => $description ?? $this->generateDescription($eventType, $entity), + 'metadata' => $metadata, + ]; + + // Check if sync logging is enabled + if (config('audit.sync', false)) { + // Synchronous logging (for development/test or critical events) + return AuditLog::create($auditData); + } + + // Asynchronous logging (production default) + // Use afterCommit to ensure transaction is committed before logging + DB::afterCommit(function () use ($auditData) { + LogAuditEvent::dispatch($auditData); + }); + + return null; // Async logging doesn't return the log immediately + } + + /** + * Log creation + */ + public function logCreation(Model $entity, ?string $description = null): ?AuditLog + { + return $this->log('created', $entity, null, $description); + } + + /** + * Log update with field changes + */ + public function logUpdate(Model $entity, ?string $description = null): ?AuditLog + { + $changes = $this->extractChanges($entity); + + if (empty($changes)) { + return null; // No changes, no log + } + + return $this->log('updated', $entity, $changes, $description); + } + + /** + * Log deletion + */ + public function logDeletion(Model $entity, ?string $description = null): ?AuditLog + { + // Store entity data before deletion + $metadata = $this->extractEntityData($entity); + + return $this->log('deleted', $entity, null, $description, $metadata); + } + + /** + * Log custom event (approval, rejection, etc.) + */ + public function logEvent( + string $eventType, + Model $entity, + ?string $description = null, + ?array $metadata = null + ): ?AuditLog { + return $this->log($eventType, $entity, null, $description, $metadata); + } + + /** + * Get audit trail for an entity + */ + public function getAuditTrail(string $entityType, int $entityId) + { + return AuditLog::forEntity($entityType, $entityId) + ->orderBy('occurred_at') + ->get(); + } + + /** + * Extract changes from model + */ + protected function extractChanges(Model $entity): array + { + $changes = []; + $original = $entity->getOriginal(); + $dirty = $entity->getDirty(); + + // Fields to exclude from audit + // Timestamps are excluded (automatically managed by Laravel) + // Blameable fields are excluded (automatically managed by Blameable trait) + // Note: created_by is NOT excluded because it's only set at creation, not during updates + $excludedFields = [ + 'updated_at', + 'created_at', + 'deleted_at', + 'updated_by', // Managed by Blameable trait - automatically set on every update + 'deleted_by', // Managed by Blameable trait - automatically set on deletion + ]; + $sensitiveFields = ['password', 'api_key', 'secret', 'token', 'remember_token']; + + foreach ($dirty as $key => $newValue) { + // Skip timestamps and hidden fields + if (in_array($key, $excludedFields) || + in_array($key, $entity->getHidden())) { + continue; + } + + // Mask sensitive fields + if (in_array($key, $sensitiveFields)) { + $changes[$key] = [ + 'old' => '***MASKED***', + 'new' => '***MASKED***', + ]; + continue; + } + + $changes[$key] = [ + 'old' => $original[$key] ?? null, + 'new' => $newValue, + ]; + } + + return $changes; + } + + /** + * Extract entity data for deletion logs + */ + protected function extractEntityData(Model $entity): array + { + $data = []; + $fillable = $entity->getFillable(); + $hidden = $entity->getHidden(); + $sensitiveFields = ['password', 'api_key', 'secret', 'token', 'remember_token']; + + foreach ($fillable as $field) { + if (!in_array($field, $hidden) && !in_array($field, $sensitiveFields)) { + $value = $entity->getAttribute($field); + if ($value !== null) { + $data[$field] = $value; + } + } + } + + return $data; + } + + /** + * Generate default description + */ + protected function generateDescription(string $eventType, Model $entity): string + { + $entityName = class_basename($entity); + + return match($eventType) { + 'created' => "Created {$entityName}", + 'updated' => "Updated {$entityName}", + 'deleted' => "Deleted {$entityName}", + default => ucfirst($eventType) . " {$entityName}", + }; + } + + /** + * Check if event is critical (should be logged synchronously) + */ + protected function isCriticalEvent(string $eventType, Model $entity): bool + { + $criticalEvents = config('audit.critical_events', [ + 'deleted', + 'approved', + 'rejected', + 'stock_adjusted', + ]); + + $criticalEntities = config('audit.critical_entities', [ + \App\Models\WorkOrder::class, + \App\Models\PurchaseOrder::class, + \App\Models\StockMovement::class, + ]); + + return in_array($eventType, $criticalEvents) || + in_array(get_class($entity), $criticalEntities); + } +} diff --git a/backend/app/Services/BomService.php b/backend/app/Services/BomService.php new file mode 100644 index 0000000..36d10ad --- /dev/null +++ b/backend/app/Services/BomService.php @@ -0,0 +1,671 @@ +withCount('items'); + + // Search + if (!empty($filters['search'])) { + $query->search($filters['search']); + } + + // Product filter + if (!empty($filters['product_id'])) { + $query->forProduct($filters['product_id']); + } + + // Status filter + if (!empty($filters['status'])) { + $query->where('status', $filters['status']); + } + + // Type filter + if (!empty($filters['bom_type'])) { + $query->where('bom_type', $filters['bom_type']); + } + + // Default only + if (!empty($filters['is_default'])) { + $query->default(); + } + + // Active only + if (!empty($filters['active_only'])) { + $query->active(); + } + + return $query->orderBy('bom_number')->paginate($perPage); + } + + /** + * Get all active BOMs for dropdowns + */ + public function getActiveBoms(): Collection + { + return Bom::active() + ->with('product:id,name,sku') + ->orderBy('bom_number') + ->get(['id', 'bom_number', 'name', 'product_id', 'version']); + } + + /** + * Get BOMs for a specific product + */ + public function getBomsForProduct(int $productId): Collection + { + return Bom::forProduct($productId) + ->with(['uom:id,code,name']) + ->withCount('items') + ->orderBy('version', 'desc') + ->get(); + } + + /** + * Get BOM with full relationships + */ + public function getBom(Bom $bom): Bom + { + return $bom->load([ + 'product:id,name,sku', + 'uom:id,code,name', + 'creator:id,first_name,last_name', + 'items.component:id,name,sku', + 'items.uom:id,code,name', + ]); + } + + /** + * Create a new BOM + */ + public function create(array $data): Bom + { + Log::info('Creating BOM', [ + 'product_id' => $data['product_id'] ?? null, + 'bom_number' => $data['bom_number'] ?? null, + ]); + + // Validate product can have BOM + $product = Product::with('productType')->findOrFail($data['product_id']); + if (!$product->canHaveBom()) { + throw new BusinessException( + "Product '{$product->name}' cannot have a BOM. Product type must allow manufacturing." + ); + } + + return DB::transaction(function () use ($data) { + $data['company_id'] = Auth::user()->company_id; + $data['created_by'] = Auth::id(); + + // Generate BOM number if not provided + if (empty($data['bom_number'])) { + $data['bom_number'] = $this->generateBomNumber(); + } + + // If this is the first BOM for the product, make it default + $existingCount = Bom::where('product_id', $data['product_id']) + ->where('company_id', $data['company_id']) + ->count(); + + if ($existingCount === 0) { + $data['is_default'] = true; + } + + $bom = Bom::create($data); + + // Create items if provided + if (!empty($data['items'])) { + $this->createItems($bom, $data['items']); + } + + Log::info('BOM created', ['id' => $bom->id, 'bom_number' => $bom->bom_number]); + + return $bom->fresh(['items']); + }); + } + + /** + * Update BOM + */ + public function update(Bom $bom, array $data): Bom + { + if (!$bom->canEdit()) { + throw new BusinessException("BOM cannot be edited in {$bom->status->label()} status."); + } + + Log::info('Updating BOM', [ + 'id' => $bom->id, + 'changes' => array_keys($data), + ]); + + $bom->update($data); + + return $bom->fresh(); + } + + /** + * Delete BOM + */ + public function delete(Bom $bom): bool + { + if ($bom->workOrders()->whereNotIn('status', ['completed', 'cancelled'])->exists()) { + throw new BusinessException("Cannot delete BOM with active work orders."); + } + + Log::info('Deleting BOM', ['id' => $bom->id]); + + return $bom->delete(); + } + + /** + * Add item to BOM + */ + public function addItem(Bom $bom, array $data): BomItem + { + if (!$bom->canEdit()) { + throw new BusinessException("Cannot add items to BOM in {$bom->status->label()} status."); + } + + // Validate no circular reference + $this->validateNoCircularReference($bom->product_id, $data['component_id']); + + // Get next line number + $nextLineNumber = ($bom->items()->max('line_number') ?? 0) + 1; + $data['line_number'] = $data['line_number'] ?? $nextLineNumber; + $data['bom_id'] = $bom->id; + + Log::info('Adding BOM item', [ + 'bom_id' => $bom->id, + 'component_id' => $data['component_id'], + ]); + + return BomItem::create($data); + } + + /** + * Update BOM item + */ + public function updateItem(Bom $bom, int $itemId, array $data): BomItem + { + if (!$bom->canEdit()) { + throw new BusinessException("Cannot update items in BOM in {$bom->status->label()} status."); + } + + $item = $bom->items()->findOrFail($itemId); + + // Validate no circular reference if component changed + if (isset($data['component_id']) && $data['component_id'] !== $item->component_id) { + $this->validateNoCircularReference($bom->product_id, $data['component_id']); + } + + Log::info('Updating BOM item', ['bom_id' => $bom->id, 'item_id' => $itemId]); + + $item->update($data); + + return $item->fresh(); + } + + /** + * Remove item from BOM + */ + public function removeItem(Bom $bom, int $itemId): bool + { + if (!$bom->canEdit()) { + throw new BusinessException("Cannot remove items from BOM in {$bom->status->label()} status."); + } + + $item = $bom->items()->findOrFail($itemId); + + Log::info('Removing BOM item', ['bom_id' => $bom->id, 'item_id' => $itemId]); + + return $item->delete(); + } + + /** + * Activate BOM + */ + public function activate(Bom $bom): Bom + { + if (!$bom->status->canTransitionTo(BomStatus::ACTIVE)) { + throw new BusinessException("Cannot activate BOM from {$bom->status->label()} status."); + } + + if ($bom->items()->count() === 0) { + throw new BusinessException("Cannot activate BOM without items."); + } + + Log::info('Activating BOM', ['id' => $bom->id]); + + $bom->update(['status' => BomStatus::ACTIVE]); + + return $bom->fresh(); + } + + /** + * Mark BOM as obsolete + */ + public function obsolete(Bom $bom): Bom + { + if (!$bom->status->canTransitionTo(BomStatus::OBSOLETE)) { + throw new BusinessException("Cannot mark BOM as obsolete from {$bom->status->label()} status."); + } + + Log::info('Marking BOM as obsolete', ['id' => $bom->id]); + + $bom->update([ + 'status' => BomStatus::OBSOLETE, + 'is_default' => false, + ]); + + return $bom->fresh(); + } + + /** + * Set BOM as default for product + */ + public function setAsDefault(Bom $bom): Bom + { + if ($bom->status !== BomStatus::ACTIVE) { + throw new BusinessException("Only active BOMs can be set as default."); + } + + Log::info('Setting BOM as default', ['id' => $bom->id, 'product_id' => $bom->product_id]); + + DB::transaction(function () use ($bom) { + // Remove default from other BOMs of same product + Bom::where('product_id', $bom->product_id) + ->where('id', '!=', $bom->id) + ->update(['is_default' => false]); + + $bom->update(['is_default' => true]); + }); + + return $bom->fresh(); + } + + /** + * Copy BOM to new version + */ + public function copy(Bom $bom, ?string $newName = null): Bom + { + Log::info('Copying BOM', ['source_id' => $bom->id]); + + return DB::transaction(function () use ($bom, $newName) { + // Get next version number + $nextVersion = Bom::where('product_id', $bom->product_id) + ->where('company_id', $bom->company_id) + ->max('version') + 1; + + // Create new BOM + $newBom = Bom::create([ + 'company_id' => $bom->company_id, + 'product_id' => $bom->product_id, + 'bom_number' => $this->generateBomNumber(), + 'version' => $nextVersion, + 'name' => $newName ?? "{$bom->name} (Copy)", + 'description' => $bom->description, + 'bom_type' => $bom->bom_type, + 'status' => BomStatus::DRAFT, + 'quantity' => $bom->quantity, + 'uom_id' => $bom->uom_id, + 'is_default' => false, + 'notes' => $bom->notes, + 'created_by' => Auth::id(), + ]); + + // Copy items + foreach ($bom->items as $item) { + BomItem::create([ + 'bom_id' => $newBom->id, + 'component_id' => $item->component_id, + 'line_number' => $item->line_number, + 'quantity' => $item->quantity, + 'uom_id' => $item->uom_id, + 'scrap_percentage' => $item->scrap_percentage, + 'is_optional' => $item->is_optional, + 'is_phantom' => $item->is_phantom, + 'notes' => $item->notes, + ]); + } + + Log::info('BOM copied', ['source_id' => $bom->id, 'new_id' => $newBom->id]); + + return $newBom->fresh(['items']); + }); + } + + /** + * Explode BOM (multi-level) + * Returns flat list of all required materials + * + * @param Bom $bom The BOM to explode + * @param float $quantity The quantity to produce + * @param int $maxLevel Maximum recursion depth + * @param bool $includeOptional Whether to include optional items + * @param bool $explodeAllLevels Whether to explode all sub-BOMs (not just phantoms) + * @param bool $aggregateByProduct Whether to aggregate quantities by product_id + */ + public function explodeBom(Bom $bom, float $quantity = 1, int $maxLevel = 10, bool $includeOptional = false, bool $explodeAllLevels = false, bool $aggregateByProduct = false, bool $asTree = false): array + { + $materials = $this->explodeBomRecursive($bom, $quantity, 0, $maxLevel, [], $includeOptional, $explodeAllLevels); + + // Check if multi-level + $hasMultiLevel = false; + foreach ($materials as $material) { + if ($material['level'] > 0) { + $hasMultiLevel = true; + break; + } + } + + // Single level: return aggregated summary + if (!$hasMultiLevel && $aggregateByProduct) { + return $this->aggregateMaterialsByProduct($materials); + } + + // Multi-level: return tree structure if requested + if ($hasMultiLevel && $asTree) { + return $this->buildTreeStructure($materials); + } + + // Default: return flat list + return $materials; + } + + /** + * Build hierarchical tree structure from flat material list + */ + protected function buildTreeStructure(array $materials): array + { + // Create a map: parent_product_id => [children] + $childrenMap = []; + $rootItems = []; + + foreach ($materials as $material) { + $parentId = $material['parent_product_id'] ?? null; + + // Clean up internal fields + $node = $material; + unset($node['parent_product_id']); + unset($node['has_children']); + + if ($parentId === null) { + // Root level item + $rootItems[] = $node; + } else { + // Child item - group by parent + if (!isset($childrenMap[$parentId])) { + $childrenMap[$parentId] = []; + } + $childrenMap[$parentId][] = $node; + } + } + + // Recursively attach children to parents + $attachChildren = function(&$items) use (&$attachChildren, &$childrenMap) { + foreach ($items as &$item) { + $productId = $item['product_id']; + if (isset($childrenMap[$productId])) { + $item['children'] = $childrenMap[$productId]; + // Recursively attach children's children + $attachChildren($item['children']); + } + } + }; + + $attachChildren($rootItems); + + return $rootItems; + } + + /** + * Aggregate materials by product_id, summing quantities + */ + public function aggregateMaterialsByProduct(array $materials): array + { + $aggregated = []; + + foreach ($materials as $material) { + $productId = $material['product_id']; + + if (!isset($aggregated[$productId])) { + // First occurrence - use as base + $aggregated[$productId] = $material; + // Don't add sources yet - we'll add it only if there are multiple sources + unset($aggregated[$productId]['sources']); + } else { + // Multiple sources detected - initialize sources array if not exists + if (!isset($aggregated[$productId]['sources'])) { + // Add the first source + $aggregated[$productId]['sources'] = [ + [ + 'bom_id' => $aggregated[$productId]['bom_id'], + 'bom_number' => $aggregated[$productId]['bom_number'], + 'bom_name' => $aggregated[$productId]['bom_name'], + 'level' => $aggregated[$productId]['level'], + 'quantity' => $aggregated[$productId]['quantity'], + ] + ]; + } + + // Sum quantities + $aggregated[$productId]['quantity'] += $material['quantity']; + + // Track sources (which BOMs contribute to this product) + $aggregated[$productId]['sources'][] = [ + 'bom_id' => $material['bom_id'], + 'bom_number' => $material['bom_number'], + 'bom_name' => $material['bom_name'], + 'level' => $material['level'], + 'quantity' => $material['quantity'], + ]; + + // Keep the highest level (most detailed) + if ($material['level'] > $aggregated[$productId]['level']) { + $aggregated[$productId]['level'] = $material['level']; + } + } + } + + // Round final quantities + foreach ($aggregated as &$item) { + $item['quantity'] = round($item['quantity'], 4); + } + + return array_values($aggregated); + } + + /** + * Recursive BOM explosion + * Returns flat list with parent tracking for tree structure + */ + protected function explodeBomRecursive(Bom $bom, float $quantity, int $level, int $maxLevel, array $visited, bool $includeOptional = false, bool $explodeAllLevels = false, ?int $parentProductId = null): array + { + if ($level > $maxLevel) { + throw new BusinessException("BOM explosion exceeded maximum level ({$maxLevel}). Possible circular reference."); + } + + // Prevent circular references + if (in_array($bom->id, $visited)) { + throw new BusinessException("Circular reference detected in BOM: {$bom->bom_number}"); + } + + $visited[] = $bom->id; + $materials = []; + + $itemsQuery = $bom->items()->with('component.boms'); + if (!$includeOptional) { + $itemsQuery->required(); + } + + foreach ($itemsQuery->get() as $item) { + $requiredQty = $item->getRequiredQuantity($quantity / $bom->quantity); + + // Check if this component should be exploded + $shouldExplode = false; + $childBom = null; + + if ($item->is_phantom) { + // Phantom items are always exploded if they have a BOM + $childBom = Bom::where('product_id', $item->component_id) + ->where('is_default', true) + ->active() + ->first(); + $shouldExplode = $childBom !== null; + } elseif ($explodeAllLevels) { + // If explodeAllLevels is true, check if component has a BOM + $childBom = Bom::where('product_id', $item->component_id) + ->where('is_default', true) + ->active() + ->first(); + $shouldExplode = $childBom !== null; + } + + if ($shouldExplode && $childBom) { + // Create parent entry (intermediate product with BOM) + $parentEntry = $this->createMaterialEntry($item, $requiredQty, $level, $bom); + $parentEntry['parent_product_id'] = $parentProductId; + $parentEntry['has_children'] = true; + $materials[] = $parentEntry; + + // Recursive explosion - children will have this product as parent + $childMaterials = $this->explodeBomRecursive( + $childBom, + $requiredQty, + $level + 1, + $maxLevel, + $visited, + $includeOptional, + $explodeAllLevels, + $item->component_id // This product is the parent for children + ); + $materials = array_merge($materials, $childMaterials); + } else { + // No BOM found or shouldn't explode, treat as raw material + $material = $this->createMaterialEntry($item, $requiredQty, $level, $bom); + $material['parent_product_id'] = $parentProductId; + $material['has_children'] = false; + $materials[] = $material; + } + } + + return $materials; + } + + /** + * Create material entry for explosion result + */ + protected function createMaterialEntry(BomItem $item, float $quantity, int $level, Bom $bom): array + { + return [ + 'product_id' => $item->component_id, + 'product_name' => $item->component->name, + 'product_sku' => $item->component->sku, + 'quantity' => round($quantity, 4), + 'uom_id' => $item->uom_id, + 'uom_code' => $item->uom->code, + 'level' => $level, + 'bom_id' => $bom->id, + 'bom_number' => $bom->bom_number, + 'bom_name' => $bom->name, + 'bom_item_id' => $item->id, + 'is_phantom' => $item->is_phantom, + 'is_optional' => $item->is_optional, + 'scrap_percentage' => $item->scrap_percentage, + ]; + } + + /** + * Validate no circular reference + */ + protected function validateNoCircularReference(int $parentProductId, int $componentProductId, array $visited = []): void + { + if ($parentProductId === $componentProductId) { + throw new BusinessException("A product cannot be a component of itself."); + } + + if (in_array($componentProductId, $visited)) { + throw new BusinessException("Circular reference detected in BOM structure."); + } + + $visited[] = $componentProductId; + + // Check if component has a BOM that contains the parent + $componentBoms = Bom::where('product_id', $componentProductId)->active()->get(); + + foreach ($componentBoms as $bom) { + foreach ($bom->items as $item) { + $this->validateNoCircularReference($parentProductId, $item->component_id, $visited); + } + } + } + + /** + * Create BOM items in bulk + */ + protected function createItems(Bom $bom, array $items): void + { + foreach ($items as $index => $itemData) { + $itemData['bom_id'] = $bom->id; + $itemData['line_number'] = $itemData['line_number'] ?? ($index + 1); + + $this->validateNoCircularReference($bom->product_id, $itemData['component_id']); + + BomItem::create($itemData); + } + } + + /** + * Generate BOM number + */ + public function generateBomNumber(): string + { + $companyId = Auth::user()->company_id; + $companyIdPadded = str_pad($companyId, 3, '0', STR_PAD_LEFT); + $prefix = "BOM-{$companyIdPadded}-"; + + $lastBom = Bom::withTrashed() + ->where('company_id', $companyId) + ->where('bom_number', 'like', "{$prefix}%") + ->orderByRaw("CAST(SUBSTRING(bom_number FROM '[0-9]+$') AS INTEGER) DESC") + ->first(); + + if ($lastBom && preg_match('/(\d+)$/', $lastBom->bom_number, $matches)) { + $nextNumber = (int) $matches[1] + 1; + } else { + $nextNumber = 1; + } + + return $prefix . str_pad($nextNumber, 5, '0', STR_PAD_LEFT); + } + + /** + * Get default BOM for a product + */ + public function getDefaultBomForProduct(int $productId): ?Bom + { + return Bom::where('product_id', $productId) + ->where('is_default', true) + ->active() + ->first(); + } +} diff --git a/backend/app/Services/CapacityService.php b/backend/app/Services/CapacityService.php new file mode 100644 index 0000000..d192b1d --- /dev/null +++ b/backend/app/Services/CapacityService.php @@ -0,0 +1,501 @@ +company_id; + $results = []; + + $workCenters = WorkCenter::where('company_id', $companyId) + ->active() + ->get(); + + foreach ($workCenters as $workCenter) { + $count = $this->generateCalendar($workCenter, $startDate, $endDate, $holidays); + $results[$workCenter->id] = [ + 'work_center' => $workCenter->name, + 'entries_created' => $count, + ]; + } + + return $results; + } + + /** + * Update calendar entry + */ + public function updateCalendarEntry(WorkCenterCalendar $entry, array $data): WorkCenterCalendar + { + $entry->update($data); + return $entry->fresh(); + } + + /** + * Set holiday for a date range + */ + public function setHoliday( + int $workCenterId, + \DateTimeInterface $startDate, + \DateTimeInterface $endDate, + string $reason + ): int { + return WorkCenterCalendar::where('work_center_id', $workCenterId) + ->dateRange($startDate, $endDate) + ->update([ + 'day_type' => CalendarDayType::HOLIDAY, + 'available_hours' => 0, + 'notes' => $reason, + ]); + } + + /** + * Set maintenance for a date + */ + public function setMaintenance( + int $workCenterId, + \DateTimeInterface $date, + float $reducedHours, + string $reason + ): WorkCenterCalendar { + $entry = WorkCenterCalendar::where('work_center_id', $workCenterId) + ->forDate($date) + ->first(); + + if (!$entry) { + $workCenter = WorkCenter::findOrFail($workCenterId); + $entry = WorkCenterCalendar::create([ + 'company_id' => $workCenter->company_id, + 'work_center_id' => $workCenterId, + 'calendar_date' => $date, + 'day_type' => CalendarDayType::MAINTENANCE, + 'available_hours' => $reducedHours, + 'notes' => $reason, + ]); + } else { + $entry->update([ + 'day_type' => CalendarDayType::MAINTENANCE, + 'capacity_override' => $reducedHours, + 'notes' => $reason, + ]); + } + + return $entry->fresh(); + } + + // ========================================= + // Capacity Calculations + // ========================================= + + /** + * Get available capacity for a work center + */ + public function getAvailableCapacity( + WorkCenter $workCenter, + \DateTimeInterface $startDate, + \DateTimeInterface $endDate + ): array { + // Get calendar-based available hours + $totalAvailable = WorkCenterCalendar::getTotalAvailableHours( + $workCenter->id, + $startDate, + $endDate + ); + + // If no calendar entries, calculate from work center defaults + if ($totalAvailable == 0) { + $days = Carbon::parse($startDate)->diffInDays(Carbon::parse($endDate)) + 1; + // Assume 5 working days per week + $workingDays = floor($days * 5 / 7); + $totalAvailable = $workingDays * $workCenter->effective_capacity; + } + + // Get scheduled load + $scheduledLoad = $this->getScheduledLoad($workCenter, $startDate, $endDate); + + return [ + 'work_center_id' => $workCenter->id, + 'work_center_name' => $workCenter->name, + 'period' => [ + 'start' => Carbon::parse($startDate)->toDateString(), + 'end' => Carbon::parse($endDate)->toDateString(), + ], + 'total_available_hours' => round($totalAvailable, 2), + 'scheduled_hours' => round($scheduledLoad, 2), + 'remaining_hours' => round($totalAvailable - $scheduledLoad, 2), + 'utilization_percent' => $totalAvailable > 0 + ? round(($scheduledLoad / $totalAvailable) * 100, 1) + : 0, + ]; + } + + /** + * Get scheduled load for a work center + */ + public function getScheduledLoad( + WorkCenter $workCenter, + \DateTimeInterface $startDate, + \DateTimeInterface $endDate + ): float { + return WorkOrderOperation::where('work_center_id', $workCenter->id) + ->whereHas('workOrder', function ($q) use ($startDate, $endDate) { + $q->whereIn('status', [ + WorkOrderStatus::RELEASED, + WorkOrderStatus::IN_PROGRESS, + ]) + ->where(function ($q2) use ($startDate, $endDate) { + $q2->whereBetween('planned_start_date', [$startDate, $endDate]) + ->orWhereBetween('planned_end_date', [$startDate, $endDate]); + }); + }) + ->whereIn('status', [OperationStatus::PENDING, OperationStatus::IN_PROGRESS]) + ->get() + ->sum(function ($op) { + // Convert minutes to hours + $setupTime = $op->actual_setup_time ?? $op->planned_setup_time ?? 0; + $runTime = $op->actual_run_time ?? $op->planned_run_time ?? 0; + return ($setupTime + $runTime) / 60; + }); + } + + /** + * Get capacity overview for all work centers + */ + public function getCapacityOverview( + \DateTimeInterface $startDate, + \DateTimeInterface $endDate + ): Collection { + $companyId = Auth::user()->company_id; + + $workCenters = WorkCenter::where('company_id', $companyId) + ->active() + ->get(); + + return $workCenters->map(function ($workCenter) use ($startDate, $endDate) { + return $this->getAvailableCapacity($workCenter, $startDate, $endDate); + }); + } + + /** + * Get daily capacity breakdown for a work center + */ + public function getDailyCapacity( + WorkCenter $workCenter, + \DateTimeInterface $startDate, + \DateTimeInterface $endDate + ): Collection { + $days = collect(); + $period = CarbonPeriod::create($startDate, $endDate); + + foreach ($period as $date) { + $dateString = $date->toDateString(); + + // Get calendar entry + $calendarEntry = WorkCenterCalendar::where('work_center_id', $workCenter->id) + ->forDate($dateString) + ->first(); + + // Calculate available hours + $availableHours = 0; + $dayType = CalendarDayType::WORKING; + + if ($calendarEntry) { + $availableHours = $calendarEntry->effective_hours; + $dayType = $calendarEntry->day_type; + } elseif (!$date->isWeekend()) { + // Default to work center capacity if no calendar entry + $availableHours = $workCenter->effective_capacity; + } + + // Calculate scheduled hours for this day + $scheduledHours = $this->getDailyScheduledLoad($workCenter->id, $date); + + $days->push([ + 'date' => $dateString, + 'day_name' => $date->format('l'), + 'day_type' => $dayType->value, + 'available_hours' => round($availableHours, 2), + 'scheduled_hours' => round($scheduledHours, 2), + 'remaining_hours' => round(max(0, $availableHours - $scheduledHours), 2), + 'utilization_percent' => $availableHours > 0 + ? round(($scheduledHours / $availableHours) * 100, 1) + : 0, + 'is_overloaded' => $scheduledHours > $availableHours, + ]); + } + + return $days; + } + + /** + * Get scheduled load for a specific day + */ + protected function getDailyScheduledLoad(int $workCenterId, Carbon $date): float + { + return WorkOrderOperation::where('work_center_id', $workCenterId) + ->whereHas('workOrder', function ($q) use ($date) { + $q->whereIn('status', [ + WorkOrderStatus::RELEASED, + WorkOrderStatus::IN_PROGRESS, + ]) + ->whereDate('planned_start_date', '<=', $date) + ->whereDate('planned_end_date', '>=', $date); + }) + ->whereIn('status', [OperationStatus::PENDING, OperationStatus::IN_PROGRESS]) + ->get() + ->sum(function ($op) { + $setupTime = $op->actual_setup_time ?? $op->planned_setup_time ?? 0; + $runTime = $op->actual_run_time ?? $op->planned_run_time ?? 0; + return ($setupTime + $runTime) / 60; + }); + } + + // ========================================= + // Capacity Planning / CRP + // ========================================= + + /** + * Check if capacity is available for a work order + */ + public function checkCapacityForWorkOrder(WorkOrder $workOrder): array + { + $issues = []; + $hasCapacity = true; + + if (!$workOrder->routing) { + return [ + 'has_capacity' => true, + 'issues' => [], + 'message' => 'No routing defined - capacity check skipped', + ]; + } + + $startDate = $workOrder->planned_start_date ?? today(); + $endDate = $workOrder->planned_end_date ?? today()->addDays(7); + + foreach ($workOrder->operations as $operation) { + if (!$operation->work_center_id) { + continue; + } + + $workCenter = $operation->workCenter; + $requiredHours = ($operation->planned_setup_time + $operation->planned_run_time) / 60; + + $capacity = $this->getAvailableCapacity($workCenter, $startDate, $endDate); + + if ($capacity['remaining_hours'] < $requiredHours) { + $hasCapacity = false; + $issues[] = [ + 'operation' => $operation->name, + 'work_center' => $workCenter->name, + 'required_hours' => round($requiredHours, 2), + 'available_hours' => $capacity['remaining_hours'], + 'shortage' => round($requiredHours - $capacity['remaining_hours'], 2), + ]; + } + } + + return [ + 'has_capacity' => $hasCapacity, + 'issues' => $issues, + 'message' => $hasCapacity + ? 'Sufficient capacity available' + : 'Capacity constraints detected', + ]; + } + + /** + * Find next available slot for required hours + */ + public function findNextAvailableSlot( + WorkCenter $workCenter, + float $requiredHours, + ?\DateTimeInterface $startFrom = null + ): ?array { + $startDate = $startFrom ? Carbon::parse($startFrom) : today(); + $maxDays = 90; // Look ahead 90 days max + + for ($i = 0; $i < $maxDays; $i++) { + $checkDate = $startDate->copy()->addDays($i); + + // Skip weekends if no calendar + if ($checkDate->isWeekend()) { + $calendarEntry = WorkCenterCalendar::where('work_center_id', $workCenter->id) + ->forDate($checkDate) + ->first(); + + if (!$calendarEntry || !$calendarEntry->isAvailable()) { + continue; + } + } + + $capacity = $this->getAvailableCapacity($workCenter, $checkDate, $checkDate); + + if ($capacity['remaining_hours'] >= $requiredHours) { + return [ + 'date' => $checkDate->toDateString(), + 'available_hours' => $capacity['remaining_hours'], + 'days_from_now' => $i, + ]; + } + } + + return null; + } + + /** + * Get work center load report + */ + public function getLoadReport( + \DateTimeInterface $startDate, + \DateTimeInterface $endDate + ): array { + $companyId = Auth::user()->company_id; + + $workCenters = WorkCenter::where('company_id', $companyId) + ->active() + ->with(['workOrderOperations' => function ($q) use ($startDate, $endDate) { + $q->whereHas('workOrder', function ($q2) use ($startDate, $endDate) { + $q2->whereIn('status', [ + WorkOrderStatus::RELEASED, + WorkOrderStatus::IN_PROGRESS, + ]) + ->whereBetween('planned_start_date', [$startDate, $endDate]); + }); + }]) + ->get(); + + $report = [ + 'period' => [ + 'start' => Carbon::parse($startDate)->toDateString(), + 'end' => Carbon::parse($endDate)->toDateString(), + ], + 'summary' => [ + 'total_work_centers' => $workCenters->count(), + 'overloaded_count' => 0, + 'underutilized_count' => 0, + 'optimal_count' => 0, + ], + 'work_centers' => [], + ]; + + foreach ($workCenters as $workCenter) { + $capacity = $this->getAvailableCapacity($workCenter, $startDate, $endDate); + + $status = 'optimal'; + if ($capacity['utilization_percent'] > 100) { + $status = 'overloaded'; + $report['summary']['overloaded_count']++; + } elseif ($capacity['utilization_percent'] < 50) { + $status = 'underutilized'; + $report['summary']['underutilized_count']++; + } else { + $report['summary']['optimal_count']++; + } + + $report['work_centers'][] = [ + 'id' => $workCenter->id, + 'code' => $workCenter->code, + 'name' => $workCenter->name, + 'type' => $workCenter->work_center_type->value, + 'capacity' => $capacity, + 'status' => $status, + ]; + } + + return $report; + } + + /** + * Get bottleneck analysis + */ + public function getBottleneckAnalysis( + \DateTimeInterface $startDate, + \DateTimeInterface $endDate + ): array { + $overview = $this->getCapacityOverview($startDate, $endDate); + + $bottlenecks = $overview + ->filter(fn($wc) => $wc['utilization_percent'] > 85) + ->sortByDesc('utilization_percent') + ->values(); + + $recommendations = []; + + foreach ($bottlenecks as $bottleneck) { + if ($bottleneck['utilization_percent'] > 100) { + $recommendations[] = [ + 'work_center' => $bottleneck['work_center_name'], + 'severity' => 'critical', + 'message' => "Work center is overloaded at {$bottleneck['utilization_percent']}% utilization", + 'suggestions' => [ + 'Consider overtime or additional shifts', + 'Reschedule some work orders', + 'Outsource to subcontractors', + ], + ]; + } elseif ($bottleneck['utilization_percent'] > 90) { + $recommendations[] = [ + 'work_center' => $bottleneck['work_center_name'], + 'severity' => 'warning', + 'message' => "Work center is near capacity at {$bottleneck['utilization_percent']}% utilization", + 'suggestions' => [ + 'Monitor closely for delays', + 'Avoid scheduling additional work if possible', + ], + ]; + } + } + + return [ + 'period' => [ + 'start' => Carbon::parse($startDate)->toDateString(), + 'end' => Carbon::parse($endDate)->toDateString(), + ], + 'bottlenecks' => $bottlenecks->toArray(), + 'recommendations' => $recommendations, + ]; + } +} diff --git a/backend/app/Services/CustomerGroupPriceService.php b/backend/app/Services/CustomerGroupPriceService.php new file mode 100644 index 0000000..f65f220 --- /dev/null +++ b/backend/app/Services/CustomerGroupPriceService.php @@ -0,0 +1,199 @@ +id) + ->with(['product', 'currency']); + + // Search by product + if (!empty($filters['search'])) { + $query->whereHas('product', function ($q) use ($filters) { + $q->where('name', 'ilike', "%{$filters['search']}%") + ->orWhere('sku', 'ilike', "%{$filters['search']}%"); + }); + } + + // Active only + if (!empty($filters['active_only'])) { + $query->active(); + } + + return $query->orderBy('created_at', 'desc')->paginate($perPage); + } + + /** + * Get price for specific product and customer group + */ + public function getPriceForProduct(int $productId, int $customerGroupId, float $quantity = 1): ?CustomerGroupPrice + { + return CustomerGroupPrice::where('product_id', $productId) + ->where('customer_group_id', $customerGroupId) + ->where('min_quantity', '<=', $quantity) + ->active() + ->orderBy('min_quantity', 'desc') + ->first(); + } + + /** + * Calculate effective price for a product and customer + */ + public function calculateEffectivePrice(Product $product, ?int $customerGroupId, float $quantity = 1): array + { + $basePrice = $product->sale_price ?? $product->cost_price ?? 0; + $effectivePrice = $basePrice; + $discountType = null; + $discountValue = 0; + + if ($customerGroupId) { + // Check for group-specific price + $groupPrice = $this->getPriceForProduct($product->id, $customerGroupId, $quantity); + + if ($groupPrice) { + $effectivePrice = $groupPrice->price; + $discountType = 'group_price'; + $discountValue = $basePrice - $effectivePrice; + } else { + // Apply group discount percentage + $group = CustomerGroup::find($customerGroupId); + if ($group && $group->discount_percentage > 0) { + $discountValue = $basePrice * ($group->discount_percentage / 100); + $effectivePrice = $basePrice - $discountValue; + $discountType = 'group_discount'; + } + } + } + + return [ + 'base_price' => $basePrice, + 'effective_price' => $effectivePrice, + 'discount_type' => $discountType, + 'discount_value' => $discountValue, + 'currency_id' => $product->currency_id, + ]; + } + + /** + * Create or update group price + */ + public function setPrice(array $data): CustomerGroupPrice + { + Log::info('Setting customer group price', [ + 'customer_group_id' => $data['customer_group_id'], + 'product_id' => $data['product_id'], + ]); + + DB::beginTransaction(); + + try { + $companyId = Auth::user()->company_id; + + // Check for existing price with same criteria + $existing = CustomerGroupPrice::where('customer_group_id', $data['customer_group_id']) + ->where('product_id', $data['product_id']) + ->where('min_quantity', $data['min_quantity'] ?? 1) + ->first(); + + if ($existing) { + $existing->update([ + 'price' => $data['price'], + 'currency_id' => $data['currency_id'] ?? $existing->currency_id, + 'valid_from' => $data['valid_from'] ?? $existing->valid_from, + 'valid_until' => $data['valid_until'] ?? $existing->valid_until, + 'is_active' => $data['is_active'] ?? $existing->is_active, + ]); + + DB::commit(); + return $existing->fresh(['product', 'customerGroup', 'currency']); + } + + $groupPrice = CustomerGroupPrice::create([ + 'company_id' => $companyId, + 'customer_group_id' => $data['customer_group_id'], + 'product_id' => $data['product_id'], + 'price' => $data['price'], + 'currency_id' => $data['currency_id'] ?? null, + 'min_quantity' => $data['min_quantity'] ?? 1, + 'valid_from' => $data['valid_from'] ?? null, + 'valid_until' => $data['valid_until'] ?? null, + 'is_active' => $data['is_active'] ?? true, + ]); + + DB::commit(); + + Log::info('Customer group price created', [ + 'group_price_id' => $groupPrice->id, + ]); + + return $groupPrice->load(['product', 'customerGroup', 'currency']); + + } catch (Exception $e) { + DB::rollBack(); + + Log::error('Failed to set customer group price', [ + 'error' => $e->getMessage(), + ]); + + throw $e; + } + } + + /** + * Bulk set prices for a customer group + */ + public function bulkSetPrices(CustomerGroup $customerGroup, array $prices): Collection + { + Log::info('Bulk setting prices for customer group', [ + 'customer_group_id' => $customerGroup->id, + 'price_count' => count($prices), + ]); + + DB::beginTransaction(); + + try { + $results = collect(); + + foreach ($prices as $priceData) { + $priceData['customer_group_id'] = $customerGroup->id; + $results->push($this->setPrice($priceData)); + } + + DB::commit(); + + return $results; + + } catch (Exception $e) { + DB::rollBack(); + throw $e; + } + } + + /** + * Delete group price + */ + public function delete(CustomerGroupPrice $groupPrice): bool + { + Log::info('Deleting customer group price', [ + 'group_price_id' => $groupPrice->id, + ]); + + return $groupPrice->delete(); + } +} diff --git a/backend/app/Services/CustomerGroupService.php b/backend/app/Services/CustomerGroupService.php new file mode 100644 index 0000000..a936681 --- /dev/null +++ b/backend/app/Services/CustomerGroupService.php @@ -0,0 +1,174 @@ +withCount('customers'); + + // Search + if (!empty($filters['search'])) { + $query->where(function ($q) use ($filters) { + $q->where('name', 'ilike', "%{$filters['search']}%") + ->orWhere('code', 'ilike', "%{$filters['search']}%"); + }); + } + + // Active filter + if (isset($filters['is_active'])) { + $query->where('is_active', $filters['is_active']); + } + + return $query->orderBy('name')->paginate($perPage); + } + + /** + * Get all active customer groups for dropdown + */ + public function getActiveGroups(): Collection + { + return CustomerGroup::where('is_active', true) + ->orderBy('name') + ->get(); + } + + /** + * Get single customer group + */ + public function getCustomerGroup(CustomerGroup $customerGroup): CustomerGroup + { + return $customerGroup->load(['customers', 'groupPrices.product']); + } + + /** + * Create new customer group + */ + public function create(array $data): CustomerGroup + { + Log::info('Creating customer group', [ + 'name' => $data['name'], + 'code' => $data['code'] ?? null, + ]); + + DB::beginTransaction(); + + try { + $companyId = Auth::user()->company_id; + + // Generate code if not provided + if (empty($data['code'])) { + $data['code'] = $this->generateCode($data['name'], $companyId); + } + + $customerGroup = CustomerGroup::create([ + 'company_id' => $companyId, + 'name' => $data['name'], + 'code' => $data['code'], + 'description' => $data['description'] ?? null, + 'discount_percentage' => $data['discount_percentage'] ?? 0, + 'payment_terms_days' => $data['payment_terms_days'] ?? null, + 'credit_limit' => $data['credit_limit'] ?? null, + 'is_active' => $data['is_active'] ?? true, + ]); + + DB::commit(); + + Log::info('Customer group created', [ + 'customer_group_id' => $customerGroup->id, + 'name' => $customerGroup->name, + ]); + + return $customerGroup; + + } catch (Exception $e) { + DB::rollBack(); + + Log::error('Failed to create customer group', [ + 'error' => $e->getMessage(), + ]); + + throw $e; + } + } + + /** + * Update customer group + */ + public function update(CustomerGroup $customerGroup, array $data): CustomerGroup + { + Log::info('Updating customer group', [ + 'customer_group_id' => $customerGroup->id, + 'changes' => array_keys($data), + ]); + + $customerGroup->update([ + 'name' => $data['name'] ?? $customerGroup->name, + 'code' => $data['code'] ?? $customerGroup->code, + 'description' => $data['description'] ?? $customerGroup->description, + 'discount_percentage' => $data['discount_percentage'] ?? $customerGroup->discount_percentage, + 'payment_terms_days' => $data['payment_terms_days'] ?? $customerGroup->payment_terms_days, + 'credit_limit' => $data['credit_limit'] ?? $customerGroup->credit_limit, + 'is_active' => $data['is_active'] ?? $customerGroup->is_active, + ]); + + return $customerGroup->fresh(); + } + + /** + * Delete customer group + */ + public function delete(CustomerGroup $customerGroup): bool + { + if ($customerGroup->customers()->exists()) { + throw new BusinessException('Cannot delete customer group with existing customers.'); + } + + Log::info('Deleting customer group', [ + 'customer_group_id' => $customerGroup->id, + 'name' => $customerGroup->name, + ]); + + return $customerGroup->delete(); + } + + /** + * Generate unique code for customer group + */ + protected function generateCode(string $name, int $companyId): string + { + // Create base code from name + $baseCode = strtoupper(preg_replace('/[^A-Z0-9]/', '', substr($name, 0, 10))); + + if (empty($baseCode)) { + $baseCode = 'CG'; + } + + // Check if code exists, append number if needed + $code = $baseCode; + $counter = 1; + + while (CustomerGroup::where('company_id', $companyId) + ->where('code', $code) + ->exists()) { + $code = $baseCode . $counter; + $counter++; + } + + return $code; + } +} diff --git a/backend/app/Services/CustomerService.php b/backend/app/Services/CustomerService.php new file mode 100644 index 0000000..0348ee0 --- /dev/null +++ b/backend/app/Services/CustomerService.php @@ -0,0 +1,217 @@ +with(['customerGroup']); + + // Search + if (!empty($filters['search'])) { + $query->where(function ($q) use ($filters) { + $q->where('name', 'ilike', "%{$filters['search']}%") + ->orWhere('customer_code', 'ilike', "%{$filters['search']}%") + ->orWhere('email', 'ilike', "%{$filters['search']}%") + ->orWhere('tax_id', 'ilike', "%{$filters['search']}%"); + }); + } + + // Customer group filter + if (!empty($filters['customer_group_id'])) { + $query->where('customer_group_id', $filters['customer_group_id']); + } + + // Active filter + if (isset($filters['is_active'])) { + $query->where('is_active', $filters['is_active']); + } + + return $query->orderBy('name')->paginate($perPage); + } + + /** + * Get all active customers for dropdown + */ + public function getActiveCustomers(): Collection + { + return Customer::where('is_active', true) + ->orderBy('name') + ->get(['id', 'name', 'customer_code', 'customer_group_id']); + } + + /** + * Get single customer with relations + */ + public function getCustomer(Customer $customer): Customer + { + return $customer->load(['customerGroup', 'salesOrders' => function ($q) { + $q->latest()->limit(10); + }]); + } + + /** + * Create new customer + */ + public function create(array $data): Customer + { + Log::info('Creating customer', [ + 'name' => $data['name'], + 'code' => $data['code'] ?? $data['customer_code'] ?? null, + ]); + + DB::beginTransaction(); + + try { + $companyId = Auth::user()->company_id; + + // Generate code if not provided + $customerCode = $data['customer_code'] ?? $data['code'] ?? null; + if (empty($customerCode)) { + $customerCode = $this->generateCustomerCode(); + } + + $customer = Customer::create([ + 'company_id' => $companyId, + 'customer_group_id' => $data['customer_group_id'] ?? null, + 'customer_code' => $customerCode, + 'name' => $data['name'], + 'email' => $data['email'] ?? null, + 'phone' => $data['phone'] ?? null, + 'tax_id' => $data['tax_id'] ?? $data['tax_number'] ?? null, + 'address' => $data['address'] ?? $data['billing_address'] ?? null, + 'city' => $data['city'] ?? null, + 'state' => $data['state'] ?? null, + 'postal_code' => $data['postal_code'] ?? null, + 'country' => $data['country'] ?? null, + 'contact_person' => $data['contact_person'] ?? null, + 'payment_terms_days' => $data['payment_terms_days'] ?? 30, + 'credit_limit' => $data['credit_limit'] ?? 0, + 'notes' => $data['notes'] ?? null, + 'is_active' => $data['is_active'] ?? true, + 'created_by' => Auth::id(), + ]); + + DB::commit(); + + Log::info('Customer created', [ + 'customer_id' => $customer->id, + 'customer_code' => $customer->customer_code, + ]); + + return $customer->load('customerGroup'); + + } catch (Exception $e) { + DB::rollBack(); + + Log::error('Failed to create customer', [ + 'error' => $e->getMessage(), + ]); + + throw $e; + } + } + + /** + * Update customer + */ + public function update(Customer $customer, array $data): Customer + { + Log::info('Updating customer', [ + 'customer_id' => $customer->id, + 'changes' => array_keys($data), + ]); + + $customer->update([ + 'customer_group_id' => $data['customer_group_id'] ?? $customer->customer_group_id, + 'name' => $data['name'] ?? $customer->name, + 'email' => $data['email'] ?? $customer->email, + 'phone' => $data['phone'] ?? $customer->phone, + 'tax_number' => $data['tax_number'] ?? $customer->tax_number, + 'billing_address' => $data['billing_address'] ?? $customer->billing_address, + 'shipping_address' => $data['shipping_address'] ?? $customer->shipping_address, + 'city' => $data['city'] ?? $customer->city, + 'state' => $data['state'] ?? $customer->state, + 'postal_code' => $data['postal_code'] ?? $customer->postal_code, + 'country' => $data['country'] ?? $customer->country, + 'contact_person' => $data['contact_person'] ?? $customer->contact_person, + 'payment_terms_days' => $data['payment_terms_days'] ?? $customer->payment_terms_days, + 'credit_limit' => $data['credit_limit'] ?? $customer->credit_limit, + 'notes' => $data['notes'] ?? $customer->notes, + 'is_active' => $data['is_active'] ?? $customer->is_active, + ]); + + return $customer->fresh('customerGroup'); + } + + /** + * Delete customer (soft delete) + */ + public function delete(Customer $customer): bool + { + // Check for pending orders + if ($customer->salesOrders()->whereNotIn('status', ['delivered', 'cancelled'])->exists()) { + throw new BusinessException('Cannot delete customer with pending orders.'); + } + + Log::info('Deleting customer', [ + 'customer_id' => $customer->id, + 'customer_code' => $customer->customer_code, + ]); + + return $customer->delete(); + } + + /** + * Generate customer code + */ + public function generateCustomerCode(): string + { + $companyId = Auth::user()->company_id; + $companyIdPadded = str_pad($companyId, 3, '0', STR_PAD_LEFT); + $prefix = "CUS-{$companyIdPadded}-"; + + $lastCustomer = Customer::withTrashed() + ->where('company_id', $companyId) + ->where('customer_code', 'like', "{$prefix}%") + ->orderByRaw("CAST(SUBSTRING(customer_code FROM '[0-9]+$') AS INTEGER) DESC") + ->first(); + + if ($lastCustomer && preg_match('/(\d+)$/', $lastCustomer->customer_code, $matches)) { + $nextNumber = (int) $matches[1] + 1; + } else { + $nextNumber = 1; + } + + return $prefix . str_pad($nextNumber, 5, '0', STR_PAD_LEFT); + } + + /** + * Get customer statistics + */ + public function getStatistics(Customer $customer): array + { + $orders = $customer->salesOrders(); + + return [ + 'total_orders' => $orders->count(), + 'total_revenue' => $orders->where('status', 'delivered')->sum('total_amount'), + 'pending_orders' => $orders->whereNotIn('status', ['delivered', 'cancelled'])->count(), + 'last_order_date' => $orders->latest()->value('created_at'), + ]; + } +} diff --git a/backend/app/Services/DeliveryNoteService.php b/backend/app/Services/DeliveryNoteService.php new file mode 100644 index 0000000..b427c71 --- /dev/null +++ b/backend/app/Services/DeliveryNoteService.php @@ -0,0 +1,541 @@ +stockService = $stockService; + } + + /** + * Get paginated delivery notes with filters + */ + public function getDeliveryNotes(array $filters = [], int $perPage = 15): LengthAwarePaginator + { + $query = DeliveryNote::query() + ->with(['salesOrder.customer', 'warehouse', 'createdBy']); + + // Search + if (!empty($filters['search'])) { + $query->where(function ($q) use ($filters) { + $q->where('delivery_number', 'ilike', "%{$filters['search']}%") + ->orWhereHas('salesOrder', function ($sq) use ($filters) { + $sq->where('order_number', 'ilike', "%{$filters['search']}%"); + }); + }); + } + + // Status filter + if (!empty($filters['status'])) { + $query->where('status', $filters['status']); + } + + // Sales order filter + if (!empty($filters['sales_order_id'])) { + $query->where('sales_order_id', $filters['sales_order_id']); + } + + // Warehouse filter + if (!empty($filters['warehouse_id'])) { + $query->where('warehouse_id', $filters['warehouse_id']); + } + + // Date range + if (!empty($filters['from_date'])) { + $query->where('delivery_date', '>=', $filters['from_date']); + } + if (!empty($filters['to_date'])) { + $query->where('delivery_date', '<=', $filters['to_date']); + } + + return $query->latest('delivery_date')->paginate($perPage); + } + + /** + * Get single delivery note with relations + */ + public function getDeliveryNote(DeliveryNote $deliveryNote): DeliveryNote + { + return $deliveryNote->load([ + 'salesOrder.customer', + 'warehouse', + 'items.product', + 'items.salesOrderItem', + 'createdBy', + ]); + } + + /** + * Create delivery note from sales order + */ + public function createFromSalesOrder(SalesOrder $salesOrder, array $data): DeliveryNote + { + // Check order status + if (!in_array($salesOrder->status, [SalesOrderStatus::CONFIRMED, SalesOrderStatus::SHIPPED])) { + throw new BusinessException('Can only create delivery notes for confirmed or partially shipped orders.'); + } + + Log::info('Creating delivery note from sales order', [ + 'sales_order_id' => $salesOrder->id, + 'order_number' => $salesOrder->order_number, + ]); + + DB::beginTransaction(); + + try { + $companyId = Auth::user()->company_id; + $warehouse = Warehouse::findOrFail($data['warehouse_id']); + + $deliveryNote = DeliveryNote::create([ + 'company_id' => $companyId, + 'sales_order_id' => $salesOrder->id, + 'customer_id' => $salesOrder->customer_id, + 'warehouse_id' => $warehouse->id, + 'delivery_number' => $this->generateDeliveryNumber(), + 'delivery_date' => $data['delivery_date'] ?? now(), + 'status' => DeliveryNoteStatus::DRAFT->value, + 'shipping_address' => $data['shipping_address'] ?? $salesOrder->shipping_address, + 'carrier' => $data['carrier'] ?? null, + 'tracking_number' => $data['tracking_number'] ?? null, + 'notes' => $data['notes'] ?? null, + 'created_by' => Auth::id(), + ]); + + // Add items + if (!empty($data['items'])) { + $this->addItems($deliveryNote, $data['items'], $salesOrder); + } + + DB::commit(); + + Log::info('Delivery note created', [ + 'delivery_note_id' => $deliveryNote->id, + 'delivery_number' => $deliveryNote->delivery_number, + ]); + + return $deliveryNote->fresh(['salesOrder.customer', 'items.product', 'warehouse']); + + } catch (Exception $e) { + DB::rollBack(); + + Log::error('Failed to create delivery note', [ + 'error' => $e->getMessage(), + ]); + + throw $e; + } + } + + /** + * Add items to delivery note + */ + protected function addItems(DeliveryNote $deliveryNote, array $items, SalesOrder $salesOrder): void + { + foreach ($items as $itemData) { + $salesOrderItem = SalesOrderItem::with('product.primaryCategory') + ->where('sales_order_id', $salesOrder->id) + ->where('id', $itemData['sales_order_item_id']) + ->first(); + + if (!$salesOrderItem) { + throw new BusinessException( + "SalesOrderItem not found. Sales Order ID: {$salesOrder->id}, Item ID: {$itemData['sales_order_item_id']}" + ); + } + + $quantity = $itemData['quantity']; + + // Calculate total quantity already in delivery notes (including DRAFT ones) + // This prevents creating multiple delivery notes that exceed the ordered quantity + $query = DeliveryNoteItem::where('sales_order_item_id', $salesOrderItem->id); + + // Exclude current delivery note if updating + if ($deliveryNote->exists) { + $query->where('delivery_note_id', '!=', $deliveryNote->id); + } + + $totalInDeliveryNotes = $query->sum('quantity_shipped'); + + $remainingQty = $salesOrderItem->quantity_ordered - $totalInDeliveryNotes; + + // Get over-delivery tolerance using fallback logic + $tolerancePercentage = $this->getOverDeliveryTolerance($salesOrderItem); + + // Calculate maximum allowed quantity (ordered + tolerance) + $maxAllowedQty = $salesOrderItem->quantity_ordered * (1 + $tolerancePercentage / 100); + $maxAllowedQtyInDeliveryNotes = $maxAllowedQty - $totalInDeliveryNotes; + + // Check if quantity exceeds remaining (without tolerance) + if ($quantity > $remainingQty) { + // Check if it's within tolerance + if ($quantity > $maxAllowedQtyInDeliveryNotes) { + throw new BusinessException( + "Cannot create delivery note with {$quantity} units. " . + "Only {$remainingQty} units remaining (max allowed with tolerance: " . number_format($maxAllowedQtyInDeliveryNotes, 2) . "). " . + "Total ordered: {$salesOrderItem->quantity_ordered}, " . + "Tolerance: {$tolerancePercentage}%, " . + "Already in delivery notes: {$totalInDeliveryNotes}." + ); + } + + // Within tolerance, log a warning + Log::warning('Over-delivery within tolerance', [ + 'sales_order_item_id' => $salesOrderItem->id, + 'quantity_ordered' => $salesOrderItem->quantity_ordered, + 'quantity_requested' => $quantity, + 'tolerance_percentage' => $tolerancePercentage, + 'max_allowed' => $maxAllowedQty, + ]); + } + + DeliveryNoteItem::create([ + 'delivery_note_id' => $deliveryNote->id, + 'sales_order_item_id' => $salesOrderItem->id, + 'product_id' => $salesOrderItem->product_id, + 'quantity_shipped' => $quantity, + 'lot_number' => $itemData['lot_number'] ?? null, + 'serial_number' => $itemData['serial_number'] ?? null, + 'notes' => $itemData['notes'] ?? null, + ]); + } + } + + /** + * Update delivery note + */ + public function update(DeliveryNote $deliveryNote, array $data): DeliveryNote + { + if (!$deliveryNote->canBeEdited()) { + throw new BusinessException('Delivery note cannot be edited in current status.'); + } + + Log::info('Updating delivery note', [ + 'delivery_note_id' => $deliveryNote->id, + 'changes' => array_keys($data), + ]); + + $deliveryNote->update([ + 'delivery_date' => $data['delivery_date'] ?? $deliveryNote->delivery_date, + 'shipping_address' => $data['shipping_address'] ?? $deliveryNote->shipping_address, + 'carrier' => $data['carrier'] ?? $deliveryNote->carrier, + 'tracking_number' => $data['tracking_number'] ?? $deliveryNote->tracking_number, + 'notes' => $data['notes'] ?? $deliveryNote->notes, + ]); + + return $deliveryNote->fresh(); + } + + /** + * Confirm delivery note (ready for shipping) + */ + public function confirm(DeliveryNote $deliveryNote): DeliveryNote + { + $currentStatus = $deliveryNote->status; + + if (!$currentStatus->canTransitionTo(DeliveryNoteStatus::CONFIRMED)) { + throw new BusinessException("Cannot confirm delivery note from {$currentStatus->label()} status."); + } + + if ($deliveryNote->items()->count() === 0) { + throw new BusinessException('Cannot confirm delivery note without items.'); + } + + Log::info('Confirming delivery note', [ + 'delivery_note_id' => $deliveryNote->id, + 'delivery_number' => $deliveryNote->delivery_number, + ]); + + $deliveryNote->update([ + 'status' => DeliveryNoteStatus::CONFIRMED->value, + ]); + + return $deliveryNote->fresh(); + } + + /** + * Ship delivery note - deduct stock and update sales order + */ + public function ship(DeliveryNote $deliveryNote): DeliveryNote + { + $currentStatus = $deliveryNote->status; + + if (!$currentStatus->canTransitionTo(DeliveryNoteStatus::SHIPPED)) { + throw new BusinessException("Cannot ship delivery note from {$currentStatus->label()} status."); + } + + Log::info('Shipping delivery note', [ + 'delivery_note_id' => $deliveryNote->id, + 'delivery_number' => $deliveryNote->delivery_number, + ]); + + DB::beginTransaction(); + + try { + // Deduct stock for each item + foreach ($deliveryNote->items as $item) { + // Release reservation first (physical stock is being issued) + try { + $this->stockService->releaseReservation( + $item->product_id, + $deliveryNote->warehouse_id, + $item->quantity_shipped, + $item->lot_number + ); + } catch (BusinessException $e) { + // If reservation doesn't exist or is less, log warning but continue + Log::warning('Could not release reservation for delivery note item', [ + 'delivery_note_id' => $deliveryNote->id, + 'item_id' => $item->id, + 'product_id' => $item->product_id, + 'error' => $e->getMessage(), + ]); + } + + $this->stockService->issueStock([ + 'product_id' => $item->product_id, + 'warehouse_id' => $deliveryNote->warehouse_id, + 'quantity' => $item->quantity_shipped, + 'operation_type' => 'sale', + 'transaction_type' => 'sales_order', + 'reference_type' => DeliveryNote::class, + 'reference_id' => $deliveryNote->id, + 'reference_number' => $deliveryNote->delivery_number, + 'lot_number' => $item->lot_number, + 'notes' => "Delivery Note: {$deliveryNote->delivery_number}", + ]); + + // Update sales order item shipped quantity + $item->salesOrderItem->increment('quantity_shipped', $item->quantity_shipped); + } + + $deliveryNote->update([ + 'status' => DeliveryNoteStatus::SHIPPED->value, + 'shipped_at' => now(), + ]); + + // Update sales order status if needed + $this->updateSalesOrderStatus($deliveryNote->salesOrder); + + DB::commit(); + + Log::info('Delivery note shipped', [ + 'delivery_note_id' => $deliveryNote->id, + 'delivery_number' => $deliveryNote->delivery_number, + ]); + + return $deliveryNote->fresh(); + + } catch (Exception $e) { + DB::rollBack(); + + Log::error('Failed to ship delivery note', [ + 'delivery_note_id' => $deliveryNote->id, + 'error' => $e->getMessage(), + ]); + + throw $e; + } + } + + /** + * Mark delivery note as delivered + */ + public function markAsDelivered(DeliveryNote $deliveryNote): DeliveryNote + { + $currentStatus = $deliveryNote->status; + + if (!$currentStatus->canTransitionTo(DeliveryNoteStatus::DELIVERED)) { + throw new BusinessException("Cannot mark as delivered from {$currentStatus->label()} status."); + } + + Log::info('Marking delivery note as delivered', [ + 'delivery_note_id' => $deliveryNote->id, + 'delivery_number' => $deliveryNote->delivery_number, + ]); + + $deliveryNote->update([ + 'status' => DeliveryNoteStatus::DELIVERED->value, + 'delivered_at' => now(), + ]); + + // Update sales order status + $this->updateSalesOrderStatus($deliveryNote->salesOrder); + + return $deliveryNote->fresh(); + } + + /** + * Update sales order status based on delivery notes + */ + protected function updateSalesOrderStatus(SalesOrder $salesOrder): void + { + $salesOrder->refresh(); + + $totalOrdered = $salesOrder->items()->sum('quantity_ordered'); + $totalShipped = $salesOrder->items()->sum('quantity_shipped'); + + if ($totalShipped >= $totalOrdered) { + // All items shipped + $allDelivered = $salesOrder->deliveryNotes() + ->where('status', '!=', DeliveryNoteStatus::DELIVERED->value) + ->doesntExist(); + + if ($allDelivered && $salesOrder->status->canTransitionTo(SalesOrderStatus::DELIVERED)) { + $salesOrder->update(['status' => SalesOrderStatus::DELIVERED->value]); + } elseif ($salesOrder->status->canTransitionTo(SalesOrderStatus::SHIPPED)) { + $salesOrder->update(['status' => SalesOrderStatus::SHIPPED->value]); + } + } + } + + /** + * Cancel delivery note + */ + public function cancel(DeliveryNote $deliveryNote, ?string $reason = null): DeliveryNote + { + if ($deliveryNote->status === DeliveryNoteStatus::SHIPPED) { + throw new BusinessException('Cannot cancel shipped delivery note. Stock has already been deducted.'); + } + + if ($deliveryNote->status === DeliveryNoteStatus::DELIVERED) { + throw new BusinessException('Cannot cancel delivered delivery note.'); + } + + Log::info('Cancelling delivery note', [ + 'delivery_note_id' => $deliveryNote->id, + 'delivery_number' => $deliveryNote->delivery_number, + 'reason' => $reason, + ]); + + $deliveryNote->update([ + 'notes' => $reason + ? $deliveryNote->notes . "\nCancellation reason: " . $reason + : $deliveryNote->notes, + ]); + + $deliveryNote->delete(); + + return $deliveryNote; + } + + /** + * Delete delivery note (only draft) + */ + public function delete(DeliveryNote $deliveryNote): bool + { + if ($deliveryNote->status !== DeliveryNoteStatus::DRAFT) { + throw new BusinessException('Only draft delivery notes can be deleted.'); + } + + Log::info('Deleting delivery note', [ + 'delivery_note_id' => $deliveryNote->id, + 'delivery_number' => $deliveryNote->delivery_number, + ]); + + $deliveryNote->items()->delete(); + return $deliveryNote->delete(); + } + + /** + * Generate delivery number + */ + public function generateDeliveryNumber(): string + { + $companyId = Auth::user()->company_id; + $year = now()->format('Y'); + $companyIdPadded = str_pad($companyId, 3, '0', STR_PAD_LEFT); + $prefix = "DN-{$year}-{$companyIdPadded}-"; + + $lastNote = DeliveryNote::withTrashed() + ->where('company_id', $companyId) + ->where('delivery_number', 'like', "{$prefix}%") + ->orderByRaw("CAST(SUBSTRING(delivery_number FROM '[0-9]+$') AS INTEGER) DESC") + ->first(); + + if ($lastNote && preg_match('/(\d+)$/', $lastNote->delivery_number, $matches)) { + $nextNumber = (int) $matches[1] + 1; + } else { + $nextNumber = 1; + } + + return $prefix . str_pad($nextNumber, 5, '0', STR_PAD_LEFT); + } + + /** + * Get available statuses for dropdown + */ + public function getStatuses(): array + { + return array_map(fn($status) => [ + 'value' => $status->value, + 'label' => $status->label(), + 'color' => $status->color(), + ], DeliveryNoteStatus::cases()); + } + + /** + * Get over-delivery tolerance percentage using fallback logic + * + * Priority order (most specific to least specific): + * 1. SalesOrderItem.over_delivery_tolerance_percentage + * 2. Product.over_delivery_tolerance_percentage + * 3. Category.over_delivery_tolerance_percentage (primary category) + * 4. Company default (settings.delivery.default_over_delivery_tolerance.{companyId}) + * + * Note: System-level tolerance removed as this is a SaaS application where each company + * manages its own tolerance settings. Company-level is the final fallback. + * + * @param SalesOrderItem $salesOrderItem + * @return float Tolerance percentage (e.g., 5.0 for 5%) + */ + protected function getOverDeliveryTolerance(SalesOrderItem $salesOrderItem): float + { + // 1. Check SalesOrderItem level (most specific) + if ($salesOrderItem->over_delivery_tolerance_percentage !== null) { + return (float) $salesOrderItem->over_delivery_tolerance_percentage; + } + + // 2. Check Product level + $product = $salesOrderItem->product; + if ($product && $product->over_delivery_tolerance_percentage !== null) { + return (float) $product->over_delivery_tolerance_percentage; + } + + // 3. Check Category level (primary category) + if ($product) { + $primaryCategory = $product->primaryCategory; + if ($primaryCategory && $primaryCategory->over_delivery_tolerance_percentage !== null) { + return (float) $primaryCategory->over_delivery_tolerance_percentage; + } + } + + // 4. Company default (company-specific, final fallback) + $companyId = Auth::user()->company_id; + $companyKey = "delivery.default_over_delivery_tolerance.{$companyId}"; + $companyDefault = Setting::get($companyKey, 0); + + $tolerance = is_array($companyDefault) ? (float) ($companyDefault[0] ?? 0) : (float) $companyDefault; + return $tolerance; + } +} diff --git a/backend/app/Services/GoodsReceivedNoteService.php b/backend/app/Services/GoodsReceivedNoteService.php index 08c1e34..749a7c0 100644 --- a/backend/app/Services/GoodsReceivedNoteService.php +++ b/backend/app/Services/GoodsReceivedNoteService.php @@ -2,6 +2,8 @@ namespace App\Services; +use App\Enums\GrnStatus; +use App\Enums\PoStatus; use App\Exceptions\BusinessException; use App\Models\GoodsReceivedNote; use App\Models\GoodsReceivedNoteItem; @@ -9,10 +11,13 @@ use App\Models\PurchaseOrderItem; use App\Models\Stock; use App\Models\StockMovement; +use App\Models\Product; +use App\Models\Category; +use App\Models\Setting; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Exception; @@ -123,7 +128,7 @@ public function create(array $data): GoodsReceivedNote 'delivery_note_date' => $data['delivery_note_date'] ?? null, 'invoice_number' => $data['invoice_number'] ?? null, 'invoice_date' => $data['invoice_date'] ?? null, - 'status' => GoodsReceivedNote::STATUS_DRAFT, + 'status' => GrnStatus::DRAFT->value, 'requires_inspection' => $data['requires_inspection'] ?? false, 'notes' => $data['notes'] ?? null, 'meta_data' => $data['meta_data'] ?? null, @@ -220,12 +225,58 @@ public function addItems(GoodsReceivedNote $grn, array $items): void foreach ($items as $item) { $lineNumber++; - $poItem = PurchaseOrderItem::find($item['purchase_order_item_id']); + $poItem = PurchaseOrderItem::with('product.primaryCategory') + ->find($item['purchase_order_item_id']); + + if (!$poItem) { + throw new BusinessException( + "PurchaseOrderItem not found. Item ID: {$item['purchase_order_item_id']}" + ); + } - // Validate quantity doesn't exceed remaining - $remainingQty = $poItem->remaining_quantity ?? $poItem->quantity_ordered; - if ($item['quantity_received'] > $remainingQty) { - throw new BusinessException("Quantity received ({$item['quantity_received']}) exceeds remaining quantity ({$remainingQty}) for item {$poItem->product->name}."); + $quantity = $item['quantity_received']; + + // Calculate total quantity already in GRNs (including DRAFT ones) + // This prevents creating multiple GRNs that exceed the ordered quantity + $query = GoodsReceivedNoteItem::where('purchase_order_item_id', $poItem->id); + + // Exclude current GRN if updating + if ($grn->exists) { + $query->where('goods_received_note_id', '!=', $grn->id); + } + + $totalInGrns = $query->sum('quantity_received'); + + $remainingQty = $poItem->quantity_ordered - $totalInGrns; + + // Get over-delivery tolerance using fallback logic + $tolerancePercentage = $this->getOverDeliveryTolerance($poItem); + + // Calculate maximum allowed quantity (ordered + tolerance) + $maxAllowedQty = $poItem->quantity_ordered * (1 + $tolerancePercentage / 100); + $maxAllowedQtyInGrns = $maxAllowedQty - $totalInGrns; + + // Check if quantity exceeds remaining (without tolerance) + if ($quantity > $remainingQty) { + // Check if it's within tolerance + if ($quantity > $maxAllowedQtyInGrns) { + throw new BusinessException( + "Cannot create GRN with {$quantity} units. " . + "Only {$remainingQty} units remaining (max allowed with tolerance: " . number_format($maxAllowedQtyInGrns, 2) . "). " . + "Total ordered: {$poItem->quantity_ordered}, " . + "Tolerance: {$tolerancePercentage}%, " . + "Already in GRNs: {$totalInGrns}." + ); + } + + // Within tolerance, log a warning + Log::warning('Over-delivery within tolerance for GRN', [ + 'purchase_order_item_id' => $poItem->id, + 'quantity_ordered' => $poItem->quantity_ordered, + 'quantity_requested' => $quantity, + 'tolerance_percentage' => $tolerancePercentage, + 'max_allowed' => $maxAllowedQty, + ]); } GoodsReceivedNoteItem::create([ @@ -266,7 +317,10 @@ public function syncItems(GoodsReceivedNote $grn, array $items): void */ public function submitForInspection(GoodsReceivedNote $grn): GoodsReceivedNote { - if ($grn->status !== GoodsReceivedNote::STATUS_DRAFT) { + $currentStatus = $grn->status_enum; + $targetStatus = GrnStatus::PENDING_INSPECTION; + + if (!$currentStatus || !in_array($targetStatus, $currentStatus->allowedTransitions())) { throw new BusinessException('Only draft GRNs can be submitted for inspection.'); } @@ -280,7 +334,7 @@ public function submitForInspection(GoodsReceivedNote $grn): GoodsReceivedNote ]); $grn->update([ - 'status' => GoodsReceivedNote::STATUS_PENDING_INSPECTION, + 'status' => $targetStatus->value, ]); return $grn->fresh(); @@ -291,7 +345,10 @@ public function submitForInspection(GoodsReceivedNote $grn): GoodsReceivedNote */ public function recordInspection(GoodsReceivedNote $grn, array $inspectionData): GoodsReceivedNote { - if ($grn->status !== GoodsReceivedNote::STATUS_PENDING_INSPECTION) { + $currentStatus = $grn->status_enum; + $targetStatus = GrnStatus::INSPECTED; + + if (!$currentStatus || !in_array($targetStatus, $currentStatus->allowedTransitions())) { throw new BusinessException('GRN is not pending inspection.'); } @@ -318,7 +375,7 @@ public function recordInspection(GoodsReceivedNote $grn, array $inspectionData): // Update GRN status $grn->update([ - 'status' => GoodsReceivedNote::STATUS_INSPECTED, + 'status' => $targetStatus->value, 'inspected_by' => Auth::id(), 'inspected_at' => now(), 'inspection_notes' => $inspectionData['inspection_notes'] ?? null, @@ -417,7 +474,7 @@ public function complete(GoodsReceivedNote $grn): GoodsReceivedNote // Update GRN status $grn->update([ - 'status' => GoodsReceivedNote::STATUS_COMPLETED, + 'status' => GrnStatus::COMPLETED->value, ]); // Update PO status @@ -463,12 +520,12 @@ protected function updatePurchaseOrderStatus(PurchaseOrder $purchaseOrder): void if ($totalReceived >= $effectiveOrdered) { $purchaseOrder->update([ - 'status' => PurchaseOrder::STATUS_RECEIVED, + 'status' => PoStatus::RECEIVED->value, 'actual_delivery_date' => now(), ]); } else { $purchaseOrder->update([ - 'status' => PurchaseOrder::STATUS_PARTIALLY_RECEIVED, + 'status' => PoStatus::PARTIALLY_RECEIVED->value, ]); } } @@ -478,8 +535,11 @@ protected function updatePurchaseOrderStatus(PurchaseOrder $purchaseOrder): void */ public function cancel(GoodsReceivedNote $grn, ?string $reason = null): GoodsReceivedNote { - if ($grn->status === GoodsReceivedNote::STATUS_COMPLETED) { - throw new BusinessException('Completed GRNs cannot be cancelled.'); + $currentStatus = $grn->status_enum; + $targetStatus = GrnStatus::CANCELLED; + + if (!$currentStatus || !in_array($targetStatus, $currentStatus->allowedTransitions())) { + throw new BusinessException('GRN cannot be cancelled in current status.'); } Log::info('Cancelling GRN', [ @@ -489,7 +549,7 @@ public function cancel(GoodsReceivedNote $grn, ?string $reason = null): GoodsRec ]); $grn->update([ - 'status' => GoodsReceivedNote::STATUS_CANCELLED, + 'status' => $targetStatus->value, 'notes' => $reason ? "Cancelled: {$reason}\n" . $grn->notes : $grn->notes, ]); @@ -501,7 +561,7 @@ public function cancel(GoodsReceivedNote $grn, ?string $reason = null): GoodsRec */ public function delete(GoodsReceivedNote $grn): bool { - if (!in_array($grn->status, [GoodsReceivedNote::STATUS_DRAFT, GoodsReceivedNote::STATUS_CANCELLED])) { + if (!$grn->status_enum?->canDelete()) { throw new BusinessException('Only draft or cancelled GRNs can be deleted.'); } @@ -520,7 +580,8 @@ public function generateGrnNumber(): string { $companyId = Auth::user()->company_id; $year = now()->format('Y'); - $prefix = "GRN-{$year}-"; + $companyIdPadded = str_pad($companyId, 3, '0', STR_PAD_LEFT); + $prefix = "GRN-{$year}-{$companyIdPadded}-"; // Include soft-deleted records to avoid duplicate GRN numbers $lastGrn = GoodsReceivedNote::withTrashed() @@ -557,4 +618,49 @@ public function getPendingInspection(): Collection ->with(['purchaseOrder', 'supplier', 'items']) ->get(); } + + /** + * Get over-delivery tolerance percentage using fallback logic + * + * Priority order (most specific to least specific): + * 1. PurchaseOrderItem.over_delivery_tolerance_percentage + * 2. Product.over_delivery_tolerance_percentage + * 3. Category.over_delivery_tolerance_percentage (primary category) + * 4. Company default (settings.delivery.default_over_delivery_tolerance.{companyId}) + * + * Note: System-level tolerance removed as this is a SaaS application where each company + * manages its own tolerance settings. Company-level is the final fallback. + * + * @param PurchaseOrderItem $purchaseOrderItem + * @return float Tolerance percentage (e.g., 5.0 for 5%) + */ + protected function getOverDeliveryTolerance(PurchaseOrderItem $purchaseOrderItem): float + { + // 1. Check PurchaseOrderItem level (most specific) + if ($purchaseOrderItem->over_delivery_tolerance_percentage !== null) { + return (float) $purchaseOrderItem->over_delivery_tolerance_percentage; + } + + // 2. Check Product level + $product = $purchaseOrderItem->product; + if ($product && $product->over_delivery_tolerance_percentage !== null) { + return (float) $product->over_delivery_tolerance_percentage; + } + + // 3. Check Category level (primary category) + if ($product) { + $primaryCategory = $product->primaryCategory; + if ($primaryCategory && $primaryCategory->over_delivery_tolerance_percentage !== null) { + return (float) $primaryCategory->over_delivery_tolerance_percentage; + } + } + + // 4. Company default (company-specific, final fallback) + $companyId = Auth::user()->company_id; + $companyKey = "delivery.default_over_delivery_tolerance.{$companyId}"; + $companyDefault = Setting::get($companyKey, 0); + + $tolerance = is_array($companyDefault) ? (float) ($companyDefault[0] ?? 0) : (float) $companyDefault; + return $tolerance; + } } diff --git a/backend/app/Services/InvitationService.php b/backend/app/Services/InvitationService.php new file mode 100644 index 0000000..e7375d1 --- /dev/null +++ b/backend/app/Services/InvitationService.php @@ -0,0 +1,308 @@ +company_id; + + if (!$companyId) { + throw new BusinessException('User must belong to a company to send invitations.'); + } + + $email = $data['email']; + $roleIds = $data['role_ids'] ?? []; + $expirationDays = $data['expiration_days'] ?? self::DEFAULT_EXPIRATION_DAYS; + + // Check if user already exists (including soft deleted) + $existingUser = User::withTrashed()->where('email', $email)->first(); + + if ($existingUser) { + if ($existingUser->deleted_at === null) { + throw new BusinessException('A user with this email already exists.'); + } + + // User is soft deleted - check if they belong to the same company + if ($existingUser->company_id !== $companyId) { + throw new BusinessException('A user with this email was previously deactivated in another company.'); + } + + // User belongs to same company - we can restore them instead + throw new BusinessException('A user with this email was previously deactivated. Please restore the user instead of sending an invitation.'); + } + + // Check if there's a pending invitation for this email + $pendingInvitation = UserInvitation::where('email', $email) + ->where('company_id', $companyId) + ->valid() + ->first(); + + if ($pendingInvitation) { + throw new BusinessException('An invitation has already been sent to this email address.'); + } + + Log::info('Sending user invitation', [ + 'email' => $email, + 'company_id' => $companyId, + 'invited_by' => $inviter->id, + ]); + + DB::beginTransaction(); + + try { + // Cancel any expired invitations for this email + UserInvitation::where('email', $email) + ->where('company_id', $companyId) + ->expired() + ->delete(); + + $invitation = UserInvitation::create([ + 'company_id' => $companyId, + 'email' => $email, + 'token' => Str::random(64), + 'invited_by' => $inviter->id, + 'role_ids' => $roleIds, + 'expires_at' => now()->addDays($expirationDays), + ]); + + // Send invitation email + $this->sendInvitationEmail($invitation); + + DB::commit(); + + Log::info('Invitation sent successfully', [ + 'invitation_id' => $invitation->id, + 'email' => $email, + ]); + + return $invitation->load(['company', 'inviter']); + + } catch (Exception $e) { + DB::rollBack(); + + Log::error('Failed to send invitation', [ + 'email' => $email, + 'error' => $e->getMessage(), + ]); + + throw $e; + } + } + + /** + * Accept invitation and create user account + */ + public function acceptInvitation(string $token, array $data): User + { + $invitation = UserInvitation::where('token', $token)->first(); + + if (!$invitation) { + throw new BusinessException('Invalid invitation token.'); + } + + if ($invitation->isExpired()) { + throw new BusinessException('This invitation has expired. Please request a new invitation.'); + } + + if ($invitation->isAccepted()) { + throw new BusinessException('This invitation has already been accepted.'); + } + + // Validate email matches + if ($invitation->email !== $data['email']) { + throw new BusinessException('Email does not match the invitation.'); + } + + // Check if user already exists + $existingUser = User::where('email', $invitation->email)->first(); + if ($existingUser) { + throw new BusinessException('A user with this email already exists.'); + } + + Log::info('Accepting invitation', [ + 'invitation_id' => $invitation->id, + 'email' => $invitation->email, + ]); + + DB::beginTransaction(); + + try { + // Create user + $user = User::create([ + 'company_id' => $invitation->company_id, + 'first_name' => $data['first_name'], + 'last_name' => $data['last_name'], + 'email' => $invitation->email, + 'password' => Hash::make($data['password']), + 'is_active' => true, + ]); + + // Assign roles if provided + if (!empty($invitation->role_ids)) { + $user->roles()->attach($invitation->role_ids); + } + + // Mark invitation as accepted + $invitation->markAsAccepted(); + + DB::commit(); + + // Load relationships + $user->load('roles'); + + Log::info('Invitation accepted and user created', [ + 'user_id' => $user->id, + 'invitation_id' => $invitation->id, + ]); + + return $user; + + } catch (Exception $e) { + DB::rollBack(); + + Log::error('Failed to accept invitation', [ + 'invitation_id' => $invitation->id, + 'error' => $e->getMessage(), + ]); + + throw $e; + } + } + + /** + * Resend invitation + */ + public function resendInvitation(int $invitationId): UserInvitation + { + $invitation = UserInvitation::findOrFail($invitationId); + + // Check permissions (inviter or admin) + /** @var User|null $user */ + $user = Auth::user(); + if (!$user || !($user instanceof User)) { + throw new BusinessException('User must be authenticated to resend invitations.'); + } + + if ($invitation->invited_by !== $user->id && !$user->hasPermission('users.create')) { + throw new BusinessException('You do not have permission to resend this invitation.'); + } + + if ($invitation->isAccepted()) { + throw new BusinessException('Cannot resend an already accepted invitation.'); + } + + // Extend expiration if needed + if ($invitation->isExpired()) { + $invitation->update([ + 'expires_at' => now()->addDays(self::DEFAULT_EXPIRATION_DAYS), + ]); + } + + // Send email + $this->sendInvitationEmail($invitation); + + Log::info('Invitation resent', [ + 'invitation_id' => $invitation->id, + ]); + + return $invitation->fresh(['company', 'inviter']); + } + + /** + * Cancel invitation + */ + public function cancelInvitation(int $invitationId): bool + { + $invitation = UserInvitation::findOrFail($invitationId); + + // Check permissions + /** @var User|null $user */ + $user = Auth::user(); + if (!$user || !($user instanceof User)) { + throw new BusinessException('User must be authenticated to cancel invitations.'); + } + + if ($invitation->invited_by !== $user->id && !$user->hasPermission('users.delete')) { + throw new BusinessException('You do not have permission to cancel this invitation.'); + } + + if ($invitation->isAccepted()) { + throw new BusinessException('Cannot cancel an already accepted invitation.'); + } + + Log::info('Cancelling invitation', [ + 'invitation_id' => $invitation->id, + ]); + + return $invitation->delete(); + } + + /** + * Get invitation by token (public endpoint) + */ + public function getInvitationByToken(string $token): ?UserInvitation + { + $invitation = UserInvitation::where('token', $token) + ->with(['company', 'inviter']) + ->first(); + + if (!$invitation) { + return null; + } + + return $invitation; + } + + /** + * Send invitation email + */ + private function sendInvitationEmail(UserInvitation $invitation): void + { + try { + // Load relationships for email + $invitation->load(['company', 'inviter']); + + // Send email using Mailable + Mail::to($invitation->email) + ->send(new UserInvitationMail($invitation)); + + Log::info('Invitation email sent', [ + 'invitation_id' => $invitation->id, + 'email' => $invitation->email, + ]); + + } catch (Exception $e) { + Log::error('Failed to send invitation email', [ + 'invitation_id' => $invitation->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + // Don't throw - invitation is still created + // Email can be resent later + } + } +} diff --git a/backend/app/Services/ModuleService.php b/backend/app/Services/ModuleService.php index f099bd9..77a0cb4 100644 --- a/backend/app/Services/ModuleService.php +++ b/backend/app/Services/ModuleService.php @@ -60,7 +60,7 @@ public function isFeatureEnabled(string $module, string $feature): bool public function getEnabledModules(): array { $modules = []; - $allModules = ['core', 'procurement', 'manufacturing']; + $allModules = ['core', 'procurement', 'manufacturing', 'sales', 'qc']; foreach ($allModules as $module) { if ($this->isModuleEnabled($module)) { @@ -150,11 +150,18 @@ public function getModuleStatus(): array */ public function clearCache(): void { - $modules = ['core', 'procurement', 'manufacturing']; + $modules = ['core', 'procurement', 'manufacturing', 'sales', 'qc']; $features = [ - 'stock_tracking', 'multi_warehouse', 'lot_tracking', 'serial_tracking', - 'stock_reservations', 'suppliers', 'purchase_orders', 'receiving', - 'quality_control', 'bom', 'work_orders', 'production', + // Core features + 'stock_tracking', 'multi_warehouse', 'lot_tracking', 'serial_tracking', 'stock_reservations', + // Procurement features + 'suppliers', 'purchase_orders', 'receiving', + // Manufacturing features + 'bom', 'work_orders', 'production', + // Sales features + 'customer_groups', 'sales_orders', 'delivery_notes', + // QC features + 'acceptance_rules', 'receiving_inspections', 'non_conformance_reports', 'supplier_quality', ]; foreach ($modules as $module) { @@ -167,11 +174,11 @@ public function clearCache(): void } /** - * Check if quality control is enabled for a module + * Check if quality control module is enabled */ - public function isQualityControlEnabled(string $module): bool + public function isQualityControlEnabled(): bool { - return $this->isFeatureEnabled($module, 'quality_control'); + return $this->isModuleEnabled('qc'); } /** @@ -181,7 +188,7 @@ public function isQualityControlEnabled(string $module): bool */ public function getAvailableModules(): array { - return ['core', 'procurement', 'manufacturing']; + return ['core', 'procurement', 'manufacturing', 'sales', 'qc']; } /** diff --git a/backend/app/Services/MrpCacheService.php b/backend/app/Services/MrpCacheService.php new file mode 100644 index 0000000..36639ea --- /dev/null +++ b/backend/app/Services/MrpCacheService.php @@ -0,0 +1,439 @@ + $bomId, + 'opt' => $includeOptional ? 1 : 0, + 'agg' => $aggregateByProduct ? 1 : 0, + 'tree' => $asTree ? 1 : 0, + ]; + $paramsHash = md5(json_encode($params)); + return self::CACHE_PREFIX . "explode:bom:{$bomId}:{$paramsHash}"; + } + + /** + * Get cache key for MRP run progress + */ + protected function getProgressCacheKey(int $runId): string + { + return self::CACHE_PREFIX . "progress:run:{$runId}"; + } + + /** + * Get cache key for distributed lock + */ + protected function getLockCacheKey(int $companyId): string + { + return self::CACHE_PREFIX . "lock:company:{$companyId}"; + } + + /** + * Get cached Low-Level Codes + */ + public function getCachedLowLevelCodes(int $companyId): ?array + { + $cacheKey = $this->getLowLevelCodeCacheKey($companyId); + $cached = Redis::get($cacheKey); + + return $cached ? json_decode($cached, true) : null; + } + + /** + * Cache Low-Level Codes + */ + public function cacheLowLevelCodes(int $companyId, array $codes): void + { + $cacheKey = $this->getLowLevelCodeCacheKey($companyId); + Redis::setex($cacheKey, self::CACHE_TTL, json_encode($codes)); + } + + /** + * Invalidate Low-Level Code cache + */ + public function invalidateLowLevelCodes(int $companyId): void + { + $cacheKey = $this->getLowLevelCodeCacheKey($companyId); + Redis::del($cacheKey); + } + + /** + * Get cached BOM structure + */ + public function getCachedBomStructure(int $bomId): ?array + { + $cacheKey = $this->getBomCacheKey($bomId); + $cached = Redis::get($cacheKey); + + return $cached ? json_decode($cached, true) : null; + } + + /** + * Cache BOM structure + */ + public function cacheBomStructure(int $bomId, array $structure): void + { + $cacheKey = $this->getBomCacheKey($bomId); + Redis::setex($cacheKey, self::CACHE_TTL, json_encode($structure)); + } + + /** + * Get cached BOM explosion result + */ + public function getCachedBomExplosion(int $productId, float $quantity): ?array + { + $cacheKey = $this->getBomExplosionCacheKey($productId, $quantity); + $cached = Redis::get($cacheKey); + + return $cached ? json_decode($cached, true) : null; + } + + /** + * Cache BOM explosion result + */ + public function cacheBomExplosion(int $productId, float $quantity, array $explosion): void + { + $cacheKey = $this->getBomExplosionCacheKey($productId, $quantity); + Redis::setex($cacheKey, self::CACHE_TTL, json_encode($explosion)); + } + + /** + * Get cached BOM explode result (BOM ID based, quantity-independent) + * Returns base structure (quantity=1), caller should scale quantities + */ + public function getCachedBomExplode(int $bomId, bool $includeOptional, bool $aggregateByProduct, bool $asTree): ?array + { + $cacheKey = $this->getBomExplodeCacheKey($bomId, $includeOptional, $aggregateByProduct, $asTree); + $cached = Redis::get($cacheKey); + + return $cached ? json_decode($cached, true) : null; + } + + /** + * Cache BOM explode result (BOM ID based, quantity-independent) + * Stores base structure (quantity=1) for linear scaling + */ + public function cacheBomExplode(int $bomId, bool $includeOptional, bool $aggregateByProduct, bool $asTree, array $explosion): void + { + $cacheKey = $this->getBomExplodeCacheKey($bomId, $includeOptional, $aggregateByProduct, $asTree); + Redis::setex($cacheKey, self::BOM_EXPLODE_TTL, json_encode($explosion)); + } + + /** + * Invalidate BOM explode cache for a specific BOM + */ + public function invalidateBomExplodeCache(int $bomId): void + { + $pattern = self::CACHE_PREFIX . "explode:bom:{$bomId}:*"; + $keys = Redis::keys($pattern); + + if (!empty($keys)) { + Redis::del($keys); + } + + // Also invalidate structure cache + $structureKey = self::CACHE_PREFIX . "bom_structure:{$bomId}"; + Redis::del($structureKey); + } + + /** + * Scale BOM explosion quantities by a factor + * Used when retrieving cached base structure (quantity=1) for different quantities + */ + public function scaleExplosionQuantities(array $explosion, float $scaleFactor): array + { + if (abs($scaleFactor - 1.0) < 0.0001) { + return $explosion; // No scaling needed + } + + return array_map(function ($item) use ($scaleFactor) { + if (isset($item['quantity'])) { + $item['quantity'] = round($item['quantity'] * $scaleFactor, 4); + } + + // Recursively scale children if tree structure + if (isset($item['children']) && is_array($item['children'])) { + $item['children'] = $this->scaleExplosionQuantities($item['children'], $scaleFactor); + } + + return $item; + }, $explosion); + } + + /** + * Update MRP run progress + */ + public function updateProgress(int $runId, int $processed, int $total, ?string $currentProduct = null): void + { + $cacheKey = $this->getProgressCacheKey($runId); + $progress = [ + 'processed' => $processed, + 'total' => $total, + 'percentage' => $total > 0 ? round(($processed / $total) * 100, 2) : 0, + 'current_product' => $currentProduct, + 'updated_at' => now()->toIso8601String(), + ]; + + Redis::setex($cacheKey, self::LOCK_TTL, json_encode($progress)); + } + + /** + * Get MRP run progress + */ + public function getProgress(int $runId): ?array + { + $cacheKey = $this->getProgressCacheKey($runId); + $cached = Redis::get($cacheKey); + + return $cached ? json_decode($cached, true) : null; + } + + /** + * Clear progress cache + */ + public function clearProgress(int $runId): void + { + $cacheKey = $this->getProgressCacheKey($runId); + Redis::del($cacheKey); + } + + /** + * Acquire distributed lock for MRP run + */ + public function acquireLock(int $companyId, int $runId, int $ttl = null): bool + { + $lockKey = $this->getLockCacheKey($companyId); + $lockValue = "run:{$runId}:" . uniqid(); + $ttl = $ttl ?? self::LOCK_TTL; + + // Try to set lock with expiration + $result = Redis::set($lockKey, $lockValue, 'EX', $ttl, 'NX'); + + return $result === true; + } + + /** + * Release distributed lock + */ + public function releaseLock(int $companyId, int $runId): void + { + $lockKey = $this->getLockCacheKey($companyId); + $lockValue = Redis::get($lockKey); + + // Only release if we own the lock + if ($lockValue && str_contains($lockValue, "run:{$runId}")) { + Redis::del($lockKey); + } + } + + /** + * Check if lock exists + */ + public function hasLock(int $companyId): bool + { + $lockKey = $this->getLockCacheKey($companyId); + return Redis::exists($lockKey) > 0; + } + + /** + * Get lock information + */ + public function getLockInfo(int $companyId): ?array + { + $lockKey = $this->getLockCacheKey($companyId); + $lockValue = Redis::get($lockKey); + $ttl = Redis::ttl($lockKey); + + if (!$lockValue) { + return null; + } + + return [ + 'value' => $lockValue, + 'ttl' => $ttl, + ]; + } + + /** + * Invalidate all MRP caches for a company + */ + public function invalidateCompanyCache(int $companyId): void + { + $pattern = self::CACHE_PREFIX . "*:company:{$companyId}*"; + $keys = Redis::keys($pattern); + + if (!empty($keys)) { + Redis::del($keys); + } + } + + /** + * Get chunk size for processing + */ + public function getChunkSize(): int + { + return self::CHUNK_SIZE; + } + + /** + * Store pre-loaded data in Redis (for large datasets) + */ + public function storePreloadedData(int $runId, string $type, array $data): void + { + $cacheKey = self::CACHE_PREFIX . "preload:run:{$runId}:{$type}"; + + // Use Redis hash for better memory efficiency + foreach ($data as $key => $value) { + Redis::hset($cacheKey, $key, json_encode($value)); + } + + Redis::expire($cacheKey, self::LOCK_TTL); + } + + /** + * Get pre-loaded data from Redis + */ + public function getPreloadedData(int $runId, string $type, string $key): ?array + { + $cacheKey = self::CACHE_PREFIX . "preload:run:{$runId}:{$type}"; + $cached = Redis::hget($cacheKey, $key); + + return $cached ? json_decode($cached, true) : null; + } + + /** + * Clear pre-loaded data + */ + public function clearPreloadedData(int $runId): void + { + $pattern = self::CACHE_PREFIX . "preload:run:{$runId}:*"; + $keys = Redis::keys($pattern); + + if (!empty($keys)) { + Redis::del($keys); + } + } + + /** + * Get cache key for dirty products set + */ + protected function getDirtyProductsCacheKey(int $companyId): string + { + return self::CACHE_PREFIX . "dirty:company:{$companyId}"; + } + + /** + * Mark a product as dirty (changed and needs MRP recalculation) + */ + public function markProductDirty(int $companyId, int $productId): void + { + $cacheKey = $this->getDirtyProductsCacheKey($companyId); + Redis::sadd($cacheKey, $productId); + Redis::expire($cacheKey, self::DIRTY_PRODUCTS_TTL); + } + + /** + * Mark multiple products as dirty + */ + public function markProductsDirty(int $companyId, array $productIds): void + { + if (empty($productIds)) { + return; + } + + $cacheKey = $this->getDirtyProductsCacheKey($companyId); + Redis::sadd($cacheKey, ...$productIds); + Redis::expire($cacheKey, self::DIRTY_PRODUCTS_TTL); + } + + /** + * Get all dirty products for a company + */ + public function getDirtyProducts(int $companyId): array + { + $cacheKey = $this->getDirtyProductsCacheKey($companyId); + $productIds = Redis::smembers($cacheKey); + + return array_map('intval', $productIds ?: []); + } + + /** + * Clear dirty products list (after MRP run) + */ + public function clearDirtyProducts(int $companyId): void + { + $cacheKey = $this->getDirtyProductsCacheKey($companyId); + Redis::del($cacheKey); + } + + /** + * Check if incremental MRP should be used + */ + public function shouldUseIncremental(int $companyId): bool + { + $dirtyCount = count($this->getDirtyProducts($companyId)); + + // Use incremental if less than 20% of products are dirty + $totalProducts = \App\Models\Product::where('company_id', $companyId) + ->where('is_active', true) + ->count(); + + if ($totalProducts === 0) { + return false; + } + + $dirtyPercentage = ($dirtyCount / $totalProducts) * 100; + return $dirtyPercentage < 20 && $dirtyCount > 0; + } +} diff --git a/backend/app/Services/MrpService.php b/backend/app/Services/MrpService.php new file mode 100644 index 0000000..f965ba9 --- /dev/null +++ b/backend/app/Services/MrpService.php @@ -0,0 +1,1991 @@ +latest(); + + if (!empty($filters['status'])) { + $query->status(MrpRunStatus::from($filters['status'])); + } + + if (!empty($filters['from_date'])) { + $query->whereDate('created_at', '>=', $filters['from_date']); + } + + if (!empty($filters['to_date'])) { + $query->whereDate('created_at', '<=', $filters['to_date']); + } + + return $query->paginate($perPage); + } + + /** + * Get a specific MRP run with recommendations + */ + public function getRun(MrpRun $run): MrpRun + { + $run = $run->load([ + 'creator:id,first_name,last_name', + 'recommendations.product:id,name,sku', + 'recommendations.warehouse:id,name,code', + ]); + + // Add progress information if run is in progress + if ($run->status === MrpRunStatus::RUNNING) { + $progress = $this->cacheService->getProgress($run->id); + if ($progress) { + $run->setAttribute('progress', $progress); + } + } + + return $run; + } + + /** + * Get MRP run progress + */ + public function getRunProgress(MrpRun $run): ?array + { + if ($run->status !== MrpRunStatus::RUNNING) { + return null; + } + + return $this->cacheService->getProgress($run->id); + } + + /** + * Invalidate MRP cache for a company + * Call this when BOMs or product structures change + */ + public function invalidateCache(?int $companyId = null): void + { + $companyId = $companyId ?? Auth::user()->company_id; + $this->cacheService->invalidateCompanyCache($companyId); + $this->cacheService->invalidateLowLevelCodes($companyId); + + Log::info('MRP cache invalidated', ['company_id' => $companyId]); + } + + /** + * Create and execute an MRP run + * + * @param array $params MRP run parameters + * @param bool $async Whether to run in background (queue) or synchronously + * @return MrpRun + */ + public function runMrp(array $params, bool $async = null): MrpRun + { + $this->companyId = Auth::user()->company_id; + $this->warnings = []; + $this->warningsSummary = []; + $this->recommendationsGenerated = 0; + + // Auto-detect if async should be used based on product count + if ($async === null) { + $async = $this->shouldUseAsync($params); + } + + Log::info('Starting MRP run', array_merge($params, ['async' => $async])); + + // Create run record first + $run = DB::transaction(function () use ($params, $async) { + // Create run record + $this->currentRun = MrpRun::create([ + 'company_id' => $this->companyId, + 'run_number' => MrpRun::generateRunNumber($this->companyId), + 'name' => $params['name'] ?? null, + 'planning_horizon_start' => $params['planning_horizon_start'] ?? today(), + 'planning_horizon_end' => $params['planning_horizon_end'] ?? today()->addDays(30), + 'include_safety_stock' => $params['include_safety_stock'] ?? true, + 'respect_lead_times' => $params['respect_lead_times'] ?? true, + 'consider_wip' => $params['consider_wip'] ?? true, + 'net_change' => $params['net_change'] ?? false, + 'product_filters' => $params['product_filters'] ?? null, + 'warehouse_filters' => $params['warehouse_filters'] ?? null, + 'status' => MrpRunStatus::PENDING, + 'created_by' => Auth::id(), + ]); + + try { + // Validate critical data before starting + $this->validateMrpRunData(); + + // Acquire distributed lock to prevent concurrent runs + if (!$this->cacheService->acquireLock($this->companyId, $this->currentRun->id)) { + throw new \Exception('Another MRP run is already in progress for this company.'); + } + + $this->currentRun->markAsRunning(); + + // Execute MRP calculation + $productsProcessed = $this->executeMrpCalculation(); + + // Log warnings if any + $totalWarnings = $this->getTotalWarningsCount(); + if ($totalWarnings > 0) { + Log::warning('MRP run completed with warnings', [ + 'run_id' => $this->currentRun->id, + 'warnings_summary' => $this->warningsSummary, + 'total_warnings' => $totalWarnings, + ]); + } + + $this->currentRun->markAsCompleted( + $productsProcessed, + $this->recommendationsGenerated, + $totalWarnings, + $this->getWarningsSummary() + ); + + // Clear progress cache + $this->cacheService->clearProgress($this->currentRun->id); + $this->cacheService->clearPreloadedData($this->currentRun->id); + + Log::info('MRP run completed', [ + 'run_id' => $this->currentRun->id, + 'products_processed' => $productsProcessed, + 'recommendations' => $this->recommendationsGenerated, + 'warnings_count' => $totalWarnings, + ]); + } catch (\Exception $e) { + Log::error('MRP run failed', [ + 'run_id' => $this->currentRun->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + 'warnings' => $this->warnings, + ]); + + // Mark as failed with detailed error message + $errorMessage = $e->getMessage(); + if (!empty($this->warnings)) { + $errorMessage .= "\n\nWarnings:\n" . implode("\n", $this->warnings); + } + + $this->currentRun->markAsFailed($errorMessage); + + // Clear caches + $this->cacheService->clearProgress($this->currentRun->id); + $this->cacheService->clearPreloadedData($this->currentRun->id); + + // Re-throw to trigger transaction rollback + throw $e; + } finally { + // Always release lock + $this->cacheService->releaseLock($this->companyId, $this->currentRun->id); + } + + return $this->currentRun->fresh(['recommendations']); + }); + + // If async, dispatch to queue and return immediately + if ($async) { + \App\Jobs\ProcessMrpRunJob::dispatch($run->id, $params) + ->onQueue('mrp'); // Use dedicated queue for MRP + + Log::info('MRP run dispatched to queue', [ + 'run_id' => $run->id, + ]); + + return $run; + } + + // Otherwise, execute synchronously (existing logic) + return $run; + } + + /** + * Determine if async processing should be used + */ + protected function shouldUseAsync(array $params): bool + { + // Check product count if filters are set + if (!empty($params['product_filters']['product_ids'])) { + $productCount = count($params['product_filters']['product_ids']); + return $productCount >= 1000; // Use queue for 1000+ products + } + + // Check total active products for company + $totalProducts = Product::where('company_id', $this->companyId) + ->where('is_active', true) + ->count(); + + // Use queue for large companies (5000+ products) + return $totalProducts >= 5000; + } + + /** + * Process an existing MRP run (used by queue job) + */ + public function processExistingRun(MrpRun $run, array $params): void + { + $this->companyId = $run->company_id; + $this->currentRun = $run; + $this->warnings = []; + $this->recommendationsGenerated = 0; + + try { + // Validate critical data before starting + $this->validateMrpRunData(); + + // Acquire distributed lock to prevent concurrent runs + if (!$this->cacheService->acquireLock($this->companyId, $this->currentRun->id)) { + throw new \Exception('Another MRP run is already in progress for this company.'); + } + + $this->currentRun->markAsRunning(); + + // Execute MRP calculation + $productsProcessed = $this->executeMrpCalculation(); + + // Log warnings if any + $totalWarnings = $this->getTotalWarningsCount(); + if ($totalWarnings > 0) { + Log::warning('MRP run completed with warnings', [ + 'run_id' => $this->currentRun->id, + 'warnings_summary' => $this->warningsSummary, + 'total_warnings' => $totalWarnings, + ]); + } + + $this->currentRun->markAsCompleted( + $productsProcessed, + $this->recommendationsGenerated, + $totalWarnings, + $this->getWarningsSummary() + ); + + // Clear progress cache + $this->cacheService->clearProgress($this->currentRun->id); + $this->cacheService->clearPreloadedData($this->currentRun->id); + + Log::info('MRP run completed', [ + 'run_id' => $this->currentRun->id, + 'products_processed' => $productsProcessed, + 'recommendations' => $this->recommendationsGenerated, + 'warnings_count' => $totalWarnings, + ]); + } catch (\Exception $e) { + Log::error('MRP run failed', [ + 'run_id' => $this->currentRun->id, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + 'warnings' => $this->warnings, + ]); + + // Mark as failed with detailed error message + $errorMessage = $e->getMessage(); + if (!empty($this->warnings)) { + $errorMessage .= "\n\nWarnings:\n" . implode("\n", $this->warnings); + } + + $this->currentRun->markAsFailed($errorMessage); + + // Clear caches + $this->cacheService->clearProgress($this->currentRun->id); + $this->cacheService->clearPreloadedData($this->currentRun->id); + + throw $e; + } finally { + // Always release lock + $this->cacheService->releaseLock($this->companyId, $this->currentRun->id); + } + } + + /** + * Cancel an MRP run + */ + public function cancelRun(MrpRun $run): MrpRun + { + if (!$run->status->canCancel()) { + throw new \Exception('This MRP run cannot be cancelled.'); + } + + $run->markAsCancelled(); + + return $run->fresh(); + } + + // ========================================= + // MRP Calculation Engine + // ========================================= + + /** + * Validate critical data before MRP run + */ + protected function validateMrpRunData(): void + { + $errors = []; + + // Validate planning horizon + if ($this->currentRun->planning_horizon_start >= $this->currentRun->planning_horizon_end) { + $errors[] = 'Planning horizon start date must be before end date.'; + } + + // Check if there are any active products + $activeProductCount = Product::where('company_id', $this->companyId) + ->where('is_active', true) + ->count(); + + if ($activeProductCount === 0) { + $errors[] = 'No active products found for MRP calculation.'; + } + + // Check if there are any BOMs for manufactured products + $manufacturedProducts = Product::where('company_id', $this->companyId) + ->where('is_active', true) + ->where('make_or_buy', 'make') + ->count(); + + if ($manufacturedProducts > 0) { + $productsWithoutBom = Product::where('company_id', $this->companyId) + ->where('is_active', true) + ->where('make_or_buy', 'make') + ->whereDoesntHave('boms', function ($q) { + $q->where('status', 'active'); + }) + ->count(); + + if ($productsWithoutBom > 0) { + $this->warningsSummary['products_without_bom'] = [ + 'type' => 'Products Without BOM', + 'count' => $productsWithoutBom, + 'message' => "{$productsWithoutBom} manufactured product(s) without active BOM found. These will be skipped.", + ]; + } + } + + if (!empty($errors)) { + throw new \Exception('MRP run validation failed: ' . implode(' ', $errors)); + } + } + + /** + * Execute the MRP calculation + * Optimized for large product lists with chunk processing and progress tracking + * Supports incremental MRP for changed products only + */ + protected function executeMrpCalculation(): int + { + // Step 1: Calculate Low-Level Codes for all products (with cache) + $this->calculateLowLevelCodes(); + + // Step 2: Check if incremental MRP should be used + $useIncremental = $this->currentRun->net_change + && $this->cacheService->shouldUseIncremental($this->companyId); + + if ($useIncremental) { + return $this->executeIncrementalMrp(); + } + + // Step 3: Full MRP calculation + return $this->executeFullMrp(); + } + + /** + * Execute incremental MRP (only changed products) + */ + protected function executeIncrementalMrp(): int + { + $dirtyProductIds = $this->cacheService->getDirtyProducts($this->companyId); + + if (empty($dirtyProductIds)) { + Log::info('Incremental MRP: No dirty products found, skipping', [ + 'run_id' => $this->currentRun->id, + ]); + return 0; + } + + Log::info('Incremental MRP: Processing dirty products', [ + 'run_id' => $this->currentRun->id, + 'dirty_count' => count($dirtyProductIds), + ]); + + // Get dirty products and their affected children (lower LLC) + $dirtyProducts = Product::whereIn('id', $dirtyProductIds) + ->where('company_id', $this->companyId) + ->where('is_active', true) + ->get(); + + // Find all products that depend on dirty products (lower LLC) + // Products that have BOMs containing dirty products as components + $maxDirtyLLC = $dirtyProducts->max('low_level_code') ?? 0; + + $affectedProductIds = Bom::where('company_id', $this->companyId) + ->where('status', 'active') + ->whereHas('items', function ($q) use ($dirtyProductIds) { + $q->whereIn('component_id', $dirtyProductIds); + }) + ->pluck('product_id') + ->unique() + ->toArray(); + + $affectedProducts = Product::whereIn('id', $affectedProductIds) + ->where('company_id', $this->companyId) + ->where('is_active', true) + ->where('low_level_code', '>', $maxDirtyLLC) + ->get(); + + // Combine dirty and affected products + $allProductIds = $dirtyProducts->pluck('id') + ->merge($affectedProducts->pluck('id')) + ->unique() + ->toArray(); + + $products = Product::whereIn('id', $allProductIds) + ->where('company_id', $this->companyId) + ->where('is_active', true) + ->orderBy('low_level_code', 'asc') + ->get(); + + $totalProducts = $products->count(); + + if ($totalProducts === 0) { + return 0; + } + + // Pre-load data + $this->preloadMrpData($products); + + // Process products + $productsProcessed = $this->processProductsWithMemoryManagement($products, $totalProducts); + + // Clear dirty products after processing + $this->cacheService->clearDirtyProducts($this->companyId); + + return $productsProcessed; + } + + /** + * Execute full MRP calculation + */ + protected function executeFullMrp(): int + { + // Step 1: Get products sorted by Low-Level Code (highest to lowest) + $products = $this->getProductsToProcess(); + $totalProducts = $products->count(); + + if ($totalProducts === 0) { + return 0; + } + + // Step 2: Pre-load all data to avoid N+1 queries + // For very large datasets, use Redis to store pre-loaded data + $this->preloadMrpData($products); + + // Step 4: Process products (with parallel chunk processing option) + $productsProcessed = $this->processProductsWithMemoryManagement($products, $totalProducts); + + // Final progress update + $this->cacheService->updateProgress( + $this->currentRun->id, + $productsProcessed, + $totalProducts, + null + ); + + return $productsProcessed; + } + + /** + * Process products with memory management and optional parallel processing + */ + protected function processProductsWithMemoryManagement(Collection $products, int $totalProducts): int + { + $chunkSize = $this->cacheService->getChunkSize(); + $productsProcessed = 0; + $dependentDemands = []; // Track dependent demands from parent products + + // Check if parallel processing should be used (for very large runs) + $useParallel = $totalProducts >= 2000 && config('queue.default') !== 'sync'; + + if ($useParallel) { + // Process chunks in parallel using queue jobs + return $this->processProductsInParallel($products, $chunkSize, $totalProducts); + } + + // Sequential processing with memory management + $productChunks = $products->chunk($chunkSize); + + foreach ($productChunks as $chunk) { + foreach ($chunk as $product) { + $this->processProduct($product, $dependentDemands); + $productsProcessed++; + + // Update progress every 10 products + if ($productsProcessed % 10 === 0) { + $this->cacheService->updateProgress( + $this->currentRun->id, + $productsProcessed, + $totalProducts, + $product->sku + ); + } + } + + // Aggressive memory cleanup after each chunk + unset($chunk); + if (function_exists('gc_collect_cycles')) { + gc_collect_cycles(); + } + + // Force garbage collection + if (function_exists('gc_mem_caches')) { + gc_mem_caches(); + } + } + + return $productsProcessed; + } + + /** + * Process products in parallel using queue jobs + */ + protected function processProductsInParallel(Collection $products, int $chunkSize, int $totalProducts): int + { + $chunks = $products->chunk($chunkSize); + $chunkCount = $chunks->count(); + $productIds = $products->pluck('id')->toArray(); + + Log::info('Processing MRP in parallel mode', [ + 'run_id' => $this->currentRun->id, + 'total_products' => $totalProducts, + 'chunk_count' => $chunkCount, + 'chunk_size' => $chunkSize, + ]); + + // Dispatch chunks to queue + $chunkIndex = 0; + foreach ($chunks as $chunk) { + $chunkProductIds = $chunk->pluck('id')->toArray(); + + \App\Jobs\ProcessMrpChunkJob::dispatch( + $this->currentRun->id, + $chunkProductIds, + $this->getMrpParams() + )->onQueue('mrp-chunks'); + + $chunkIndex++; + + // Update progress + $this->cacheService->updateProgress( + $this->currentRun->id, + 0, + $totalProducts, + "Dispatching chunk {$chunkIndex}/{$chunkCount}" + ); + } + + // For parallel processing, return estimated count + // Actual processing happens in background + return $totalProducts; + } + + /** + * Process a chunk of products (used by parallel jobs) + */ + public function processProductChunk(MrpRun $run, Collection $products, array $params): int + { + $this->companyId = $run->company_id; + $this->currentRun = $run; + $this->warnings = []; + $this->recommendationsGenerated = 0; + + // Pre-load data for this chunk + $this->preloadMrpData($products); + + $productsProcessed = 0; + $dependentDemands = []; + + foreach ($products as $product) { + $this->processProduct($product, $dependentDemands); + $productsProcessed++; + } + + // Memory cleanup + unset($products, $dependentDemands); + if (function_exists('gc_collect_cycles')) { + gc_collect_cycles(); + } + + return $productsProcessed; + } + + /** + * Get MRP parameters for job serialization + */ + protected function getMrpParams(): array + { + return [ + 'planning_horizon_start' => $this->currentRun->planning_horizon_start, + 'planning_horizon_end' => $this->currentRun->planning_horizon_end, + 'include_safety_stock' => $this->currentRun->include_safety_stock, + 'respect_lead_times' => $this->currentRun->respect_lead_times, + 'consider_wip' => $this->currentRun->consider_wip, + 'warehouse_filters' => $this->currentRun->warehouse_filters, + ]; + } + + /** + * Calculate Low-Level Codes for all products + * Low-Level Code determines the processing order in MRP: + * - Level 0: Finished goods (not used as components) + * - Level 1+: Components used in higher-level products + * + * Uses Redis cache for performance optimization + */ + protected function calculateLowLevelCodes(): void + { + // Check cache first (if BOMs haven't changed) + $cachedCodes = $this->cacheService->getCachedLowLevelCodes($this->companyId); + if ($cachedCodes !== null) { + // Apply cached codes + foreach ($cachedCodes as $productId => $level) { + Product::where('id', $productId) + ->where('company_id', $this->companyId) + ->update(['low_level_code' => $level]); + } + Log::info('Low-Level Codes loaded from cache', ['company_id' => $this->companyId]); + return; + } + + // Reset all low-level codes to 0 + Product::where('company_id', $this->companyId) + ->update(['low_level_code' => 0]); + + $changed = true; + $maxIterations = 100; // Prevent infinite loops + $iteration = 0; + $codesToUpdate = []; // Batch updates instead of individual saves + + while ($changed && $iteration < $maxIterations) { + $changed = false; + $iteration++; + + // Get all active BOMs (cache BOM structure if possible) + $boms = Bom::where('company_id', $this->companyId) + ->where('status', 'active') + ->with('items.component') + ->get(); + + foreach ($boms as $bom) { + $parentProduct = $bom->product; + if (!$parentProduct || !$parentProduct->is_active) { + continue; + } + + $parentLevel = $parentProduct->low_level_code ?? 0; + + // Check all components in this BOM + foreach ($bom->items as $item) { + $component = $item->component; + if (!$component || !$component->is_active) { + continue; + } + + // Component's level should be at least parent's level + 1 + $requiredLevel = $parentLevel + 1; + $currentLevel = $component->low_level_code ?? 0; + + if ($requiredLevel > $currentLevel) { + $codesToUpdate[$component->id] = $requiredLevel; + $component->low_level_code = $requiredLevel; + $changed = true; + } + } + } + + // Batch update products + if (!empty($codesToUpdate)) { + foreach ($codesToUpdate as $productId => $level) { + Product::where('id', $productId) + ->where('company_id', $this->companyId) + ->update(['low_level_code' => $level]); + } + $codesToUpdate = []; + } + } + + if ($iteration >= $maxIterations) { + $this->warningsSummary['llc_calculation_warning'] = [ + 'type' => 'Low-Level Code Calculation Warning', + 'count' => 1, + 'message' => 'Low-Level Code calculation reached maximum iterations. Possible circular BOM reference.', + ]; + Log::warning('Low-Level Code calculation reached max iterations', [ + 'company_id' => $this->companyId, + ]); + } + + // Cache the results + $finalCodes = Product::where('company_id', $this->companyId) + ->pluck('low_level_code', 'id') + ->toArray(); + $this->cacheService->cacheLowLevelCodes($this->companyId, $finalCodes); + } + + /** + * Get products to process based on filters, sorted by Low-Level Code + */ + protected function getProductsToProcess(): Collection + { + $query = Product::where('company_id', $this->companyId) + ->where('is_active', true) + ->orderBy('low_level_code', 'asc'); // Process from highest level (0) to lowest + + // Apply product filters if set + if (!empty($this->currentRun->product_filters)) { + $filters = $this->currentRun->product_filters; + + if (!empty($filters['product_ids'])) { + $query->whereIn('id', $filters['product_ids']); + } + + if (!empty($filters['category_ids'])) { + $query->whereHas('categories', function ($q) use ($filters) { + $q->whereIn('categories.id', $filters['category_ids']); + }); + } + + if (!empty($filters['make_or_buy'])) { + $query->where('make_or_buy', $filters['make_or_buy']); + } + } + + return $query->get(); + } + + /** + * Pre-load MRP data to avoid N+1 queries + */ + protected function preloadMrpData(Collection $products): void + { + $productIds = $products->pluck('id')->toArray(); + $startDate = $this->currentRun->planning_horizon_start; + $endDate = $this->currentRun->planning_horizon_end; + + // Initialize collections + $this->preloadedStocks = collect(); + $this->preloadedSalesDemands = collect(); + $this->preloadedPoReceipts = collect(); + $this->preloadedWoDemands = collect(); + $this->preloadedWoReceipts = collect(); + + if (empty($productIds)) { + return; + } + + // Pre-load stocks + $stockQuery = Stock::whereIn('product_id', $productIds) + ->qualityAvailable(); + + if (!empty($this->currentRun->warehouse_filters)) { + $filters = $this->currentRun->warehouse_filters; + + // Include specific warehouses + if (!empty($filters['include'])) { + $stockQuery->whereIn('warehouse_id', $filters['include']); + } + + // Exclude specific warehouses + if (!empty($filters['exclude'])) { + $stockQuery->whereNotIn('warehouse_id', $filters['exclude']); + } + } + + $this->preloadedStocks = $stockQuery->get()->groupBy('product_id'); + + // Pre-load sales order demands + // Include: approved, pending_approval, confirmed, processing, partially_shipped + // (approved and pending_approval are included because they represent committed demand) + $this->preloadedSalesDemands = DB::table('sales_order_items') + ->join('sales_orders', 'sales_orders.id', '=', 'sales_order_items.sales_order_id') + ->where('sales_orders.company_id', $this->companyId) + ->whereIn('sales_order_items.product_id', $productIds) + ->whereIn('sales_orders.status', ['approved', 'pending_approval', 'confirmed', 'processing', 'partially_shipped']) + ->where(function ($query) use ($startDate, $endDate) { + // Use requested_delivery_date if available, otherwise fall back to order_date + $query->whereBetween( + DB::raw('COALESCE(sales_orders.requested_delivery_date, sales_orders.order_date)'), + [$startDate, $endDate] + ); + }) + ->select([ + 'sales_order_items.product_id', + 'sales_orders.id as source_id', + DB::raw('COALESCE(sales_orders.requested_delivery_date, sales_orders.order_date) as required_date'), + DB::raw('(sales_order_items.quantity_ordered - COALESCE(sales_order_items.quantity_shipped, 0)) as quantity'), + ]) + ->get() + ->groupBy('product_id'); + + // Debug: Log how many sales order demands were found + $totalSalesDemands = $this->preloadedSalesDemands->sum(function ($group) { + return $group->count(); + }); + Log::info('MRP: Pre-loaded sales order demands', [ + 'run_id' => $this->currentRun->id, + 'total_demands' => $totalSalesDemands, + 'planning_horizon' => [$startDate, $endDate], + 'products_with_demands' => $this->preloadedSalesDemands->count(), + ]); + + // Pre-load purchase order receipts + $this->preloadedPoReceipts = DB::table('purchase_order_items') + ->join('purchase_orders', 'purchase_orders.id', '=', 'purchase_order_items.purchase_order_id') + ->where('purchase_orders.company_id', $this->companyId) + ->whereIn('purchase_order_items.product_id', $productIds) + ->whereIn('purchase_orders.status', ['approved', 'sent', 'partially_received']) + ->whereBetween('purchase_orders.expected_delivery_date', [$startDate, $endDate]) + ->select([ + 'purchase_order_items.product_id', + 'purchase_orders.id as source_id', + 'purchase_orders.expected_delivery_date as receipt_date', + DB::raw('(purchase_order_items.quantity_ordered - COALESCE(purchase_order_items.quantity_received, 0)) as quantity'), + ]) + ->get() + ->groupBy('product_id'); + + // Pre-load work order demands (if considering WIP) + if ($this->currentRun->consider_wip) { + $this->preloadedWoDemands = DB::table('work_order_materials') + ->join('work_orders', 'work_orders.id', '=', 'work_order_materials.work_order_id') + ->where('work_orders.company_id', $this->companyId) + ->whereIn('work_order_materials.product_id', $productIds) + ->whereIn('work_orders.status', ['released', 'in_progress']) + ->whereBetween('work_orders.planned_start_date', [$startDate, $endDate]) + ->select([ + 'work_order_materials.product_id', + 'work_orders.id as source_id', + 'work_orders.planned_start_date as required_date', + DB::raw('(work_order_materials.quantity_required - COALESCE(work_order_materials.quantity_issued, 0)) as quantity'), + ]) + ->get() + ->groupBy('product_id'); + + $this->preloadedWoReceipts = DB::table('work_orders') + ->where('company_id', $this->companyId) + ->whereIn('product_id', $productIds) + ->whereIn('status', ['released', 'in_progress']) + ->whereBetween('planned_end_date', [$startDate, $endDate]) + ->select([ + 'product_id', + 'id as source_id', + 'planned_end_date as receipt_date', + DB::raw('(quantity_ordered - quantity_completed - quantity_scrapped) as quantity'), + ]) + ->get() + ->groupBy('product_id'); + } + } + + /** + * Process a single product for MRP + * + * @param Product $product The product to process + * @param array $dependentDemands Reference to dependent demands array (passed by reference) + */ + protected function processProduct(Product $product, array &$dependentDemands): void + { + try { + // Get current stock + $currentStock = $this->getCurrentStock($product); + + // Get independent demand (sales orders, manual work orders) + $independentDemands = $this->getIndependentDemands($product); + + // Get dependent demand (from parent products that need this as component) + $dependentDemandForProduct = $dependentDemands[$product->id] ?? collect(); + + // Merge all demands + $allDemands = $independentDemands->merge($dependentDemandForProduct)->sortBy('required_date'); + + // Get scheduled receipts (open POs, WOs producing this product) + $scheduledReceipts = $this->getScheduledReceipts($product); + + // Debug logging for products with demands but no recommendations + if ($allDemands->isNotEmpty() && $currentStock > 0) { + Log::debug('MRP: Processing product with demands', [ + 'product_id' => $product->id, + 'product_sku' => $product->sku, + 'current_stock' => $currentStock, + 'independent_demands_count' => $independentDemands->count(), + 'dependent_demands_count' => $dependentDemandForProduct->count(), + 'total_demands_count' => $allDemands->count(), + 'scheduled_receipts_count' => $scheduledReceipts->count(), + 'safety_stock' => $product->safety_stock, + ]); + } + + // Calculate net requirements + $netRequirements = $this->calculateNetRequirements( + $product, + $currentStock, + $allDemands, + $scheduledReceipts + ); + + // Debug logging if no net requirements despite having demands + if ($allDemands->isNotEmpty() && empty($netRequirements)) { + Log::debug('MRP: No net requirements despite having demands', [ + 'product_id' => $product->id, + 'product_sku' => $product->sku, + 'make_or_buy' => $product->make_or_buy, + 'current_stock' => $currentStock, + 'safety_stock' => $product->safety_stock, + 'total_demands' => $allDemands->sum('quantity'), + 'include_safety_stock' => $this->currentRun->include_safety_stock, + 'demands_by_date' => $allDemands->groupBy(function ($d) { + return $d['required_date']->toDateString(); + })->map(function ($group) { + return $group->sum('quantity'); + }), + 'scheduled_receipts_count' => $scheduledReceipts->count(), + ]); + } + + // Generate recommendations + $this->generateRecommendations($product, $netRequirements); + + // If this product has a BOM and we're generating work orders, explode BOM to create dependent demands + if ($product->shouldManufacture() && !empty($netRequirements)) { + $this->explodeBomForDependentDemands($product, $netRequirements, $dependentDemands); + } + } catch (\Exception $e) { + // Group warnings by error type instead of storing individual messages + if (!isset($this->warningsSummary['product_processing_errors'])) { + $this->warningsSummary['product_processing_errors'] = [ + 'type' => 'Product Processing Errors', + 'count' => 0, + 'examples' => [], + ]; + } + $this->warningsSummary['product_processing_errors']['count']++; + if (count($this->warningsSummary['product_processing_errors']['examples']) < 3) { + $this->warningsSummary['product_processing_errors']['examples'][] = [ + 'product_sku' => $product->sku, + 'error' => $e->getMessage(), + ]; + } + + Log::error('Error processing product in MRP', [ + 'product_id' => $product->id, + 'product_sku' => $product->sku, + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + } + } + + /** + * Explode BOM to create dependent demands for components + */ + protected function explodeBomForDependentDemands(Product $product, array $netRequirements, array &$dependentDemands): void + { + $defaultBom = $product->defaultBom; + if (!$defaultBom) { + return; + } + + try { + // Group net requirements by date + $requirementsByDate = collect($netRequirements)->groupBy(function ($req) { + return $req['date']->toDateString(); + }); + + foreach ($requirementsByDate as $dateString => $requirements) { + $totalQtyNeeded = $requirements->sum('net_requirement'); + + if ($totalQtyNeeded <= 0) { + continue; + } + + // Check cache first for BOM explosion + $explodedMaterials = $this->cacheService->getCachedBomExplosion($product->id, $totalQtyNeeded); + + if ($explodedMaterials === null) { + // Explode BOM for this quantity + $explodedMaterials = $this->bomService->explodeBom($defaultBom, $totalQtyNeeded, 10, false); + + // Cache the result + $this->cacheService->cacheBomExplosion($product->id, $totalQtyNeeded, $explodedMaterials); + } + + // Create dependent demands for each component + foreach ($explodedMaterials as $material) { + $componentId = $material['product_id']; + $componentQty = $material['quantity']; + $requiredDate = Carbon::parse($dateString); + + // Adjust required date based on product's lead time (backward scheduling) + // Component is needed before parent product can be completed + $componentRequiredDate = $this->calculateWorkingDate( + $requiredDate->copy()->subDays($product->lead_time_days ?? 0) + ); + + if (!isset($dependentDemands[$componentId])) { + $dependentDemands[$componentId] = collect(); + } + + // Check if demand already exists for this date + $existingDemand = $dependentDemands[$componentId]->first(function ($demand) use ($componentRequiredDate) { + return $demand['required_date']->toDateString() === $componentRequiredDate->toDateString(); + }); + + if ($existingDemand) { + // Add to existing demand + $existingDemand['quantity'] += $componentQty; + } else { + // Create new dependent demand + $dependentDemands[$componentId]->push([ + 'source_type' => 'dependent_demand', + 'source_id' => $product->id, + 'source_sku' => $product->sku, + 'required_date' => $componentRequiredDate, + 'quantity' => $componentQty, + ]); + } + } + } + } catch (\Exception $e) { + // Group warnings by error type + if (!isset($this->warningsSummary['bom_explosion_errors'])) { + $this->warningsSummary['bom_explosion_errors'] = [ + 'type' => 'BOM Explosion Errors', + 'count' => 0, + 'examples' => [], + ]; + } + $this->warningsSummary['bom_explosion_errors']['count']++; + if (count($this->warningsSummary['bom_explosion_errors']['examples']) < 3) { + $this->warningsSummary['bom_explosion_errors']['examples'][] = [ + 'product_sku' => $product->sku, + 'error' => $e->getMessage(), + ]; + } + + Log::error('Error exploding BOM in MRP', [ + 'product_id' => $product->id, + 'bom_id' => $defaultBom->id, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * Get current stock for a product (using pre-loaded data) + */ + protected function getCurrentStock(Product $product): float + { + if (isset($this->preloadedStocks[$product->id])) { + return $this->preloadedStocks[$product->id]->sum('quantity_available'); + } + + // Fallback if not pre-loaded + $query = Stock::where('product_id', $product->id) + ->qualityAvailable(); + + if (!empty($this->currentRun->warehouse_filters)) { + $filters = $this->currentRun->warehouse_filters; + + // Include specific warehouses + if (!empty($filters['include'])) { + $query->whereIn('warehouse_id', $filters['include']); + } + + // Exclude specific warehouses + if (!empty($filters['exclude'])) { + $query->whereNotIn('warehouse_id', $filters['exclude']); + } + } + + return $query->sum('quantity_available'); + } + + /** + * Get independent demands for a product (sales orders, manual work orders) + * Uses pre-loaded data for performance + */ + protected function getIndependentDemands(Product $product): Collection + { + $demands = collect(); + + // Sales Order demands (pre-loaded) + if (isset($this->preloadedSalesDemands[$product->id])) { + $salesDemands = $this->preloadedSalesDemands[$product->id]->map(function ($item) { + return [ + 'source_type' => 'sales_order', + 'source_id' => $item->source_id, + 'required_date' => Carbon::parse($item->required_date), + 'quantity' => (float) $item->quantity, + ]; + }); + $demands = $demands->merge($salesDemands); + } + + // Work Order component demands (pre-loaded, if considering WIP) + if ($this->currentRun->consider_wip && isset($this->preloadedWoDemands[$product->id])) { + $woDemands = $this->preloadedWoDemands[$product->id]->map(function ($item) { + return [ + 'source_type' => 'work_order', + 'source_id' => $item->source_id, + 'required_date' => Carbon::parse($item->required_date), + 'quantity' => (float) $item->quantity, + ]; + }); + $demands = $demands->merge($woDemands); + } + + // Sort by required date + return $demands->sortBy('required_date'); + } + + /** + * Get scheduled receipts for a product (using pre-loaded data) + */ + protected function getScheduledReceipts(Product $product): Collection + { + $receipts = collect(); + + // Purchase Order receipts (pre-loaded) + if (isset($this->preloadedPoReceipts[$product->id])) { + $poReceipts = $this->preloadedPoReceipts[$product->id]->map(function ($item) { + return [ + 'source_type' => 'purchase_order', + 'source_id' => $item->source_id, + 'receipt_date' => Carbon::parse($item->receipt_date), + 'quantity' => (float) $item->quantity, + ]; + }); + $receipts = $receipts->merge($poReceipts); + } + + // Work Order receipts (pre-loaded, if considering WIP) + if ($this->currentRun->consider_wip && isset($this->preloadedWoReceipts[$product->id])) { + $woReceipts = $this->preloadedWoReceipts[$product->id]->map(function ($item) { + return [ + 'source_type' => 'work_order_output', + 'source_id' => $item->source_id, + 'receipt_date' => Carbon::parse($item->receipt_date), + 'quantity' => (float) $item->quantity, + ]; + }); + $receipts = $receipts->merge($woReceipts); + } + + return $receipts->sortBy('receipt_date'); + } + + /** + * Calculate net requirements using time-phased calculation + */ + protected function calculateNetRequirements( + Product $product, + float $currentStock, + Collection $demands, + Collection $scheduledReceipts + ): array { + $requirements = []; + $projectedStock = $currentStock; + $safetyStock = $this->currentRun->include_safety_stock ? ($product->safety_stock ?? 0) : 0; + + // If current stock is negative, this is a priority requirement + $negativeStock = min(0, $currentStock); + $hasNegativeStock = $negativeStock < 0; + $negativeStockImpact = $hasNegativeStock ? abs($negativeStock) : 0; + + // Group demands by date + $demandsByDate = $demands->groupBy(function ($demand) { + return $demand['required_date']->toDateString(); + }); + + // Group receipts by date + $receiptsByDate = $scheduledReceipts->groupBy(function ($receipt) { + return $receipt['receipt_date']->toDateString(); + }); + + // Get all dates in planning horizon + $startDate = $this->currentRun->planning_horizon_start; + $endDate = $this->currentRun->planning_horizon_end; + $currentDate = Carbon::parse($startDate); + + while ($currentDate <= $endDate) { + $dateString = $currentDate->toDateString(); + + // Add scheduled receipts for this date + if (isset($receiptsByDate[$dateString])) { + foreach ($receiptsByDate[$dateString] as $receipt) { + $projectedStock += $receipt['quantity']; + } + } + + // Subtract demands for this date + $dayDemands = $demandsByDate[$dateString] ?? collect(); + $totalDemand = $dayDemands->sum('quantity'); + + if ($totalDemand > 0) { + // If negative stock exists and this is the first demand, add negative stock impact + $adjustedDemand = $totalDemand; + if ($hasNegativeStock && $projectedStock < 0) { + $adjustedDemand += $negativeStockImpact; + $hasNegativeStock = false; // Only add to first demand + } + + $projectedStock -= $totalDemand; + + // Check if we fall below safety stock OR if projected stock becomes negative + // Net requirement should be created if: + // 1. Projected stock falls below safety stock, OR + // 2. Projected stock becomes negative (even if above safety stock) + if ($projectedStock < $safetyStock || $projectedStock < 0) { + // Calculate shortage: either to safety stock level or to zero (whichever is higher) + $shortage = max($safetyStock - $projectedStock, abs(min(0, $projectedStock))); + + $requirements[] = [ + 'date' => $currentDate->copy(), + 'gross_requirement' => $totalDemand, + 'net_requirement' => $shortage, + 'projected_stock' => $projectedStock, + 'negative_stock_impact' => $hasNegativeStock ? $negativeStockImpact : 0, + 'priority' => ($hasNegativeStock || $projectedStock < 0) ? 'high' : 'normal', + 'demands' => $dayDemands->toArray(), + ]; + } + } + + $currentDate->addDay(); + } + + return $requirements; + } + + /** + * Generate recommendations for net requirements + */ + protected function generateRecommendations(Product $product, array $netRequirements): void + { + $currentStock = $this->getCurrentStock($product); + $hasNegativeStock = $currentStock < 0; + foreach ($netRequirements as $requirement) { + $suggestedQty = $product->calculateOrderQuantity($requirement['net_requirement']); + + if ($suggestedQty <= 0) { + Log::debug('MRP: Skipping recommendation - suggested quantity <= 0', [ + 'product_id' => $product->id, + 'product_sku' => $product->sku, + 'net_requirement' => $requirement['net_requirement'], + 'suggested_qty' => $suggestedQty, + 'minimum_order_qty' => $product->minimum_order_qty, + 'order_multiple' => $product->order_multiple, + ]); + continue; + } + + // Determine recommendation type based on make_or_buy + // For Work Orders, we need to check if product has a BOM + if ($product->shouldManufacture()) { + // Check if product has an active BOM before creating Work Order recommendation + if (!$product->hasActiveBom()) { + Log::warning('MRP: Skipping Work Order recommendation - product has no active BOM', [ + 'product_id' => $product->id, + 'product_sku' => $product->sku, + 'recommendation_type' => 'work_order', + 'net_requirement' => $requirement['net_requirement'], + ]); + continue; // Skip this recommendation - cannot create WO without BOM + } + $type = MrpRecommendationType::WORK_ORDER; + } else { + $type = MrpRecommendationType::PURCHASE_ORDER; + } + + // Calculate suggested order date (considering lead time and working days) + $requiredDate = $requirement['date']; + $suggestedDate = $this->currentRun->respect_lead_times + ? $this->calculateOrderDate($product, $requiredDate) + : $requiredDate; + + // Determine priority based on urgency and negative stock + $priority = $this->determinePriority($suggestedDate); + $isUrgent = $suggestedDate <= today(); + $urgencyReason = null; + + // If negative stock exists, mark as high priority + if ($hasNegativeStock || ($requirement['negative_stock_impact'] ?? 0) > 0) { + $priority = MrpPriority::HIGH; + $urgencyReason = 'Negative stock status: ' . abs($currentStock) . ' units. Priority requirement.'; + } elseif ($isUrgent) { + $urgencyReason = 'Order date is today or in the past - immediate action required'; + } elseif ($suggestedDate <= today()->addDays(3)) { + $urgencyReason = 'Order date is within 3 days'; + } + + // Get demand source info + $demandSource = $requirement['demands'][0] ?? null; + + MrpRecommendation::create([ + 'company_id' => $this->companyId, + 'mrp_run_id' => $this->currentRun->id, + 'product_id' => $product->id, + 'warehouse_id' => null, // Could be enhanced to specify warehouse + 'recommendation_type' => $type, + 'required_date' => $requiredDate, + 'suggested_date' => $suggestedDate, + 'due_date' => $requiredDate, + 'gross_requirement' => $requirement['gross_requirement'], + 'net_requirement' => $requirement['net_requirement'], + 'suggested_quantity' => $suggestedQty, + 'current_stock' => $requirement['projected_stock'] + $requirement['net_requirement'], + 'projected_stock' => $requirement['projected_stock'] + $suggestedQty, + 'demand_source_type' => $demandSource['source_type'] ?? null, + 'demand_source_id' => $demandSource['source_id'] ?? null, + 'priority' => $priority, + 'is_urgent' => $isUrgent, + 'urgency_reason' => $urgencyReason, + 'status' => MrpRecommendationStatus::PENDING, + 'calculation_details' => [ + 'safety_stock' => $product->safety_stock, + 'lead_time_days' => $product->lead_time_days, + 'minimum_order_qty' => $product->minimum_order_qty, + 'order_multiple' => $product->order_multiple, + 'negative_stock_impact' => $requirement['negative_stock_impact'] ?? 0, + 'demands' => $requirement['demands'], + ], + ]); + + $this->recommendationsGenerated++; + } + } + + /** + * Calculate order date considering lead time and working days + * Uses working hours to calculate more accurately + */ + protected function calculateOrderDate(Product $product, Carbon $requiredDate): Carbon + { + $leadTimeDays = $product->lead_time_days ?? 0; + + if ($leadTimeDays <= 0) { + return $requiredDate->copy(); + } + + // Calculate working days backward from required date + // Note: This uses calendar days, but only counts working days + // For more precise calculation with hours, we could enhance this later + $orderDate = $requiredDate->copy(); + $workingDaysToSubtract = $leadTimeDays; + + while ($workingDaysToSubtract > 0) { + $orderDate->subDay(); + + // Check if this is a working day (considers company calendar overrides) + if ($this->isWorkingDay($orderDate)) { + $workingDaysToSubtract--; + } + } + + return $orderDate; + } + + /** + * Calculate next working date from a given date + * Skips weekends and holidays + */ + protected function calculateWorkingDate(Carbon $date): Carbon + { + $workingDate = $date->copy(); + + // Move forward until we find a working day + while (!$this->isWorkingDay($workingDate)) { + $workingDate->addDay(); + } + + return $workingDate; + } + + /** + * Check if a date is a working day + * Priority: + * 1. Company calendar override (specific date) + * 2. Standard working days from settings + */ + protected function isWorkingDay(Carbon $date): bool + { + // First, check company calendar for specific date override + $calendarEntry = CompanyCalendar::where('company_id', $this->companyId) + ->forDate($date->toDateString()) + ->first(); + + if ($calendarEntry) { + // Company calendar entry overrides standard rules + return $calendarEntry->isWorkingDay(); + } + + // No calendar override, check standard working days from settings + // Format: [1,2,3,4,5] where 0=Sunday, 1=Monday, ..., 6=Saturday + $workingDays = Setting::get('mrp.working_days', [1, 2, 3, 4, 5]); // Default: Mon-Fri + + // Ensure it's an array + if (!is_array($workingDays)) { + $workingDays = [1, 2, 3, 4, 5]; // Fallback to Mon-Fri + } + + // Get day of week (0=Sunday, 1=Monday, ..., 6=Saturday) + $dayOfWeek = (int) $date->dayOfWeek; + + // Check if this day is in the working days array + return in_array($dayOfWeek, $workingDays, true); + } + + /** + * Get working hours for a specific date + * Returns hours available for work on this date + */ + protected function getWorkingHours(Carbon $date): float + { + // First, check company calendar for specific date override + $calendarEntry = CompanyCalendar::where('company_id', $this->companyId) + ->forDate($date->toDateString()) + ->first(); + + if ($calendarEntry) { + $hours = $calendarEntry->getEffectiveWorkingHours(); + if ($hours !== null) { + return $hours; + } + } + + // No calendar override, use default shift from settings + $defaultShift = Setting::get('mrp.default_shift', [ + 'working_hours' => 8.0, + ]); + + return (float) ($defaultShift['working_hours'] ?? 8.0); + } + + /** + * Determine priority based on suggested date + */ + protected function determinePriority(Carbon $suggestedDate): MrpPriority + { + $daysUntil = today()->diffInDays($suggestedDate, false); + + if ($daysUntil < 0) { + return MrpPriority::CRITICAL; // Past due + } elseif ($daysUntil <= 3) { + return MrpPriority::HIGH; + } elseif ($daysUntil <= 7) { + return MrpPriority::MEDIUM; + } + + return MrpPriority::LOW; + } + + // ========================================= + // Recommendation Management + // ========================================= + + /** + * Get recommendations for an MRP run + */ + public function getRecommendations(MrpRun $run, array $filters = [], int $perPage = 25): LengthAwarePaginator + { + $query = $run->recommendations() + ->with(['product:id,name,sku', 'warehouse:id,name,code']); + + if (!empty($filters['status'])) { + $query->where('status', $filters['status']); + } + + if (!empty($filters['type'])) { + $query->where('recommendation_type', $filters['type']); + } + + if (!empty($filters['priority'])) { + $query->where('priority', $filters['priority']); + } + + if (!empty($filters['product_id'])) { + $query->where('product_id', $filters['product_id']); + } + + if (!empty($filters['urgent_only'])) { + $query->urgent(); + } + + return $query->byPriority() + ->orderBy('required_date') + ->paginate($perPage); + } + + /** + * Approve a recommendation + */ + public function approveRecommendation(MrpRecommendation $recommendation): MrpRecommendation + { + if (!$recommendation->status->canApprove()) { + throw new \Exception('Recommendation cannot be approved.'); + } + + DB::beginTransaction(); + + try { + // Update status to APPROVED first + $recommendation->update([ + 'status' => MrpRecommendationStatus::APPROVED, + ]); + + // Automatically create Purchase Order or Work Order based on recommendation type + $createdDocument = null; + if ($recommendation->recommendation_type === MrpRecommendationType::PURCHASE_ORDER) { + $createdDocument = $this->createPurchaseOrderFromRecommendation($recommendation); + } elseif ($recommendation->recommendation_type === MrpRecommendationType::WORK_ORDER) { + $createdDocument = $this->createWorkOrderFromRecommendation($recommendation); + } + + // Audit logging + $this->auditLogService->logEvent( + 'approved', + $recommendation, + "MRP recommendation approved: {$recommendation->recommendation_type->value} for product {$recommendation->product->name}", + [ + 'recommendation_type' => $recommendation->recommendation_type->value, + 'product_id' => $recommendation->product_id, + 'created_document' => $createdDocument ? [ + 'type' => $recommendation->recommendation_type->value, + 'id' => $createdDocument->id ?? null, + ] : null, + ] + ); + + DB::commit(); + + return $recommendation->fresh(); + } catch (\Exception $e) { + DB::rollBack(); + Log::error('Failed to approve MRP recommendation and create document', [ + 'recommendation_id' => $recommendation->id, + 'error' => $e->getMessage(), + ]); + throw $e; + } + } + + /** + * Create Purchase Order from MRP recommendation + */ + protected function createPurchaseOrderFromRecommendation(MrpRecommendation $recommendation): PurchaseOrder + { + $product = $recommendation->product; + $companyId = $recommendation->company_id; + + // Try to get supplier for the product (optional - can be assigned later) + $supplierService = app(\App\Services\SupplierService::class); + $supplier = $supplierService->getPreferredSupplier($product->id); + + if (!$supplier) { + // Try to get any active supplier for this product + $suppliers = $supplierService->getSuppliersForProduct($product->id); + $supplier = $suppliers->first(); + } + + // Supplier is optional at creation - will be required during approval + // If no supplier found, PO will be created without supplier and user can assign it later + + // Get warehouse (from recommendation or use default) + $warehouseId = $recommendation->warehouse_id; + if (!$warehouseId) { + $warehouse = \App\Models\Warehouse::where('company_id', $companyId) + ->where('is_default', true) + ->where('is_active', true) + ->first(); + + if (!$warehouse) { + $warehouse = \App\Models\Warehouse::where('company_id', $companyId) + ->where('is_active', true) + ->first(); + } + + if (!$warehouse) { + throw new \Exception('No warehouse found. Please create a warehouse first.'); + } + + $warehouseId = $warehouse->id; + } + + // Get supplier product info for pricing (if supplier exists) + $unitPrice = $product->cost_price ?? $product->price ?? 0; + if ($supplier) { + $supplierProduct = $supplier->products() + ->where('product_id', $product->id) + ->where('supplier_products.is_active', true) + ->first(); + + $unitPrice = $supplierProduct?->pivot?->unit_price ?? $unitPrice; + } + $uomId = $product->uom_id ?? 1; + + // Create Purchase Order + $purchaseOrderService = app(\App\Services\PurchaseOrderService::class); + $purchaseOrder = $purchaseOrderService->create([ + 'supplier_id' => $supplier?->id, // Optional - can be null + 'warehouse_id' => $warehouseId, + 'order_date' => $recommendation->suggested_date, + 'expected_delivery_date' => $recommendation->required_date, + 'status' => PoStatus::DRAFT->value, + 'notes' => "Auto-generated from MRP Recommendation #{$recommendation->id}", + 'internal_notes' => "MRP Run: {$recommendation->mrpRun->run_number}", + 'items' => [ + [ + 'product_id' => $product->id, + 'quantity_ordered' => $recommendation->suggested_quantity, + 'uom_id' => $uomId, + 'unit_price' => $unitPrice, + 'expected_delivery_date' => $recommendation->required_date, + ], + ], + ]); + + // Link recommendation to purchase order + $purchaseOrder->update([ + 'mrp_recommendation_id' => $recommendation->id, + ]); + + // Mark recommendation as actioned + $recommendation->markAsActioned( + \App\Models\PurchaseOrder::class, + $purchaseOrder->id, + "Purchase Order {$purchaseOrder->order_number} created automatically", + Auth::id() + ); + + Log::info('Purchase Order created from MRP recommendation', [ + 'recommendation_id' => $recommendation->id, + 'purchase_order_id' => $purchaseOrder->id, + 'order_number' => $purchaseOrder->order_number, + ]); + + return $purchaseOrder; + } + + /** + * Create Work Order from MRP recommendation + */ + protected function createWorkOrderFromRecommendation(MrpRecommendation $recommendation): WorkOrder + { + $product = $recommendation->product; + $companyId = $recommendation->company_id; + + // Get default BOM for the product + $bomService = app(\App\Services\BomService::class); + $bom = $bomService->getDefaultBomForProduct($product->id); + + if (!$bom) { + throw new \Exception("No active BOM found for product: {$product->sku}. Please create a BOM for this product."); + } + + // Get default routing for the product + $routingService = app(\App\Services\RoutingService::class); + $routing = $routingService->getDefaultRoutingForProduct($product->id); + + // Get warehouse (from recommendation or use default) + $warehouseId = $recommendation->warehouse_id; + if (!$warehouseId) { + $warehouse = \App\Models\Warehouse::where('company_id', $companyId) + ->where('is_default', true) + ->where('is_active', true) + ->first(); + + if (!$warehouse) { + $warehouse = \App\Models\Warehouse::where('company_id', $companyId) + ->where('is_active', true) + ->first(); + } + + if (!$warehouse) { + throw new \Exception('No warehouse found. Please create a warehouse first.'); + } + + $warehouseId = $warehouse->id; + } + + // Map MRP Priority to Work Order Priority + // MRP: low, medium, high, critical + // Work Order: low, normal, high, urgent + $mrpPriority = $recommendation->priority; + $workOrderPriority = match ($mrpPriority) { + \App\Enums\MrpPriority::CRITICAL => \App\Enums\WorkOrderPriority::URGENT, + \App\Enums\MrpPriority::HIGH => \App\Enums\WorkOrderPriority::HIGH, + \App\Enums\MrpPriority::MEDIUM => \App\Enums\WorkOrderPriority::NORMAL, + \App\Enums\MrpPriority::LOW => \App\Enums\WorkOrderPriority::LOW, + }; + + // Get UOM from product (required field) + $uomId = $product->uom_id ?? 1; // Fallback to 1 if not set + + // Create Work Order + $workOrderService = app(\App\Services\WorkOrderService::class); + $workOrder = $workOrderService->create([ + 'product_id' => $product->id, + 'bom_id' => $bom->id, + 'routing_id' => $routing?->id, + 'warehouse_id' => $warehouseId, + 'uom_id' => $uomId, + 'quantity_ordered' => $recommendation->suggested_quantity, + 'priority' => $workOrderPriority->value, + 'planned_start_date' => $recommendation->suggested_date, + 'planned_end_date' => $recommendation->required_date, + 'status' => WorkOrderStatus::DRAFT->value, + 'notes' => "Auto-generated from MRP Recommendation #{$recommendation->id}", + ]); + + // Link recommendation to work order + $workOrder->update([ + 'mrp_recommendation_id' => $recommendation->id, + ]); + + // Mark recommendation as actioned + $recommendation->markAsActioned( + \App\Models\WorkOrder::class, + $workOrder->id, + "Work Order {$workOrder->work_order_number} created automatically", + Auth::id() + ); + + Log::info('Work Order created from MRP recommendation', [ + 'recommendation_id' => $recommendation->id, + 'work_order_id' => $workOrder->id, + 'work_order_number' => $workOrder->work_order_number, + ]); + + return $workOrder; + } + + /** + * Reject a recommendation + */ + public function rejectRecommendation(MrpRecommendation $recommendation, ?string $notes = null): MrpRecommendation + { + if (!$recommendation->reject($notes, Auth::id())) { + throw new \Exception('Recommendation cannot be rejected.'); + } + + return $recommendation->fresh(); + } + + /** + * Bulk approve recommendations + */ + public function bulkApprove(array $ids): int + { + $count = 0; + $errors = []; + + $recommendations = MrpRecommendation::whereIn('id', $ids) + ->where('status', MrpRecommendationStatus::PENDING) + ->get(); + + foreach ($recommendations as $recommendation) { + try { + // Use the same approveRecommendation method to ensure Purchase Orders/Work Orders are created + $this->approveRecommendation($recommendation); + $count++; + } catch (\Exception $e) { + // Log error but continue with other recommendations + $errors[] = [ + 'recommendation_id' => $recommendation->id, + 'error' => $e->getMessage(), + ]; + Log::warning('Failed to approve MRP recommendation in bulk operation', [ + 'recommendation_id' => $recommendation->id, + 'error' => $e->getMessage(), + ]); + } + } + + if (!empty($errors)) { + Log::error('Some recommendations failed during bulk approve', [ + 'total_requested' => count($ids), + 'successful' => $count, + 'failed' => count($errors), + 'errors' => $errors, + ]); + } + + return $count; + } + + /** + * Bulk reject recommendations + */ + public function bulkReject(array $ids, ?string $notes = null): int + { + $count = 0; + + $recommendations = MrpRecommendation::whereIn('id', $ids) + ->where('status', MrpRecommendationStatus::PENDING) + ->get(); + + foreach ($recommendations as $recommendation) { + if ($recommendation->reject($notes, Auth::id())) { + $count++; + } + } + + return $count; + } + + // ========================================= + // Statistics and Reports + // ========================================= + + /** + * Get MRP statistics + */ + public function getStatistics(): array + { + $companyId = Auth::user()->company_id; + + // Get latest run + $latestRun = MrpRun::where('company_id', $companyId) + ->completed() + ->latest() + ->first(); + + $pendingRecommendations = MrpRecommendation::where('company_id', $companyId) + ->pending() + ->count(); + + $urgentRecommendations = MrpRecommendation::where('company_id', $companyId) + ->pending() + ->urgent() + ->count(); + + $overdueRecommendations = MrpRecommendation::where('company_id', $companyId) + ->overdue() + ->count(); + + $byType = MrpRecommendation::where('company_id', $companyId) + ->pending() + ->selectRaw('recommendation_type, COUNT(*) as count') + ->groupBy('recommendation_type') + ->pluck('count', 'recommendation_type') + ->toArray(); + + return [ + 'latest_run' => $latestRun ? [ + 'id' => $latestRun->id, + 'run_number' => $latestRun->run_number, + 'completed_at' => $latestRun->completed_at, + 'recommendations_generated' => $latestRun->recommendations_generated, + ] : null, + 'pending_recommendations' => $pendingRecommendations, + 'urgent_recommendations' => $urgentRecommendations, + 'overdue_recommendations' => $overdueRecommendations, + 'by_type' => $byType, + ]; + } + + /** + * Get products needing attention (below reorder point) + */ + public function getProductsNeedingAttention(int $limit = 10): Collection + { + $companyId = Auth::user()->company_id; + + return Product::where('company_id', $companyId) + ->where('is_active', true) + ->whereNotNull('reorder_point') + ->where('reorder_point', '>', 0) + ->get() + ->filter(function ($product) { + return $product->isBelowReorderPoint(); + }) + ->take($limit) + ->map(function ($product) { + return [ + 'id' => $product->id, + 'name' => $product->name, + 'sku' => $product->sku, + 'current_stock' => $product->getTotalStock(), + 'reorder_point' => $product->reorder_point, + 'safety_stock' => $product->safety_stock, + 'is_below_safety' => $product->isBelowSafetyStock(), + ]; + }); + } + + /** + * Get total warnings count from summary + */ + protected function getTotalWarningsCount(): int + { + $total = 0; + foreach ($this->warningsSummary as $summary) { + $total += $summary['count'] ?? 1; + } + return $total; + } + + /** + * Get warnings summary for response + */ + public function getWarningsSummary(): array + { + return array_values($this->warningsSummary); + } +} diff --git a/backend/app/Services/NonConformanceReportService.php b/backend/app/Services/NonConformanceReportService.php index 743f2b0..2aa6f44 100644 --- a/backend/app/Services/NonConformanceReportService.php +++ b/backend/app/Services/NonConformanceReportService.php @@ -2,6 +2,9 @@ namespace App\Services; +use App\Enums\NcrDisposition; +use App\Enums\NcrSeverity; +use App\Enums\NcrStatus; use App\Exceptions\BusinessException; use App\Models\NonConformanceReport; use App\Models\ReceivingInspection; @@ -147,12 +150,12 @@ public function create(array $data): NonConformanceReport 'batch_number' => $data['batch_number'] ?? null, 'quantity_affected' => $data['quantity_affected'] ?? null, 'unit_of_measure' => $data['unit_of_measure'] ?? null, - 'severity' => $data['severity'] ?? NonConformanceReport::SEVERITY_MINOR, + 'severity' => $data['severity'] ?? NcrSeverity::MINOR->value, 'priority' => $data['priority'] ?? 'medium', 'defect_type' => $data['defect_type'] ?? 'other', 'root_cause' => $data['root_cause'] ?? null, - 'disposition' => NonConformanceReport::DISPOSITION_PENDING, - 'status' => NonConformanceReport::STATUS_OPEN, + 'disposition' => NcrDisposition::PENDING->value, + 'status' => NcrStatus::OPEN->value, 'attachments' => $data['attachments'] ?? null, 'reported_by' => Auth::id(), 'reported_at' => now(), @@ -216,7 +219,10 @@ public function update(NonConformanceReport $ncr, array $data): NonConformanceRe */ public function submitForReview(NonConformanceReport $ncr): NonConformanceReport { - if ($ncr->status !== NonConformanceReport::STATUS_OPEN) { + $currentStatus = $ncr->status_enum; + $targetStatus = NcrStatus::UNDER_REVIEW; + + if (!$currentStatus || !in_array($targetStatus, $currentStatus->allowedTransitions())) { throw new BusinessException('Only open NCRs can be submitted for review.'); } @@ -226,7 +232,7 @@ public function submitForReview(NonConformanceReport $ncr): NonConformanceReport ]); $ncr->update([ - 'status' => NonConformanceReport::STATUS_UNDER_REVIEW, + 'status' => $targetStatus->value, ]); return $ncr->fresh(); @@ -237,7 +243,10 @@ public function submitForReview(NonConformanceReport $ncr): NonConformanceReport */ public function completeReview(NonConformanceReport $ncr, array $data): NonConformanceReport { - if ($ncr->status !== NonConformanceReport::STATUS_UNDER_REVIEW) { + $currentStatus = $ncr->status_enum; + $targetStatus = NcrStatus::PENDING_DISPOSITION; + + if (!$currentStatus || !in_array($targetStatus, $currentStatus->allowedTransitions())) { throw new BusinessException('NCR is not under review.'); } @@ -247,7 +256,7 @@ public function completeReview(NonConformanceReport $ncr, array $data): NonConfo ]); $ncr->update([ - 'status' => NonConformanceReport::STATUS_PENDING_DISPOSITION, + 'status' => $targetStatus->value, 'root_cause' => $data['root_cause'] ?? $ncr->root_cause, 'reviewed_by' => Auth::id(), 'reviewed_at' => now(), @@ -261,10 +270,10 @@ public function completeReview(NonConformanceReport $ncr, array $data): NonConfo */ public function setDisposition(NonConformanceReport $ncr, array $data): NonConformanceReport { - if (!in_array($ncr->status, [ - NonConformanceReport::STATUS_PENDING_DISPOSITION, - NonConformanceReport::STATUS_UNDER_REVIEW, - ])) { + $currentStatus = $ncr->status_enum; + $targetStatus = NcrStatus::DISPOSITION_APPROVED; + + if (!$currentStatus || !in_array($targetStatus, $currentStatus->allowedTransitions())) { throw new BusinessException('NCR is not ready for disposition.'); } @@ -274,7 +283,7 @@ public function setDisposition(NonConformanceReport $ncr, array $data): NonConfo ]); $ncr->update([ - 'status' => NonConformanceReport::STATUS_DISPOSITION_APPROVED, + 'status' => $targetStatus->value, 'disposition' => $data['disposition'], 'disposition_reason' => $data['disposition_reason'] ?? null, 'cost_impact' => $data['cost_impact'] ?? null, @@ -291,12 +300,15 @@ public function setDisposition(NonConformanceReport $ncr, array $data): NonConfo */ public function startProgress(NonConformanceReport $ncr): NonConformanceReport { - if ($ncr->status !== NonConformanceReport::STATUS_DISPOSITION_APPROVED) { + $currentStatus = $ncr->status_enum; + $targetStatus = NcrStatus::IN_PROGRESS; + + if (!$currentStatus || !in_array($targetStatus, $currentStatus->allowedTransitions())) { throw new BusinessException('NCR disposition must be approved first.'); } $ncr->update([ - 'status' => NonConformanceReport::STATUS_IN_PROGRESS, + 'status' => $targetStatus->value, ]); return $ncr->fresh(); @@ -307,8 +319,11 @@ public function startProgress(NonConformanceReport $ncr): NonConformanceReport */ public function close(NonConformanceReport $ncr, array $data): NonConformanceReport { - if (!$ncr->isOpen()) { - throw new BusinessException('NCR is already closed.'); + $currentStatus = $ncr->status_enum; + $targetStatus = NcrStatus::CLOSED; + + if (!$currentStatus || !in_array($targetStatus, $currentStatus->allowedTransitions())) { + throw new BusinessException('NCR cannot be closed from current status.'); } Log::info('Closing NCR', [ @@ -317,7 +332,7 @@ public function close(NonConformanceReport $ncr, array $data): NonConformanceRep ]); $ncr->update([ - 'status' => NonConformanceReport::STATUS_CLOSED, + 'status' => $targetStatus->value, 'closure_notes' => $data['closure_notes'] ?? null, 'closed_by' => Auth::id(), 'closed_at' => now(), @@ -331,8 +346,11 @@ public function close(NonConformanceReport $ncr, array $data): NonConformanceRep */ public function cancel(NonConformanceReport $ncr, ?string $reason = null): NonConformanceReport { - if (!$ncr->isOpen()) { - throw new BusinessException('NCR is already closed.'); + $currentStatus = $ncr->status_enum; + $targetStatus = NcrStatus::CANCELLED; + + if (!$currentStatus || !in_array($targetStatus, $currentStatus->allowedTransitions())) { + throw new BusinessException('NCR cannot be cancelled from current status.'); } Log::info('Cancelling NCR', [ @@ -342,7 +360,7 @@ public function cancel(NonConformanceReport $ncr, ?string $reason = null): NonCo ]); $ncr->update([ - 'status' => NonConformanceReport::STATUS_CANCELLED, + 'status' => $targetStatus->value, 'closure_notes' => $reason ? "Cancelled: {$reason}" : null, 'closed_by' => Auth::id(), 'closed_at' => now(), @@ -356,10 +374,9 @@ public function cancel(NonConformanceReport $ncr, ?string $reason = null): NonCo */ public function delete(NonConformanceReport $ncr): bool { - if (!in_array($ncr->status, [ - NonConformanceReport::STATUS_OPEN, - NonConformanceReport::STATUS_CANCELLED, - ])) { + $currentStatus = $ncr->status_enum; + + if (!$currentStatus || !in_array($currentStatus, [NcrStatus::OPEN, NcrStatus::CANCELLED])) { throw new BusinessException('Only open or cancelled NCRs can be deleted.'); } @@ -378,7 +395,8 @@ public function generateNcrNumber(): string { $companyId = Auth::user()->company_id; $year = now()->format('Y'); - $prefix = "NCR-{$year}-"; + $companyIdPadded = str_pad($companyId, 3, '0', STR_PAD_LEFT); + $prefix = "NCR-{$year}-{$companyIdPadded}-"; $lastNcr = NonConformanceReport::withTrashed() ->where('company_id', $companyId) @@ -416,9 +434,9 @@ public function getStatistics(array $filters = []): array 'total_ncrs' => $query->clone()->count(), 'open_ncrs' => $openNcrs->clone()->count(), 'closed_ncrs' => $query->clone()->closed()->count(), - 'critical_open' => $openNcrs->clone()->bySeverity(NonConformanceReport::SEVERITY_CRITICAL)->count(), - 'major_open' => $openNcrs->clone()->bySeverity(NonConformanceReport::SEVERITY_MAJOR)->count(), - 'minor_open' => $openNcrs->clone()->bySeverity(NonConformanceReport::SEVERITY_MINOR)->count(), + 'critical_open' => $openNcrs->clone()->bySeverity(NcrSeverity::CRITICAL->value)->count(), + 'major_open' => $openNcrs->clone()->bySeverity(NcrSeverity::MAJOR->value)->count(), + 'minor_open' => $openNcrs->clone()->bySeverity(NcrSeverity::MINOR->value)->count(), 'avg_days_open' => $query->clone()->open()->avg(DB::raw('EXTRACT(DAY FROM NOW() - reported_at)')), 'total_cost_impact' => $query->clone()->whereNotNull('cost_impact')->sum('cost_impact'), 'by_source' => [ @@ -445,9 +463,9 @@ public function getSupplierSummary(int $supplierId, array $filters = []): array 'total_ncrs' => $query->clone()->count(), 'open_ncrs' => $query->clone()->open()->count(), 'by_severity' => [ - 'critical' => $query->clone()->bySeverity(NonConformanceReport::SEVERITY_CRITICAL)->count(), - 'major' => $query->clone()->bySeverity(NonConformanceReport::SEVERITY_MAJOR)->count(), - 'minor' => $query->clone()->bySeverity(NonConformanceReport::SEVERITY_MINOR)->count(), + 'critical' => $query->clone()->bySeverity(NcrSeverity::CRITICAL->value)->count(), + 'major' => $query->clone()->bySeverity(NcrSeverity::MAJOR->value)->count(), + 'minor' => $query->clone()->bySeverity(NcrSeverity::MINOR->value)->count(), ], 'total_cost_impact' => $query->clone()->whereNotNull('cost_impact')->sum('cost_impact'), ]; @@ -458,7 +476,11 @@ public function getSupplierSummary(int $supplierId, array $filters = []): array */ public function getStatuses(): array { - return NonConformanceReport::STATUSES; + $statuses = []; + foreach (\App\Enums\NcrStatus::cases() as $status) { + $statuses[$status->value] = $status->fallbackLabel(); + } + return $statuses; } /** @@ -466,7 +488,11 @@ public function getStatuses(): array */ public function getSeverities(): array { - return NonConformanceReport::SEVERITIES; + $severities = []; + foreach (\App\Enums\NcrSeverity::cases() as $severity) { + $severities[$severity->value] = $severity->fallbackLabel(); + } + return $severities; } /** @@ -482,6 +508,10 @@ public function getDefectTypes(): array */ public function getDispositions(): array { - return NonConformanceReport::DISPOSITIONS; + $dispositions = []; + foreach (\App\Enums\NcrDisposition::cases() as $disposition) { + $dispositions[$disposition->value] = $disposition->fallbackLabel(); + } + return $dispositions; } } diff --git a/backend/app/Services/NumberFormatService.php b/backend/app/Services/NumberFormatService.php new file mode 100644 index 0000000..01be3bb --- /dev/null +++ b/backend/app/Services/NumberFormatService.php @@ -0,0 +1,245 @@ + '{PREFIX}-{YEAR}-{SEQUENCE}', + 'sales_order' => '{PREFIX}-{YEAR}-{SEQUENCE}', + 'work_order' => '{PREFIX}-{YEARMONTH}-{SEQUENCE}', + 'grn' => '{PREFIX}-{YEAR}-{SEQUENCE}', + 'delivery_note' => '{PREFIX}-{YEAR}-{SEQUENCE}', + 'ncr' => '{PREFIX}-{YEAR}-{SEQUENCE}', + 'inspection' => '{PREFIX}-{YEAR}-{SEQUENCE}', + 'routing' => '{PREFIX}-{SEQUENCE}', + 'bom' => '{PREFIX}-{SEQUENCE}', + 'customer_code' => '{PREFIX}-{SEQUENCE}', + 'supplier_code' => '{PREFIX}-{SEQUENCE}', + 'work_center' => '{PREFIX}-{SEQUENCE}', + 'acceptance_rule' => '{PREFIX}-{SEQUENCE}', + 'mrp_run' => '{PREFIX}-{DATE}-{SEQUENCE}', + ]; + + /** + * Default prefixes for different entity types + */ + private const DEFAULT_PREFIXES = [ + 'purchase_order' => 'PO', + 'sales_order' => 'SO', + 'work_order' => 'WO', + 'grn' => 'GRN', + 'delivery_note' => 'DN', + 'ncr' => 'NCR', + 'inspection' => 'INS', + 'routing' => 'RTG', + 'bom' => 'BOM', + 'customer_code' => 'CUS', + 'supplier_code' => 'SUP', + 'work_center' => 'WC', + 'acceptance_rule' => 'AR', + 'mrp_run' => 'MRP', + ]; + + /** + * Default sequence padding + */ + private const DEFAULT_SEQUENCE_PADDING = [ + 'purchase_order' => 5, + 'sales_order' => 5, + 'work_order' => 4, + 'grn' => 5, + 'delivery_note' => 5, + 'ncr' => 5, + 'inspection' => 5, + 'routing' => 5, + 'bom' => 5, + 'customer_code' => 5, + 'supplier_code' => 5, + 'work_center' => 4, + 'acceptance_rule' => 4, + 'mrp_run' => 3, + ]; + + /** + * Generate number based on format template + * + * @param string $entityType Entity type (e.g., 'purchase_order', 'sales_order') + * @param int $sequence Sequence number + * @param int|null $companyId Company ID (if null, uses authenticated user's company) + * @param string|null $customPrefix Custom prefix (if null, uses default) + * @param bool|null $includeCompanyId Whether to include company ID (null = check company settings, default: false) + * @return string Generated number + */ + public function generate( + string $entityType, + int $sequence, + ?int $companyId = null, + ?string $customPrefix = null, + ?bool $includeCompanyId = null + ): string { + $companyId = $companyId ?? Auth::user()->company_id; + $company = Company::find($companyId); + + // Get format from company settings or use default + $format = $this->getFormat($entityType, $company); + + // Get prefix + $prefix = $customPrefix ?? $this->getPrefix($entityType, $company); + + // Determine if company ID should be included + // Priority: 1. Parameter, 2. Company settings, 3. Default (false for privacy) + if ($includeCompanyId === null) { + $includeCompanyId = $company->settings['number_formats']['include_company_id'] ?? false; + } + + // Prepare replacement values + $replacements = [ + '{PREFIX}' => $prefix, + '{YEAR}' => now()->format('Y'), + '{YEARMONTH}' => now()->format('Ym'), + '{DATE}' => now()->format('Ymd'), + '{SEQUENCE}' => str_pad( + $sequence, + self::DEFAULT_SEQUENCE_PADDING[$entityType] ?? 5, + '0', + STR_PAD_LEFT + ), + ]; + + // Add company ID if requested + if ($includeCompanyId) { + $replacements['{COMPANY_ID}'] = str_pad($companyId, 3, '0', STR_PAD_LEFT); + // Insert COMPANY_ID into format if not already present + if (strpos($format, '{COMPANY_ID}') === false) { + // Insert after YEAR/YEARMONTH/DATE if present, otherwise after PREFIX + if (strpos($format, '{YEAR}') !== false) { + $format = str_replace('{YEAR}', '{YEAR}-{COMPANY_ID}', $format); + } elseif (strpos($format, '{YEARMONTH}') !== false) { + $format = str_replace('{YEARMONTH}', '{YEARMONTH}-{COMPANY_ID}', $format); + } elseif (strpos($format, '{DATE}') !== false) { + $format = str_replace('{DATE}', '{DATE}-{COMPANY_ID}', $format); + } else { + $format = str_replace('{PREFIX}', '{PREFIX}-{COMPANY_ID}', $format); + } + } + } else { + // Remove COMPANY_ID placeholder if not included + $format = str_replace('{COMPANY_ID}-', '', $format); + $format = str_replace('-{COMPANY_ID}', '', $format); + $format = str_replace('{COMPANY_ID}', '', $format); + } + + // Replace placeholders + return str_replace(array_keys($replacements), array_values($replacements), $format); + } + + /** + * Get format template for entity type + * + * @param string $entityType + * @param Company|null $company + * @return string + */ + private function getFormat(string $entityType, ?Company $company): string + { + // Try to get from company settings + if ($company && isset($company->settings['number_formats'][$entityType])) { + return $company->settings['number_formats'][$entityType]; + } + + // Use default format + return self::DEFAULT_FORMATS[$entityType] ?? '{PREFIX}-{SEQUENCE}'; + } + + /** + * Get prefix for entity type + * + * @param string $entityType + * @param Company|null $company + * @return string + */ + private function getPrefix(string $entityType, ?Company $company): string + { + // Try to get from company settings + if ($company && isset($company->settings['number_prefixes'][$entityType])) { + return $company->settings['number_prefixes'][$entityType]; + } + + // Use default prefix + return self::DEFAULT_PREFIXES[$entityType] ?? 'NUM'; + } + + /** + * Extract sequence number from a generated number + * Works with any format - extracts the last numeric part + * + * @param string $number + * @return int Sequence number (0 if not found) + */ + public function extractSequence(string $number): int + { + // Extract last numeric sequence (handles both old and new formats) + if (preg_match('/(\d+)$/', $number, $matches)) { + return (int) $matches[1]; + } + return 0; + } + + /** + * Parse number to extract components + * + * @param string $number + * @param string $entityType + * @return array|null Returns null if parsing fails + */ + public function parse(string $number, string $entityType): ?array + { + $format = self::DEFAULT_FORMATS[$entityType] ?? '{PREFIX}-{SEQUENCE}'; + + // Convert format to regex pattern + $pattern = preg_replace( + ['/\{PREFIX\}/', '/\{YEAR\}/', '/\{YEARMONTH\}/', '/\{DATE\}/', '/\{COMPANY_ID\}/', '/\{SEQUENCE\}/'], + ['([A-Z]+)', '(\d{4})', '(\d{6})', '(\d{8})', '(\d{3})', '(\d+)'], + preg_quote($format, '/') + ); + + if (preg_match('/^' . $pattern . '$/', $number, $matches)) { + return [ + 'prefix' => $matches[1] ?? null, + 'year' => $matches[2] ?? null, + 'yearmonth' => $matches[3] ?? null, + 'date' => $matches[4] ?? null, + 'company_id' => isset($matches[5]) ? (int) $matches[5] : null, + 'sequence' => isset($matches[6]) ? (int) $matches[6] : (int) end($matches), + ]; + } + + return null; + } + + /** + * Get available format placeholders + * + * @return array + */ + public function getAvailablePlaceholders(): array + { + return [ + '{PREFIX}' => 'Entity prefix (e.g., PO, SO, WO)', + '{YEAR}' => 'Current year (YYYY)', + '{YEARMONTH}' => 'Current year and month (YYYYMM)', + '{DATE}' => 'Current date (YYYYMMDD)', + '{COMPANY_ID}' => 'Company ID (3 digits, zero-padded)', + '{SEQUENCE}' => 'Sequence number (padded based on entity type)', + ]; + } +} diff --git a/backend/app/Services/ProductUomConversionService.php b/backend/app/Services/ProductUomConversionService.php new file mode 100644 index 0000000..7b7d109 --- /dev/null +++ b/backend/app/Services/ProductUomConversionService.php @@ -0,0 +1,214 @@ +uomConversions() + ->with(['fromUom', 'toUom']) + ->orderBy('is_default', 'desc') + ->orderBy('created_at', 'desc') + ->get(); + + return [ + 'conversions' => $conversions, + 'base_unit' => $product->unitOfMeasure, + 'available_units' => $product->getAvailableUnits(), + ]; + } + + /** + * Create a new product-specific conversion + */ + public function create(Product $product, array $data): ProductUomConversion + { + return DB::transaction(function () use ($product, $data) { + // If this is set as default, unset other defaults for same from_uom + if ($data['is_default'] ?? false) { + $product->uomConversions() + ->where('from_uom_id', $data['from_uom_id']) + ->update(['is_default' => false]); + } + + return ProductUomConversion::create([ + 'company_id' => Auth::user()->company_id, + 'product_id' => $product->id, + 'from_uom_id' => $data['from_uom_id'], + 'to_uom_id' => $data['to_uom_id'], + 'conversion_factor' => $data['conversion_factor'], + 'is_default' => $data['is_default'] ?? false, + 'is_active' => $data['is_active'] ?? true, + ]); + }); + } + + /** + * Update an existing conversion + */ + public function update(ProductUomConversion $conversion, array $data): ProductUomConversion + { + return DB::transaction(function () use ($conversion, $data) { + // If this is set as default, unset other defaults for same from_uom + if (($data['is_default'] ?? false) && !$conversion->is_default) { + ProductUomConversion::where('product_id', $conversion->product_id) + ->where('from_uom_id', $data['from_uom_id'] ?? $conversion->from_uom_id) + ->where('id', '!=', $conversion->id) + ->update(['is_default' => false]); + } + + $conversion->update($data); + + return $conversion->fresh(['fromUom', 'toUom']); + }); + } + + /** + * Delete a conversion + */ + public function delete(ProductUomConversion $conversion): bool + { + return $conversion->delete(); + } + + /** + * Toggle conversion active status + */ + public function toggleActive(ProductUomConversion $conversion): ProductUomConversion + { + $conversion->update(['is_active' => !$conversion->is_active]); + + return $conversion->fresh(); + } + + /** + * Convert quantity for a specific product + * + * @param Product $product + * @param float $quantity + * @param int $fromUomId + * @param int $toUomId + * @return array Result with converted quantity and conversion info + */ + public function convert(Product $product, float $quantity, int $fromUomId, int $toUomId): array + { + $fromUom = UnitOfMeasure::findOrFail($fromUomId); + $toUom = UnitOfMeasure::findOrFail($toUomId); + + // Try product-specific conversion first + $productConversion = $product->uomConversions() + ->active() + ->where(function ($query) use ($fromUomId, $toUomId) { + $query->where(function ($q) use ($fromUomId, $toUomId) { + $q->where('from_uom_id', $fromUomId) + ->where('to_uom_id', $toUomId); + })->orWhere(function ($q) use ($fromUomId, $toUomId) { + $q->where('from_uom_id', $toUomId) + ->where('to_uom_id', $fromUomId); + }); + }) + ->first(); + + if ($productConversion) { + $isReverse = $productConversion->from_uom_id === $toUomId; + $result = $isReverse + ? $productConversion->reverseConvert($quantity) + : $productConversion->convert($quantity); + + return [ + 'success' => true, + 'from' => [ + 'quantity' => $quantity, + 'unit' => $fromUom->code, + 'unit_name' => $fromUom->name, + ], + 'to' => [ + 'quantity' => $result, + 'unit' => $toUom->code, + 'unit_name' => $toUom->name, + 'formatted' => $toUom->formatQuantity($result) . ' ' . $toUom->code, + ], + 'conversion_type' => 'product_specific', + 'conversion_display' => $productConversion->getDisplayString(), + ]; + } + + // Fall back to standard conversion + $result = $fromUom->convertTo($quantity, $toUom); + + if ($result === null) { + return [ + 'success' => false, + 'error' => 'Cannot convert between these units. They may be of different types or missing conversion factors.', + ]; + } + + return [ + 'success' => true, + 'from' => [ + 'quantity' => $quantity, + 'unit' => $fromUom->code, + 'unit_name' => $fromUom->name, + ], + 'to' => [ + 'quantity' => $result, + 'unit' => $toUom->code, + 'unit_name' => $toUom->name, + 'formatted' => $toUom->formatQuantity($result) . ' ' . $toUom->code, + ], + 'conversion_type' => 'standard', + ]; + } + + /** + * Bulk create conversions for a product + */ + public function bulkCreate(Product $product, array $conversions): array + { + $created = []; + + DB::transaction(function () use ($product, $conversions, &$created) { + foreach ($conversions as $data) { + $created[] = $this->create($product, $data); + } + }); + + return $created; + } + + /** + * Copy conversions from one product to another + */ + public function copyFromProduct(Product $sourceProduct, Product $targetProduct): array + { + $copied = []; + + DB::transaction(function () use ($sourceProduct, $targetProduct, &$copied) { + $conversions = $sourceProduct->uomConversions()->get(); + + foreach ($conversions as $conversion) { + $copied[] = ProductUomConversion::create([ + 'company_id' => Auth::user()->company_id, + 'product_id' => $targetProduct->id, + 'from_uom_id' => $conversion->from_uom_id, + 'to_uom_id' => $conversion->to_uom_id, + 'conversion_factor' => $conversion->conversion_factor, + 'is_default' => $conversion->is_default, + 'is_active' => $conversion->is_active, + ]); + } + }); + + return $copied; + } +} diff --git a/backend/app/Services/PurchaseOrderService.php b/backend/app/Services/PurchaseOrderService.php index 363969e..11499c8 100644 --- a/backend/app/Services/PurchaseOrderService.php +++ b/backend/app/Services/PurchaseOrderService.php @@ -2,19 +2,28 @@ namespace App\Services; +use App\Enums\PoStatus; use App\Exceptions\BusinessException; +use App\Models\Company; use App\Models\PurchaseOrder; use App\Models\PurchaseOrderItem; use App\Models\Supplier; +use App\Services\NumberFormatService; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; -use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Exception; class PurchaseOrderService { + protected AuditLogService $auditLogService; + + public function __construct(AuditLogService $auditLogService) + { + $this->auditLogService = $auditLogService; + } /** * Get paginated purchase orders with filters */ @@ -97,12 +106,14 @@ public function create(array $data): PurchaseOrder $data['order_number'] = $this->generateOrderNumber(); } - // Get supplier defaults - $supplier = Supplier::find($data['supplier_id']); - if ($supplier) { - $data['currency'] = $data['currency'] ?? $supplier->currency; - $data['payment_terms'] = $data['payment_terms'] ?? "{$supplier->payment_terms_days} days"; - $data['payment_due_days'] = $data['payment_due_days'] ?? $supplier->payment_terms_days; + // Get supplier defaults (if supplier is provided) + if (!empty($data['supplier_id'])) { + $supplier = Supplier::find($data['supplier_id']); + if ($supplier) { + $data['currency'] = $data['currency'] ?? $supplier->currency; + $data['payment_terms'] = $data['payment_terms'] ?? "{$supplier->payment_terms_days} days"; + $data['payment_due_days'] = $data['payment_due_days'] ?? $supplier->payment_terms_days; + } } // Create purchase order @@ -113,7 +124,7 @@ public function create(array $data): PurchaseOrder 'warehouse_id' => $data['warehouse_id'], 'order_date' => $data['order_date'] ?? now(), 'expected_delivery_date' => $data['expected_delivery_date'] ?? null, - 'status' => $data['status'] ?? PurchaseOrder::STATUS_DRAFT, + 'status' => $data['status'] ?? PoStatus::DRAFT->value, 'currency' => $data['currency'] ?? 'USD', 'exchange_rate' => $data['exchange_rate'] ?? 1.0, 'discount_amount' => $data['discount_amount'] ?? 0, @@ -286,7 +297,10 @@ public function deleteItem(PurchaseOrderItem $item): void */ public function submitForApproval(PurchaseOrder $purchaseOrder): PurchaseOrder { - if ($purchaseOrder->status !== PurchaseOrder::STATUS_DRAFT) { + $currentStatus = $purchaseOrder->status_enum; + $targetStatus = PoStatus::PENDING_APPROVAL; + + if (!$currentStatus || !in_array($targetStatus, $currentStatus->allowedTransitions())) { throw new BusinessException('Only draft orders can be submitted for approval.'); } @@ -294,16 +308,27 @@ public function submitForApproval(PurchaseOrder $purchaseOrder): PurchaseOrder throw new BusinessException('Cannot submit an order without items.'); } + if (!$purchaseOrder->supplier_id) { + throw new BusinessException('Cannot submit an order without a supplier. Please assign a supplier first.'); + } + Log::info('Submitting purchase order for approval', [ 'purchase_order_id' => $purchaseOrder->id, 'order_number' => $purchaseOrder->order_number, ]); $purchaseOrder->update([ - 'status' => PurchaseOrder::STATUS_PENDING_APPROVAL, + 'status' => $targetStatus->value, 'updated_by' => Auth::id(), ]); + // Audit logging + $this->auditLogService->logEvent( + 'submitted_for_approval', + $purchaseOrder, + "Purchase order submitted for approval: {$purchaseOrder->order_number}" + ); + return $purchaseOrder->fresh(); } @@ -312,10 +337,17 @@ public function submitForApproval(PurchaseOrder $purchaseOrder): PurchaseOrder */ public function approve(PurchaseOrder $purchaseOrder): PurchaseOrder { - if (!$purchaseOrder->canBeApproved()) { + $currentStatus = $purchaseOrder->status_enum; + $targetStatus = PoStatus::APPROVED; + + if (!$currentStatus || !in_array($targetStatus, $currentStatus->allowedTransitions())) { throw new BusinessException('Purchase order cannot be approved in current status.'); } + if (!$purchaseOrder->supplier_id) { + throw new BusinessException('Cannot approve an order without a supplier. Please assign a supplier first.'); + } + Log::info('Approving purchase order', [ 'purchase_order_id' => $purchaseOrder->id, 'order_number' => $purchaseOrder->order_number, @@ -323,12 +355,20 @@ public function approve(PurchaseOrder $purchaseOrder): PurchaseOrder ]); $purchaseOrder->update([ - 'status' => PurchaseOrder::STATUS_APPROVED, + 'status' => $targetStatus->value, 'approved_by' => Auth::id(), 'approved_at' => now(), 'updated_by' => Auth::id(), ]); + // Audit logging + $this->auditLogService->logEvent( + 'approved', + $purchaseOrder, + "Purchase order approved: {$purchaseOrder->order_number}", + ['approved_by' => Auth::id(), 'approved_at' => now()->toIso8601String()] + ); + return $purchaseOrder->fresh(); } @@ -337,7 +377,10 @@ public function approve(PurchaseOrder $purchaseOrder): PurchaseOrder */ public function reject(PurchaseOrder $purchaseOrder, ?string $reason = null): PurchaseOrder { - if ($purchaseOrder->status !== PurchaseOrder::STATUS_PENDING_APPROVAL) { + $currentStatus = $purchaseOrder->status_enum; + $targetStatus = PoStatus::DRAFT; + + if (!$currentStatus || !in_array($targetStatus, $currentStatus->allowedTransitions())) { throw new BusinessException('Only pending orders can be rejected.'); } @@ -348,11 +391,19 @@ public function reject(PurchaseOrder $purchaseOrder, ?string $reason = null): Pu ]); $purchaseOrder->update([ - 'status' => PurchaseOrder::STATUS_DRAFT, + 'status' => $targetStatus->value, 'internal_notes' => $reason ? "Rejected: {$reason}\n" . $purchaseOrder->internal_notes : $purchaseOrder->internal_notes, 'updated_by' => Auth::id(), ]); + // Audit logging + $this->auditLogService->logEvent( + 'rejected', + $purchaseOrder, + "Purchase order rejected: {$purchaseOrder->order_number}", + ['reason' => $reason] + ); + return $purchaseOrder->fresh(); } @@ -361,7 +412,10 @@ public function reject(PurchaseOrder $purchaseOrder, ?string $reason = null): Pu */ public function markAsSent(PurchaseOrder $purchaseOrder): PurchaseOrder { - if (!$purchaseOrder->canBeSent()) { + $currentStatus = $purchaseOrder->status_enum; + $targetStatus = PoStatus::SENT; + + if (!$currentStatus || !in_array($targetStatus, $currentStatus->allowedTransitions())) { throw new BusinessException('Purchase order cannot be sent in current status.'); } @@ -371,10 +425,17 @@ public function markAsSent(PurchaseOrder $purchaseOrder): PurchaseOrder ]); $purchaseOrder->update([ - 'status' => PurchaseOrder::STATUS_SENT, + 'status' => $targetStatus->value, 'updated_by' => Auth::id(), ]); + // Audit logging + $this->auditLogService->logEvent( + 'sent', + $purchaseOrder, + "Purchase order marked as sent to supplier: {$purchaseOrder->order_number}" + ); + return $purchaseOrder->fresh(); } @@ -383,7 +444,10 @@ public function markAsSent(PurchaseOrder $purchaseOrder): PurchaseOrder */ public function cancel(PurchaseOrder $purchaseOrder, ?string $reason = null): PurchaseOrder { - if (!$purchaseOrder->canBeCancelled()) { + $currentStatus = $purchaseOrder->status_enum; + $targetStatus = PoStatus::CANCELLED; + + if (!$currentStatus || !in_array($targetStatus, $currentStatus->allowedTransitions())) { throw new BusinessException('Purchase order cannot be cancelled in current status.'); } @@ -394,11 +458,19 @@ public function cancel(PurchaseOrder $purchaseOrder, ?string $reason = null): Pu ]); $purchaseOrder->update([ - 'status' => PurchaseOrder::STATUS_CANCELLED, + 'status' => $targetStatus->value, 'internal_notes' => $reason ? "Cancelled: {$reason}\n" . $purchaseOrder->internal_notes : $purchaseOrder->internal_notes, 'updated_by' => Auth::id(), ]); + // Audit logging + $this->auditLogService->logEvent( + 'cancelled', + $purchaseOrder, + "Purchase order cancelled: {$purchaseOrder->order_number}", + ['reason' => $reason] + ); + return $purchaseOrder->fresh(); } @@ -407,16 +479,30 @@ public function cancel(PurchaseOrder $purchaseOrder, ?string $reason = null): Pu */ public function close(PurchaseOrder $purchaseOrder): PurchaseOrder { + $currentStatus = $purchaseOrder->status_enum; + $targetStatus = PoStatus::CLOSED; + + if (!$currentStatus || !in_array($targetStatus, $currentStatus->allowedTransitions())) { + throw new BusinessException('Purchase order cannot be closed in current status.'); + } + Log::info('Closing purchase order', [ 'purchase_order_id' => $purchaseOrder->id, 'order_number' => $purchaseOrder->order_number, ]); $purchaseOrder->update([ - 'status' => PurchaseOrder::STATUS_CLOSED, + 'status' => $targetStatus->value, 'updated_by' => Auth::id(), ]); + // Audit logging + $this->auditLogService->logEvent( + 'closed', + $purchaseOrder, + "Purchase order closed: {$purchaseOrder->order_number}" + ); + return $purchaseOrder->fresh(); } @@ -425,7 +511,9 @@ public function close(PurchaseOrder $purchaseOrder): PurchaseOrder */ public function delete(PurchaseOrder $purchaseOrder): bool { - if (!in_array($purchaseOrder->status, [PurchaseOrder::STATUS_DRAFT, PurchaseOrder::STATUS_CANCELLED])) { + $currentStatus = $purchaseOrder->status_enum; + + if (!$currentStatus || !in_array($currentStatus, [PoStatus::DRAFT, PoStatus::CANCELLED])) { throw new BusinessException('Only draft or cancelled orders can be deleted.'); } @@ -443,23 +531,33 @@ public function delete(PurchaseOrder $purchaseOrder): bool public function generateOrderNumber(): string { $companyId = Auth::user()->company_id; + $numberFormatService = app(NumberFormatService::class); + + // Get year prefix for filtering (format may vary, so use flexible pattern) $year = now()->format('Y'); $prefix = "PO-{$year}-"; - + // Include soft-deleted records to avoid duplicate order numbers + // Filter by company and prefix pattern $lastOrder = PurchaseOrder::withTrashed() ->where('company_id', $companyId) ->where('order_number', 'like', "{$prefix}%") ->orderByRaw("CAST(SUBSTRING(order_number FROM '[0-9]+$') AS INTEGER) DESC") ->first(); - if ($lastOrder && preg_match('/(\d+)$/', $lastOrder->order_number, $matches)) { - $nextNumber = (int) $matches[1] + 1; - } else { - $nextNumber = 1; - } - - return $prefix . str_pad($nextNumber, 5, '0', STR_PAD_LEFT); + // Extract sequence from last order or start from 1 + $nextNumber = $lastOrder + ? $numberFormatService->extractSequence($lastOrder->order_number) + 1 + : 1; + + // Generate number using format service + // Company ID inclusion controlled by company settings (default: false for privacy) + return $numberFormatService->generate( + 'purchase_order', + $nextNumber, + $companyId, + 'PO' + ); } /** @@ -496,16 +594,16 @@ public function getStatistics(array $filters = []): array return [ 'total_orders' => $query->clone()->count(), - 'draft_orders' => $query->clone()->status(PurchaseOrder::STATUS_DRAFT)->count(), - 'pending_approval' => $query->clone()->status(PurchaseOrder::STATUS_PENDING_APPROVAL)->count(), - 'sent_orders' => $query->clone()->status(PurchaseOrder::STATUS_SENT)->count(), + 'draft_orders' => $query->clone()->status(PoStatus::DRAFT->value)->count(), + 'pending_approval' => $query->clone()->status(PoStatus::PENDING_APPROVAL->value)->count(), + 'sent_orders' => $query->clone()->status(PoStatus::SENT->value)->count(), 'received_orders' => $query->clone()->whereIn('status', [ - PurchaseOrder::STATUS_RECEIVED, - PurchaseOrder::STATUS_PARTIALLY_RECEIVED, + PoStatus::RECEIVED->value, + PoStatus::PARTIALLY_RECEIVED->value, ])->count(), 'total_amount' => $query->clone()->whereNotIn('status', [ - PurchaseOrder::STATUS_DRAFT, - PurchaseOrder::STATUS_CANCELLED, + PoStatus::DRAFT->value, + PoStatus::CANCELLED->value, ])->sum('total_amount'), 'overdue_orders' => PurchaseOrder::overdue()->count(), ]; diff --git a/backend/app/Services/QualityHoldService.php b/backend/app/Services/QualityHoldService.php index d337b35..d6354a6 100644 --- a/backend/app/Services/QualityHoldService.php +++ b/backend/app/Services/QualityHoldService.php @@ -7,6 +7,7 @@ use App\Models\StockMovement; use App\Exceptions\QualityHoldException; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; class QualityHoldService @@ -87,7 +88,7 @@ public function placeHold( $reason, $holdUntil, $restrictions, - auth()->id(), + Auth::id(), $referenceType, $referenceId ); @@ -133,7 +134,7 @@ public function setConditional( 'quality_status' => Stock::QUALITY_CONDITIONAL, 'hold_reason' => $reason, 'quality_restrictions' => $restrictions, - 'quality_hold_by' => auth()->id(), + 'quality_hold_by' => Auth::id(), 'quality_hold_at' => now(), 'quality_reference_type' => $referenceType, 'quality_reference_id' => $referenceId, @@ -195,7 +196,7 @@ public function transferToQuarantine( 'warehouse_id' => $quarantineWarehouse->id, 'quality_status' => Stock::QUALITY_QUARANTINE, 'hold_reason' => $reason, - 'quality_hold_by' => auth()->id(), + 'quality_hold_by' => Auth::id(), 'quality_hold_at' => now(), 'quality_reference_type' => $referenceType, 'quality_reference_id' => $referenceId, @@ -217,7 +218,7 @@ public function transferToQuarantine( 'quality_status_from' => $previousStatus, 'quality_status_to' => Stock::QUALITY_QUARANTINE, 'notes' => "Transferred to quarantine: {$reason}", - 'created_by' => auth()->id(), + 'created_by' => Auth::id(), ]); return $stock->fresh(); @@ -250,7 +251,7 @@ public function transferToRejection( 'warehouse_id' => $rejectionWarehouse->id, 'quality_status' => Stock::QUALITY_REJECTED, 'hold_reason' => $reason, - 'quality_hold_by' => auth()->id(), + 'quality_hold_by' => Auth::id(), 'quality_hold_at' => now(), 'quality_reference_type' => $referenceType, 'quality_reference_id' => $referenceId, @@ -272,7 +273,7 @@ public function transferToRejection( 'quality_status_from' => $previousStatus, 'quality_status_to' => Stock::QUALITY_REJECTED, 'notes' => "Transferred to rejection: {$reason}", - 'created_by' => auth()->id(), + 'created_by' => Auth::id(), ]); return $stock->fresh(); @@ -333,7 +334,7 @@ public function releaseFromQcZone( 'quality_status_from' => $previousStatus, 'quality_status_to' => $newQualityStatus, 'notes' => "Released from QC zone", - 'created_by' => auth()->id(), + 'created_by' => Auth::id(), ]); return $stock->fresh(); @@ -422,7 +423,7 @@ protected function createQualityMovement( 'quality_status_from' => $fromStatus, 'quality_status_to' => $toStatus, 'notes' => "Quality status changed from {$fromStatus} to {$toStatus}", - 'created_by' => auth()->id(), + 'created_by' => Auth::id(), ]); } diff --git a/backend/app/Services/ReceivingInspectionService.php b/backend/app/Services/ReceivingInspectionService.php index c98190e..3a1f044 100644 --- a/backend/app/Services/ReceivingInspectionService.php +++ b/backend/app/Services/ReceivingInspectionService.php @@ -2,6 +2,8 @@ namespace App\Services; +use App\Enums\InspectionDisposition; +use App\Enums\InspectionResult; use App\Exceptions\BusinessException; use App\Models\GoodsReceivedNote; use App\Models\GoodsReceivedNoteItem; @@ -11,6 +13,7 @@ use App\Models\Warehouse; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Support\Collection as SupportCollection; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; @@ -107,7 +110,7 @@ public function getInspectionsForGrn(GoodsReceivedNote $grn): Collection /** * Create inspections for all items in a GRN */ - public function createInspectionsForGrn(GoodsReceivedNote $grn): Collection + public function createInspectionsForGrn(GoodsReceivedNote $grn): SupportCollection { Log::info('Creating inspections for GRN', [ 'grn_id' => $grn->id, @@ -144,8 +147,8 @@ public function createInspectionsForGrn(GoodsReceivedNote $grn): Collection 'batch_number' => $item->batch_number, 'quantity_received' => $item->quantity_received, 'quantity_inspected' => $sampleSize, - 'result' => ReceivingInspection::RESULT_PENDING, - 'disposition' => ReceivingInspection::DISPOSITION_PENDING, + 'result' => InspectionResult::PENDING->value, + 'disposition' => InspectionDisposition::PENDING->value, ]); $inspections->push($inspection); @@ -207,7 +210,7 @@ public function recordResult(ReceivingInspection $inspection, array $data): Rece 'quantity_failed' => $quantityFailed, 'quantity_on_hold' => $quantityOnHold, 'result' => $result, - 'disposition' => $data['disposition'] ?? ReceivingInspection::DISPOSITION_PENDING, + 'disposition' => $data['disposition'] ?? InspectionDisposition::PENDING->value, 'inspection_data' => $data['inspection_data'] ?? null, 'failure_reason' => $data['failure_reason'] ?? null, 'notes' => $data['notes'] ?? null, @@ -298,22 +301,22 @@ public function updateDisposition(ReceivingInspection $inspection, string $dispo protected function determineResult(float $passed, float $failed, float $onHold, float $total): string { if ($onHold > 0) { - return ReceivingInspection::RESULT_ON_HOLD; + return InspectionResult::ON_HOLD->value; } if ($failed === 0 && $passed > 0) { - return ReceivingInspection::RESULT_PASSED; + return InspectionResult::PASSED->value; } if ($passed === 0 && $failed > 0) { - return ReceivingInspection::RESULT_FAILED; + return InspectionResult::FAILED->value; } if ($passed > 0 && $failed > 0) { - return ReceivingInspection::RESULT_PARTIAL; + return InspectionResult::PARTIAL->value; } - return ReceivingInspection::RESULT_PENDING; + return InspectionResult::PENDING->value; } /** @@ -327,35 +330,33 @@ protected function updateGrnItemQuantities(ReceivingInspection $inspection): voi return; } - // Calculate accepted/rejected based on disposition - switch ($inspection->disposition) { - case ReceivingInspection::DISPOSITION_ACCEPT: - case ReceivingInspection::DISPOSITION_USE_AS_IS: - $grnItem->update([ - 'quantity_accepted' => $inspection->quantity_passed + $inspection->quantity_on_hold, - 'quantity_rejected' => $inspection->quantity_failed, - ]); - break; - - case ReceivingInspection::DISPOSITION_REJECT: - case ReceivingInspection::DISPOSITION_RETURN: - $grnItem->update([ - 'quantity_accepted' => 0, - 'quantity_rejected' => $inspection->quantity_received, - ]); - break; + $disposition = $inspection->disposition_enum; - case ReceivingInspection::DISPOSITION_REWORK: - // Rework items are on hold until rework is complete - $grnItem->update([ - 'quantity_accepted' => $inspection->quantity_passed, - 'quantity_rejected' => $inspection->quantity_failed, - ]); - break; + if (!$disposition || !$disposition->isFinal()) { + // Pending - no changes + $this->updateStockQualityStatus($inspection); + return; + } - default: - // Pending - no changes - break; + // Calculate accepted/rejected based on disposition using Enum methods + if ($disposition->allowsStockEntry()) { + // Accept or Use As Is + $grnItem->update([ + 'quantity_accepted' => $inspection->quantity_passed + $inspection->quantity_on_hold, + 'quantity_rejected' => $inspection->quantity_failed, + ]); + } elseif ($disposition->notifySupplier()) { + // Reject or Return to Supplier + $grnItem->update([ + 'quantity_accepted' => 0, + 'quantity_rejected' => $inspection->quantity_received, + ]); + } elseif ($disposition === InspectionDisposition::REWORK) { + // Rework items are on hold until rework is complete + $grnItem->update([ + 'quantity_accepted' => $inspection->quantity_passed, + 'quantity_rejected' => $inspection->quantity_failed, + ]); } // Update stock quality status based on disposition @@ -431,14 +432,19 @@ protected function updateStockQualityStatus(ReceivingInspection $inspection): vo */ protected function getQualityStatusFromDisposition(ReceivingInspection $inspection): ?string { - return match ($inspection->disposition) { - ReceivingInspection::DISPOSITION_ACCEPT => Stock::QUALITY_AVAILABLE, - ReceivingInspection::DISPOSITION_USE_AS_IS => Stock::QUALITY_CONDITIONAL, - ReceivingInspection::DISPOSITION_REJECT, - ReceivingInspection::DISPOSITION_RETURN => Stock::QUALITY_REJECTED, - ReceivingInspection::DISPOSITION_REWORK => Stock::QUALITY_ON_HOLD, - ReceivingInspection::DISPOSITION_QUARANTINE => Stock::QUALITY_QUARANTINE, - default => null, // Pending - no change + $disposition = $inspection->disposition_enum; + + if (!$disposition || !$disposition->isFinal()) { + return null; // Pending - no change + } + + return match ($disposition) { + InspectionDisposition::ACCEPT => Stock::QUALITY_AVAILABLE, + InspectionDisposition::USE_AS_IS => Stock::QUALITY_CONDITIONAL, + InspectionDisposition::REJECT, + InspectionDisposition::RETURN_TO_SUPPLIER => Stock::QUALITY_REJECTED, + InspectionDisposition::REWORK => Stock::QUALITY_ON_HOLD, + default => null, }; } @@ -469,7 +475,9 @@ protected function getReasonFromInspection(ReceivingInspection $inspection): ?st */ protected function getRestrictionsFromInspection(ReceivingInspection $inspection): ?array { - if ($inspection->disposition !== ReceivingInspection::DISPOSITION_USE_AS_IS) { + $disposition = $inspection->disposition_enum; + + if ($disposition !== InspectionDisposition::USE_AS_IS) { return null; } @@ -501,13 +509,29 @@ public function transferToQcZone(ReceivingInspection $inspection, int $targetWar throw new BusinessException('Target warehouse must be a QC zone (quarantine or rejection)'); } - $stock = Stock::where('product_id', $inspection->product_id) - ->where('warehouse_id', $grn->warehouse_id) - ->where('lot_number', $inspection->lot_number) - ->first(); + // Find stock - match by product, warehouse, and lot_number (if provided) + $stockQuery = Stock::where('product_id', $inspection->product_id) + ->where('warehouse_id', $grn->warehouse_id); + + if ($inspection->lot_number) { + $stockQuery->where('lot_number', $inspection->lot_number); + } else { + // If no lot_number, find stock with null lot_number or most recent stock for this product/warehouse + $stockQuery->whereNull('lot_number'); + } + + $stock = $stockQuery->first(); + + // If still not found and lot_number is null, try to find any stock for this product/warehouse + if (!$stock && !$inspection->lot_number) { + $stock = Stock::where('product_id', $inspection->product_id) + ->where('warehouse_id', $grn->warehouse_id) + ->orderBy('created_at', 'desc') + ->first(); + } if (!$stock) { - throw new BusinessException('Stock not found for inspection'); + throw new BusinessException('Stock not found for inspection. Ensure GRN has been completed and stock has been created.'); } DB::beginTransaction(); @@ -550,7 +574,8 @@ public function generateInspectionNumber(): string { $companyId = Auth::user()->company_id; $year = now()->format('Y'); - $prefix = "INS-{$year}-"; + $companyIdPadded = str_pad($companyId, 3, '0', STR_PAD_LEFT); + $prefix = "INS-{$year}-{$companyIdPadded}-"; $lastInspection = ReceivingInspection::where('company_id', $companyId) ->where('inspection_number', 'like', "{$prefix}%") @@ -588,9 +613,9 @@ public function getStatistics(array $filters = []): array return [ 'total_inspections' => $query->clone()->count(), 'pending_inspections' => $query->clone()->pending()->count(), - 'passed_inspections' => $query->clone()->where('result', ReceivingInspection::RESULT_PASSED)->count(), - 'failed_inspections' => $query->clone()->where('result', ReceivingInspection::RESULT_FAILED)->count(), - 'partial_inspections' => $query->clone()->where('result', ReceivingInspection::RESULT_PARTIAL)->count(), + 'passed_inspections' => $query->clone()->where('result', InspectionResult::PASSED->value)->count(), + 'failed_inspections' => $query->clone()->where('result', InspectionResult::FAILED->value)->count(), + 'partial_inspections' => $query->clone()->where('result', InspectionResult::PARTIAL->value)->count(), 'total_quantity_inspected' => $totalInspected, 'total_quantity_passed' => $totalPassed, 'total_quantity_failed' => $totalFailed, diff --git a/backend/app/Services/RoutingService.php b/backend/app/Services/RoutingService.php new file mode 100644 index 0000000..f9e3734 --- /dev/null +++ b/backend/app/Services/RoutingService.php @@ -0,0 +1,447 @@ +withCount('operations'); + + // Search + if (!empty($filters['search'])) { + $query->search($filters['search']); + } + + // Product filter + if (!empty($filters['product_id'])) { + $query->forProduct($filters['product_id']); + } + + // Status filter + if (!empty($filters['status'])) { + $query->where('status', $filters['status']); + } + + // Default only + if (!empty($filters['is_default'])) { + $query->default(); + } + + // Active only + if (!empty($filters['active_only'])) { + $query->active(); + } + + return $query->orderBy('routing_number')->paginate($perPage); + } + + /** + * Get all active routings for dropdowns + */ + public function getActiveRoutings(): Collection + { + return Routing::active() + ->with('product:id,name,sku') + ->orderBy('routing_number') + ->get(['id', 'routing_number', 'name', 'product_id', 'version']); + } + + /** + * Get routings for a specific product + */ + public function getRoutingsForProduct(int $productId): Collection + { + return Routing::forProduct($productId) + ->withCount('operations') + ->orderBy('version', 'desc') + ->get(); + } + + /** + * Get routing with full relationships + */ + public function getRouting(Routing $routing): Routing + { + return $routing->load([ + 'product:id,name,sku', + 'creator:id,first_name,last_name', + 'operations.workCenter:id,code,name,cost_per_hour', + 'operations.subcontractor:id,name', + ]); + } + + /** + * Create a new routing + */ + public function create(array $data): Routing + { + Log::info('Creating routing', [ + 'product_id' => $data['product_id'] ?? null, + 'routing_number' => $data['routing_number'] ?? null, + ]); + + // Validate product can have routing + $product = Product::with('productType')->findOrFail($data['product_id']); + if (!$product->isManufacturable()) { + throw new BusinessException( + "Product '{$product->name}' cannot have a routing. Product type must allow manufacturing." + ); + } + + return DB::transaction(function () use ($data) { + $data['company_id'] = Auth::user()->company_id; + $data['created_by'] = Auth::id(); + + // Generate routing number if not provided + if (empty($data['routing_number'])) { + $data['routing_number'] = $this->generateRoutingNumber(); + } + + // If this is the first routing for the product, make it default + $existingCount = Routing::where('product_id', $data['product_id']) + ->where('company_id', $data['company_id']) + ->count(); + + if ($existingCount === 0) { + $data['is_default'] = true; + } + + $routing = Routing::create($data); + + // Create operations if provided + if (!empty($data['operations'])) { + $this->createOperations($routing, $data['operations']); + } + + Log::info('Routing created', ['id' => $routing->id, 'routing_number' => $routing->routing_number]); + + return $routing->fresh(['operations']); + }); + } + + /** + * Update routing + */ + public function update(Routing $routing, array $data): Routing + { + if (!$routing->canEdit()) { + throw new BusinessException("Routing cannot be edited in {$routing->status->label()} status."); + } + + Log::info('Updating routing', [ + 'id' => $routing->id, + 'changes' => array_keys($data), + ]); + + $routing->update($data); + + return $routing->fresh(); + } + + /** + * Delete routing + */ + public function delete(Routing $routing): bool + { + if ($routing->workOrders()->whereNotIn('status', ['completed', 'cancelled'])->exists()) { + throw new BusinessException("Cannot delete routing with active work orders."); + } + + Log::info('Deleting routing', ['id' => $routing->id]); + + return $routing->delete(); + } + + /** + * Add operation to routing + */ + public function addOperation(Routing $routing, array $data): RoutingOperation + { + if (!$routing->canEdit()) { + throw new BusinessException("Cannot add operations to routing in {$routing->status->label()} status."); + } + + // Get next operation number + $nextOpNumber = ($routing->operations()->max('operation_number') ?? 0) + 10; + $data['operation_number'] = $data['operation_number'] ?? $nextOpNumber; + $data['routing_id'] = $routing->id; + + Log::info('Adding routing operation', [ + 'routing_id' => $routing->id, + 'operation_number' => $data['operation_number'], + ]); + + return RoutingOperation::create($data); + } + + /** + * Update routing operation + */ + public function updateOperation(Routing $routing, int $operationId, array $data): RoutingOperation + { + if (!$routing->canEdit()) { + throw new BusinessException("Cannot update operations in routing in {$routing->status->label()} status."); + } + + $operation = $routing->operations()->findOrFail($operationId); + + Log::info('Updating routing operation', ['routing_id' => $routing->id, 'operation_id' => $operationId]); + + $operation->update($data); + + return $operation->fresh(); + } + + /** + * Remove operation from routing + */ + public function removeOperation(Routing $routing, int $operationId): bool + { + if (!$routing->canEdit()) { + throw new BusinessException("Cannot remove operations from routing in {$routing->status->label()} status."); + } + + $operation = $routing->operations()->findOrFail($operationId); + + Log::info('Removing routing operation', ['routing_id' => $routing->id, 'operation_id' => $operationId]); + + return $operation->delete(); + } + + /** + * Reorder operations + */ + public function reorderOperations(Routing $routing, array $operationIds): void + { + if (!$routing->canEdit()) { + throw new BusinessException("Cannot reorder operations in routing in {$routing->status->label()} status."); + } + + Log::info('Reordering routing operations', ['routing_id' => $routing->id]); + + DB::transaction(function () use ($routing, $operationIds) { + // First, set all to negative temporary values to avoid unique constraint conflicts + foreach ($operationIds as $index => $operationId) { + $routing->operations() + ->where('id', $operationId) + ->update(['operation_number' => -($index + 1)]); + } + + // Then, set to final positive values + foreach ($operationIds as $index => $operationId) { + $routing->operations() + ->where('id', $operationId) + ->update(['operation_number' => ($index + 1) * 10]); + } + }); + } + + /** + * Activate routing + */ + public function activate(Routing $routing): Routing + { + if (!$routing->status->canTransitionTo(RoutingStatus::ACTIVE)) { + throw new BusinessException("Cannot activate routing from {$routing->status->label()} status."); + } + + if ($routing->operations()->count() === 0) { + throw new BusinessException("Cannot activate routing without operations."); + } + + Log::info('Activating routing', ['id' => $routing->id]); + + $routing->update(['status' => RoutingStatus::ACTIVE]); + + return $routing->fresh(); + } + + /** + * Mark routing as obsolete + */ + public function obsolete(Routing $routing): Routing + { + if (!$routing->status->canTransitionTo(RoutingStatus::OBSOLETE)) { + throw new BusinessException("Cannot mark routing as obsolete from {$routing->status->label()} status."); + } + + Log::info('Marking routing as obsolete', ['id' => $routing->id]); + + $routing->update([ + 'status' => RoutingStatus::OBSOLETE, + 'is_default' => false, + ]); + + return $routing->fresh(); + } + + /** + * Set routing as default for product + */ + public function setAsDefault(Routing $routing): Routing + { + if ($routing->status !== RoutingStatus::ACTIVE) { + throw new BusinessException("Only active routings can be set as default."); + } + + Log::info('Setting routing as default', ['id' => $routing->id, 'product_id' => $routing->product_id]); + + DB::transaction(function () use ($routing) { + // Remove default from other routings of same product + Routing::where('product_id', $routing->product_id) + ->where('id', '!=', $routing->id) + ->update(['is_default' => false]); + + $routing->update(['is_default' => true]); + }); + + return $routing->fresh(); + } + + /** + * Copy routing to new version + */ + public function copy(Routing $routing, ?string $newName = null): Routing + { + Log::info('Copying routing', ['source_id' => $routing->id]); + + return DB::transaction(function () use ($routing, $newName) { + // Get next version number + $nextVersion = Routing::where('product_id', $routing->product_id) + ->where('company_id', $routing->company_id) + ->max('version') + 1; + + // Create new routing + $newRouting = Routing::create([ + 'company_id' => $routing->company_id, + 'product_id' => $routing->product_id, + 'routing_number' => $this->generateRoutingNumber(), + 'version' => $nextVersion, + 'name' => $newName ?? "{$routing->name} (Copy)", + 'description' => $routing->description, + 'status' => RoutingStatus::DRAFT, + 'is_default' => false, + 'notes' => $routing->notes, + 'created_by' => Auth::id(), + ]); + + // Copy operations + foreach ($routing->operations as $operation) { + RoutingOperation::create([ + 'routing_id' => $newRouting->id, + 'work_center_id' => $operation->work_center_id, + 'operation_number' => $operation->operation_number, + 'name' => $operation->name, + 'description' => $operation->description, + 'setup_time' => $operation->setup_time, + 'run_time_per_unit' => $operation->run_time_per_unit, + 'queue_time' => $operation->queue_time, + 'move_time' => $operation->move_time, + 'is_subcontracted' => $operation->is_subcontracted, + 'subcontractor_id' => $operation->subcontractor_id, + 'subcontract_cost' => $operation->subcontract_cost, + 'instructions' => $operation->instructions, + 'settings' => $operation->settings, + ]); + } + + Log::info('Routing copied', ['source_id' => $routing->id, 'new_id' => $newRouting->id]); + + return $newRouting->fresh(['operations']); + }); + } + + /** + * Calculate total lead time for a quantity + */ + public function calculateLeadTime(Routing $routing, float $quantity): array + { + $totalSetup = 0; + $totalRun = 0; + $totalQueue = 0; + $totalMove = 0; + + foreach ($routing->operations as $op) { + $totalSetup += $op->setup_time; + $totalRun += $op->run_time_per_unit * $quantity; + $totalQueue += $op->queue_time; + $totalMove += $op->move_time; + } + + $totalMinutes = $totalSetup + $totalRun + $totalQueue + $totalMove; + + return [ + 'setup_time' => round($totalSetup, 2), + 'run_time' => round($totalRun, 2), + 'queue_time' => round($totalQueue, 2), + 'move_time' => round($totalMove, 2), + 'total_minutes' => round($totalMinutes, 2), + 'total_hours' => round($totalMinutes / 60, 2), + 'total_days' => round($totalMinutes / 60 / 8, 2), // Assuming 8-hour workday + ]; + } + + /** + * Create routing operations in bulk + */ + protected function createOperations(Routing $routing, array $operations): void + { + foreach ($operations as $index => $opData) { + $opData['routing_id'] = $routing->id; + $opData['operation_number'] = $opData['operation_number'] ?? (($index + 1) * 10); + + RoutingOperation::create($opData); + } + } + + /** + * Generate routing number + */ + public function generateRoutingNumber(): string + { + $companyId = Auth::user()->company_id; + $companyIdPadded = str_pad($companyId, 3, '0', STR_PAD_LEFT); + $prefix = "RTG-{$companyIdPadded}-"; + + $lastRouting = Routing::withTrashed() + ->where('company_id', $companyId) + ->where('routing_number', 'like', "{$prefix}%") + ->orderByRaw("CAST(SUBSTRING(routing_number FROM '[0-9]+$') AS INTEGER) DESC") + ->first(); + + if ($lastRouting && preg_match('/(\d+)$/', $lastRouting->routing_number, $matches)) { + $nextNumber = (int) $matches[1] + 1; + } else { + $nextNumber = 1; + } + + return $prefix . str_pad($nextNumber, 5, '0', STR_PAD_LEFT); + } + + /** + * Get default routing for a product + */ + public function getDefaultRoutingForProduct(int $productId): ?Routing + { + return Routing::where('product_id', $productId) + ->where('is_default', true) + ->active() + ->first(); + } +} diff --git a/backend/app/Services/SalesOrderService.php b/backend/app/Services/SalesOrderService.php new file mode 100644 index 0000000..cfe1282 --- /dev/null +++ b/backend/app/Services/SalesOrderService.php @@ -0,0 +1,725 @@ +priceService = $priceService; + $this->stockService = $stockService; + $this->auditLogService = $auditLogService; + } + + /** + * Get paginated sales orders with filters + */ + public function getSalesOrders(array $filters = [], int $perPage = 15): LengthAwarePaginator + { + $query = SalesOrder::query() + ->with(['customer', 'createdBy']); + + // Search + if (!empty($filters['search'])) { + $query->where(function ($q) use ($filters) { + $q->where('order_number', 'ilike', "%{$filters['search']}%") + ->orWhereHas('customer', function ($cq) use ($filters) { + $cq->where('name', 'ilike', "%{$filters['search']}%"); + }); + }); + } + + // Status filter + if (!empty($filters['status'])) { + $query->where('status', $filters['status']); + } + + // Customer filter + if (!empty($filters['customer_id'])) { + $query->where('customer_id', $filters['customer_id']); + } + + // Date range + if (!empty($filters['from_date'])) { + $query->where('order_date', '>=', $filters['from_date']); + } + if (!empty($filters['to_date'])) { + $query->where('order_date', '<=', $filters['to_date']); + } + + // Pending approval + if (!empty($filters['pending_approval'])) { + $query->where('status', SalesOrderStatus::PENDING_APPROVAL->value); + } + + return $query->latest('order_date')->paginate($perPage); + } + + /** + * Get single sales order with relations + */ + public function getSalesOrder(SalesOrder $salesOrder): SalesOrder + { + return $salesOrder->load([ + 'customer.customerGroup', + 'items.product', + 'deliveryNotes', + 'createdBy', + 'approvedBy', + ]); + } + + /** + * Create new sales order + */ + public function create(array $data): SalesOrder + { + Log::info('Creating sales order', [ + 'customer_id' => $data['customer_id'], + ]); + + DB::beginTransaction(); + + try { + $companyId = Auth::user()->company_id; + $customer = Customer::findOrFail($data['customer_id']); + + $salesOrder = SalesOrder::create([ + 'company_id' => $companyId, + 'customer_id' => $customer->id, + 'warehouse_id' => $data['warehouse_id'], + 'order_number' => $this->generateOrderNumber(), + 'order_date' => $data['order_date'] ?? now(), + 'requested_delivery_date' => $data['expected_delivery_date'] ?? $data['requested_delivery_date'] ?? null, + 'status' => SalesOrderStatus::DRAFT->value, + 'shipping_address' => $data['shipping_address'] ?? $customer->shipping_address, + 'notes' => $data['notes'] ?? null, + 'internal_notes' => $data['internal_notes'] ?? null, + 'subtotal' => 0, + 'tax_amount' => 0, + 'discount_amount' => 0, + 'total_amount' => 0, + 'created_by' => Auth::id(), + ]); + + // Add items + if (!empty($data['items'])) { + $this->addItems($salesOrder, $data['items'], $customer); + } + + DB::commit(); + + Log::info('Sales order created', [ + 'sales_order_id' => $salesOrder->id, + 'order_number' => $salesOrder->order_number, + ]); + + return $salesOrder->fresh(['customer', 'items.product']); + + } catch (Exception $e) { + DB::rollBack(); + + Log::error('Failed to create sales order', [ + 'error' => $e->getMessage(), + ]); + + throw $e; + } + } + + /** + * Update sales order + * Releases reservation if status changes from CONFIRMED to a non-confirmed status + */ + public function update(SalesOrder $salesOrder, array $data): SalesOrder + { + if (!$salesOrder->canBeEdited()) { + throw new BusinessException('Sales order cannot be edited in current status.'); + } + + Log::info('Updating sales order', [ + 'sales_order_id' => $salesOrder->id, + 'changes' => array_keys($data), + ]); + + DB::beginTransaction(); + + try { + $oldStatus = $salesOrder->status; + $newStatus = isset($data['status']) ? SalesOrderStatus::from($data['status']) : null; + + // If status is being changed from CONFIRMED to a non-confirmed status, release reservations + if ($oldStatus === SalesOrderStatus::CONFIRMED && $newStatus && $newStatus !== SalesOrderStatus::CONFIRMED) { + $this->releaseStockForOrder($salesOrder); + } + + $salesOrder->update([ + 'requested_delivery_date' => $data['expected_delivery_date'] ?? $data['requested_delivery_date'] ?? $salesOrder->requested_delivery_date, + 'shipping_address' => $data['shipping_address'] ?? $salesOrder->shipping_address, + 'notes' => $data['notes'] ?? $salesOrder->notes, + 'internal_notes' => $data['internal_notes'] ?? $salesOrder->internal_notes, + 'discount_amount' => $data['discount_amount'] ?? $salesOrder->discount_amount, + 'tax_amount' => $data['tax_amount'] ?? $salesOrder->tax_amount, + 'status' => $newStatus?->value ?? $salesOrder->status, + ]); + + // Update items if provided + if (isset($data['items'])) { + $customer = $salesOrder->customer; + $salesOrder->items()->delete(); + $this->addItems($salesOrder, $data['items'], $customer); + } + + $this->recalculateTotals($salesOrder); + + DB::commit(); + + return $salesOrder->fresh(['customer', 'items.product']); + + } catch (Exception $e) { + DB::rollBack(); + throw $e; + } + } + + /** + * Add items to sales order + */ + protected function addItems(SalesOrder $salesOrder, array $items, Customer $customer): void + { + $subtotal = 0; + + foreach ($items as $itemData) { + $product = Product::findOrFail($itemData['product_id']); + $quantity = $itemData['quantity']; + + // Calculate price + $priceInfo = $this->priceService->calculateEffectivePrice( + $product, + $customer->customer_group_id, + $quantity + ); + + $unitPrice = $itemData['unit_price'] ?? $priceInfo['effective_price']; + $lineTotal = $quantity * $unitPrice; + + // Get UOM - use product's default UOM if not provided + $uomId = $itemData['uom_id'] ?? $product->uom_id ?? 1; // Default to UOM ID 1 if not set + + SalesOrderItem::create([ + 'sales_order_id' => $salesOrder->id, + 'product_id' => $product->id, + 'quantity_ordered' => $quantity, + 'uom_id' => $uomId, + 'unit_price' => $unitPrice, + 'discount_amount' => $itemData['discount_amount'] ?? 0, + 'tax_amount' => $itemData['tax_amount'] ?? 0, + 'line_total' => $lineTotal, + 'notes' => $itemData['notes'] ?? null, + ]); + + $subtotal += $lineTotal; + } + + $salesOrder->update([ + 'subtotal' => $subtotal, + 'total_amount' => $subtotal + $salesOrder->tax_amount - $salesOrder->discount_amount, + ]); + } + + /** + * Recalculate order totals + */ + protected function recalculateTotals(SalesOrder $salesOrder): void + { + $subtotal = $salesOrder->items()->sum('line_total'); + $taxAmount = $salesOrder->items()->sum('tax_amount'); + + $salesOrder->update([ + 'subtotal' => $subtotal, + 'tax_amount' => $taxAmount, + 'total_amount' => $subtotal + $taxAmount - $salesOrder->discount_amount, + ]); + } + + /** + * Submit order for approval + */ + public function submitForApproval(SalesOrder $salesOrder): SalesOrder + { + $currentStatus = $salesOrder->status; + + if (!$currentStatus->canTransitionTo(SalesOrderStatus::PENDING_APPROVAL)) { + throw new BusinessException("Cannot submit order for approval from {$currentStatus->label()} status."); + } + + if ($salesOrder->items()->count() === 0) { + throw new BusinessException('Cannot submit order without items.'); + } + + Log::info('Submitting sales order for approval', [ + 'sales_order_id' => $salesOrder->id, + 'order_number' => $salesOrder->order_number, + ]); + + $salesOrder->update([ + 'status' => SalesOrderStatus::PENDING_APPROVAL->value, + ]); + + // Audit logging + $this->auditLogService->logEvent( + 'submitted_for_approval', + $salesOrder, + "Sales order submitted for approval: {$salesOrder->order_number}" + ); + + return $salesOrder->fresh(); + } + + /** + * Approve sales order + */ + public function approve(SalesOrder $salesOrder): SalesOrder + { + $currentStatus = $salesOrder->status; + + if (!$currentStatus->canTransitionTo(SalesOrderStatus::APPROVED)) { + throw new BusinessException("Cannot approve order from {$currentStatus->label()} status."); + } + + Log::info('Approving sales order', [ + 'sales_order_id' => $salesOrder->id, + 'order_number' => $salesOrder->order_number, + 'approved_by' => Auth::id(), + ]); + + $salesOrder->update([ + 'status' => SalesOrderStatus::APPROVED->value, + 'approved_by' => Auth::id(), + 'approved_at' => now(), + ]); + + // Audit logging + $this->auditLogService->logEvent( + 'approved', + $salesOrder, + "Sales order approved: {$salesOrder->order_number}", + ['approved_by' => Auth::id(), 'approved_at' => now()->toIso8601String()] + ); + + return $salesOrder->fresh(); + } + + /** + * Reject sales order + * Releases any reserved stock if order was confirmed + */ + public function reject(SalesOrder $salesOrder, ?string $reason = null): SalesOrder + { + $currentStatus = $salesOrder->status; + + if (!$currentStatus->canTransitionTo(SalesOrderStatus::REJECTED)) { + throw new BusinessException("Cannot reject order from {$currentStatus->label()} status."); + } + + Log::info('Rejecting sales order', [ + 'sales_order_id' => $salesOrder->id, + 'order_number' => $salesOrder->order_number, + 'reason' => $reason, + ]); + + DB::beginTransaction(); + + try { + // Release reserved stock if order was confirmed + if ($currentStatus === SalesOrderStatus::CONFIRMED) { + $this->releaseStockForOrder($salesOrder); + } + + $salesOrder->update([ + 'status' => SalesOrderStatus::REJECTED->value, + 'internal_notes' => $reason + ? $salesOrder->internal_notes . "\nRejection reason: " . $reason + : $salesOrder->internal_notes, + ]); + + // Audit logging + $this->auditLogService->logEvent( + 'rejected', + $salesOrder, + "Sales order rejected: {$salesOrder->order_number}", + ['reason' => $reason] + ); + + DB::commit(); + + return $salesOrder->fresh(); + + } catch (Exception $e) { + DB::rollBack(); + Log::error('Failed to reject sales order', [ + 'sales_order_id' => $salesOrder->id, + 'error' => $e->getMessage(), + ]); + throw $e; + } + } + + /** + * Confirm sales order (after approval) + * Automatically reserves stock for all items + */ + public function confirm(SalesOrder $salesOrder): SalesOrder + { + $currentStatus = $salesOrder->status; + + if (!$currentStatus->canTransitionTo(SalesOrderStatus::CONFIRMED)) { + throw new BusinessException("Cannot confirm order from {$currentStatus->label()} status."); + } + + Log::info('Confirming sales order', [ + 'sales_order_id' => $salesOrder->id, + 'order_number' => $salesOrder->order_number, + ]); + + DB::beginTransaction(); + + try { + $salesOrder->update([ + 'status' => SalesOrderStatus::CONFIRMED->value, + ]); + + // Automatically reserve stock for all items + $this->reserveStockForOrder($salesOrder); + + // Audit logging + $this->auditLogService->logEvent( + 'confirmed', + $salesOrder, + "Sales order confirmed: {$salesOrder->order_number} (stock reserved)" + ); + + DB::commit(); + + Log::info('Sales order confirmed and stock reserved', [ + 'sales_order_id' => $salesOrder->id, + ]); + + return $salesOrder->fresh(['customer', 'items.product']); + + } catch (Exception $e) { + DB::rollBack(); + Log::error('Failed to confirm sales order', [ + 'sales_order_id' => $salesOrder->id, + 'error' => $e->getMessage(), + ]); + throw $e; + } + } + + /** + * Mark order as shipped + */ + public function markAsShipped(SalesOrder $salesOrder): SalesOrder + { + $currentStatus = $salesOrder->status; + + if (!$currentStatus->canTransitionTo(SalesOrderStatus::SHIPPED)) { + throw new BusinessException("Cannot mark order as shipped from {$currentStatus->label()} status."); + } + + // Check if all items are shipped via delivery notes + $totalOrdered = $salesOrder->items()->sum('quantity_ordered'); + $totalShipped = $salesOrder->items()->sum('quantity_shipped'); + + if ($totalShipped < $totalOrdered) { + throw new BusinessException('Cannot mark as shipped: not all items have been shipped.'); + } + + Log::info('Marking sales order as shipped', [ + 'sales_order_id' => $salesOrder->id, + 'order_number' => $salesOrder->order_number, + ]); + + $salesOrder->update([ + 'status' => SalesOrderStatus::SHIPPED->value, + ]); + + // Audit logging + $this->auditLogService->logEvent( + 'shipped', + $salesOrder, + "Sales order marked as shipped: {$salesOrder->order_number}" + ); + + return $salesOrder->fresh(); + } + + /** + * Mark order as delivered + */ + public function markAsDelivered(SalesOrder $salesOrder): SalesOrder + { + $currentStatus = $salesOrder->status; + + if (!$currentStatus->canTransitionTo(SalesOrderStatus::DELIVERED)) { + throw new BusinessException("Cannot mark order as delivered from {$currentStatus->label()} status."); + } + + Log::info('Marking sales order as delivered', [ + 'sales_order_id' => $salesOrder->id, + 'order_number' => $salesOrder->order_number, + ]); + + $salesOrder->update([ + 'status' => SalesOrderStatus::DELIVERED->value, + ]); + + // Audit logging + $this->auditLogService->logEvent( + 'delivered', + $salesOrder, + "Sales order marked as delivered: {$salesOrder->order_number}" + ); + + return $salesOrder->fresh(); + } + + /** + * Cancel sales order + * Releases any reserved stock if order was confirmed + */ + public function cancel(SalesOrder $salesOrder, ?string $reason = null): SalesOrder + { + $currentStatus = $salesOrder->status; + + if (!$currentStatus->canTransitionTo(SalesOrderStatus::CANCELLED)) { + throw new BusinessException("Cannot cancel order from {$currentStatus->label()} status."); + } + + Log::info('Cancelling sales order', [ + 'sales_order_id' => $salesOrder->id, + 'order_number' => $salesOrder->order_number, + 'reason' => $reason, + ]); + + DB::beginTransaction(); + + try { + // Release reserved stock if order was confirmed + if ($currentStatus === SalesOrderStatus::CONFIRMED) { + $this->releaseStockForOrder($salesOrder); + } + + $salesOrder->update([ + 'status' => SalesOrderStatus::CANCELLED->value, + 'internal_notes' => $reason + ? $salesOrder->internal_notes . "\nCancellation reason: " . $reason + : $salesOrder->internal_notes, + ]); + + // Audit logging + $this->auditLogService->logEvent( + 'cancelled', + $salesOrder, + "Sales order cancelled: {$salesOrder->order_number}", + ['reason' => $reason] + ); + + DB::commit(); + + return $salesOrder->fresh(); + + } catch (Exception $e) { + DB::rollBack(); + Log::error('Failed to cancel sales order', [ + 'sales_order_id' => $salesOrder->id, + 'error' => $e->getMessage(), + ]); + throw $e; + } + } + + /** + * Delete sales order (only draft) + */ + public function delete(SalesOrder $salesOrder): bool + { + if ($salesOrder->status !== SalesOrderStatus::DRAFT) { + throw new BusinessException('Only draft orders can be deleted.'); + } + + Log::info('Deleting sales order', [ + 'sales_order_id' => $salesOrder->id, + 'order_number' => $salesOrder->order_number, + ]); + + $salesOrder->items()->delete(); + return $salesOrder->delete(); + } + + /** + * Reserve stock for all items in a confirmed sales order + */ + protected function reserveStockForOrder(SalesOrder $salesOrder): void + { + if (!$salesOrder->warehouse_id) { + Log::warning('Cannot reserve stock: sales order has no warehouse', [ + 'sales_order_id' => $salesOrder->id, + ]); + return; + } + + $salesOrder->load('items.product'); + + foreach ($salesOrder->items as $item) { + try { + $this->stockService->reserveStock( + $item->product_id, + $salesOrder->warehouse_id, + $item->quantity_ordered, + null, // lot_number + Stock::OPERATION_SALE, + false // skipQualityCheck + ); + + Log::info('Stock reserved for sales order item', [ + 'sales_order_id' => $salesOrder->id, + 'item_id' => $item->id, + 'product_id' => $item->product_id, + 'quantity' => $item->quantity_ordered, + ]); + } catch (BusinessException $e) { + Log::error('Failed to reserve stock for sales order item', [ + 'sales_order_id' => $salesOrder->id, + 'item_id' => $item->id, + 'product_id' => $item->product_id, + 'error' => $e->getMessage(), + ]); + // Continue with other items even if one fails + } + } + } + + /** + * Release reserved stock for all items in a sales order + */ + protected function releaseStockForOrder(SalesOrder $salesOrder): void + { + if (!$salesOrder->warehouse_id) { + Log::warning('Cannot release stock: sales order has no warehouse', [ + 'sales_order_id' => $salesOrder->id, + ]); + return; + } + + $salesOrder->load('items.product'); + + foreach ($salesOrder->items as $item) { + try { + $this->stockService->releaseReservation( + $item->product_id, + $salesOrder->warehouse_id, + $item->quantity_ordered, + null // lot_number + ); + + Log::info('Stock reservation released for sales order item', [ + 'sales_order_id' => $salesOrder->id, + 'item_id' => $item->id, + 'product_id' => $item->product_id, + 'quantity' => $item->quantity_ordered, + ]); + } catch (BusinessException $e) { + Log::error('Failed to release stock reservation for sales order item', [ + 'sales_order_id' => $salesOrder->id, + 'item_id' => $item->id, + 'product_id' => $item->product_id, + 'error' => $e->getMessage(), + ]); + // Continue with other items even if one fails + } + } + } + + /** + * Generate order number + */ + public function generateOrderNumber(): string + { + $companyId = Auth::user()->company_id; + $year = now()->format('Y'); + $companyIdPadded = str_pad($companyId, 3, '0', STR_PAD_LEFT); + $prefix = "SO-{$year}-{$companyIdPadded}-"; + + $lastOrder = SalesOrder::withTrashed() + ->where('company_id', $companyId) + ->where('order_number', 'like', "{$prefix}%") + ->orderByRaw("CAST(SUBSTRING(order_number FROM '[0-9]+$') AS INTEGER) DESC") + ->first(); + + if ($lastOrder && preg_match('/(\d+)$/', $lastOrder->order_number, $matches)) { + $nextNumber = (int) $matches[1] + 1; + } else { + $nextNumber = 1; + } + + return $prefix . str_pad($nextNumber, 5, '0', STR_PAD_LEFT); + } + + /** + * Get order statistics + */ + public function getStatistics(array $filters = []): array + { + $query = SalesOrder::query(); + + if (!empty($filters['from_date']) && !empty($filters['to_date'])) { + $query->whereBetween('order_date', [$filters['from_date'], $filters['to_date']]); + } + + return [ + 'total_orders' => $query->clone()->count(), + 'draft_orders' => $query->clone()->where('status', SalesOrderStatus::DRAFT->value)->count(), + 'pending_approval' => $query->clone()->where('status', SalesOrderStatus::PENDING_APPROVAL->value)->count(), + 'confirmed_orders' => $query->clone()->where('status', SalesOrderStatus::CONFIRMED->value)->count(), + 'shipped_orders' => $query->clone()->where('status', SalesOrderStatus::SHIPPED->value)->count(), + 'delivered_orders' => $query->clone()->where('status', SalesOrderStatus::DELIVERED->value)->count(), + 'cancelled_orders' => $query->clone()->where('status', SalesOrderStatus::CANCELLED->value)->count(), + 'total_revenue' => $query->clone()->where('status', SalesOrderStatus::DELIVERED->value)->sum('total_amount'), + ]; + } + + /** + * Get available statuses for dropdown + */ + public function getStatuses(): array + { + return array_map(fn($status) => [ + 'value' => $status->value, + 'label' => $status->label(), + 'color' => $status->color(), + ], SalesOrderStatus::cases()); + } +} diff --git a/backend/app/Services/StockAlertService.php b/backend/app/Services/StockAlertService.php new file mode 100644 index 0000000..8e6cdef --- /dev/null +++ b/backend/app/Services/StockAlertService.php @@ -0,0 +1,195 @@ +company_id; + + return Stock::where('company_id', $companyId) + ->where('quantity_on_hand', '<', 0) + ->with(['product', 'warehouse']) + ->get() + ->map(function ($stock) { + $product = $stock->product; + $policy = $product->negative_stock_policy ?? 'NEVER'; + + return [ + 'stock_id' => $stock->id, + 'product_id' => $product->id, + 'product_name' => $product->name, + 'product_sku' => $product->sku, + 'warehouse_id' => $stock->warehouse_id, + 'warehouse_name' => $stock->warehouse->name, + 'negative_quantity' => abs($stock->quantity_on_hand), + 'policy' => $policy, + 'severity' => $this->calculateSeverity($stock, $product), + 'outstanding_debt' => $this->getOutstandingDebt($stock), + ]; + }); + } + + /** + * Get weekly negative stock report + */ + public function getWeeklyNegativeStockReport(): array + { + $companyId = Auth::user()->company_id; + + $negativeStocks = Stock::where('company_id', $companyId) + ->where('quantity_on_hand', '<', 0) + ->with(['product.productType', 'warehouse']) + ->get() + ->groupBy('product.product_type_id'); + + return [ + 'total_items' => $negativeStocks->flatten()->count(), + 'by_category' => $negativeStocks->map(function ($stocks, $typeId) { + return [ + 'category_id' => $typeId, + 'category_name' => $stocks->first()->product->productType?->name, + 'count' => $stocks->count(), + 'total_negative_quantity' => $stocks->sum(function ($s) { + return abs($s->quantity_on_hand); + }), + 'items' => $stocks->map(function ($stock) { + return [ + 'product_id' => $stock->product_id, + 'product_name' => $stock->product->name, + 'warehouse_id' => $stock->warehouse_id, + 'warehouse_name' => $stock->warehouse->name, + 'negative_quantity' => abs($stock->quantity_on_hand), + 'policy' => $stock->product->negative_stock_policy, + 'days_negative' => $this->getDaysNegative($stock), + ]; + }), + ]; + }), + ]; + } + + /** + * Check long-term negative stock (outstanding for more than threshold days) + */ + public function checkLongTermNegativeStock(int $thresholdDays = 7): Collection + { + $cutoffDate = now()->subDays($thresholdDays); + + return StockDebt::whereColumn('reconciled_quantity', '<', 'quantity') + ->where('created_at', '<', $cutoffDate) + ->with(['product', 'warehouse']) + ->get() + ->map(function ($debt) { + return [ + 'debt_id' => $debt->id, + 'product_id' => $debt->product_id, + 'product_name' => $debt->product->name, + 'warehouse_id' => $debt->warehouse_id, + 'warehouse_name' => $debt->warehouse->name, + 'outstanding_quantity' => $debt->quantity - $debt->reconciled_quantity, + 'days_outstanding' => $debt->created_at->diffInDays(now()), + 'severity' => $this->calculateDebtSeverity($debt), + ]; + }); + } + + /** + * Calculate severity for negative stock + */ + protected function calculateSeverity(Stock $stock, Product $product): string + { + $negativeQty = abs($stock->quantity_on_hand); + $policy = $product->negative_stock_policy ?? 'NEVER'; + + if ($policy === 'NEVER') { + return 'critical'; // Should never happen + } + + if ($policy === 'LIMITED') { + $limit = $product->negative_stock_limit ?? 0; + $percentage = $limit > 0 ? ($negativeQty / $limit) * 100 : 0; + + if ($percentage >= 90) { + return 'critical'; + } elseif ($percentage >= 70) { + return 'high'; + } elseif ($percentage >= 50) { + return 'medium'; + } + return 'low'; + } + + // ALLOWED policy - check days negative + $daysNegative = $this->getDaysNegative($stock); + if ($daysNegative > 14) { + return 'critical'; + } elseif ($daysNegative > 7) { + return 'high'; + } elseif ($daysNegative > 3) { + return 'medium'; + } + + return 'low'; + } + + /** + * Calculate severity for stock debt + */ + protected function calculateDebtSeverity(StockDebt $debt): string + { + $daysOutstanding = $debt->created_at->diffInDays(now()); + + if ($daysOutstanding > 14) { + return 'critical'; + } elseif ($daysOutstanding > 7) { + return 'high'; + } elseif ($daysOutstanding > 3) { + return 'medium'; + } + + return 'low'; + } + + /** + * Get outstanding debt for stock + */ + protected function getOutstandingDebt(Stock $stock): float + { + return StockDebt::where('company_id', $stock->company_id) + ->where('product_id', $stock->product_id) + ->where('warehouse_id', $stock->warehouse_id) + ->whereColumn('reconciled_quantity', '<', 'quantity') + ->sum(DB::raw('quantity - reconciled_quantity')); + } + + /** + * Get days stock has been negative + */ + protected function getDaysNegative(Stock $stock): int + { + $oldestDebt = StockDebt::where('company_id', $stock->company_id) + ->where('product_id', $stock->product_id) + ->where('warehouse_id', $stock->warehouse_id) + ->whereColumn('reconciled_quantity', '<', 'quantity') + ->orderBy('created_at', 'asc') + ->first(); + + if ($oldestDebt) { + return $oldestDebt->created_at->diffInDays(now()); + } + + return 0; + } +} diff --git a/backend/app/Services/StockService.php b/backend/app/Services/StockService.php index 780a197..07c9893 100644 --- a/backend/app/Services/StockService.php +++ b/backend/app/Services/StockService.php @@ -4,10 +4,12 @@ use App\Exceptions\BusinessException; use App\Exceptions\QualityHoldException; +use App\Enums\ReservationPolicy; use App\Models\Stock; use App\Models\Product; use App\Models\Warehouse; use App\Models\StockMovement; +use App\Models\StockDebt; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Auth; @@ -156,6 +158,9 @@ public function receiveStock(array $data): Stock $stock->status = $data['status'] ?? Stock::STATUS_AVAILABLE; $stock->save(); + // If there is stock debt, automatically reconcile it + $this->reconcileStockDebts($stock, $data['quantity']); + // Create movement record $this->movementService->createMovement([ 'product_id' => $data['product_id'], @@ -216,8 +221,23 @@ public function issueStock(array $data): Stock $data['serial_number'] ?? null ); + // If stock not found, check if we can create it (for negative stock with ALLOWED policy) if (!$stock) { - throw new BusinessException("Stock not found for the specified product and warehouse."); + $product = Product::findOrFail($data['product_id']); + $policy = $product->negative_stock_policy ?? 'NEVER'; + + // Only allow creating stock if policy is ALLOWED or LIMITED + if ($policy === 'NEVER') { + throw new BusinessException("Stock not found for the specified product and warehouse."); + } + + // Create stock record with 0 quantity (will go negative) + $stock = $this->findOrCreateStock( + $data['product_id'], + $data['warehouse_id'], + $data['lot_number'] ?? null, + $data['serial_number'] ?? null + ); } // Check quality status for sale operation (unless skip_quality_check is set) @@ -226,21 +246,40 @@ public function issueStock(array $data): Stock $this->validateQualityStatus($stock, $operation); } - // Check available quantity - if ($stock->quantity_available < $data['quantity']) { - throw new BusinessException( - "Insufficient stock. Available: {$stock->quantity_available}, Requested: {$data['quantity']}" - ); - } + // Lock for update (atomicity) + $stock = Stock::where('id', $stock->id) + ->lockForUpdate() + ->first(); $quantityBefore = $stock->quantity_on_hand; + $quantityAfter = $quantityBefore - $data['quantity']; + + // Load product with fresh data + $product = Product::findOrFail($stock->product_id); + + // Check if we have enough stock or can go negative + if ($stock->quantity_available < $data['quantity']) { + // We don't have enough stock - check if we can go negative + $negativeAmount = $data['quantity'] - $stock->quantity_available; + + if (!$this->canGoNegative($product, $negativeAmount)) { + $policy = $product->negative_stock_policy ?? 'NEVER'; + throw new BusinessException( + "Insufficient stock. Available: {$stock->quantity_available}, Requested: {$data['quantity']}. " . + "Product policy: {$policy}" + ); + } + + // We can go negative - quantityAfter will be negative + // Stock debt will be created below + } // Update stock - $stock->quantity_on_hand -= $data['quantity']; + $stock->quantity_on_hand = $quantityAfter; $stock->save(); // Create movement record - $this->movementService->createMovement([ + $movement = $this->movementService->createMovement([ 'product_id' => $data['product_id'], 'warehouse_id' => $data['warehouse_id'], 'lot_number' => $data['lot_number'] ?? null, @@ -251,11 +290,16 @@ public function issueStock(array $data): Stock 'reference_id' => $data['reference_id'] ?? null, 'quantity' => -$data['quantity'], 'quantity_before' => $quantityBefore, - 'quantity_after' => $stock->quantity_on_hand, + 'quantity_after' => $quantityAfter, 'unit_cost' => $stock->unit_cost, 'notes' => $data['notes'] ?? null, ]); + // Create stock debt if going negative + if ($quantityAfter < 0) { + $this->createStockDebt($stock, abs($quantityAfter), $data, $movement); + } + DB::commit(); Log::info('Stock issued successfully', [ @@ -493,6 +537,48 @@ public function reserveStock( $this->validateQualityStatus($stock, $operation); } + // Get product and reservation policy + $product = Product::findOrFail($productId); + $policy = ReservationPolicy::tryFrom($product->reservation_policy ?? 'full') ?? ReservationPolicy::FULL; + + $availableQty = $stock->quantity_available; + $requestedQty = $quantity; + + // Check if we have enough stock + if ($availableQty < $requestedQty) { + // Handle based on reservation policy + if ($policy->shouldReject()) { + throw new BusinessException( + "Cannot reserve {$requestedQty} units. Available: {$availableQty}. " . + "Policy: {$policy->label()} (requires full quantity)." + ); + } + + // PARTIAL policy: reserve what's available + if ($policy->allowsPartial()) { + $quantity = $availableQty; // Reserve only available quantity + Log::info('Partial reservation applied', [ + 'product_id' => $productId, + 'requested' => $requestedQty, + 'reserved' => $availableQty, + 'policy' => $policy->value, + ]); + } + + // WAIT policy: TODO - Future implementation + // This policy should queue the reservation request and automatically retry + // when stock becomes available (e.g., via stock receipt webhook/event). + // Requires: Queue system, event listeners, retry mechanism. + if ($policy === ReservationPolicy::WAIT) { + throw new BusinessException( + "Insufficient stock. Available: {$availableQty}, Requested: {$requestedQty}. " . + "Policy: {$policy->label()} - ⚠️ WAIT policy is not yet implemented. " . + "Currently throws error. Future: Will queue and auto-retry when stock arrives." + ); + } + } + + // Attempt reservation if (!$stock->reserve($quantity)) { throw new BusinessException( "Cannot reserve {$quantity} units. Available: {$stock->quantity_available}" @@ -502,6 +588,7 @@ public function reserveStock( Log::info('Stock reserved', [ 'stock_id' => $stock->id, 'quantity' => $quantity, + 'policy' => $policy->value, ]); return $stock->fresh(); @@ -579,8 +666,25 @@ protected function findOrCreateStock( */ public function getLowStockProducts(int $perPage = 15): LengthAwarePaginator { + $companyId = Auth::user()->company_id; + + // Get products where total stock across all warehouses is below threshold + $lowStockProductIds = Product::where('company_id', $companyId) + ->where('is_active', true) + ->whereNotNull('low_stock_threshold') + ->where('low_stock_threshold', '>', 0) + ->get() + ->filter(function ($product) { + $totalStock = $product->getTotalStock(); + return $totalStock <= $product->low_stock_threshold; + }) + ->pluck('id'); + + // Get all stock records for these products return Stock::with(['product:id,name,sku,low_stock_threshold', 'warehouse:id,name,code']) - ->lowStock() + ->whereIn('product_id', $lowStockProductIds) + ->orderBy('product_id') + ->orderBy('warehouse_id') ->paginate($perPage); } @@ -642,4 +746,95 @@ public function getStocksByQualityStatus(string $qualityStatus, int $perPage = 1 ->orderBy('updated_at', 'desc') ->paginate($perPage); } + + /** + * Check if product can go negative + */ + protected function canGoNegative(Product $product, float $negativeAmount): bool + { + $policy = $product->negative_stock_policy ?? 'NEVER'; + + if ($policy === 'NEVER') { + return false; + } + + if ($policy === 'ALLOWED') { + return true; + } + + if ($policy === 'LIMITED') { + $limit = $product->negative_stock_limit ?? 0; + $stockData = $this->getProductStock($product->id, null); + $currentStock = $stockData['total_available'] ?? 0; + $currentNegative = max(0, -$currentStock); + return ($currentNegative + $negativeAmount) <= $limit; + } + + return false; + } + + /** + * Create stock debt record + */ + protected function createStockDebt(Stock $stock, float $debtQuantity, array $data, ?StockMovement $movement = null): void + { + StockDebt::create([ + 'company_id' => $stock->company_id, + 'product_id' => $stock->product_id, + 'warehouse_id' => $stock->warehouse_id, + 'stock_movement_id' => $movement?->id, + 'quantity' => $debtQuantity, + 'reconciled_quantity' => 0, + 'reference_type' => $data['reference_type'] ?? null, + 'reference_id' => $data['reference_id'] ?? null, + ]); + + Log::info('Stock debt created', [ + 'product_id' => $stock->product_id, + 'warehouse_id' => $stock->warehouse_id, + 'debt_quantity' => $debtQuantity, + ]); + } + + /** + * Reconcile stock debts when stock is received + */ + protected function reconcileStockDebts(Stock $stock, float $receivedQuantity): void + { + $debts = StockDebt::where('company_id', $stock->company_id) + ->where('product_id', $stock->product_id) + ->where('warehouse_id', $stock->warehouse_id) + ->whereColumn('reconciled_quantity', '<', 'quantity') + ->orderBy('created_at', 'asc') // FIFO + ->get(); + + if ($debts->isEmpty()) { + return; + } + + $remaining = $receivedQuantity; + + foreach ($debts as $debt) { + if ($remaining <= 0) { + break; + } + + $outstanding = $debt->quantity - $debt->reconciled_quantity; + $toReconcile = min($remaining, $outstanding); + + $debt->reconciled_quantity += $toReconcile; + if ($debt->reconciled_quantity >= $debt->quantity) { + $debt->reconciled_at = now(); + } + $debt->save(); + + $remaining -= $toReconcile; + + Log::info('Stock debt reconciled', [ + 'debt_id' => $debt->id, + 'reconciled_quantity' => $toReconcile, + 'remaining_debt' => $debt->quantity - $debt->reconciled_quantity, + ]); + } + } } diff --git a/backend/app/Services/SupplierService.php b/backend/app/Services/SupplierService.php index b788628..a035b0b 100644 --- a/backend/app/Services/SupplierService.php +++ b/backend/app/Services/SupplierService.php @@ -5,6 +5,7 @@ use App\Exceptions\BusinessException; use App\Models\Supplier; use App\Models\SupplierProduct; +use App\Models\ReceivingInspection; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Facades\DB; @@ -274,19 +275,144 @@ public function getStatistics(Supplier $supplier): array public function generateSupplierCode(): string { $companyId = Auth::user()->company_id; + $companyIdPadded = str_pad($companyId, 3, '0', STR_PAD_LEFT); + $prefix = "SUP-{$companyIdPadded}-"; // Include soft-deleted records to avoid duplicate codes $lastSupplier = Supplier::withTrashed() ->where('company_id', $companyId) - ->orderByRaw("CAST(SUBSTRING(supplier_code FROM '[0-9]+') AS INTEGER) DESC") + ->where('supplier_code', 'like', "{$prefix}%") + ->orderByRaw("CAST(SUBSTRING(supplier_code FROM '[0-9]+$') AS INTEGER) DESC") ->first(); - if ($lastSupplier && preg_match('/(\d+)/', $lastSupplier->supplier_code, $matches)) { + if ($lastSupplier && preg_match('/(\d+)$/', $lastSupplier->supplier_code, $matches)) { $nextNumber = (int) $matches[1] + 1; } else { $nextNumber = 1; } - return 'SUP-' . str_pad($nextNumber, 5, '0', STR_PAD_LEFT); + return $prefix . str_pad($nextNumber, 5, '0', STR_PAD_LEFT); + } + + /** + * Get supplier quality score based on inspection results + * Score is calculated as: (passed quantity / total inspected quantity) * 100 + */ + public function getQualityScore(Supplier $supplier): array + { + $stats = $this->getQualityStatistics($supplier); + + // Calculate quality score (0-100) + $score = $stats['total_inspected'] > 0 + ? round(($stats['total_passed'] / $stats['total_inspected']) * 100, 2) + : null; + + // Determine grade based on score + $grade = $this->calculateGrade($score); + + return [ + 'supplier_id' => $supplier->id, + 'supplier_name' => $supplier->name, + 'quality_score' => $score, + 'grade' => $grade, + 'total_inspections' => $stats['total_inspections'], + 'total_inspected' => $stats['total_inspected'], + 'total_passed' => $stats['total_passed'], + 'total_failed' => $stats['total_failed'], + 'pass_rate' => $stats['pass_rate'], + 'fail_rate' => $stats['fail_rate'], + 'last_inspection_date' => $stats['last_inspection_date'], + ]; + } + + /** + * Get detailed quality statistics for a supplier + */ + public function getQualityStatistics(Supplier $supplier, ?array $dateRange = null): array + { + $query = ReceivingInspection::query() + ->whereHas('goodsReceivedNote', function ($q) use ($supplier) { + $q->where('supplier_id', $supplier->id); + }) + ->whereNotNull('inspected_at'); // Only completed inspections + + if ($dateRange && !empty($dateRange['from']) && !empty($dateRange['to'])) { + $query->whereBetween('inspected_at', [$dateRange['from'], $dateRange['to']]); + } + + $inspections = $query->get(); + + $totalInspections = $inspections->count(); + $totalInspected = $inspections->sum('quantity_inspected'); + $totalPassed = $inspections->sum('quantity_passed'); + $totalFailed = $inspections->sum('quantity_failed'); + $totalOnHold = $inspections->sum('quantity_on_hold'); + + // Count by result + $resultCounts = $inspections->groupBy('result')->map->count(); + + // Get last inspection date + $lastInspection = $inspections->sortByDesc('inspected_at')->first(); + + return [ + 'total_inspections' => $totalInspections, + 'total_inspected' => $totalInspected, + 'total_passed' => $totalPassed, + 'total_failed' => $totalFailed, + 'total_on_hold' => $totalOnHold, + 'pass_rate' => $totalInspected > 0 ? round(($totalPassed / $totalInspected) * 100, 2) : 0, + 'fail_rate' => $totalInspected > 0 ? round(($totalFailed / $totalInspected) * 100, 2) : 0, + 'by_result' => [ + 'passed' => $resultCounts->get('passed', 0), + 'failed' => $resultCounts->get('failed', 0), + 'partial' => $resultCounts->get('partial', 0), + 'on_hold' => $resultCounts->get('on_hold', 0), + 'pending' => $resultCounts->get('pending', 0), + ], + 'last_inspection_date' => $lastInspection?->inspected_at?->toDateTimeString(), + ]; + } + + /** + * Get quality scores for all suppliers (for ranking/comparison) + */ + public function getQualityScoreRanking(int $limit = 10): array + { + $companyId = Auth::user()->company_id; + + $suppliers = Supplier::where('company_id', $companyId) + ->where('is_active', true) + ->get(); + + $scores = []; + foreach ($suppliers as $supplier) { + $score = $this->getQualityScore($supplier); + if ($score['total_inspections'] > 0) { + $scores[] = $score; + } + } + + // Sort by quality score descending + usort($scores, fn($a, $b) => ($b['quality_score'] ?? 0) <=> ($a['quality_score'] ?? 0)); + + return array_slice($scores, 0, $limit); + } + + /** + * Calculate grade based on quality score + */ + protected function calculateGrade(?float $score): ?string + { + if ($score === null) { + return null; + } + + return match (true) { + $score >= 95 => 'A', + $score >= 85 => 'B', + $score >= 70 => 'C', + $score >= 50 => 'D', + default => 'F', + }; } } diff --git a/backend/app/Services/UserService.php b/backend/app/Services/UserService.php index 1caaa8c..859e3c8 100755 --- a/backend/app/Services/UserService.php +++ b/backend/app/Services/UserService.php @@ -4,6 +4,7 @@ use App\Exceptions\BusinessException; use App\Models\User; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Log; @@ -22,31 +23,61 @@ public function getUser(User $user): User /** * Get paginated users with optional search + * Automatically filtered by company via BelongsToCompany trait */ public function getUsers(?string $search = null, int $perPage = 15): LengthAwarePaginator { $query = User::with('roles'); - if ($search) { - $query->where(function ($q) use ($search) { - $q->where('first_name', 'like', "%{$search}%") - ->orWhere('last_name', 'like', "%{$search}%") - ->orWhere('email', 'like', "%{$search}%"); - }); - } + if ($search) { + $query->where(function ($q) use ($search) { + $q->where('first_name', 'like', "%{$search}%") + ->orWhere('last_name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%"); + }); + } return $query->latest()->paginate($perPage); } /** * Create a new user + * Automatically assigns to authenticated user's company */ public function createUser(array $data): User { + // Get company ID from authenticated user + $companyId = Auth::user()->company_id; + + if (!$companyId) { + throw new BusinessException('User must belong to a company to create users.'); + } + + // Security: Prevent company_id from being set via request + unset($data['company_id']); + + // Check if user with this email exists (including soft deleted) + $existingUser = User::withTrashed()->where('email', $data['email'])->first(); + + if ($existingUser) { + if ($existingUser->deleted_at === null) { + throw new BusinessException('A user with this email already exists.'); + } + + // User is soft deleted - check if they belong to the same company + if ($existingUser->company_id !== $companyId) { + throw new BusinessException('A user with this email was previously deactivated in another company.'); + } + + // User belongs to same company - suggest restoration + throw new BusinessException('A user with this email was previously deactivated. Please restore the user instead of creating a new one.'); + } + Log::info('Creating new user', [ 'first_name' => $data['first_name'], 'last_name' => $data['last_name'], 'email' => $data['email'], + 'company_id' => $companyId, ]); DB::beginTransaction(); @@ -57,10 +88,11 @@ public function createUser(array $data): User 'last_name' => $data['last_name'], 'email' => $data['email'], 'password' => Hash::make($data['password']), + 'company_id' => $companyId, // Set from authenticated user's company ]); // Assign roles if provided - if (isset($data['role_ids'])) { + if (isset($data['role_ids']) && !empty($data['role_ids'])) { $this->assignRoles($user, $data['role_ids']); Log::debug('Assigned roles to user', [ 'user_id' => $user->id, @@ -70,12 +102,15 @@ public function createUser(array $data): User DB::commit(); + // Load roles relationship for response + $user->load('roles'); + Log::info('User created successfully', [ 'user_id' => $user->id, 'email' => $user->email, ]); - return $user->fresh(['roles']); + return $user; } catch (Exception $e) { DB::rollBack(); @@ -92,9 +127,13 @@ public function createUser(array $data): User /** * Update user + * Prevents company_id from being changed */ public function updateUser(User $user, array $data): User { + // Security: Prevent company_id from being changed via request + unset($data['company_id']); + Log::info('Updating user', [ 'user_id' => $user->id, 'changes' => array_keys($data), diff --git a/backend/app/Services/WorkCenterService.php b/backend/app/Services/WorkCenterService.php new file mode 100644 index 0000000..d1c65ba --- /dev/null +++ b/backend/app/Services/WorkCenterService.php @@ -0,0 +1,206 @@ +search($filters['search']); + } + + // Active filter + if (isset($filters['is_active'])) { + $query->where('is_active', filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN)); + } + + // Type filter + if (!empty($filters['work_center_type'])) { + $query->where('work_center_type', $filters['work_center_type']); + } + + return $query->orderBy('code')->paginate($perPage); + } + + /** + * Get all active work centers for dropdowns + */ + public function getActiveWorkCenters(): Collection + { + return WorkCenter::active() + ->orderBy('name') + ->get(['id', 'code', 'name', 'work_center_type', 'cost_per_hour']); + } + + /** + * Get work center with relationships + */ + public function getWorkCenter(WorkCenter $workCenter): WorkCenter + { + return $workCenter->load(['creator:id,first_name,last_name']); + } + + /** + * Create a new work center + */ + public function create(array $data): WorkCenter + { + Log::info('Creating work center', [ + 'code' => $data['code'] ?? null, + 'name' => $data['name'] ?? null, + ]); + + $data['company_id'] = Auth::user()->company_id; + $data['created_by'] = Auth::id(); + + $workCenter = WorkCenter::create($data); + + Log::info('Work center created', ['id' => $workCenter->id]); + + return $workCenter; + } + + /** + * Update work center + */ + public function update(WorkCenter $workCenter, array $data): WorkCenter + { + Log::info('Updating work center', [ + 'id' => $workCenter->id, + 'changes' => array_keys($data), + ]); + + $workCenter->update($data); + + return $workCenter->fresh(); + } + + /** + * Delete work center + */ + public function delete(WorkCenter $workCenter): bool + { + // Check if work center has active operations + $activeOperations = $workCenter->workOrderOperations() + ->whereHas('workOrder', function ($q) { + $q->whereNotIn('status', ['completed', 'cancelled']); + }) + ->count(); + + if ($activeOperations > 0) { + throw new \App\Exceptions\BusinessException( + "Cannot delete work center with {$activeOperations} active operations." + ); + } + + Log::info('Deleting work center', ['id' => $workCenter->id]); + + return $workCenter->delete(); + } + + /** + * Toggle work center active status + */ + public function toggleActive(WorkCenter $workCenter): WorkCenter + { + $newStatus = !$workCenter->is_active; + + Log::info('Toggling work center status', [ + 'id' => $workCenter->id, + 'new_status' => $newStatus, + ]); + + $workCenter->update(['is_active' => $newStatus]); + + return $workCenter->fresh(); + } + + /** + * Generate work center code + */ + public function generateCode(string $prefix = 'WC'): string + { + $companyId = Auth::user()->company_id; + $companyIdPadded = str_pad($companyId, 3, '0', STR_PAD_LEFT); + $fullPrefix = "{$prefix}-{$companyIdPadded}-"; + + $lastWC = WorkCenter::withTrashed() + ->where('company_id', $companyId) + ->where('code', 'like', "{$fullPrefix}%") + ->orderByRaw("CAST(SUBSTRING(code FROM '[0-9]+$') AS INTEGER) DESC") + ->first(); + + if ($lastWC && preg_match('/(\d+)$/', $lastWC->code, $matches)) { + $nextNumber = (int) $matches[1] + 1; + } else { + $nextNumber = 1; + } + + return $fullPrefix . str_pad($nextNumber, 4, '0', STR_PAD_LEFT); + } + + /** + * Get work center availability for a date range + */ + public function getAvailability(WorkCenter $workCenter, \DateTimeInterface $startDate, \DateTimeInterface $endDate): array + { + $availableHours = $workCenter->calculateAvailableHours($startDate, $endDate); + + // Calculate scheduled hours + $scheduledHours = $workCenter->workOrderOperations() + ->whereHas('workOrder', function ($q) { + $q->whereNotIn('status', ['completed', 'cancelled']); + }) + ->where(function ($q) use ($startDate, $endDate) { + $q->whereBetween('planned_start', [$startDate, $endDate]) + ->orWhereBetween('planned_end', [$startDate, $endDate]); + }) + ->get() + ->sum(function ($op) { + $routingOp = $op->routingOperation; + if ($routingOp) { + return ($routingOp->setup_time + ($routingOp->run_time_per_unit * $op->workOrder->quantity_ordered)) / 60; + } + return 0; + }); + + return [ + 'work_center_id' => $workCenter->id, + 'work_center_name' => $workCenter->name, + 'start_date' => $startDate->format('Y-m-d'), + 'end_date' => $endDate->format('Y-m-d'), + 'total_available_hours' => round($availableHours, 2), + 'scheduled_hours' => round($scheduledHours, 2), + 'remaining_hours' => round($availableHours - $scheduledHours, 2), + 'utilization_percentage' => $availableHours > 0 + ? round(($scheduledHours / $availableHours) * 100, 2) + : 0, + ]; + } + + /** + * Get work centers by type + */ + public function getByType(WorkCenterType $type): Collection + { + return WorkCenter::active() + ->ofType($type) + ->orderBy('name') + ->get(); + } +} diff --git a/backend/app/Services/WorkOrderService.php b/backend/app/Services/WorkOrderService.php new file mode 100644 index 0000000..ad531e3 --- /dev/null +++ b/backend/app/Services/WorkOrderService.php @@ -0,0 +1,896 @@ +search($filters['search']); + } + + // Product filter + if (!empty($filters['product_id'])) { + $query->forProduct($filters['product_id']); + } + + // Status filter + if (!empty($filters['status'])) { + $query->where('status', $filters['status']); + } + + // Priority filter + if (!empty($filters['priority'])) { + $query->where('priority', $filters['priority']); + } + + // Active only + if (!empty($filters['active_only'])) { + $query->active(); + } + + // Date range filter + if (!empty($filters['from_date']) && !empty($filters['to_date'])) { + $query->plannedBetween($filters['from_date'], $filters['to_date']); + } + + // Order by priority and status + if (!empty($filters['order_by_priority'])) { + $query->orderByRaw(" + CASE priority + WHEN 'urgent' THEN 1 + WHEN 'high' THEN 2 + WHEN 'normal' THEN 3 + WHEN 'low' THEN 4 + END + "); + } + + return $query->orderBy('created_at', 'desc')->paginate($perPage); + } + + /** + * Get work order with full relationships + */ + public function getWorkOrder(WorkOrder $workOrder): WorkOrder + { + return $workOrder->load([ + 'product:id,name,sku', + 'bom:id,bom_number,name', + 'routing:id,routing_number,name', + 'warehouse:id,name,code', + 'uom:id,code,name', + 'creator:id,first_name,last_name', + 'approver:id,first_name,last_name', + 'releaser:id,first_name,last_name', + 'operations.workCenter:id,code,name', + 'materials.product:id,name,sku', + 'materials.uom:id,code,name', + ]); + } + + /** + * Create a new work order + */ + public function create(array $data): WorkOrder + { + Log::info('Creating work order', [ + 'product_id' => $data['product_id'] ?? null, + 'quantity' => $data['quantity_ordered'] ?? null, + ]); + + return DB::transaction(function () use ($data) { + $data['company_id'] = Auth::user()->company_id; + $data['created_by'] = Auth::id(); + + // Generate work order number if not provided + if (empty($data['work_order_number'])) { + $data['work_order_number'] = $this->generateWorkOrderNumber(); + } + + // Get default BOM if not specified + if (empty($data['bom_id'])) { + $defaultBom = $this->bomService->getDefaultBomForProduct($data['product_id']); + $data['bom_id'] = $defaultBom?->id; + } + + // Get default routing if not specified + if (empty($data['routing_id'])) { + $defaultRouting = $this->routingService->getDefaultRoutingForProduct($data['product_id']); + $data['routing_id'] = $defaultRouting?->id; + } + + $workOrder = WorkOrder::create($data); + + // Copy operations from routing + if ($workOrder->routing_id) { + $this->copyOperationsFromRouting($workOrder); + } + + // Calculate material requirements from BOM + if ($workOrder->bom_id) { + $this->calculateMaterialRequirements($workOrder); + } + + // Calculate estimated cost + $this->calculateEstimatedCost($workOrder); + + Log::info('Work order created', [ + 'id' => $workOrder->id, + 'work_order_number' => $workOrder->work_order_number, + ]); + + return $workOrder->fresh(['operations', 'materials']); + }); + } + + /** + * Update work order + */ + public function update(WorkOrder $workOrder, array $data): WorkOrder + { + if (!$workOrder->canEdit()) { + throw new BusinessException("Work order cannot be edited in {$workOrder->status->label()} status."); + } + + Log::info('Updating work order', [ + 'id' => $workOrder->id, + 'changes' => array_keys($data), + ]); + + $workOrder->update($data); + + // Recalculate materials if quantity changed + if (isset($data['quantity_ordered']) && $workOrder->bom_id) { + $this->calculateMaterialRequirements($workOrder); + } + + return $workOrder->fresh(); + } + + /** + * Delete work order + */ + public function delete(WorkOrder $workOrder): bool + { + if ($workOrder->status !== WorkOrderStatus::DRAFT) { + throw new BusinessException("Only draft work orders can be deleted."); + } + + Log::info('Deleting work order', ['id' => $workOrder->id]); + + return $workOrder->delete(); + } + + /** + * Release work order for production + * Automatically reserves materials for all items + */ + public function release(WorkOrder $workOrder): WorkOrder + { + if (!$workOrder->canRelease()) { + throw new BusinessException("Work order cannot be released from {$workOrder->status->label()} status."); + } + + Log::info('Releasing work order', ['id' => $workOrder->id]); + + DB::beginTransaction(); + + try { + $workOrder->update([ + 'status' => WorkOrderStatus::RELEASED, + 'released_by' => Auth::id(), + 'released_at' => now(), + ]); + + // Automatically reserve materials for all items + $this->reserveMaterialsForOrder($workOrder); + + // Audit logging + $this->auditLogService->logEvent( + 'released', + $workOrder, + "Work order released: {$workOrder->work_order_number} (materials reserved)", + ['released_by' => Auth::id(), 'released_at' => now()->toIso8601String()] + ); + + DB::commit(); + + Log::info('Work order released and materials reserved', [ + 'work_order_id' => $workOrder->id, + ]); + + return $workOrder->fresh(['materials.product']); + + } catch (Exception $e) { + DB::rollBack(); + Log::error('Failed to release work order', [ + 'work_order_id' => $workOrder->id, + 'error' => $e->getMessage(), + ]); + throw $e; + } + } + + /** + * Start work order + */ + public function start(WorkOrder $workOrder): WorkOrder + { + if (!$workOrder->canStart()) { + throw new BusinessException("Work order cannot be started from {$workOrder->status->label()} status."); + } + + Log::info('Starting work order', ['id' => $workOrder->id]); + + $workOrder->update([ + 'status' => WorkOrderStatus::IN_PROGRESS, + 'actual_start_date' => $workOrder->actual_start_date ?? now(), + ]); + + // Audit logging + $this->auditLogService->logEvent( + 'started', + $workOrder, + "Work order started: {$workOrder->work_order_number}" + ); + + return $workOrder->fresh(); + } + + /** + * Complete work order + */ + public function complete(WorkOrder $workOrder): WorkOrder + { + if (!$workOrder->canComplete()) { + throw new BusinessException("Work order cannot be completed from {$workOrder->status->label()} status."); + } + + // Check if all operations are completed + if (!$workOrder->allOperationsCompleted()) { + throw new BusinessException("All operations must be completed before completing the work order."); + } + + Log::info('Completing work order', ['id' => $workOrder->id]); + + $workOrder->update([ + 'status' => WorkOrderStatus::COMPLETED, + 'actual_end_date' => now(), + 'completed_by' => Auth::id(), + 'completed_at' => now(), + ]); + + // Audit logging + $this->auditLogService->logEvent( + 'completed', + $workOrder, + "Work order completed: {$workOrder->work_order_number}", + ['completed_by' => Auth::id(), 'completed_at' => now()->toIso8601String()] + ); + + return $workOrder->fresh(); + } + + /** + * Cancel work order + * Releases any reserved materials if order was released + */ + public function cancel(WorkOrder $workOrder, ?string $reason = null): WorkOrder + { + if (!$workOrder->canCancel()) { + throw new BusinessException("Work order cannot be cancelled from {$workOrder->status->label()} status."); + } + + Log::info('Cancelling work order', ['id' => $workOrder->id, 'reason' => $reason]); + + DB::beginTransaction(); + + try { + // Release reserved materials if order was released + if (in_array($workOrder->status, [WorkOrderStatus::RELEASED, WorkOrderStatus::IN_PROGRESS, WorkOrderStatus::ON_HOLD])) { + $this->releaseMaterialsForOrder($workOrder); + } + + $workOrder->update([ + 'status' => WorkOrderStatus::CANCELLED, + 'notes' => $reason ? ($workOrder->notes . "\n\nCancelled: " . $reason) : $workOrder->notes, + ]); + + // Audit logging + $this->auditLogService->logEvent( + 'cancelled', + $workOrder, + "Work order cancelled: {$workOrder->work_order_number}", + ['reason' => $reason] + ); + + DB::commit(); + + return $workOrder->fresh(); + + } catch (Exception $e) { + DB::rollBack(); + Log::error('Failed to cancel work order', [ + 'work_order_id' => $workOrder->id, + 'error' => $e->getMessage(), + ]); + throw $e; + } + } + + /** + * Put work order on hold + */ + public function hold(WorkOrder $workOrder, ?string $reason = null): WorkOrder + { + if (!$workOrder->status->canHold()) { + throw new BusinessException("Work order cannot be put on hold from {$workOrder->status->label()} status."); + } + + Log::info('Putting work order on hold', ['id' => $workOrder->id, 'reason' => $reason]); + + $workOrder->update([ + 'status' => WorkOrderStatus::ON_HOLD, + 'notes' => $reason ? ($workOrder->notes . "\n\nOn hold: " . $reason) : $workOrder->notes, + ]); + + return $workOrder->fresh(); + } + + /** + * Resume work order from hold + */ + public function resume(WorkOrder $workOrder): WorkOrder + { + if ($workOrder->status !== WorkOrderStatus::ON_HOLD) { + throw new BusinessException("Only on-hold work orders can be resumed."); + } + + // Determine previous state + $newStatus = $workOrder->actual_start_date + ? WorkOrderStatus::IN_PROGRESS + : WorkOrderStatus::RELEASED; + + Log::info('Resuming work order', ['id' => $workOrder->id, 'new_status' => $newStatus->value]); + + $workOrder->update(['status' => $newStatus]); + + return $workOrder->fresh(); + } + + /** + * Start an operation + */ + public function startOperation(WorkOrder $workOrder, int $operationId): WorkOrderOperation + { + if (!$workOrder->status->isActive()) { + throw new BusinessException("Cannot start operations on inactive work order."); + } + + $operation = $workOrder->operations()->findOrFail($operationId); + + if (!$operation->canStart()) { + throw new BusinessException("Operation cannot be started from {$operation->status->label()} status."); + } + + Log::info('Starting operation', [ + 'work_order_id' => $workOrder->id, + 'operation_id' => $operationId, + ]); + + $operation->update([ + 'status' => OperationStatus::IN_PROGRESS, + 'actual_start' => now(), + 'started_by' => Auth::id(), + ]); + + // Start work order if not already started + if ($workOrder->status === WorkOrderStatus::RELEASED) { + $this->start($workOrder); + } + + return $operation->fresh(); + } + + /** + * Complete an operation + */ + public function completeOperation( + WorkOrder $workOrder, + int $operationId, + float $quantityCompleted, + float $quantityScrapped = 0, + ?float $actualSetupTime = null, + ?float $actualRunTime = null, + ?string $notes = null + ): WorkOrderOperation { + $operation = $workOrder->operations()->findOrFail($operationId); + + if (!$operation->canComplete()) { + throw new BusinessException("Operation cannot be completed from {$operation->status->label()} status."); + } + + Log::info('Completing operation', [ + 'work_order_id' => $workOrder->id, + 'operation_id' => $operationId, + 'quantity_completed' => $quantityCompleted, + ]); + + $operation->update([ + 'status' => OperationStatus::COMPLETED, + 'quantity_completed' => $quantityCompleted, + 'quantity_scrapped' => $quantityScrapped, + 'actual_end' => now(), + 'actual_setup_time' => $actualSetupTime ?? $operation->actual_setup_time, + 'actual_run_time' => $actualRunTime ?? $operation->actual_run_time, + 'notes' => $notes, + 'completed_by' => Auth::id(), + ]); + + // Calculate operation cost + $this->calculateOperationCost($operation->fresh()); + + return $operation->fresh(); + } + + /** + * Get material requirements for work order + */ + public function getMaterialRequirements(WorkOrder $workOrder): array + { + $materials = $workOrder->materials() + ->with(['product:id,name,sku', 'uom:id,code,name', 'warehouse:id,name,code']) + ->get(); + + $requirements = []; + + foreach ($materials as $material) { + // Get available stock + $availableStock = Stock::where('product_id', $material->product_id) + ->where('warehouse_id', $material->warehouse_id) + ->qualityAvailable() + ->sum('quantity_available'); + + $requirements[] = [ + 'material' => $material, + 'available_stock' => $availableStock, + 'shortage' => max(0, $material->outstanding_quantity - $availableStock), + ]; + } + + return $requirements; + } + + /** + * Issue materials for work order + */ + public function issueMaterials(WorkOrder $workOrder, ?array $materialIds = null): WorkOrder + { + if (!$workOrder->canIssueMaterials()) { + throw new BusinessException("Materials cannot be issued in {$workOrder->status->label()} status."); + } + + Log::info('Issuing materials for work order', ['id' => $workOrder->id]); + + return DB::transaction(function () use ($workOrder, $materialIds) { + $query = $workOrder->materials()->withOutstanding(); + + if ($materialIds) { + $query->whereIn('id', $materialIds); + } + + $materials = $query->get(); + + foreach ($materials as $material) { + $this->issueMaterial($workOrder, $material); + } + + return $workOrder->fresh(['materials']); + }); + } + + /** + * Issue single material + * Releases reservation and issues physical stock + */ + protected function issueMaterial(WorkOrder $workOrder, WorkOrderMaterial $material): void + { + $outstandingQty = $material->outstanding_quantity; + + if ($outstandingQty <= 0) { + return; + } + + // Release reservation first (physical stock is being issued) + try { + $this->stockService->releaseReservation( + $material->product_id, + $material->warehouse_id, + $outstandingQty, + null // lot_number + ); + } catch (BusinessException $e) { + // If reservation doesn't exist or is less, log warning but continue + Log::warning('Could not release reservation for work order material', [ + 'work_order_id' => $workOrder->id, + 'material_id' => $material->id, + 'product_id' => $material->product_id, + 'error' => $e->getMessage(), + ]); + } + + // Issue stock + $this->stockService->issueStock([ + 'product_id' => $material->product_id, + 'warehouse_id' => $material->warehouse_id, + 'quantity' => $outstandingQty, + 'operation_type' => Stock::OPERATION_PRODUCTION, + 'transaction_type' => 'production_order', + 'reference_type' => WorkOrder::class, + 'reference_id' => $workOrder->id, + 'notes' => "Material issued for WO: {$workOrder->work_order_number}", + ]); + + // Update material record + $material->update([ + 'quantity_issued' => $material->quantity_issued + $outstandingQty, + ]); + + Log::info('Material issued', [ + 'work_order_id' => $workOrder->id, + 'product_id' => $material->product_id, + 'quantity' => $outstandingQty, + ]); + } + + /** + * Receive finished goods + */ + public function receiveFinishedGoods( + WorkOrder $workOrder, + float $quantity, + ?string $lotNumber = null, + ?float $unitCost = null + ): WorkOrder { + if (!$workOrder->canReceiveFinishedGoods()) { + throw new BusinessException("Finished goods cannot be received in {$workOrder->status->label()} status."); + } + + $maxReceivable = $workOrder->remaining_quantity; + + if ($quantity > $maxReceivable) { + throw new BusinessException("Cannot receive more than remaining quantity ({$maxReceivable})."); + } + + Log::info('Receiving finished goods', [ + 'work_order_id' => $workOrder->id, + 'quantity' => $quantity, + ]); + + return DB::transaction(function () use ($workOrder, $quantity, $lotNumber, $unitCost) { + // Calculate unit cost if not provided + if ($unitCost === null) { + $unitCost = $this->calculateUnitCost($workOrder); + } + + // Receive stock + $this->stockService->receiveStock([ + 'product_id' => $workOrder->product_id, + 'warehouse_id' => $workOrder->warehouse_id, + 'quantity' => $quantity, + 'unit_cost' => $unitCost, + 'lot_number' => $lotNumber, + 'transaction_type' => 'production_order', + 'reference_type' => WorkOrder::class, + 'reference_id' => $workOrder->id, + 'notes' => "Finished goods from WO: {$workOrder->work_order_number}", + ]); + + // Update work order + $workOrder->update([ + 'quantity_completed' => $workOrder->quantity_completed + $quantity, + ]); + + // Auto-complete if all quantity received + if ($workOrder->fresh()->remaining_quantity <= 0 && $workOrder->allOperationsCompleted()) { + $this->complete($workOrder); + } + + return $workOrder->fresh(); + }); + } + + /** + * Get work order statistics + */ + public function getStatistics(): array + { + $companyId = Auth::user()->company_id; + + $workOrders = WorkOrder::where('company_id', $companyId); + + return [ + 'total' => $workOrders->clone()->count(), + 'by_status' => [ + 'draft' => $workOrders->clone()->where('status', WorkOrderStatus::DRAFT)->count(), + 'released' => $workOrders->clone()->where('status', WorkOrderStatus::RELEASED)->count(), + 'in_progress' => $workOrders->clone()->where('status', WorkOrderStatus::IN_PROGRESS)->count(), + 'completed' => $workOrders->clone()->where('status', WorkOrderStatus::COMPLETED)->count(), + 'cancelled' => $workOrders->clone()->where('status', WorkOrderStatus::CANCELLED)->count(), + 'on_hold' => $workOrders->clone()->where('status', WorkOrderStatus::ON_HOLD)->count(), + ], + 'by_priority' => [ + 'urgent' => $workOrders->clone()->active()->where('priority', WorkOrderPriority::URGENT)->count(), + 'high' => $workOrders->clone()->active()->where('priority', WorkOrderPriority::HIGH)->count(), + 'normal' => $workOrders->clone()->active()->where('priority', WorkOrderPriority::NORMAL)->count(), + 'low' => $workOrders->clone()->active()->where('priority', WorkOrderPriority::LOW)->count(), + ], + 'overdue' => $workOrders->clone() + ->active() + ->where('planned_end_date', '<', now()) + ->count(), + ]; + } + + /** + * Copy operations from routing to work order + */ + protected function copyOperationsFromRouting(WorkOrder $workOrder): void + { + $routing = $workOrder->routing; + + if (!$routing) { + return; + } + + foreach ($routing->operations as $routingOp) { + WorkOrderOperation::create([ + 'work_order_id' => $workOrder->id, + 'routing_operation_id' => $routingOp->id, + 'work_center_id' => $routingOp->work_center_id, + 'operation_number' => $routingOp->operation_number, + 'name' => $routingOp->name, + 'description' => $routingOp->description, + 'status' => OperationStatus::PENDING, + ]); + } + } + + /** + * Calculate material requirements from BOM + */ + protected function calculateMaterialRequirements(WorkOrder $workOrder): void + { + // Clear existing materials + $workOrder->materials()->delete(); + + $bom = $workOrder->bom; + + if (!$bom) { + return; + } + + // Explode BOM + $materials = $this->bomService->explodeBom($bom, $workOrder->quantity_ordered); + + // Create material records + foreach ($materials as $material) { + WorkOrderMaterial::create([ + 'work_order_id' => $workOrder->id, + 'product_id' => $material['product_id'], + 'bom_item_id' => $material['bom_item_id'], + 'quantity_required' => $material['quantity'], + 'uom_id' => $material['uom_id'], + 'warehouse_id' => $workOrder->warehouse_id, + ]); + } + } + + /** + * Calculate estimated cost + */ + protected function calculateEstimatedCost(WorkOrder $workOrder): void + { + $materialCost = 0; + $laborCost = 0; + + // Material cost - use cost_price, fallback to average stock cost + foreach ($workOrder->materials as $material) { + $product = $material->product; + $unitCost = $product->cost_price ?? $product->stocks()->avg('unit_cost') ?? 0; + $materialCost += $material->quantity_required * $unitCost; + } + + // Labor cost from routing + if ($workOrder->routing) { + foreach ($workOrder->routing->operations as $op) { + $laborCost += $op->calculateCost($workOrder->quantity_ordered); + } + } + + $workOrder->update([ + 'estimated_cost' => $materialCost + $laborCost, + ]); + } + + /** + * Calculate operation cost + */ + protected function calculateOperationCost(WorkOrderOperation $operation): void + { + $workCenter = $operation->workCenter; + + if (!$workCenter) { + return; + } + + $hours = $operation->total_actual_time / 60; + $cost = $hours * $workCenter->cost_per_hour; + + $operation->update(['actual_cost' => $cost]); + + // Update work order actual cost + $workOrder = $operation->workOrder; + $totalCost = $workOrder->operations()->sum('actual_cost'); + $materialCost = $workOrder->materials()->sum('total_cost'); + + $workOrder->update(['actual_cost' => $totalCost + $materialCost]); + } + + /** + * Calculate unit cost for finished goods + */ + protected function calculateUnitCost(WorkOrder $workOrder): float + { + if ($workOrder->quantity_completed + $workOrder->quantity_scrapped == 0) { + return 0; + } + + return $workOrder->actual_cost / ($workOrder->quantity_completed + $workOrder->quantity_scrapped); + } + + /** + * Generate work order number + */ + public function generateWorkOrderNumber(): string + { + $companyId = Auth::user()->company_id; + $companyIdPadded = str_pad($companyId, 3, '0', STR_PAD_LEFT); + $prefix = 'WO-' . now()->format('Ym') . "-{$companyIdPadded}-"; + + $lastWO = WorkOrder::withTrashed() + ->where('company_id', $companyId) + ->where('work_order_number', 'like', "{$prefix}%") + ->orderByRaw("CAST(SUBSTRING(work_order_number FROM '[0-9]+$') AS INTEGER) DESC") + ->first(); + + if ($lastWO && preg_match('/(\d+)$/', $lastWO->work_order_number, $matches)) { + $nextNumber = (int) $matches[1] + 1; + } else { + $nextNumber = 1; + } + + return $prefix . str_pad($nextNumber, 4, '0', STR_PAD_LEFT); + } + + /** + * Reserve materials for all items in a released work order + */ + protected function reserveMaterialsForOrder(WorkOrder $workOrder): void + { + if (!$workOrder->warehouse_id) { + Log::warning('Cannot reserve materials: work order has no warehouse', [ + 'work_order_id' => $workOrder->id, + ]); + return; + } + + $workOrder->load('materials.product'); + + foreach ($workOrder->materials as $material) { + try { + $this->stockService->reserveStock( + $material->product_id, + $material->warehouse_id, + $material->quantity_required, + null, // lot_number + Stock::OPERATION_PRODUCTION, + false // skipQualityCheck + ); + + Log::info('Material reserved for work order', [ + 'work_order_id' => $workOrder->id, + 'material_id' => $material->id, + 'product_id' => $material->product_id, + 'quantity' => $material->quantity_required, + ]); + } catch (BusinessException $e) { + Log::error('Failed to reserve material for work order', [ + 'work_order_id' => $workOrder->id, + 'material_id' => $material->id, + 'product_id' => $material->product_id, + 'error' => $e->getMessage(), + ]); + // Continue with other materials even if one fails + } + } + } + + /** + * Release reserved materials for all items in a work order + */ + protected function releaseMaterialsForOrder(WorkOrder $workOrder): void + { + if (!$workOrder->warehouse_id) { + Log::warning('Cannot release materials: work order has no warehouse', [ + 'work_order_id' => $workOrder->id, + ]); + return; + } + + $workOrder->load('materials.product'); + + foreach ($workOrder->materials as $material) { + try { + $this->stockService->releaseReservation( + $material->product_id, + $material->warehouse_id, + $material->quantity_required, + null // lot_number + ); + + Log::info('Material reservation released for work order', [ + 'work_order_id' => $workOrder->id, + 'material_id' => $material->id, + 'product_id' => $material->product_id, + 'quantity' => $material->quantity_required, + ]); + } catch (BusinessException $e) { + Log::error('Failed to release material reservation for work order', [ + 'work_order_id' => $workOrder->id, + 'material_id' => $material->id, + 'product_id' => $material->product_id, + 'error' => $e->getMessage(), + ]); + // Continue with other materials even if one fails + } + } + } +} diff --git a/backend/app/Traits/BelongsToCompany.php b/backend/app/Traits/BelongsToCompany.php index 287392e..0d8b039 100644 --- a/backend/app/Traits/BelongsToCompany.php +++ b/backend/app/Traits/BelongsToCompany.php @@ -46,16 +46,49 @@ public function company(): BelongsTo /** * Scope to query without company filter - * Useful for admin operations that need to see all companies' data + * + * ⚠️ SECURITY WARNING: This bypasses company isolation! + * + * This method should ONLY be used by: + * - Platform/Super administrators (users without company_id or with special permission) + * - Background jobs that explicitly need cross-company access + * - System-level operations + * + * ⚠️ DO NOT use this in regular controller/service methods accessible by company admins. + * Normal company admins should only see their own company's data. + * + * If you need to query a specific company, use scopeForCompany() instead. + * + * @throws \Exception if called by a regular company user (optional - can be enabled for strict mode) */ public function scopeWithoutCompanyScope($query) { + // Strict security check: Only platform admins can bypass company scope + if (Auth::check()) { + $user = Auth::user(); + + // Platform admin: company_id must be null + // Users with company_id cannot bypass company isolation + if ($user->company_id !== null) { + throw new \Exception('Access denied: withoutCompanyScope() can only be used by platform administrators (users with company_id = null). Regular company users cannot bypass company isolation.'); + } + } + return $query->withoutGlobalScope(CompanyScope::class); } /** * Scope to query a specific company's data - * Useful for admin operations or background jobs + * + * Safer alternative to withoutCompanyScope() when you need to query a specific company. + * Still bypasses the global scope but explicitly filters by company_id. + * + * Use cases: + * - Background jobs processing specific companies + * - Platform admin viewing a specific company's data + * - System operations that need company-specific queries + * + * @param int $companyId The company ID to query */ public function scopeForCompany($query, int $companyId) { diff --git a/backend/app/Traits/Blameable.php b/backend/app/Traits/Blameable.php new file mode 100644 index 0000000..13d8a81 --- /dev/null +++ b/backend/app/Traits/Blameable.php @@ -0,0 +1,80 @@ +created_by)) { + $model->created_by = Auth::id(); + } + }); + + // Set updated_by when updating + static::updating(function ($model) { + if (Auth::check() && in_array('updated_by', $model->getFillable())) { + $model->updated_by = Auth::id(); + } + }); + + // Set deleted_by when soft deleting + static::deleting(function ($model) { + if (Auth::check() && method_exists($model, 'isForceDeleting') && !$model->isForceDeleting()) { + // Soft delete + if (in_array('deleted_by', $model->getFillable())) { + $model->deleted_by = Auth::id(); + $model->saveQuietly(); // Save without triggering events + } + } elseif (Auth::check() && !method_exists($model, 'isForceDeleting')) { + // Hard delete (no soft deletes) + if (in_array('deleted_by', $model->getFillable())) { + $model->deleted_by = Auth::id(); + $model->saveQuietly(); // Save before deletion + } + } + }); + } + + /** + * Get the user who created this model + */ + public function creator() + { + return $this->belongsTo(\App\Models\User::class, 'created_by'); + } + + /** + * Get the user who last updated this model + */ + public function updater() + { + return $this->belongsTo(\App\Models\User::class, 'updated_by'); + } + + /** + * Get the user who deleted this model + */ + public function deleter() + { + return $this->belongsTo(\App\Models\User::class, 'deleted_by'); + } +} diff --git a/backend/bootstrap/app.php b/backend/bootstrap/app.php index 194267c..55252bb 100644 --- a/backend/bootstrap/app.php +++ b/backend/bootstrap/app.php @@ -70,6 +70,13 @@ } }); + // Module disabled errors (403) - must be before BusinessException since it extends it + $exceptions->render(function (ModuleDisabledException $e, Request $request) { + if ($request->is('api/*') || $request->expectsJson()) { + return response()->json($e->getErrorDetails(), $e->getStatusCode()); + } + }); + // Business logic errors (custom status code, default 422) $exceptions->render(function (BusinessException $e, Request $request) { if ($request->is('api/*') || $request->expectsJson()) { @@ -79,12 +86,5 @@ ], $e->getStatusCode()); } }); - - // Module disabled errors (403) - $exceptions->render(function (ModuleDisabledException $e, Request $request) { - if ($request->is('api/*') || $request->expectsJson()) { - return response()->json($e->getErrorDetails(), $e->getStatusCode()); - } - }); }) ->create(); diff --git a/backend/config/audit.php b/backend/config/audit.php new file mode 100644 index 0000000..f21ad02 --- /dev/null +++ b/backend/config/audit.php @@ -0,0 +1,125 @@ + env('AUDIT_SYNC', false), + + /* + |-------------------------------------------------------------------------- + | Queue Connection + |-------------------------------------------------------------------------- + | + | The queue connection to use for async audit logging. + | + */ + + 'queue' => env('AUDIT_QUEUE', 'default'), + + /* + |-------------------------------------------------------------------------- + | Retention Days + |-------------------------------------------------------------------------- + | + | How many days to keep audit logs before cleanup. + | Default: 2555 days (7 years) for compliance requirements. + | + */ + + 'retention_days' => env('AUDIT_RETENTION_DAYS', 2555), + + /* + |-------------------------------------------------------------------------- + | Critical Events + |-------------------------------------------------------------------------- + | + | Events that should always be logged synchronously, even if sync=false. + | These are critical events that must not be lost. + | + */ + + 'critical_events' => [ + 'deleted', + 'approved', + 'rejected', + 'stock_adjusted', + 'quality_hold_placed', + 'quality_hold_released', + ], + + /* + |-------------------------------------------------------------------------- + | Critical Entities + |-------------------------------------------------------------------------- + | + | Entity types that should always be logged synchronously, even if sync=false. + | These are critical models that must not lose audit logs. + | + */ + + 'critical_entities' => [ + \App\Models\WorkOrder::class, + \App\Models\PurchaseOrder::class, + \App\Models\StockMovement::class, + \App\Models\SalesOrder::class, + \App\Models\GoodsReceivedNote::class, + \App\Models\DeliveryNote::class, + ], + + /* + |-------------------------------------------------------------------------- + | Excluded Fields + |-------------------------------------------------------------------------- + | + | Fields that should never be logged in audit changes. + | These are typically timestamps or system fields. + | + */ + + 'excluded_fields' => [ + 'updated_at', + 'created_at', + 'deleted_at', + ], + + /* + |-------------------------------------------------------------------------- + | Sensitive Fields + |-------------------------------------------------------------------------- + | + | Fields that should be masked in audit logs (shown as ***MASKED***). + | These are sensitive data like passwords, API keys, etc. + | + */ + + 'sensitive_fields' => [ + 'password', + 'api_key', + 'secret', + 'token', + 'remember_token', + ], +]; diff --git a/backend/config/cache.php b/backend/config/cache.php index b32aead..b375022 100644 --- a/backend/config/cache.php +++ b/backend/config/cache.php @@ -15,7 +15,7 @@ | */ - 'default' => env('CACHE_STORE', 'database'), + 'default' => env('CACHE_STORE', 'redis'), /* |-------------------------------------------------------------------------- diff --git a/backend/config/modules.php b/backend/config/modules.php index 1db2981..6ff650c 100644 --- a/backend/config/modules.php +++ b/backend/config/modules.php @@ -42,18 +42,16 @@ |-------------------------------------------------------------------------- | | Handles supplier management, purchase orders, and receiving operations. - | Includes optional basic quality control (pass/fail inspections). | */ 'procurement' => [ - 'enabled' => env('MODULE_PROCUREMENT_ENABLED', true), + 'enabled' => env('MODULE_PROCUREMENT_ENABLED', false), 'name' => 'Procurement', - 'description' => 'Supplier management, purchase orders, receiving, and quality control', + 'description' => 'Supplier management, purchase orders, and receiving', 'features' => [ 'suppliers' => true, 'purchase_orders' => true, 'receiving' => true, - 'quality_control' => env('MODULE_PROCUREMENT_QC_ENABLED', true), ], ], @@ -63,18 +61,58 @@ |-------------------------------------------------------------------------- | | Handles bill of materials, work orders, and production operations. - | Includes optional basic quality control (pass/fail inspections). | */ 'manufacturing' => [ 'enabled' => env('MODULE_MANUFACTURING_ENABLED', false), 'name' => 'Manufacturing', - 'description' => 'Bill of materials, work orders, production, and quality control', + 'description' => 'Bill of materials, work orders, and production', 'features' => [ 'bom' => true, 'work_orders' => true, 'production' => true, - 'quality_control' => env('MODULE_MANUFACTURING_QC_ENABLED', true), + ], + ], + + /* + |-------------------------------------------------------------------------- + | Sales Module + |-------------------------------------------------------------------------- + | + | Handles customer management, sales orders, and delivery operations. + | Provides data for ML forecasting service. + | + */ + 'sales' => [ + 'enabled' => env('MODULE_SALES_ENABLED', false), + 'name' => 'Sales', + 'description' => 'Customer management, sales orders, delivery notes, and customer group pricing', + 'features' => [ + 'customer_groups' => true, + 'sales_orders' => true, + 'delivery_notes' => true, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Quality Control Module + |-------------------------------------------------------------------------- + | + | Handles quality control operations including acceptance rules, + | receiving inspections, and non-conformance reports. + | Can be used independently or with Procurement/Manufacturing modules. + | + */ + 'qc' => [ + 'enabled' => env('MODULE_QC_ENABLED', false), + 'name' => 'Quality Control', + 'description' => 'Acceptance rules, receiving inspections, non-conformance reports, and supplier quality tracking', + 'features' => [ + 'acceptance_rules' => true, + 'receiving_inspections' => true, + 'non_conformance_reports' => true, + 'supplier_quality' => true, ], ], diff --git a/backend/config/queue.php b/backend/config/queue.php index 79c2c0a..a0f669c 100644 --- a/backend/config/queue.php +++ b/backend/config/queue.php @@ -13,7 +13,7 @@ | */ - 'default' => env('QUEUE_CONNECTION', 'database'), + 'default' => env('QUEUE_CONNECTION', 'redis'), /* |-------------------------------------------------------------------------- diff --git a/backend/database/migrations/2025_12_09_213228_create_categories_table.php b/backend/database/migrations/2025_12_09_213228_create_categories_table.php index 44f2a75..f72b25c 100644 --- a/backend/database/migrations/2025_12_09_213228_create_categories_table.php +++ b/backend/database/migrations/2025_12_09_213228_create_categories_table.php @@ -15,15 +15,19 @@ public function up(): void $table->id(); $table->foreignId('company_id')->constrained()->cascadeOnDelete(); $table->string('name'); - $table->string('slug')->unique(); + $table->string('slug'); // Company-scoped unique (composite index below) $table->text('description')->nullable(); $table->foreignId('parent_id')->nullable()->constrained('categories')->nullOnDelete(); $table->boolean('is_active')->default(true); $table->integer('sort_order')->default(0); + $table->decimal('over_delivery_tolerance_percentage', 5, 2)->nullable()->after('sort_order') + ->comment('Over-delivery tolerance percentage for this category. Null means use product or system default.'); $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); $table->timestamps(); $table->softDeletes(); + // Composite unique constraint: company_id + slug (allows same slug for different companies) + $table->unique(['company_id', 'slug'], 'categories_company_slug_unique'); $table->index('company_id'); $table->index('parent_id'); $table->index('is_active'); diff --git a/backend/database/migrations/2025_12_09_213318_create_products_table.php b/backend/database/migrations/2025_12_09_213318_create_products_table.php index bc7f88e..4345464 100644 --- a/backend/database/migrations/2025_12_09_213318_create_products_table.php +++ b/backend/database/migrations/2025_12_09_213318_create_products_table.php @@ -15,7 +15,7 @@ public function up(): void $table->id(); $table->foreignId('company_id')->constrained()->cascadeOnDelete(); $table->string('name'); - $table->string('slug')->unique(); + $table->string('slug'); // Company-scoped unique (composite index below) $table->string('sku'); // Unique per company, handled by composite index $table->text('description')->nullable(); $table->text('short_description')->nullable(); @@ -27,15 +27,41 @@ public function up(): void // category_id removed - now using category_product pivot table $table->boolean('is_active')->default(true); $table->boolean('is_featured')->default(false); + + // MRP Planning fields + $table->unsignedSmallInteger('lead_time_days')->default(0)->after('is_active'); + $table->decimal('safety_stock', 15, 4)->default(0)->after('lead_time_days'); + $table->decimal('reorder_point', 15, 4)->default(0)->after('safety_stock'); + $table->string('make_or_buy', 10)->default('buy')->after('reorder_point'); + $table->unsignedSmallInteger('low_level_code')->default(0)->after('make_or_buy'); + $table->decimal('minimum_order_qty', 15, 4)->default(1)->after('low_level_code'); + $table->decimal('order_multiple', 15, 4)->default(1)->after('minimum_order_qty'); + $table->decimal('maximum_stock', 15, 4)->nullable()->after('order_multiple'); + + // Negative stock policy + $table->string('negative_stock_policy', 20)->default('NEVER')->after('maximum_stock'); + $table->decimal('negative_stock_limit', 15, 3)->default(0)->after('negative_stock_policy'); + + // Reservation policy + $table->string('reservation_policy', 20)->default('full')->after('negative_stock_limit'); + + // Over-delivery tolerance + $table->decimal('over_delivery_tolerance_percentage', 5, 2)->nullable()->after('reservation_policy') + ->comment('Over-delivery tolerance percentage for this product. Null means use category or system default.'); + $table->json('meta_data')->nullable(); $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); $table->timestamps(); $table->softDeletes(); + // Composite unique constraints: company-scoped uniqueness + $table->unique(['company_id', 'slug'], 'products_company_slug_unique'); // Slug unique per company $table->unique(['company_id', 'sku']); // SKU unique per company $table->index('company_id'); $table->index('is_active'); $table->index('is_featured'); + $table->index(['make_or_buy', 'is_active'], 'idx_products_mrp'); + $table->index('low_level_code', 'idx_products_low_level_code'); }); } diff --git a/backend/database/migrations/2025_12_09_213319_create_product_variants_table.php b/backend/database/migrations/2025_12_09_213319_create_product_variants_table.php index 275055f..9ec4cd0 100644 --- a/backend/database/migrations/2025_12_09_213319_create_product_variants_table.php +++ b/backend/database/migrations/2025_12_09_213319_create_product_variants_table.php @@ -15,7 +15,7 @@ public function up(): void $table->id(); $table->foreignId('product_id')->constrained()->cascadeOnDelete(); $table->string('name'); // e.g., "Small - Red", "Large - Blue" - $table->string('sku')->unique(); + $table->string('sku'); // Unique per product (composite index below) $table->decimal('price', 10, 2)->nullable(); // If null, uses product price $table->integer('stock')->default(0); $table->json('attributes'); // e.g., {"size": "L", "color": "Red"} @@ -23,6 +23,8 @@ public function up(): void $table->timestamps(); $table->softDeletes(); + // Composite unique constraint: product_id + sku (variants belong to products, which are company-scoped) + $table->unique(['product_id', 'sku'], 'product_variants_product_sku_unique'); $table->index('product_id'); $table->index('sku'); }); diff --git a/backend/database/migrations/2025_12_18_132000_create_units_of_measure_table.php b/backend/database/migrations/2025_12_18_132000_create_units_of_measure_table.php index c6a9489..27e4b7b 100644 --- a/backend/database/migrations/2025_12_18_132000_create_units_of_measure_table.php +++ b/backend/database/migrations/2025_12_18_132000_create_units_of_measure_table.php @@ -16,7 +16,7 @@ public function up(): void $table->foreignId('company_id')->constrained()->cascadeOnDelete(); $table->string('code', 20); // 'kg', 'lbs', 'pcs', 'l', 'm', 'box' $table->string('name', 100); // 'Kilogram', 'Pound', 'Piece', 'Liter' - $table->enum('uom_type', ['weight', 'volume', 'length', 'area', 'quantity', 'time'])->default('quantity'); + $table->string('uom_type', 30)->default('quantity'); // Validated by App\Enums\UomType $table->foreignId('base_unit_id')->nullable()->constrained('units_of_measure')->nullOnDelete(); $table->decimal('conversion_factor', 20, 6)->nullable(); // Conversion to base unit $table->integer('precision')->default(2); // Decimal places diff --git a/backend/database/migrations/2025_12_19_100000_create_warehouses_table.php b/backend/database/migrations/2025_12_19_100000_create_warehouses_table.php index 435fd10..2306b1b 100644 --- a/backend/database/migrations/2025_12_19_100000_create_warehouses_table.php +++ b/backend/database/migrations/2025_12_19_100000_create_warehouses_table.php @@ -26,6 +26,14 @@ public function up(): void $table->string('contact_email', 255)->nullable(); $table->boolean('is_active')->default(true); $table->boolean('is_default')->default(false); + // QC-related settings + $table->boolean('is_quarantine_zone')->default(false)->after('is_default'); + $table->boolean('is_rejection_zone')->default(false)->after('is_quarantine_zone'); + $table->boolean('requires_qc_release')->default(false)->after('is_rejection_zone'); + $table->foreignId('linked_quarantine_warehouse_id')->nullable()->after('requires_qc_release') + ->constrained('warehouses')->nullOnDelete(); + $table->foreignId('linked_rejection_warehouse_id')->nullable()->after('linked_quarantine_warehouse_id') + ->constrained('warehouses')->nullOnDelete(); $table->json('settings')->nullable(); $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); $table->timestamps(); diff --git a/backend/database/migrations/2025_12_19_100001_create_stock_table.php b/backend/database/migrations/2025_12_19_100001_create_stock_table.php index 4ab37e7..a557809 100644 --- a/backend/database/migrations/2025_12_19_100001_create_stock_table.php +++ b/backend/database/migrations/2025_12_19_100001_create_stock_table.php @@ -30,6 +30,25 @@ public function up(): void $table->date('expiry_date')->nullable(); $table->date('received_date')->nullable(); $table->enum('status', ['available', 'reserved', 'quarantine', 'damaged', 'expired'])->default('available'); + + // Quality control fields + $table->enum('quality_status', [ + 'available', // Ready for use + 'pending_inspection', // Awaiting QC inspection + 'on_hold', // Held - no operations allowed + 'conditional', // Conditional use with restrictions + 'rejected', // Rejected - awaiting disposition + 'quarantine' // In quarantine + ])->default('available')->after('quantity_reserved'); + $table->text('hold_reason')->nullable()->after('quality_status'); + $table->timestamp('hold_until')->nullable()->after('hold_reason'); + $table->json('quality_restrictions')->nullable()->after('hold_until'); + $table->foreignId('quality_hold_by')->nullable()->after('quality_restrictions') + ->constrained('users')->nullOnDelete(); + $table->timestamp('quality_hold_at')->nullable()->after('quality_hold_by'); + $table->string('quality_reference_type', 50)->nullable()->after('quality_hold_at'); + $table->unsignedBigInteger('quality_reference_id')->nullable()->after('quality_reference_type'); + $table->text('notes')->nullable(); $table->timestamps(); @@ -43,6 +62,8 @@ public function up(): void $table->index(['company_id', 'product_id']); $table->index(['company_id', 'warehouse_id']); $table->index(['product_id', 'warehouse_id', 'status']); + $table->index(['company_id', 'quality_status']); + $table->index(['quality_reference_type', 'quality_reference_id']); $table->index('lot_number'); $table->index('serial_number'); $table->index('expiry_date'); diff --git a/backend/database/migrations/2025_12_19_100002_create_stock_movements_table.php b/backend/database/migrations/2025_12_19_100002_create_stock_movements_table.php index 006ca38..f4a4221 100644 --- a/backend/database/migrations/2025_12_19_100002_create_stock_movements_table.php +++ b/backend/database/migrations/2025_12_19_100002_create_stock_movements_table.php @@ -50,6 +50,16 @@ public function up(): void $table->string('reference_type', 100)->nullable(); // Polymorphic: App\Models\PurchaseOrder $table->unsignedBigInteger('reference_id')->nullable(); + // Quality control tracking + $table->enum('quality_status_from', [ + 'available', 'pending_inspection', 'on_hold', 'conditional', 'rejected', 'quarantine' + ])->nullable()->after('reference_id'); + $table->enum('quality_status_to', [ + 'available', 'pending_inspection', 'on_hold', 'conditional', 'rejected', 'quarantine' + ])->nullable()->after('quality_status_from'); + $table->string('qc_reference_type', 50)->nullable()->after('quality_status_to'); + $table->unsignedBigInteger('qc_reference_id')->nullable()->after('qc_reference_type'); + // Quantities $table->decimal('quantity', 15, 3); $table->decimal('quantity_before', 15, 3)->default(0); diff --git a/backend/database/migrations/2025_12_21_100001_create_purchase_orders_table.php b/backend/database/migrations/2025_12_21_100001_create_purchase_orders_table.php index 5dbe581..45fdc74 100644 --- a/backend/database/migrations/2025_12_21_100001_create_purchase_orders_table.php +++ b/backend/database/migrations/2025_12_21_100001_create_purchase_orders_table.php @@ -15,7 +15,8 @@ public function up(): void $table->id(); $table->foreignId('company_id')->constrained()->onDelete('cascade'); $table->string('order_number', 50); - $table->foreignId('supplier_id')->constrained()->onDelete('restrict'); + $table->foreignId('supplier_id')->nullable()->constrained()->onDelete('restrict'); + $table->unsignedBigInteger('mrp_recommendation_id')->nullable(); $table->foreignId('warehouse_id')->constrained()->onDelete('restrict'); // Dates @@ -78,6 +79,7 @@ public function up(): void $table->index(['company_id', 'supplier_id']); $table->index(['company_id', 'order_date']); $table->index('expected_delivery_date'); + $table->index('mrp_recommendation_id'); }); Schema::create('purchase_order_items', function (Blueprint $table) { @@ -110,6 +112,8 @@ public function up(): void // Tracking $table->string('lot_number', 100)->nullable(); $table->text('notes')->nullable(); + $table->decimal('over_delivery_tolerance_percentage', 5, 2)->nullable()->after('notes') + ->comment('Over-delivery tolerance percentage for this specific order item. Null means use product, category or system default. This is the most specific level.'); $table->timestamps(); diff --git a/backend/database/migrations/2025_12_26_100003_add_qc_fields_to_warehouses_and_stock.php b/backend/database/migrations/2025_12_26_100003_add_qc_fields_to_warehouses_and_stock.php deleted file mode 100644 index 16ed215..0000000 --- a/backend/database/migrations/2025_12_26_100003_add_qc_fields_to_warehouses_and_stock.php +++ /dev/null @@ -1,116 +0,0 @@ -boolean('is_quarantine_zone')->default(false)->after('is_default'); - $table->boolean('is_rejection_zone')->default(false)->after('is_quarantine_zone'); - $table->boolean('requires_qc_release')->default(false)->after('is_rejection_zone'); - $table->foreignId('linked_quarantine_warehouse_id')->nullable()->after('requires_qc_release') - ->constrained('warehouses')->nullOnDelete(); - $table->foreignId('linked_rejection_warehouse_id')->nullable()->after('linked_quarantine_warehouse_id') - ->constrained('warehouses')->nullOnDelete(); - }); - - // Add quality status fields to stock table - Schema::table('stock', function (Blueprint $table) { - $table->enum('quality_status', [ - 'available', // Ready for use - 'pending_inspection', // Awaiting QC inspection - 'on_hold', // Held - no operations allowed - 'conditional', // Conditional use with restrictions - 'rejected', // Rejected - awaiting disposition - 'quarantine' // In quarantine - ])->default('available')->after('reserved_quantity'); - - $table->text('hold_reason')->nullable()->after('quality_status'); - $table->timestamp('hold_until')->nullable()->after('hold_reason'); - $table->json('quality_restrictions')->nullable()->after('hold_until'); - $table->foreignId('quality_hold_by')->nullable()->after('quality_restrictions') - ->constrained('users')->nullOnDelete(); - $table->timestamp('quality_hold_at')->nullable()->after('quality_hold_by'); - - // Reference to inspection/NCR that caused the hold - $table->string('quality_reference_type', 50)->nullable()->after('quality_hold_at'); - $table->unsignedBigInteger('quality_reference_id')->nullable()->after('quality_reference_type'); - - // Indexes - $table->index(['company_id', 'quality_status']); - $table->index(['quality_reference_type', 'quality_reference_id']); - }); - - // Add quality tracking to stock_movements - Schema::table('stock_movements', function (Blueprint $table) { - $table->enum('quality_status_from', [ - 'available', 'pending_inspection', 'on_hold', 'conditional', 'rejected', 'quarantine' - ])->nullable()->after('reference_id'); - $table->enum('quality_status_to', [ - 'available', 'pending_inspection', 'on_hold', 'conditional', 'rejected', 'quarantine' - ])->nullable()->after('quality_status_from'); - $table->string('qc_reference_type', 50)->nullable()->after('quality_status_to'); - $table->unsignedBigInteger('qc_reference_id')->nullable()->after('qc_reference_type'); - }); - } - - /** - * Reverse the migrations. - */ - public function down(): void - { - Schema::table('stock_movements', function (Blueprint $table) { - $table->dropColumn([ - 'quality_status_from', - 'quality_status_to', - 'qc_reference_type', - 'qc_reference_id', - ]); - }); - - Schema::table('stock', function (Blueprint $table) { - $table->dropIndex(['company_id', 'quality_status']); - $table->dropIndex(['quality_reference_type', 'quality_reference_id']); - $table->dropForeign(['quality_hold_by']); - $table->dropColumn([ - 'quality_status', - 'hold_reason', - 'hold_until', - 'quality_restrictions', - 'quality_hold_by', - 'quality_hold_at', - 'quality_reference_type', - 'quality_reference_id', - ]); - }); - - Schema::table('warehouses', function (Blueprint $table) { - $table->dropForeign(['linked_quarantine_warehouse_id']); - $table->dropForeign(['linked_rejection_warehouse_id']); - $table->dropColumn([ - 'is_quarantine_zone', - 'is_rejection_zone', - 'requires_qc_release', - 'linked_quarantine_warehouse_id', - 'linked_rejection_warehouse_id', - ]); - }); - - // Revert warehouse_type to original enum - DB::statement("ALTER TABLE warehouses ALTER COLUMN warehouse_type TYPE VARCHAR(50)"); - } -}; diff --git a/backend/database/migrations/2025_12_30_100000_create_work_centers_table.php b/backend/database/migrations/2025_12_30_100000_create_work_centers_table.php new file mode 100644 index 0000000..4f13a1f --- /dev/null +++ b/backend/database/migrations/2025_12_30_100000_create_work_centers_table.php @@ -0,0 +1,59 @@ +id(); + $table->foreignId('company_id')->constrained()->onDelete('cascade'); + $table->string('code', 50); + $table->string('name', 255); + $table->text('description')->nullable(); + + // Work center type + $table->enum('work_center_type', ['machine', 'labor', 'subcontract', 'tool'])->default('machine'); + + // Costing + $table->decimal('cost_per_hour', 15, 4)->default(0); + $table->string('cost_currency', 3)->default('USD'); + + // Capacity + $table->decimal('capacity_per_day', 15, 3)->default(8); // Hours per day + $table->decimal('efficiency_percentage', 5, 2)->default(100.00); + + // Status + $table->boolean('is_active')->default(true); + + // Configuration + $table->json('settings')->nullable(); + + // Tracking + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + + // Unique constraint: company + code + $table->unique(['company_id', 'code']); + + // Indexes + $table->index(['company_id', 'is_active']); + $table->index(['company_id', 'work_center_type']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('work_centers'); + } +}; diff --git a/backend/database/migrations/2025_12_30_100001_create_boms_table.php b/backend/database/migrations/2025_12_30_100001_create_boms_table.php new file mode 100644 index 0000000..c5803cb --- /dev/null +++ b/backend/database/migrations/2025_12_30_100001_create_boms_table.php @@ -0,0 +1,101 @@ +id(); + $table->foreignId('company_id')->constrained()->onDelete('cascade'); + $table->foreignId('product_id')->constrained()->onDelete('cascade'); + + // Identification + $table->string('bom_number', 50); + $table->integer('version')->default(1); + $table->string('name', 255); + $table->text('description')->nullable(); + + // Type and status + $table->enum('bom_type', ['manufacturing', 'engineering', 'phantom'])->default('manufacturing'); + $table->enum('status', ['draft', 'active', 'obsolete'])->default('draft'); + + // Quantity info + $table->decimal('quantity', 15, 4)->default(1); // Base quantity for BOM + $table->foreignId('uom_id')->constrained('units_of_measure')->onDelete('restrict'); + + // Flags + $table->boolean('is_default')->default(false); + + // Effectivity dates + $table->date('effective_date')->nullable(); + $table->date('expiry_date')->nullable(); + + // Notes + $table->text('notes')->nullable(); + $table->json('meta_data')->nullable(); + + // Tracking + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + + // Unique constraint: company + bom_number + $table->unique(['company_id', 'bom_number']); + + // Indexes + $table->index(['company_id', 'product_id']); + $table->index(['company_id', 'status']); + $table->index(['product_id', 'is_default']); + }); + + // BOM Items (Components) + Schema::create('bom_items', function (Blueprint $table) { + $table->id(); + $table->foreignId('bom_id')->constrained()->onDelete('cascade'); + $table->foreignId('component_id')->constrained('products')->onDelete('restrict'); + + // Line info + $table->integer('line_number')->default(1); + + // Quantity + $table->decimal('quantity', 15, 4); + $table->foreignId('uom_id')->constrained('units_of_measure')->onDelete('restrict'); + + // Scrap/yield + $table->decimal('scrap_percentage', 5, 2)->default(0); + + // Flags + $table->boolean('is_optional')->default(false); + $table->boolean('is_phantom')->default(false); // Pass-through item + + // Notes + $table->text('notes')->nullable(); + + $table->timestamps(); + + // Indexes + $table->index(['bom_id', 'line_number']); + $table->index('component_id'); + + // Prevent duplicate component in same BOM + $table->unique(['bom_id', 'component_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('bom_items'); + Schema::dropIfExists('boms'); + } +}; diff --git a/backend/database/migrations/2025_12_30_100002_create_routings_table.php b/backend/database/migrations/2025_12_30_100002_create_routings_table.php new file mode 100644 index 0000000..578af2e --- /dev/null +++ b/backend/database/migrations/2025_12_30_100002_create_routings_table.php @@ -0,0 +1,99 @@ +id(); + $table->foreignId('company_id')->constrained()->onDelete('cascade'); + $table->foreignId('product_id')->constrained()->onDelete('cascade'); + + // Identification + $table->string('routing_number', 50); + $table->integer('version')->default(1); + $table->string('name', 255); + $table->text('description')->nullable(); + + // Status + $table->enum('status', ['draft', 'active', 'obsolete'])->default('draft'); + + // Flags + $table->boolean('is_default')->default(false); + + // Effectivity + $table->date('effective_date')->nullable(); + $table->date('expiry_date')->nullable(); + + // Notes + $table->text('notes')->nullable(); + $table->json('meta_data')->nullable(); + + // Tracking + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + + // Unique constraint: company + routing_number + $table->unique(['company_id', 'routing_number']); + + // Indexes + $table->index(['company_id', 'product_id']); + $table->index(['company_id', 'status']); + $table->index(['product_id', 'is_default']); + }); + + // Routing Operations + Schema::create('routing_operations', function (Blueprint $table) { + $table->id(); + $table->foreignId('routing_id')->constrained()->onDelete('cascade'); + $table->foreignId('work_center_id')->constrained()->onDelete('restrict'); + + // Operation info + $table->integer('operation_number'); + $table->string('name', 255); + $table->text('description')->nullable(); + + // Time estimates (in minutes) + $table->decimal('setup_time', 10, 2)->default(0); // Setup time in minutes + $table->decimal('run_time_per_unit', 10, 4)->default(0); // Run time per unit in minutes + $table->decimal('queue_time', 10, 2)->default(0); // Wait time before operation + $table->decimal('move_time', 10, 2)->default(0); // Move time to next operation + + // Subcontracting + $table->boolean('is_subcontracted')->default(false); + $table->foreignId('subcontractor_id')->nullable()->constrained('suppliers')->nullOnDelete(); + $table->decimal('subcontract_cost', 15, 4)->nullable(); + + // Instructions + $table->text('instructions')->nullable(); + $table->json('settings')->nullable(); + + $table->timestamps(); + + // Indexes + $table->index(['routing_id', 'operation_number']); + $table->index('work_center_id'); + + // Unique operation number per routing + $table->unique(['routing_id', 'operation_number']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('routing_operations'); + Schema::dropIfExists('routings'); + } +}; diff --git a/backend/database/migrations/2025_12_30_100003_create_work_orders_table.php b/backend/database/migrations/2025_12_30_100003_create_work_orders_table.php new file mode 100644 index 0000000..3607fe7 --- /dev/null +++ b/backend/database/migrations/2025_12_30_100003_create_work_orders_table.php @@ -0,0 +1,180 @@ +id(); + $table->foreignId('company_id')->constrained()->onDelete('cascade'); + + // Product being manufactured + $table->foreignId('product_id')->constrained()->onDelete('restrict'); + $table->foreignId('bom_id')->nullable()->constrained()->onDelete('set null'); + $table->foreignId('routing_id')->nullable()->constrained()->onDelete('set null'); + $table->unsignedBigInteger('mrp_recommendation_id')->nullable(); + + // Identification + $table->string('work_order_number', 50); + + // Quantities + $table->decimal('quantity_ordered', 15, 3); + $table->decimal('quantity_completed', 15, 3)->default(0); + $table->decimal('quantity_scrapped', 15, 3)->default(0); + $table->foreignId('uom_id')->constrained('units_of_measure')->onDelete('restrict'); + + // Warehouse for finished goods + $table->foreignId('warehouse_id')->constrained()->onDelete('restrict'); + + // Status and priority + $table->enum('status', [ + 'draft', + 'released', + 'in_progress', + 'completed', + 'cancelled', + 'on_hold' + ])->default('draft'); + $table->enum('priority', ['low', 'normal', 'high', 'urgent'])->default('normal'); + + // Planned dates + $table->dateTime('planned_start_date')->nullable(); + $table->dateTime('planned_end_date')->nullable(); + + // Actual dates + $table->dateTime('actual_start_date')->nullable(); + $table->dateTime('actual_end_date')->nullable(); + + // Cost tracking + $table->decimal('estimated_cost', 15, 4)->default(0); + $table->decimal('actual_cost', 15, 4)->default(0); + + // Notes + $table->text('notes')->nullable(); + $table->text('internal_notes')->nullable(); + $table->json('meta_data')->nullable(); + + // Workflow tracking + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('approved_at')->nullable(); + $table->foreignId('released_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('released_at')->nullable(); + $table->foreignId('completed_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('completed_at')->nullable(); + + $table->timestamps(); + $table->softDeletes(); + + // Unique constraint: company + work_order_number + $table->unique(['company_id', 'work_order_number']); + + // Indexes + $table->index(['company_id', 'status']); + $table->index(['company_id', 'product_id']); + $table->index(['company_id', 'priority', 'status']); + $table->index('planned_start_date'); + $table->index('planned_end_date'); + $table->index('mrp_recommendation_id'); + }); + + // Work Order Operations + Schema::create('work_order_operations', function (Blueprint $table) { + $table->id(); + $table->foreignId('work_order_id')->constrained()->onDelete('cascade'); + $table->foreignId('routing_operation_id')->nullable()->constrained()->onDelete('set null'); + $table->foreignId('work_center_id')->constrained()->onDelete('restrict'); + + // Operation info (copied from routing) + $table->integer('operation_number'); + $table->string('name', 255); + $table->text('description')->nullable(); + + // Status + $table->enum('status', ['pending', 'in_progress', 'completed', 'skipped'])->default('pending'); + + // Quantities + $table->decimal('quantity_completed', 15, 3)->default(0); + $table->decimal('quantity_scrapped', 15, 3)->default(0); + + // Planned times + $table->dateTime('planned_start')->nullable(); + $table->dateTime('planned_end')->nullable(); + + // Actual times + $table->dateTime('actual_start')->nullable(); + $table->dateTime('actual_end')->nullable(); + + // Actual time spent (in minutes) + $table->decimal('actual_setup_time', 10, 2)->default(0); + $table->decimal('actual_run_time', 10, 2)->default(0); + + // Cost + $table->decimal('actual_cost', 15, 4)->default(0); + + // Notes + $table->text('notes')->nullable(); + + // Tracking + $table->foreignId('started_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('completed_by')->nullable()->constrained('users')->nullOnDelete(); + + $table->timestamps(); + + // Indexes + $table->index(['work_order_id', 'operation_number']); + $table->index(['work_order_id', 'status']); + $table->index('work_center_id'); + + // Unique operation number per work order + $table->unique(['work_order_id', 'operation_number']); + }); + + // Work Order Material Consumption (issued materials) + Schema::create('work_order_materials', function (Blueprint $table) { + $table->id(); + $table->foreignId('work_order_id')->constrained()->onDelete('cascade'); + $table->foreignId('product_id')->constrained()->onDelete('restrict'); + $table->foreignId('bom_item_id')->nullable()->constrained()->onDelete('set null'); + + // Quantities + $table->decimal('quantity_required', 15, 4); + $table->decimal('quantity_issued', 15, 4)->default(0); + $table->decimal('quantity_returned', 15, 4)->default(0); + $table->foreignId('uom_id')->constrained('units_of_measure')->onDelete('restrict'); + + // Source + $table->foreignId('warehouse_id')->constrained()->onDelete('restrict'); + + // Cost + $table->decimal('unit_cost', 15, 4)->default(0); + $table->decimal('total_cost', 15, 4)->default(0); + + // Notes + $table->text('notes')->nullable(); + + $table->timestamps(); + + // Indexes + $table->index(['work_order_id', 'product_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('work_order_materials'); + Schema::dropIfExists('work_order_operations'); + Schema::dropIfExists('work_orders'); + } +}; diff --git a/backend/database/migrations/2025_12_31_131041_fix_unique_constraints_for_soft_deletes.php b/backend/database/migrations/2025_12_31_131041_fix_unique_constraints_for_soft_deletes.php new file mode 100644 index 0000000..146ce87 --- /dev/null +++ b/backend/database/migrations/2025_12_31_131041_fix_unique_constraints_for_soft_deletes.php @@ -0,0 +1,55 @@ += 0 AND scrap_percentage <= 100) + '); + + // Work centers: efficiency_percentage must be > 0 and <= 100 + DB::statement(' + ALTER TABLE work_centers + ADD CONSTRAINT check_work_centers_efficiency_percentage + CHECK (efficiency_percentage > 0 AND efficiency_percentage <= 100) + '); + + // Work orders: quantities must be non-negative and not exceed ordered + DB::statement(' + ALTER TABLE work_orders + ADD CONSTRAINT check_work_orders_quantities + CHECK ( + quantity_completed >= 0 + AND quantity_scrapped >= 0 + AND (quantity_completed + quantity_scrapped) <= quantity_ordered + ) + '); + + // Add performance indexes for common queries + DB::statement(' + CREATE INDEX IF NOT EXISTS idx_boms_product_default + ON boms(product_id, is_default, status) + WHERE deleted_at IS NULL + '); + + DB::statement(' + CREATE INDEX IF NOT EXISTS idx_work_orders_warehouse_status + ON work_orders(company_id, warehouse_id, status) + WHERE deleted_at IS NULL + '); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Drop check constraints + DB::statement('ALTER TABLE bom_items DROP CONSTRAINT IF EXISTS check_bom_items_scrap_percentage'); + DB::statement('ALTER TABLE work_centers DROP CONSTRAINT IF EXISTS check_work_centers_efficiency_percentage'); + DB::statement('ALTER TABLE work_orders DROP CONSTRAINT IF EXISTS check_work_orders_quantities'); + + // Drop indexes + DB::statement('DROP INDEX IF EXISTS idx_boms_product_default'); + DB::statement('DROP INDEX IF EXISTS idx_work_orders_warehouse_status'); + } +}; diff --git a/backend/database/migrations/2026_01_01_135201_create_customer_groups_table.php b/backend/database/migrations/2026_01_01_135201_create_customer_groups_table.php new file mode 100644 index 0000000..3ce7256 --- /dev/null +++ b/backend/database/migrations/2026_01_01_135201_create_customer_groups_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('company_id')->constrained()->onDelete('cascade'); + $table->string('code', 20); + $table->string('name', 100); + $table->text('description')->nullable(); + $table->decimal('discount_percentage', 5, 2)->default(0); + $table->integer('payment_terms_days')->nullable()->after('discount_percentage'); + $table->decimal('credit_limit', 15, 2)->nullable()->after('payment_terms_days'); + $table->boolean('is_active')->default(true); + $table->timestamps(); + + $table->unique(['company_id', 'code']); + $table->index(['company_id', 'is_active']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('customer_groups'); + } +}; diff --git a/backend/database/migrations/2026_01_01_135202_create_customer_group_prices_table.php b/backend/database/migrations/2026_01_01_135202_create_customer_group_prices_table.php new file mode 100644 index 0000000..306f808 --- /dev/null +++ b/backend/database/migrations/2026_01_01_135202_create_customer_group_prices_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('company_id')->constrained()->onDelete('cascade'); + $table->foreignId('customer_group_id')->constrained()->onDelete('cascade'); + $table->foreignId('product_id')->constrained()->onDelete('cascade'); + $table->decimal('price', 15, 4); + $table->foreignId('currency_id')->nullable()->after('price')->constrained()->nullOnDelete(); + $table->decimal('min_quantity', 15, 4)->default(1); + $table->date('valid_from')->nullable(); + $table->date('valid_to')->nullable(); + $table->boolean('is_active')->default(true)->after('valid_to'); + $table->timestamps(); + + $table->unique(['customer_group_id', 'product_id', 'min_quantity'], 'cgp_unique'); + $table->index(['product_id', 'valid_from', 'valid_to']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('customer_group_prices'); + } +}; diff --git a/backend/database/migrations/2026_01_01_135203_create_customers_table.php b/backend/database/migrations/2026_01_01_135203_create_customers_table.php new file mode 100644 index 0000000..c8b38c3 --- /dev/null +++ b/backend/database/migrations/2026_01_01_135203_create_customers_table.php @@ -0,0 +1,58 @@ +id(); + $table->foreignId('company_id')->constrained()->onDelete('cascade'); + $table->foreignId('customer_group_id')->nullable()->constrained()->nullOnDelete(); + $table->string('customer_code', 50); + $table->string('name', 200); + $table->string('legal_name', 200)->nullable(); + $table->string('tax_id', 50)->nullable(); + $table->string('email', 150)->nullable(); + $table->string('phone', 50)->nullable(); + $table->string('website', 200)->nullable(); + $table->text('address')->nullable(); + $table->string('city', 100)->nullable(); + $table->string('state', 100)->nullable(); + $table->string('country', 100)->nullable(); + $table->string('postal_code', 20)->nullable(); + $table->string('contact_person', 150)->nullable(); + $table->string('contact_email', 150)->nullable(); + $table->string('contact_phone', 50)->nullable(); + $table->string('currency', 3)->default('TRY'); + $table->integer('payment_terms_days')->default(30); + $table->decimal('credit_limit', 15, 2)->default(0); + $table->boolean('is_active')->default(true); + $table->tinyInteger('rating')->nullable(); + $table->text('notes')->nullable(); + $table->json('meta_data')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['company_id', 'customer_code']); + $table->index(['company_id', 'is_active']); + $table->index(['company_id', 'customer_group_id']); + $table->index(['company_id', 'name']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('customers'); + } +}; diff --git a/backend/database/migrations/2026_01_01_135204_create_sales_orders_table.php b/backend/database/migrations/2026_01_01_135204_create_sales_orders_table.php new file mode 100644 index 0000000..1ab6bcb --- /dev/null +++ b/backend/database/migrations/2026_01_01_135204_create_sales_orders_table.php @@ -0,0 +1,59 @@ +id(); + $table->foreignId('company_id')->constrained()->onDelete('cascade'); + $table->string('order_number', 50); + $table->foreignId('customer_id')->constrained()->onDelete('restrict'); + $table->foreignId('warehouse_id')->constrained()->onDelete('restrict'); + $table->date('order_date'); + $table->date('requested_delivery_date')->nullable(); + $table->date('promised_delivery_date')->nullable(); + $table->string('status', 30)->default('draft'); + $table->string('currency', 3)->default('TRY'); + $table->decimal('exchange_rate', 12, 6)->default(1); + $table->decimal('subtotal', 15, 2)->default(0); + $table->decimal('discount_amount', 15, 2)->default(0); + $table->decimal('tax_amount', 15, 2)->default(0); + $table->decimal('shipping_cost', 15, 2)->default(0); + $table->decimal('total_amount', 15, 2)->default(0); + $table->string('payment_terms', 100)->nullable(); + $table->string('shipping_method', 100)->nullable(); + $table->text('shipping_address')->nullable(); + $table->text('notes')->nullable(); + $table->text('internal_notes')->nullable(); + $table->json('meta_data')->nullable(); + $table->foreignId('approved_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('approved_at')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('updated_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['company_id', 'order_number']); + $table->index(['company_id', 'status']); + $table->index(['company_id', 'customer_id']); + $table->index(['company_id', 'order_date']); + $table->index(['company_id', 'warehouse_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('sales_orders'); + } +}; diff --git a/backend/database/migrations/2026_01_01_135205_create_sales_order_items_table.php b/backend/database/migrations/2026_01_01_135205_create_sales_order_items_table.php new file mode 100644 index 0000000..ed80207 --- /dev/null +++ b/backend/database/migrations/2026_01_01_135205_create_sales_order_items_table.php @@ -0,0 +1,47 @@ +id(); + $table->foreignId('sales_order_id')->constrained()->onDelete('cascade'); + $table->foreignId('product_id')->constrained()->onDelete('restrict'); + $table->integer('line_number')->default(1); + $table->string('description', 500)->nullable(); + $table->decimal('quantity_ordered', 15, 4); + $table->decimal('quantity_shipped', 15, 4)->default(0); + $table->decimal('quantity_cancelled', 15, 4)->default(0); + $table->foreignId('uom_id')->constrained('units_of_measure')->onDelete('restrict'); + $table->decimal('unit_price', 15, 4); + $table->decimal('discount_percentage', 5, 2)->default(0); + $table->decimal('discount_amount', 15, 4)->default(0); + $table->decimal('tax_percentage', 5, 2)->default(0); + $table->decimal('tax_amount', 15, 4)->default(0); + $table->decimal('line_total', 15, 2)->default(0); + $table->text('notes')->nullable(); + $table->decimal('over_delivery_tolerance_percentage', 5, 2)->nullable()->after('notes') + ->comment('Over-delivery tolerance percentage for this specific order item. Null means use product, category or system default. This is the most specific level.'); + $table->timestamps(); + + $table->index(['sales_order_id', 'line_number']); + $table->index(['product_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('sales_order_items'); + } +}; diff --git a/backend/database/migrations/2026_01_01_135206_create_delivery_notes_table.php b/backend/database/migrations/2026_01_01_135206_create_delivery_notes_table.php new file mode 100644 index 0000000..330a59b --- /dev/null +++ b/backend/database/migrations/2026_01_01_135206_create_delivery_notes_table.php @@ -0,0 +1,47 @@ +id(); + $table->foreignId('company_id')->constrained()->onDelete('cascade'); + $table->string('delivery_number', 50); + $table->foreignId('sales_order_id')->constrained()->onDelete('restrict'); + $table->foreignId('customer_id')->constrained()->onDelete('restrict'); + $table->foreignId('warehouse_id')->constrained()->onDelete('restrict'); + $table->date('delivery_date'); + $table->string('status', 30)->default('draft'); + $table->string('shipping_method', 100)->nullable(); + $table->string('tracking_number', 100)->nullable(); + $table->text('notes')->nullable(); + $table->foreignId('delivered_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamp('delivered_at')->nullable(); + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['company_id', 'delivery_number']); + $table->index(['company_id', 'status']); + $table->index(['company_id', 'sales_order_id']); + $table->index(['company_id', 'customer_id']); + $table->index(['company_id', 'delivery_date']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('delivery_notes'); + } +}; diff --git a/backend/database/migrations/2026_01_01_135207_create_delivery_note_items_table.php b/backend/database/migrations/2026_01_01_135207_create_delivery_note_items_table.php new file mode 100644 index 0000000..97ba057 --- /dev/null +++ b/backend/database/migrations/2026_01_01_135207_create_delivery_note_items_table.php @@ -0,0 +1,38 @@ +id(); + $table->foreignId('delivery_note_id')->constrained()->onDelete('cascade'); + $table->foreignId('sales_order_item_id')->constrained()->onDelete('restrict'); + $table->foreignId('product_id')->constrained()->onDelete('restrict'); + $table->decimal('quantity_shipped', 15, 4); + $table->string('lot_number', 100)->nullable(); + $table->string('serial_number', 100)->nullable(); + $table->text('notes')->nullable(); + $table->timestamps(); + + $table->index(['delivery_note_id']); + $table->index(['sales_order_item_id']); + $table->index(['product_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('delivery_note_items'); + } +}; diff --git a/backend/database/migrations/2026_01_03_200619_create_product_uom_conversions_table.php b/backend/database/migrations/2026_01_03_200619_create_product_uom_conversions_table.php new file mode 100644 index 0000000..0e08b20 --- /dev/null +++ b/backend/database/migrations/2026_01_03_200619_create_product_uom_conversions_table.php @@ -0,0 +1,51 @@ +id(); + $table->foreignId('company_id')->constrained()->cascadeOnDelete(); + $table->foreignId('product_id')->constrained()->cascadeOnDelete(); + $table->foreignId('from_uom_id')->constrained('units_of_measure')->cascadeOnDelete(); + $table->foreignId('to_uom_id')->constrained('units_of_measure')->cascadeOnDelete(); + $table->decimal('conversion_factor', 20, 6); // from_uom * factor = to_uom + $table->boolean('is_default')->default(false); // Default conversion for this product + $table->boolean('is_active')->default(true); + $table->timestamps(); + + // Each product can have only one conversion between two specific units + $table->unique(['product_id', 'from_uom_id', 'to_uom_id'], 'product_uom_unique'); + + // Indexes for common queries + $table->index(['company_id', 'product_id']); + $table->index(['product_id', 'is_active']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('product_uom_conversions'); + } +}; diff --git a/backend/database/migrations/2026_01_04_102334_create_work_center_calendars_table.php b/backend/database/migrations/2026_01_04_102334_create_work_center_calendars_table.php new file mode 100644 index 0000000..f4cc292 --- /dev/null +++ b/backend/database/migrations/2026_01_04_102334_create_work_center_calendars_table.php @@ -0,0 +1,61 @@ +id(); + $table->foreignId('company_id')->constrained()->onDelete('cascade'); + $table->foreignId('work_center_id')->constrained()->onDelete('cascade'); + + // Calendar date + $table->date('calendar_date'); + + // Working hours + $table->time('shift_start')->default('08:00:00'); + $table->time('shift_end')->default('17:00:00'); + $table->decimal('break_hours', 4, 2)->default(1.00); // Lunch/breaks + + // Available capacity in hours (calculated: shift_end - shift_start - break_hours) + $table->decimal('available_hours', 6, 2)->default(8.00); + + // Capacity adjustments + $table->decimal('efficiency_override', 5, 2)->nullable(); // Override work center default + $table->decimal('capacity_override', 6, 2)->nullable(); // Override available hours + + // Day type + $table->enum('day_type', ['working', 'holiday', 'maintenance', 'shutdown'])->default('working'); + + // Notes (reason for holiday, maintenance details, etc.) + $table->string('notes', 500)->nullable(); + + $table->timestamps(); + + // Unique: one entry per work center per date + $table->unique(['work_center_id', 'calendar_date'], 'uk_wc_calendar_date'); + + // Indexes for CRP queries + $table->index(['company_id', 'calendar_date'], 'idx_wc_calendar_company_date'); + $table->index(['work_center_id', 'calendar_date', 'day_type'], 'idx_wc_calendar_lookup'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('work_center_calendars'); + } +}; diff --git a/backend/database/migrations/2026_01_04_102427_create_mrp_tables.php b/backend/database/migrations/2026_01_04_102427_create_mrp_tables.php new file mode 100644 index 0000000..369a6d1 --- /dev/null +++ b/backend/database/migrations/2026_01_04_102427_create_mrp_tables.php @@ -0,0 +1,153 @@ +id(); + $table->foreignId('company_id')->constrained()->onDelete('cascade'); + + // Run identification + $table->string('run_number', 50); + $table->string('name', 255)->nullable(); + + // Run parameters + $table->date('planning_horizon_start'); + $table->date('planning_horizon_end'); + $table->boolean('include_safety_stock')->default(true); + $table->boolean('respect_lead_times')->default(true); + $table->boolean('consider_wip')->default(true); // Work in progress + $table->boolean('net_change')->default(false); // Full vs net change MRP + + // Filters (optional) + $table->json('product_filters')->nullable(); // Specific products/categories + $table->json('warehouse_filters')->nullable(); // Specific warehouses + + // Run status + $table->enum('status', ['pending', 'running', 'completed', 'failed', 'cancelled'])->default('pending'); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->text('error_message')->nullable(); + + // Statistics + $table->unsignedInteger('products_processed')->default(0); + $table->unsignedInteger('recommendations_generated')->default(0); + $table->unsignedInteger('warnings_count')->default(0); + $table->json('warnings_summary')->nullable()->after('warnings_count'); + + // Tracking + $table->foreignId('created_by')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + // Unique run number per company + $table->unique(['company_id', 'run_number']); + + // Indexes + $table->index(['company_id', 'status']); + $table->index(['company_id', 'created_at']); + }); + + // MRP Recommendations - Generated suggestions from MRP runs + Schema::create('mrp_recommendations', function (Blueprint $table) { + $table->id(); + $table->foreignId('company_id')->constrained()->onDelete('cascade'); + $table->foreignId('mrp_run_id')->constrained('mrp_runs')->onDelete('cascade'); + $table->foreignId('product_id')->constrained()->onDelete('cascade'); + $table->foreignId('warehouse_id')->nullable()->constrained()->nullOnDelete(); + + // Recommendation type + $table->enum('recommendation_type', [ + 'purchase_order', // Buy from supplier + 'work_order', // Manufacture in-house + 'transfer', // Transfer between warehouses + 'reschedule_in', // Move order date earlier + 'reschedule_out', // Move order date later + 'cancel', // Cancel existing order + 'expedite', // Expedite existing order + ]); + + // Timing + $table->date('required_date'); // When the material is needed + $table->date('suggested_date'); // When to place order (considering lead time) + $table->date('due_date'); // When order should arrive + + // Quantities + $table->decimal('gross_requirement', 15, 4); // Total needed + $table->decimal('net_requirement', 15, 4); // After considering stock + $table->decimal('suggested_quantity', 15, 4); // Recommended order qty + $table->decimal('current_stock', 15, 4); // Stock at calculation time + $table->decimal('projected_stock', 15, 4); // Expected stock after action + + // Source of demand (what triggered this recommendation) + $table->string('demand_source_type', 50)->nullable(); // work_order, sales_order, forecast + $table->unsignedBigInteger('demand_source_id')->nullable(); + + // Priority and urgency + $table->enum('priority', ['low', 'medium', 'high', 'critical'])->default('medium'); + $table->boolean('is_urgent')->default(false); // Past due or near lead time + $table->text('urgency_reason')->nullable(); + + // Action status + $table->enum('status', ['pending', 'approved', 'rejected', 'actioned', 'expired'])->default('pending'); + $table->timestamp('actioned_at')->nullable(); + $table->foreignId('actioned_by')->nullable()->constrained('users')->nullOnDelete(); + $table->string('action_reference_type', 50)->nullable(); // purchase_order, work_order + $table->unsignedBigInteger('action_reference_id')->nullable(); + $table->text('action_notes')->nullable(); + + // Calculation details (for audit/debugging) + $table->json('calculation_details')->nullable(); + + $table->timestamps(); + + // Indexes for common queries + $table->index(['mrp_run_id', 'status']); + $table->index(['company_id', 'product_id', 'status']); + $table->index(['company_id', 'recommendation_type', 'status']); + $table->index(['company_id', 'required_date']); + $table->index(['company_id', 'priority', 'status']); + }); + + // Add foreign key constraints to work_orders and purchase_orders + // (These tables are created before mrp_recommendations, so we add FK here) + if (Schema::hasTable('work_orders') && Schema::hasColumn('work_orders', 'mrp_recommendation_id')) { + Schema::table('work_orders', function (Blueprint $table) { + $table->foreign('mrp_recommendation_id') + ->references('id') + ->on('mrp_recommendations') + ->onDelete('set null'); + }); + } + + if (Schema::hasTable('purchase_orders') && Schema::hasColumn('purchase_orders', 'mrp_recommendation_id')) { + Schema::table('purchase_orders', function (Blueprint $table) { + $table->foreign('mrp_recommendation_id') + ->references('id') + ->on('mrp_recommendations') + ->onDelete('set null'); + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('mrp_recommendations'); + Schema::dropIfExists('mrp_runs'); + } +}; diff --git a/backend/database/migrations/2026_01_05_110000_create_company_calendars_table.php b/backend/database/migrations/2026_01_05_110000_create_company_calendars_table.php new file mode 100644 index 0000000..051f274 --- /dev/null +++ b/backend/database/migrations/2026_01_05_110000_create_company_calendars_table.php @@ -0,0 +1,63 @@ +id(); + $table->foreignId('company_id')->constrained()->onDelete('cascade'); + + // Calendar date + $table->date('calendar_date'); + + // Day type: 'working' (override holiday to working), 'holiday' (override working to holiday) + $table->enum('day_type', ['working', 'holiday'])->default('holiday'); + + // Shift information (optional - if null, uses default shift from settings) + $table->string('shift_name', 50)->nullable(); // e.g., 'morning', 'afternoon', 'night' + $table->time('shift_start')->nullable(); // Override shift start time + $table->time('shift_end')->nullable(); // Override shift end time + $table->decimal('break_hours', 4, 2)->nullable(); // Override break hours + + // Working hours for this day (calculated or manual override) + $table->decimal('working_hours', 6, 2)->nullable(); + + // Reason/description + $table->string('reason', 255)->nullable(); // e.g., 'National Holiday', 'Company Event', 'Maintenance' + $table->text('notes')->nullable(); + + // Recurring pattern (optional) + $table->boolean('is_recurring')->default(false); + $table->enum('recurrence_type', ['yearly', 'monthly', 'weekly'])->nullable(); + $table->json('recurrence_pattern')->nullable(); // Additional recurrence data + + $table->timestamps(); + + // Unique: one entry per company per date + $table->unique(['company_id', 'calendar_date'], 'uk_company_calendar_date'); + + // Indexes + $table->index(['company_id', 'calendar_date', 'day_type'], 'idx_company_calendar_lookup'); + $table->index(['company_id', 'calendar_date'], 'idx_company_calendar_date'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('company_calendars'); + } +}; diff --git a/backend/database/migrations/2026_01_07_000002_create_stock_debts_table.php b/backend/database/migrations/2026_01_07_000002_create_stock_debts_table.php new file mode 100644 index 0000000..22514bd --- /dev/null +++ b/backend/database/migrations/2026_01_07_000002_create_stock_debts_table.php @@ -0,0 +1,48 @@ +id(); + $table->foreignId('company_id')->constrained()->onDelete('cascade'); + $table->foreignId('product_id')->constrained()->onDelete('cascade'); + $table->foreignId('warehouse_id')->constrained()->onDelete('cascade'); + $table->foreignId('stock_movement_id')->nullable()->constrained('stock_movements')->nullOnDelete(); + + // Debt information + $table->decimal('quantity', 15, 3)->comment('Debt amount (positive value)'); + $table->decimal('reconciled_quantity', 15, 3)->default(0)->comment('Settled amount'); + $table->decimal('outstanding_quantity', 15, 3)->storedAs('quantity - reconciled_quantity'); + + // Reference to what caused this debt + $table->string('reference_type', 50)->nullable()->comment('DeliveryNote, WorkOrder, etc.'); + $table->unsignedBigInteger('reference_id')->nullable(); + + // Timestamps + $table->timestamps(); + $table->timestamp('reconciled_at')->nullable(); + + // Indexes + $table->index(['company_id', 'product_id', 'warehouse_id', 'outstanding_quantity'], 'idx_stock_debts_outstanding'); + $table->index(['reference_type', 'reference_id'], 'idx_stock_debts_reference'); + $table->index(['created_at'], 'idx_stock_debts_created'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('stock_debts'); + } +}; diff --git a/backend/database/migrations/2026_01_08_220000_create_user_invitations_table.php b/backend/database/migrations/2026_01_08_220000_create_user_invitations_table.php new file mode 100644 index 0000000..a6c0994 --- /dev/null +++ b/backend/database/migrations/2026_01_08_220000_create_user_invitations_table.php @@ -0,0 +1,39 @@ +id(); + $table->foreignId('company_id')->constrained()->onDelete('cascade'); + $table->string('email'); + $table->string('token', 64)->unique(); + $table->foreignId('invited_by')->constrained('users')->onDelete('cascade'); + $table->json('role_ids')->nullable(); // Array of role IDs to assign + $table->timestamp('expires_at'); + $table->timestamp('accepted_at')->nullable(); + $table->timestamps(); + + // Indexes + $table->index(['company_id', 'email']); + $table->index('token'); + $table->index('expires_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_invitations'); + } +}; diff --git a/backend/database/migrations/2026_01_09_084441_create_audit_logs_table.php b/backend/database/migrations/2026_01_09_084441_create_audit_logs_table.php new file mode 100644 index 0000000..d5eea4f --- /dev/null +++ b/backend/database/migrations/2026_01_09_084441_create_audit_logs_table.php @@ -0,0 +1,60 @@ +id(); + $table->foreignId('company_id')->constrained()->cascadeOnDelete(); + + // What happened + $table->string('event_type', 50); // 'created', 'updated', 'deleted', 'approved', etc. + $table->string('entity_type', 100); // 'App\Models\Product' + $table->unsignedBigInteger('entity_id'); + + // Who did it + $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete(); + $table->string('user_name')->nullable(); // Snapshot + $table->string('user_email')->nullable(); // Snapshot + + // When + $table->timestamp('occurred_at'); + + // What changed (for updates) + $table->json('changes')->nullable(); // {"field": {"old": "value", "new": "value"}} + + // Context + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->string('request_id')->nullable(); + + // Additional info + $table->text('description')->nullable(); + $table->json('metadata')->nullable(); + + $table->timestamps(); + + // Indexes + $table->index(['company_id', 'entity_type', 'entity_id'], 'idx_audit_company_entity'); + $table->index(['user_id', 'occurred_at'], 'idx_audit_user'); + $table->index(['event_type', 'occurred_at'], 'idx_audit_event'); + $table->index('occurred_at', 'idx_audit_date'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('audit_logs'); + } +}; diff --git a/backend/database/migrations/2026_01_09_084813_add_blameable_fields_to_products_table.php b/backend/database/migrations/2026_01_09_084813_add_blameable_fields_to_products_table.php new file mode 100644 index 0000000..8df858b --- /dev/null +++ b/backend/database/migrations/2026_01_09_084813_add_blameable_fields_to_products_table.php @@ -0,0 +1,44 @@ +foreignId('updated_by')->nullable()->after('created_by')->constrained('users')->nullOnDelete(); + } + + // Add deleted_by if it doesn't exist (for soft deletes) + if (!Schema::hasColumn('products', 'deleted_by')) { + $table->foreignId('deleted_by')->nullable()->after('updated_by')->constrained('users')->nullOnDelete(); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + if (Schema::hasColumn('products', 'updated_by')) { + $table->dropForeign(['updated_by']); + $table->dropColumn('updated_by'); + } + + if (Schema::hasColumn('products', 'deleted_by')) { + $table->dropForeign(['deleted_by']); + $table->dropColumn('deleted_by'); + } + }); + } +}; diff --git a/backend/database/seeders/AttributeSeeder.php b/backend/database/seeders/AttributeSeeder.php index 87eb534..6bb6437 100644 --- a/backend/database/seeders/AttributeSeeder.php +++ b/backend/database/seeders/AttributeSeeder.php @@ -10,6 +10,8 @@ class AttributeSeeder extends Seeder { /** * Run the database seeds. + * + * Agricultural Machinery Attributes */ public function run(): void { @@ -18,173 +20,294 @@ public function run(): void $companyId = $company?->id; $attributes = [ + // ======================================== + // VARIANT ATTRIBUTES (for product variants) + // ======================================== [ - 'name' => 'color', - 'display_name' => 'Color', + 'name' => 'engine_power', + 'display_name' => 'Engine Power', 'type' => 'select', 'order' => 1, 'is_variant_attribute' => true, 'is_filterable' => true, 'is_visible' => true, 'is_required' => false, - 'description' => 'Product color', + 'description' => 'Engine power in horsepower (HP)', 'values' => [ - ['value' => 'Black', 'order' => 1], - ['value' => 'White', 'order' => 2], - ['value' => 'Gray', 'order' => 3], - ['value' => 'Blue', 'order' => 4], - ['value' => 'Red', 'order' => 5], - ['value' => 'Green', 'order' => 6], - ['value' => 'Yellow', 'order' => 7], - ['value' => 'Orange', 'order' => 8], - ['value' => 'Purple', 'order' => 9], - ['value' => 'Pink', 'order' => 10], - ['value' => 'Brown', 'order' => 11], - ['value' => 'Navy', 'order' => 12], + ['value' => '25 HP', 'order' => 1], + ['value' => '35 HP', 'order' => 2], + ['value' => '50 HP', 'order' => 3], + ['value' => '75 HP', 'order' => 4], + ['value' => '100 HP', 'order' => 5], + ['value' => '120 HP', 'order' => 6], + ['value' => '150 HP', 'order' => 7], + ['value' => '180 HP', 'order' => 8], + ['value' => '200 HP', 'order' => 9], + ['value' => '250 HP', 'order' => 10], + ['value' => '300 HP', 'order' => 11], + ['value' => '400 HP', 'order' => 12], ] ], [ - 'name' => 'size', - 'display_name' => 'Size', + 'name' => 'working_width', + 'display_name' => 'Working Width', 'type' => 'select', 'order' => 2, 'is_variant_attribute' => true, 'is_filterable' => true, 'is_visible' => true, 'is_required' => false, - 'description' => 'Product size', + 'description' => 'Working width in meters', 'values' => [ - ['value' => 'XS', 'order' => 1], - ['value' => 'S', 'order' => 2], - ['value' => 'M', 'order' => 3], - ['value' => 'L', 'order' => 4], - ['value' => 'XL', 'order' => 5], - ['value' => 'XXL', 'order' => 6], - ['value' => 'XXXL', 'order' => 7], + ['value' => '1.5 m', 'order' => 1], + ['value' => '2.0 m', 'order' => 2], + ['value' => '2.5 m', 'order' => 3], + ['value' => '3.0 m', 'order' => 4], + ['value' => '4.0 m', 'order' => 5], + ['value' => '5.0 m', 'order' => 6], + ['value' => '6.0 m', 'order' => 7], + ['value' => '8.0 m', 'order' => 8], + ['value' => '10.0 m', 'order' => 9], + ['value' => '12.0 m', 'order' => 10], ] ], [ - 'name' => 'storage', - 'display_name' => 'Storage', + 'name' => 'tank_capacity', + 'display_name' => 'Tank Capacity', 'type' => 'select', 'order' => 3, 'is_variant_attribute' => true, 'is_filterable' => true, 'is_visible' => true, 'is_required' => false, - 'description' => 'Storage capacity', + 'description' => 'Tank/hopper capacity in liters', 'values' => [ - ['value' => '32GB', 'order' => 1], - ['value' => '64GB', 'order' => 2], - ['value' => '128GB', 'order' => 3], - ['value' => '256GB', 'order' => 4], - ['value' => '512GB', 'order' => 5], - ['value' => '1TB', 'order' => 6], - ['value' => '2TB', 'order' => 7], + ['value' => '200 L', 'order' => 1], + ['value' => '400 L', 'order' => 2], + ['value' => '600 L', 'order' => 3], + ['value' => '800 L', 'order' => 4], + ['value' => '1000 L', 'order' => 5], + ['value' => '1500 L', 'order' => 6], + ['value' => '2000 L', 'order' => 7], + ['value' => '3000 L', 'order' => 8], + ['value' => '4000 L', 'order' => 9], + ['value' => '5000 L', 'order' => 10], ] ], [ - 'name' => 'ram', - 'display_name' => 'RAM', + 'name' => 'number_of_rows', + 'display_name' => 'Number of Rows', 'type' => 'select', 'order' => 4, 'is_variant_attribute' => true, 'is_filterable' => true, 'is_visible' => true, 'is_required' => false, - 'description' => 'RAM capacity', + 'description' => 'Number of planting/harvesting rows', 'values' => [ - ['value' => '4GB', 'order' => 1], - ['value' => '6GB', 'order' => 2], - ['value' => '8GB', 'order' => 3], - ['value' => '12GB', 'order' => 4], - ['value' => '16GB', 'order' => 5], - ['value' => '32GB', 'order' => 6], + ['value' => '2 Rows', 'order' => 1], + ['value' => '4 Rows', 'order' => 2], + ['value' => '6 Rows', 'order' => 3], + ['value' => '8 Rows', 'order' => 4], + ['value' => '12 Rows', 'order' => 5], + ['value' => '16 Rows', 'order' => 6], + ['value' => '24 Rows', 'order' => 7], ] ], + + // ======================================== + // SPECIFICATION ATTRIBUTES + // ======================================== [ - 'name' => 'material', - 'display_name' => 'Material', + 'name' => 'brand', + 'display_name' => 'Brand', 'type' => 'select', 'order' => 5, 'is_variant_attribute' => false, 'is_filterable' => true, 'is_visible' => true, 'is_required' => false, - 'description' => 'Product material', + 'description' => 'Manufacturer brand', 'values' => [ - ['value' => 'Cotton', 'order' => 1], - ['value' => 'Polyester', 'order' => 2], - ['value' => 'Leather', 'order' => 3], - ['value' => 'Metal', 'order' => 4], - ['value' => 'Plastic', 'order' => 5], - ['value' => 'Wood', 'order' => 6], - ['value' => 'Glass', 'order' => 7], + ['value' => 'AgriTech NL', 'order' => 1], + ['value' => 'HollandAgro', 'order' => 2], + ['value' => 'EuroFarm', 'order' => 3], + ['value' => 'Deutz-Fahr', 'order' => 4], + ['value' => 'Kverneland', 'order' => 5], + ['value' => 'Lemken', 'order' => 6], + ['value' => 'Amazone', 'order' => 7], + ['value' => 'Grimme', 'order' => 8], + ['value' => 'Lely', 'order' => 9], + ['value' => 'Priva', 'order' => 10], ] ], [ - 'name' => 'brand', - 'display_name' => 'Brand', + 'name' => 'transmission_type', + 'display_name' => 'Transmission Type', 'type' => 'select', 'order' => 6, 'is_variant_attribute' => false, 'is_filterable' => true, 'is_visible' => true, 'is_required' => false, - 'description' => 'Product brand', + 'description' => 'Type of transmission system', + 'values' => [ + ['value' => 'Manual', 'order' => 1], + ['value' => 'Synchro Shuttle', 'order' => 2], + ['value' => 'Power Shuttle', 'order' => 3], + ['value' => 'CVT', 'order' => 4], + ['value' => 'Powershift', 'order' => 5], + ['value' => 'Hydrostatic', 'order' => 6], + ] + ], + [ + 'name' => 'drive_type', + 'display_name' => 'Drive Type', + 'type' => 'select', + 'order' => 7, + 'is_variant_attribute' => false, + 'is_filterable' => true, + 'is_visible' => true, + 'is_required' => false, + 'description' => 'Drive configuration', 'values' => [ - ['value' => 'Apple', 'order' => 1], - ['value' => 'Samsung', 'order' => 2], - ['value' => 'Huawei', 'order' => 3], - ['value' => 'Xiaomi', 'order' => 4], - ['value' => 'Sony', 'order' => 5], - ['value' => 'LG', 'order' => 6], - ['value' => 'Asus', 'order' => 7], - ['value' => 'Lenovo', 'order' => 8], - ['value' => 'Dell', 'order' => 9], - ['value' => 'HP', 'order' => 10], + ['value' => '2WD', 'order' => 1], + ['value' => '4WD', 'order' => 2], + ['value' => 'MFWD', 'order' => 3], + ['value' => 'Track', 'order' => 4], + ] + ], + [ + 'name' => 'hydraulic_flow', + 'display_name' => 'Hydraulic Flow', + 'type' => 'select', + 'order' => 8, + 'is_variant_attribute' => false, + 'is_filterable' => true, + 'is_visible' => true, + 'is_required' => false, + 'description' => 'Hydraulic pump flow rate', + 'values' => [ + ['value' => '40 L/min', 'order' => 1], + ['value' => '60 L/min', 'order' => 2], + ['value' => '80 L/min', 'order' => 3], + ['value' => '100 L/min', 'order' => 4], + ['value' => '120 L/min', 'order' => 5], + ['value' => '150 L/min', 'order' => 6], + ] + ], + [ + 'name' => 'pto_speed', + 'display_name' => 'PTO Speed', + 'type' => 'select', + 'order' => 9, + 'is_variant_attribute' => false, + 'is_filterable' => true, + 'is_visible' => true, + 'is_required' => false, + 'description' => 'Power Take-Off speed options', + 'values' => [ + ['value' => '540 RPM', 'order' => 1], + ['value' => '540/1000 RPM', 'order' => 2], + ['value' => '1000 RPM', 'order' => 3], + ['value' => '540E/1000E RPM', 'order' => 4], + ] + ], + [ + 'name' => 'lift_capacity', + 'display_name' => 'Lift Capacity', + 'type' => 'select', + 'order' => 10, + 'is_variant_attribute' => false, + 'is_filterable' => true, + 'is_visible' => true, + 'is_required' => false, + 'description' => 'Rear lift capacity in kg', + 'values' => [ + ['value' => '1500 kg', 'order' => 1], + ['value' => '2500 kg', 'order' => 2], + ['value' => '3500 kg', 'order' => 3], + ['value' => '5000 kg', 'order' => 4], + ['value' => '7000 kg', 'order' => 5], + ['value' => '9000 kg', 'order' => 6], + ['value' => '11000 kg', 'order' => 7], ] ], [ 'name' => 'warranty', 'display_name' => 'Warranty', 'type' => 'select', - 'order' => 7, + 'order' => 11, 'is_variant_attribute' => false, 'is_filterable' => true, 'is_visible' => true, 'is_required' => false, 'description' => 'Warranty period', 'values' => [ - ['value' => '6 Months', 'order' => 1], - ['value' => '1 Year', 'order' => 2], - ['value' => '2 Years', 'order' => 3], - ['value' => '3 Years', 'order' => 4], - ['value' => '5 Years', 'order' => 5], + ['value' => '1 Year', 'order' => 1], + ['value' => '2 Years', 'order' => 2], + ['value' => '3 Years', 'order' => 3], + ['value' => '5 Years', 'order' => 4], + ['value' => '2000 Hours', 'order' => 5], + ['value' => '3000 Hours', 'order' => 6], ] ], [ - 'name' => 'screen_size', - 'display_name' => 'Screen Size', + 'name' => 'material', + 'display_name' => 'Material', 'type' => 'select', - 'order' => 8, - 'is_variant_attribute' => true, + 'order' => 12, + 'is_variant_attribute' => false, + 'is_filterable' => true, + 'is_visible' => true, + 'is_required' => false, + 'description' => 'Primary construction material', + 'values' => [ + ['value' => 'Steel', 'order' => 1], + ['value' => 'Stainless Steel', 'order' => 2], + ['value' => 'Hardox Steel', 'order' => 3], + ['value' => 'Cast Iron', 'order' => 4], + ['value' => 'Aluminum', 'order' => 5], + ['value' => 'Polyethylene', 'order' => 6], + ['value' => 'Composite', 'order' => 7], + ] + ], + [ + 'name' => 'hitch_type', + 'display_name' => 'Hitch Type', + 'type' => 'select', + 'order' => 13, + 'is_variant_attribute' => false, + 'is_filterable' => true, + 'is_visible' => true, + 'is_required' => false, + 'description' => 'Attachment hitch type', + 'values' => [ + ['value' => '3-Point Cat I', 'order' => 1], + ['value' => '3-Point Cat II', 'order' => 2], + ['value' => '3-Point Cat III', 'order' => 3], + ['value' => 'Drawbar', 'order' => 4], + ['value' => 'Quick Hitch', 'order' => 5], + ['value' => 'Front Loader', 'order' => 6], + ] + ], + [ + 'name' => 'automation_level', + 'display_name' => 'Automation Level', + 'type' => 'select', + 'order' => 14, + 'is_variant_attribute' => false, 'is_filterable' => true, 'is_visible' => true, 'is_required' => false, - 'description' => 'Screen size', + 'description' => 'Level of automation/precision farming', 'values' => [ - ['value' => '5.5"', 'order' => 1], - ['value' => '6.0"', 'order' => 2], - ['value' => '6.1"', 'order' => 3], - ['value' => '6.5"', 'order' => 4], - ['value' => '6.7"', 'order' => 5], - ['value' => '10.2"', 'order' => 6], - ['value' => '11"', 'order' => 7], - ['value' => '12.9"', 'order' => 8], - ['value' => '13"', 'order' => 9], - ['value' => '15"', 'order' => 10], - ['value' => '17"', 'order' => 11], + ['value' => 'Manual', 'order' => 1], + ['value' => 'Semi-Automatic', 'order' => 2], + ['value' => 'GPS Ready', 'order' => 3], + ['value' => 'GPS Guided', 'order' => 4], + ['value' => 'ISOBUS Compatible', 'order' => 5], + ['value' => 'Fully Autonomous', 'order' => 6], ] ], ]; @@ -207,6 +330,6 @@ public function run(): void $this->command->info("Created attribute: {$attribute->display_name} with " . count($values) . " values"); } - $this->command->info('Attribute seeding completed!'); + $this->command->info('Agricultural Machinery attribute seeding completed!'); } } diff --git a/backend/database/seeders/CategorySeeder.php b/backend/database/seeders/CategorySeeder.php index a2c80a5..22ef0eb 100644 --- a/backend/database/seeders/CategorySeeder.php +++ b/backend/database/seeders/CategorySeeder.php @@ -10,278 +10,447 @@ class CategorySeeder extends Seeder { /** * Run the database seeds. + * + * Agricultural Machinery Manufacturing Categories + * Based on Netherlands agricultural technology sector */ public function run(): void { - // Get default company - $company = Company::first(); - $companyId = $company?->id; + // Get all companies + $companies = Company::all(); + + if ($companies->isEmpty()) { + $this->command->error('No companies found! Please run CompanySeeder first.'); + return; + } // Clear existing categories Category::query()->forceDelete(); - // Main categories (parent categories) - $electronics = Category::create([ + // Create categories for each company + foreach ($companies as $company) { + $this->createCategoriesForCompany($company); + } + + $totalCategories = Category::count(); + $parentCategories = Category::whereNull('parent_id')->count(); + $childCategories = Category::whereNotNull('parent_id')->count(); + + $this->command->info("Agricultural Machinery categories seeded successfully!"); + $this->command->info("Total: {$totalCategories} categories ({$parentCategories} parent + {$childCategories} subcategories) for " . $companies->count() . " companies"); + } + + private function createCategoriesForCompany($company): void + { + $companyId = $company->id; + + // ======================================== + // MAIN CATEGORIES (Parent categories) + // ======================================== + + $tractors = Category::create([ 'company_id' => $companyId, - 'name' => 'Electronics', - 'slug' => 'electronics', - 'description' => 'Electronic devices and accessories' + 'name' => 'Tractors', + 'slug' => 'tractors', + 'description' => 'Agricultural tractors for farming operations' ]); - $computersLaptops = Category::create([ + $harvestingEquipment = Category::create([ 'company_id' => $companyId, - 'name' => 'Computers & Laptops', - 'slug' => 'computers-laptops', - 'description' => 'Desktop computers, laptops, and computer accessories' + 'name' => 'Harvesting Equipment', + 'slug' => 'harvesting-equipment', + 'description' => 'Combine harvesters, balers, and harvesting machinery' ]); - $mobilePhones = Category::create([ + $soilPreparation = Category::create([ 'company_id' => $companyId, - 'name' => 'Mobile Phones', - 'slug' => 'mobile-phones', - 'description' => 'Smartphones, feature phones, and mobile accessories' + 'name' => 'Soil Preparation', + 'slug' => 'soil-preparation', + 'description' => 'Ploughs, cultivators, and tillage equipment' ]); - $camerasPhotography = Category::create([ + $seedingPlanting = Category::create([ 'company_id' => $companyId, - 'name' => 'Cameras & Photography', - 'slug' => 'cameras-photography', - 'description' => 'Digital cameras, lenses, and photography equipment' + 'name' => 'Seeding & Planting', + 'slug' => 'seeding-planting', + 'description' => 'Seed drills, planters, and transplanting equipment' ]); - $audioHeadphones = Category::create([ + $irrigationSystems = Category::create([ 'company_id' => $companyId, - 'name' => 'Audio & Headphones', - 'slug' => 'audio-headphones', - 'description' => 'Speakers, headphones, and audio equipment' + 'name' => 'Irrigation Systems', + 'slug' => 'irrigation-systems', + 'description' => 'Irrigation and water management equipment' ]); - $gaming = Category::create([ + $sprayingEquipment = Category::create([ 'company_id' => $companyId, - 'name' => 'Gaming', - 'slug' => 'gaming', - 'description' => 'Gaming consoles, accessories, and video games' + 'name' => 'Spraying Equipment', + 'slug' => 'spraying-equipment', + 'description' => 'Crop sprayers and fertilizer spreaders' ]); - $wearables = Category::create([ + $livestockEquipment = Category::create([ 'company_id' => $companyId, - 'name' => 'Wearables', - 'slug' => 'wearables', - 'description' => 'Smartwatches, fitness trackers, and wearable tech' + 'name' => 'Livestock Equipment', + 'slug' => 'livestock-equipment', + 'description' => 'Dairy, feeding, and animal husbandry equipment' ]); - $homeAppliances = Category::create([ + $greenhouseEquipment = Category::create([ 'company_id' => $companyId, - 'name' => 'Home Appliances', - 'slug' => 'home-appliances', - 'description' => 'Kitchen appliances and home electronics' + 'name' => 'Greenhouse Equipment', + 'slug' => 'greenhouse-equipment', + 'description' => 'Climate control and greenhouse systems' ]); - $smartHome = Category::create([ + $spareParts = Category::create([ 'company_id' => $companyId, - 'name' => 'Smart Home', - 'slug' => 'smart-home', - 'description' => 'Smart home devices and automation systems' + 'name' => 'Spare Parts', + 'slug' => 'spare-parts', + 'description' => 'Replacement parts and components' ]); - $officeSupplies = Category::create([ + $rawMaterials = Category::create([ 'company_id' => $companyId, - 'name' => 'Office Supplies', - 'slug' => 'office-supplies', - 'description' => 'Office equipment and supplies' + 'name' => 'Raw Materials', + 'slug' => 'raw-materials', + 'description' => 'Steel, metals, and manufacturing materials' ]); - // Subcategories - Electronics + // ======================================== + // SUBCATEGORIES - Tractors + // ======================================== + Category::create([ 'company_id' => $companyId, - 'name' => 'Tablets', - 'slug' => 'tablets', - 'description' => 'Tablets and e-readers', - 'parent_id' => $electronics->id + 'name' => 'Compact Tractors', + 'slug' => 'compact-tractors', + 'description' => 'Small tractors 25-50 HP for orchards and gardens', + 'parent_id' => $tractors->id ]); Category::create([ 'company_id' => $companyId, - 'name' => 'Accessories', - 'slug' => 'accessories', - 'description' => 'Electronic accessories', - 'parent_id' => $electronics->id + 'name' => 'Utility Tractors', + 'slug' => 'utility-tractors', + 'description' => 'Medium tractors 50-100 HP for general farming', + 'parent_id' => $tractors->id ]); - // Subcategories - Computers & Laptops Category::create([ 'company_id' => $companyId, - 'name' => 'Laptops', - 'slug' => 'laptops', - 'description' => 'Portable laptops and notebooks', - 'parent_id' => $computersLaptops->id + 'name' => 'Row Crop Tractors', + 'slug' => 'row-crop-tractors', + 'description' => 'Large tractors 100-200 HP for field work', + 'parent_id' => $tractors->id ]); Category::create([ 'company_id' => $companyId, - 'name' => 'Desktops', - 'slug' => 'desktops', - 'description' => 'Desktop computers and workstations', - 'parent_id' => $computersLaptops->id + 'name' => 'Specialty Tractors', + 'slug' => 'specialty-tractors', + 'description' => 'Vineyard, orchard, and narrow tractors', + 'parent_id' => $tractors->id ]); + // ======================================== + // SUBCATEGORIES - Harvesting Equipment + // ======================================== + Category::create([ 'company_id' => $companyId, - 'name' => 'Computer Accessories', - 'slug' => 'computer-accessories', - 'description' => 'Keyboards, mice, and computer peripherals', - 'parent_id' => $computersLaptops->id + 'name' => 'Combine Harvesters', + 'slug' => 'combine-harvesters', + 'description' => 'Grain and crop combine harvesters', + 'parent_id' => $harvestingEquipment->id ]); - // Subcategories - Mobile Phones Category::create([ 'company_id' => $companyId, - 'name' => 'Smartphones', - 'slug' => 'smartphones', - 'description' => 'High-end smartphones', - 'parent_id' => $mobilePhones->id + 'name' => 'Forage Harvesters', + 'slug' => 'forage-harvesters', + 'description' => 'Silage and forage harvesting equipment', + 'parent_id' => $harvestingEquipment->id ]); Category::create([ 'company_id' => $companyId, - 'name' => 'Mobile Accessories', - 'slug' => 'mobile-accessories', - 'description' => 'Cases, chargers, and mobile accessories', - 'parent_id' => $mobilePhones->id + 'name' => 'Balers', + 'slug' => 'balers', + 'description' => 'Round and square balers for hay and straw', + 'parent_id' => $harvestingEquipment->id ]); - // Subcategories - Cameras & Photography Category::create([ 'company_id' => $companyId, - 'name' => 'DSLR Cameras', - 'slug' => 'dslr-cameras', - 'description' => 'Professional DSLR cameras', - 'parent_id' => $camerasPhotography->id + 'name' => 'Potato Harvesters', + 'slug' => 'potato-harvesters', + 'description' => 'Specialized potato and root vegetable harvesters', + 'parent_id' => $harvestingEquipment->id ]); + // ======================================== + // SUBCATEGORIES - Soil Preparation + // ======================================== + Category::create([ 'company_id' => $companyId, - 'name' => 'Mirrorless Cameras', - 'slug' => 'mirrorless-cameras', - 'description' => 'Mirrorless camera systems', - 'parent_id' => $camerasPhotography->id + 'name' => 'Ploughs', + 'slug' => 'ploughs', + 'description' => 'Reversible and conventional ploughs', + 'parent_id' => $soilPreparation->id ]); Category::create([ 'company_id' => $companyId, - 'name' => 'Lenses', - 'slug' => 'lenses', - 'description' => 'Camera lenses and optics', - 'parent_id' => $camerasPhotography->id + 'name' => 'Disc Harrows', + 'slug' => 'disc-harrows', + 'description' => 'Disc harrows for soil cultivation', + 'parent_id' => $soilPreparation->id ]); - // Subcategories - Audio & Headphones Category::create([ 'company_id' => $companyId, - 'name' => 'Headphones', - 'slug' => 'headphones', - 'description' => 'Over-ear and in-ear headphones', - 'parent_id' => $audioHeadphones->id + 'name' => 'Cultivators', + 'slug' => 'cultivators', + 'description' => 'Field cultivators and chisel ploughs', + 'parent_id' => $soilPreparation->id ]); Category::create([ 'company_id' => $companyId, - 'name' => 'Speakers', - 'slug' => 'speakers', - 'description' => 'Bluetooth and wired speakers', - 'parent_id' => $audioHeadphones->id + 'name' => 'Rotary Tillers', + 'slug' => 'rotary-tillers', + 'description' => 'Rotavators and power tillers', + 'parent_id' => $soilPreparation->id ]); - // Subcategories - Gaming + // ======================================== + // SUBCATEGORIES - Seeding & Planting + // ======================================== + Category::create([ 'company_id' => $companyId, - 'name' => 'Gaming Consoles', - 'slug' => 'gaming-consoles', - 'description' => 'PlayStation, Xbox, Nintendo consoles', - 'parent_id' => $gaming->id + 'name' => 'Seed Drills', + 'slug' => 'seed-drills', + 'description' => 'Precision seed drills for row crops', + 'parent_id' => $seedingPlanting->id ]); Category::create([ 'company_id' => $companyId, - 'name' => 'Gaming Accessories', - 'slug' => 'gaming-accessories', - 'description' => 'Controllers, headsets, and gaming peripherals', - 'parent_id' => $gaming->id + 'name' => 'Planters', + 'slug' => 'planters', + 'description' => 'Pneumatic and mechanical planters', + 'parent_id' => $seedingPlanting->id ]); - // Subcategories - Wearables Category::create([ 'company_id' => $companyId, - 'name' => 'Smartwatches', - 'slug' => 'smartwatches', - 'description' => 'Smart watches and wearable devices', - 'parent_id' => $wearables->id + 'name' => 'Transplanters', + 'slug' => 'transplanters', + 'description' => 'Vegetable and seedling transplanters', + 'parent_id' => $seedingPlanting->id ]); + // ======================================== + // SUBCATEGORIES - Irrigation Systems + // ======================================== + Category::create([ 'company_id' => $companyId, - 'name' => 'Fitness Trackers', - 'slug' => 'fitness-trackers', - 'description' => 'Activity and fitness tracking devices', - 'parent_id' => $wearables->id + 'name' => 'Drip Irrigation', + 'slug' => 'drip-irrigation', + 'description' => 'Drip lines and micro-irrigation systems', + 'parent_id' => $irrigationSystems->id ]); - // Subcategories - Home Appliances Category::create([ 'company_id' => $companyId, - 'name' => 'Kitchen Appliances', - 'slug' => 'kitchen-appliances', - 'description' => 'Kitchen tools and appliances', - 'parent_id' => $homeAppliances->id + 'name' => 'Sprinkler Systems', + 'slug' => 'sprinkler-systems', + 'description' => 'Center pivot and linear move sprinklers', + 'parent_id' => $irrigationSystems->id ]); Category::create([ 'company_id' => $companyId, - 'name' => 'Cleaning Appliances', - 'slug' => 'cleaning-appliances', - 'description' => 'Vacuum cleaners and cleaning devices', - 'parent_id' => $homeAppliances->id + 'name' => 'Pumps', + 'slug' => 'pumps', + 'description' => 'Irrigation and water transfer pumps', + 'parent_id' => $irrigationSystems->id ]); - // Subcategories - Smart Home + // ======================================== + // SUBCATEGORIES - Spraying Equipment + // ======================================== + + Category::create([ + 'company_id' => $companyId, + 'name' => 'Field Sprayers', + 'slug' => 'field-sprayers', + 'description' => 'Mounted and trailed crop sprayers', + 'parent_id' => $sprayingEquipment->id + ]); + + Category::create([ + 'company_id' => $companyId, + 'name' => 'Fertilizer Spreaders', + 'slug' => 'fertilizer-spreaders', + 'description' => 'Broadcast and precision fertilizer spreaders', + 'parent_id' => $sprayingEquipment->id + ]); + + Category::create([ + 'company_id' => $companyId, + 'name' => 'Orchard Sprayers', + 'slug' => 'orchard-sprayers', + 'description' => 'Air blast sprayers for orchards and vineyards', + 'parent_id' => $sprayingEquipment->id + ]); + + // ======================================== + // SUBCATEGORIES - Livestock Equipment + // ======================================== + + Category::create([ + 'company_id' => $companyId, + 'name' => 'Milking Systems', + 'slug' => 'milking-systems', + 'description' => 'Automated milking parlors and robots', + 'parent_id' => $livestockEquipment->id + ]); + + Category::create([ + 'company_id' => $companyId, + 'name' => 'Feeding Systems', + 'slug' => 'feeding-systems', + 'description' => 'Feed mixers and automated feeding equipment', + 'parent_id' => $livestockEquipment->id + ]); + + Category::create([ + 'company_id' => $companyId, + 'name' => 'Manure Handling', + 'slug' => 'manure-handling', + 'description' => 'Slurry tankers and manure spreaders', + 'parent_id' => $livestockEquipment->id + ]); + + // ======================================== + // SUBCATEGORIES - Greenhouse Equipment + // ======================================== + + Category::create([ + 'company_id' => $companyId, + 'name' => 'Climate Control', + 'slug' => 'climate-control', + 'description' => 'Heating, ventilation, and cooling systems', + 'parent_id' => $greenhouseEquipment->id + ]); + + Category::create([ + 'company_id' => $companyId, + 'name' => 'Greenhouse Irrigation', + 'slug' => 'greenhouse-irrigation', + 'description' => 'Hydroponic and fertigation systems', + 'parent_id' => $greenhouseEquipment->id + ]); + + Category::create([ + 'company_id' => $companyId, + 'name' => 'Automation Systems', + 'slug' => 'automation-systems', + 'description' => 'Greenhouse automation and robotics', + 'parent_id' => $greenhouseEquipment->id + ]); + + // ======================================== + // SUBCATEGORIES - Spare Parts + // ======================================== + + Category::create([ + 'company_id' => $companyId, + 'name' => 'Engine Parts', + 'slug' => 'engine-parts', + 'description' => 'Diesel engine components and filters', + 'parent_id' => $spareParts->id + ]); + + Category::create([ + 'company_id' => $companyId, + 'name' => 'Hydraulic Components', + 'slug' => 'hydraulic-components', + 'description' => 'Hydraulic pumps, cylinders, and valves', + 'parent_id' => $spareParts->id + ]); + + Category::create([ + 'company_id' => $companyId, + 'name' => 'Transmission Parts', + 'slug' => 'transmission-parts', + 'description' => 'Gearbox, clutch, and drivetrain components', + 'parent_id' => $spareParts->id + ]); + + Category::create([ + 'company_id' => $companyId, + 'name' => 'Electrical Components', + 'slug' => 'electrical-components', + 'description' => 'Sensors, wiring, and control units', + 'parent_id' => $spareParts->id + ]); + + Category::create([ + 'company_id' => $companyId, + 'name' => 'Wear Parts', + 'slug' => 'wear-parts', + 'description' => 'Blades, tines, and replacement wear items', + 'parent_id' => $spareParts->id + ]); + + // ======================================== + // SUBCATEGORIES - Raw Materials + // ======================================== + Category::create([ 'company_id' => $companyId, - 'name' => 'Smart Speakers', - 'slug' => 'smart-speakers', - 'description' => 'Voice-controlled smart speakers', - 'parent_id' => $smartHome->id + 'name' => 'Steel & Metals', + 'slug' => 'steel-metals', + 'description' => 'Steel sheets, tubes, and metal profiles', + 'parent_id' => $rawMaterials->id ]); Category::create([ 'company_id' => $companyId, - 'name' => 'Security Cameras', - 'slug' => 'security-cameras', - 'description' => 'Home security and surveillance cameras', - 'parent_id' => $smartHome->id + 'name' => 'Fasteners', + 'slug' => 'fasteners', + 'description' => 'Bolts, nuts, screws, and hardware', + 'parent_id' => $rawMaterials->id ]); - // Subcategories - Office Supplies Category::create([ 'company_id' => $companyId, - 'name' => 'Office Furniture', - 'slug' => 'office-furniture', - 'description' => 'Desks, chairs, and office furniture', - 'parent_id' => $officeSupplies->id + 'name' => 'Bearings & Seals', + 'slug' => 'bearings-seals', + 'description' => 'Ball bearings, roller bearings, and seals', + 'parent_id' => $rawMaterials->id ]); Category::create([ 'company_id' => $companyId, - 'name' => 'Printers & Scanners', - 'slug' => 'printers-scanners', - 'description' => 'Printing and scanning equipment', - 'parent_id' => $officeSupplies->id + 'name' => 'Rubber & Plastics', + 'slug' => 'rubber-plastics', + 'description' => 'Hoses, belts, and plastic components', + 'parent_id' => $rawMaterials->id ]); $totalCategories = Category::count(); $parentCategories = Category::whereNull('parent_id')->count(); $childCategories = Category::whereNotNull('parent_id')->count(); - $this->command->info("Categories seeded successfully!"); + $this->command->info("Agricultural Machinery categories seeded successfully!"); $this->command->info("Total: {$totalCategories} categories ({$parentCategories} parent + {$childCategories} subcategories)"); } } diff --git a/backend/database/seeders/CompanySeeder.php b/backend/database/seeders/CompanySeeder.php index 2bae542..17b8307 100644 --- a/backend/database/seeders/CompanySeeder.php +++ b/backend/database/seeders/CompanySeeder.php @@ -12,10 +12,9 @@ class CompanySeeder extends Seeder */ public function run(): void { - // Create default company - $company = Company::firstOrCreate( - ['name' => 'Demo Company'], + $companies = [ [ + 'name' => 'Demo Company', 'legal_name' => 'Demo Company Ltd.', 'tax_id' => 'DEMO-TAX-001', 'email' => 'info@demo-company.com', @@ -32,9 +31,75 @@ public function run(): void 'auto_reorder' => false, ], 'is_active' => true, - ] - ); + ], + [ + 'name' => 'AgriTech Netherlands B.V.', + 'legal_name' => 'AgriTech Netherlands Besloten Vennootschap', + 'tax_id' => 'NL123456789B01', + 'email' => 'info@agritech-nl.com', + 'phone' => '+31-20-1234567', + 'address' => 'Europaweg 245', + 'city' => 'Rotterdam', + 'country' => 'Netherlands', + 'postal_code' => '3199 LC', + 'base_currency' => 'EUR', + 'supported_currencies' => ['EUR', 'USD', 'GBP'], + 'timezone' => 'Europe/Amsterdam', + 'settings' => [ + 'low_stock_alert' => true, + 'auto_reorder' => true, + ], + 'is_active' => true, + ], + [ + 'name' => 'Global Manufacturing Inc.', + 'legal_name' => 'Global Manufacturing Incorporated', + 'tax_id' => 'GM-TAX-2024', + 'email' => 'contact@globalmfg.com', + 'phone' => '+44-20-7890123', + 'address' => 'Industrial Park 789', + 'city' => 'London', + 'country' => 'United Kingdom', + 'postal_code' => 'SW1A 1AA', + 'base_currency' => 'GBP', + 'supported_currencies' => ['GBP', 'EUR', 'USD'], + 'timezone' => 'Europe/London', + 'settings' => [ + 'low_stock_alert' => true, + 'auto_reorder' => true, + ], + 'is_active' => true, + ], + [ + 'name' => 'TechSolutions GmbH', + 'legal_name' => 'TechSolutions Gesellschaft mit beschränkter Haftung', + 'tax_id' => 'DE987654321', + 'email' => 'info@techsolutions.de', + 'phone' => '+49-30-4567890', + 'address' => 'Technologiepark 12', + 'city' => 'Berlin', + 'country' => 'Germany', + 'postal_code' => '10115', + 'base_currency' => 'EUR', + 'supported_currencies' => ['EUR', 'USD', 'CHF'], + 'timezone' => 'Europe/Berlin', + 'settings' => [ + 'low_stock_alert' => true, + 'auto_reorder' => false, + ], + 'is_active' => true, + ], + ]; + + foreach ($companies as $companyData) { + $company = Company::firstOrCreate( + ['name' => $companyData['name']], + $companyData + ); + + $this->command->info("Company created: {$company->name} (ID: {$company->id})"); + } - $this->command->info("Demo company created: {$company->name} (ID: {$company->id})"); + $this->command->info('Total companies: ' . Company::count()); } } diff --git a/backend/database/seeders/DatabaseSeeder.php b/backend/database/seeders/DatabaseSeeder.php index e5c6275..9e67347 100644 --- a/backend/database/seeders/DatabaseSeeder.php +++ b/backend/database/seeders/DatabaseSeeder.php @@ -2,66 +2,108 @@ namespace Database\Seeders; -use App\Models\User; +use Database\Seeders\Traits\SeederModeTrait; use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; -use Illuminate\Support\Facades\Hash; class DatabaseSeeder extends Seeder { use WithoutModelEvents; + use SeederModeTrait; /** * Seed the application's database. + * + * Usage: + * php artisan db:seed # Minimal mode (system essentials only) + * php artisan db:seed --demo # Demo mode (with sample data) + * php artisan migrate:fresh --seed # Minimal mode + * php artisan migrate:fresh --seed --demo # Demo mode + * + * Or set environment variable: + * SEED_MODE=demo php artisan db:seed */ public function run(): void { - // Seed company first (required for multi-tenant data) + $this->outputModeInfo(); + + // ═══════════════════════════════════════════════════════════════ + // CORE SYSTEM (Always Required) + // These seeders run in both minimal and demo modes + // ═══════════════════════════════════════════════════════════════ + + // Company (required for multi-tenant architecture) $this->call(CompanySeeder::class); - // Seed system settings (lookup values) + // System settings (app configuration) $this->call(SettingsSeeder::class); - // Seed roles and permissions + // Roles and permissions (RBAC) $this->call(RolePermissionSeeder::class); - // Seed test users (with company assignment) + // Admin user (at minimum, need one admin to login) $this->call(UserSeeder::class); - // Seed currencies and exchange rates + // Currencies (base currency required) $this->call(CurrencySeeder::class); - // Seed product types + // Product types (Simple, Configurable, etc.) $this->call(ProductTypeSeeder::class); - // Seed units of measure + // Units of measure (kg, pcs, m, etc.) $this->call(UnitOfMeasureSeeder::class); - // Seed categories - $this->call(CategorySeeder::class); + // ═══════════════════════════════════════════════════════════════ + // DEMO DATA (Optional - only in demo mode) + // Sample data for testing and demonstration + // ═══════════════════════════════════════════════════════════════ + + if ($this->isDemoMode()) { + $this->command->info('Seeding demo data...'); + + // Categories (sample category hierarchy) + $this->call(CategorySeeder::class); + + // Attributes and values (Color, Size, Material, etc.) + $this->call(AttributeSeeder::class); + + // Products (sample products) + $this->call(ProductSeeder::class); + + // Product-specific UOM conversions + $this->call(ProductUomConversionSeeder::class); + + // Category-attribute assignments + $this->call(CategoryAttributeSeeder::class); + + // Product-attribute assignments + $this->call(ProductAttributeSeeder::class); - // Seed attributes and values - $this->call(AttributeSeeder::class); + // Product variants (Color/Size combinations) + $this->call(ProductVariantSeeder::class); - // Seed products (depends on categories) - $this->call(ProductSeeder::class); + // Warehouses (sample locations) + $this->call(WarehouseSeeder::class); - // Assign attributes to categories - $this->call(CategoryAttributeSeeder::class); + // Stock and movements (sample inventory) + $this->call(StockSeeder::class); - // Assign attributes to products (Brand, Warranty, Material) - $this->call(ProductAttributeSeeder::class); + // Suppliers (sample vendors) + $this->call(SupplierSeeder::class); - // Generate product variants (Color, Size, Storage combinations) - $this->call(ProductVariantSeeder::class); + // QC data (acceptance rules, inspections, NCRs) + $this->call(QualityControlSeeder::class); - // Seed warehouses - $this->call(WarehouseSeeder::class); + // Manufacturing (work centers, BOMs, routings, work orders) + $this->call(ManufacturingSeeder::class); - // Seed stock and stock movements - $this->call(StockSeeder::class); + // Sales (customer groups, customers, orders, deliveries) + $this->call(SalesSeeder::class); - // Seed suppliers (Phase 3 - Procurement) - $this->call(SupplierSeeder::class); + $this->command->info('Demo data seeding completed!'); + } else { + $this->command->info('Minimal mode: Skipping demo data.'); + $this->command->info('To include demo data, run: php artisan db:seed --demo'); + } } } diff --git a/backend/database/seeders/ManufacturingSeeder.php b/backend/database/seeders/ManufacturingSeeder.php new file mode 100644 index 0000000..1451907 --- /dev/null +++ b/backend/database/seeders/ManufacturingSeeder.php @@ -0,0 +1,714 @@ +isEmpty()) { + $this->command->error('No companies found! Please run CompanySeeder first.'); + return; + } + + // Create manufacturing data for each company + foreach ($companies as $company) { + $this->createManufacturingDataForCompany($company); + } + + $this->command->info('Manufacturing data seeded successfully for ' . $companies->count() . ' companies'); + } + + private function createManufacturingDataForCompany($company): void + { + $this->command->info("Seeding Agricultural Machinery Manufacturing data for {$company->name}..."); + + $companyId = $company->id; + $userId = \App\Models\User::where('company_id', $companyId)->first()?->id ?? 1; + + // Get default UOM (piece) + $pieceUom = UnitOfMeasure::where('code', 'pcs')->first(); + $uomId = $pieceUom?->id ?? 1; + + // Get WIP warehouse (Production Plant) for this company + $wipWarehouse = Warehouse::where('code', 'LIKE', 'WH-PROD-%') + ->where('company_id', $companyId) + ->first() + ?? Warehouse::where('company_id', $companyId) + ->where('warehouse_type', 'wip') + ->first(); + $warehouseId = $wipWarehouse?->id ?? 1; + + // Create Agricultural Machinery Work Centers + $this->command->info('Creating Agricultural Machinery Work Centers...'); + + $workCenters = [ + // ======================================== + // FABRICATION CENTERS + // ======================================== + [ + 'company_id' => $companyId, + 'code' => 'WC-LASER-01', + 'name' => 'Laser Cutting Center', + 'description' => 'High-precision laser cutting for steel plates and tubes', + 'work_center_type' => WorkCenterType::MACHINE, + 'cost_per_hour' => 185.00, + 'capacity_per_day' => 16, + 'efficiency_percentage' => 92, + 'is_active' => true, + 'created_by' => $userId, + ], + [ + 'company_id' => $companyId, + 'code' => 'WC-PLASMA-01', + 'name' => 'Plasma Cutting Station', + 'description' => 'Heavy-duty plasma cutting for thick steel', + 'work_center_type' => WorkCenterType::MACHINE, + 'cost_per_hour' => 145.00, + 'capacity_per_day' => 16, + 'efficiency_percentage' => 88, + 'is_active' => true, + 'created_by' => $userId, + ], + [ + 'company_id' => $companyId, + 'code' => 'WC-BEND-01', + 'name' => 'CNC Press Brake', + 'description' => 'Hydraulic press brake for bending steel plates', + 'work_center_type' => WorkCenterType::MACHINE, + 'cost_per_hour' => 125.00, + 'capacity_per_day' => 16, + 'efficiency_percentage' => 90, + 'is_active' => true, + 'created_by' => $userId, + ], + [ + 'company_id' => $companyId, + 'code' => 'WC-CNC-01', + 'name' => 'CNC Machining Center', + 'description' => '5-axis CNC machining for precision components', + 'work_center_type' => WorkCenterType::MACHINE, + 'cost_per_hour' => 175.00, + 'capacity_per_day' => 16, + 'efficiency_percentage' => 85, + 'is_active' => true, + 'created_by' => $userId, + ], + [ + 'company_id' => $companyId, + 'code' => 'WC-LATHE-01', + 'name' => 'CNC Lathe Station', + 'description' => 'CNC lathe for shafts and cylindrical parts', + 'work_center_type' => WorkCenterType::MACHINE, + 'cost_per_hour' => 145.00, + 'capacity_per_day' => 16, + 'efficiency_percentage' => 88, + 'is_active' => true, + 'created_by' => $userId, + ], + + // ======================================== + // WELDING & JOINING + // ======================================== + [ + 'company_id' => $companyId, + 'code' => 'WC-WELD-01', + 'name' => 'Robot Welding Cell 1', + 'description' => 'Automated MIG/MAG welding for frame assemblies', + 'work_center_type' => WorkCenterType::MACHINE, + 'cost_per_hour' => 165.00, + 'capacity_per_day' => 16, + 'efficiency_percentage' => 94, + 'is_active' => true, + 'created_by' => $userId, + ], + [ + 'company_id' => $companyId, + 'code' => 'WC-WELD-02', + 'name' => 'Robot Welding Cell 2', + 'description' => 'Automated welding for implement frames', + 'work_center_type' => WorkCenterType::MACHINE, + 'cost_per_hour' => 165.00, + 'capacity_per_day' => 16, + 'efficiency_percentage' => 92, + 'is_active' => true, + 'created_by' => $userId, + ], + [ + 'company_id' => $companyId, + 'code' => 'WC-WELD-MAN', + 'name' => 'Manual Welding Station', + 'description' => 'Manual welding for complex and custom assemblies', + 'work_center_type' => WorkCenterType::LABOR, + 'cost_per_hour' => 85.00, + 'capacity_per_day' => 8, + 'efficiency_percentage' => 80, + 'is_active' => true, + 'created_by' => $userId, + ], + + // ======================================== + // SURFACE TREATMENT + // ======================================== + [ + 'company_id' => $companyId, + 'code' => 'WC-BLAST-01', + 'name' => 'Shot Blasting Chamber', + 'description' => 'Surface preparation and rust removal', + 'work_center_type' => WorkCenterType::MACHINE, + 'cost_per_hour' => 95.00, + 'capacity_per_day' => 16, + 'efficiency_percentage' => 90, + 'is_active' => true, + 'created_by' => $userId, + ], + [ + 'company_id' => $companyId, + 'code' => 'WC-PAINT-01', + 'name' => 'Powder Coating Line', + 'description' => 'Electrostatic powder coating for corrosion protection', + 'work_center_type' => WorkCenterType::MACHINE, + 'cost_per_hour' => 125.00, + 'capacity_per_day' => 16, + 'efficiency_percentage' => 88, + 'is_active' => true, + 'created_by' => $userId, + ], + [ + 'company_id' => $companyId, + 'code' => 'WC-PAINT-02', + 'name' => 'Wet Paint Booth', + 'description' => 'Spray painting for special finishes', + 'work_center_type' => WorkCenterType::LABOR, + 'cost_per_hour' => 75.00, + 'capacity_per_day' => 8, + 'efficiency_percentage' => 85, + 'is_active' => true, + 'created_by' => $userId, + ], + + // ======================================== + // ASSEMBLY LINES + // ======================================== + [ + 'company_id' => $companyId, + 'code' => 'WC-ASM-TRAC', + 'name' => 'Tractor Assembly Line', + 'description' => 'Main tractor assembly and integration', + 'work_center_type' => WorkCenterType::LABOR, + 'cost_per_hour' => 95.00, + 'capacity_per_day' => 8, + 'efficiency_percentage' => 88, + 'is_active' => true, + 'created_by' => $userId, + ], + [ + 'company_id' => $companyId, + 'code' => 'WC-ASM-IMPL', + 'name' => 'Implement Assembly Line', + 'description' => 'Assembly of soil and harvesting implements', + 'work_center_type' => WorkCenterType::LABOR, + 'cost_per_hour' => 85.00, + 'capacity_per_day' => 8, + 'efficiency_percentage' => 90, + 'is_active' => true, + 'created_by' => $userId, + ], + [ + 'company_id' => $companyId, + 'code' => 'WC-ASM-HYD', + 'name' => 'Hydraulics Assembly', + 'description' => 'Hydraulic system assembly and testing', + 'work_center_type' => WorkCenterType::LABOR, + 'cost_per_hour' => 95.00, + 'capacity_per_day' => 8, + 'efficiency_percentage' => 92, + 'is_active' => true, + 'created_by' => $userId, + ], + [ + 'company_id' => $companyId, + 'code' => 'WC-ASM-ELEC', + 'name' => 'Electrical Assembly', + 'description' => 'Wiring harness and electronics installation', + 'work_center_type' => WorkCenterType::LABOR, + 'cost_per_hour' => 85.00, + 'capacity_per_day' => 8, + 'efficiency_percentage' => 90, + 'is_active' => true, + 'created_by' => $userId, + ], + + // ======================================== + // TESTING & QC + // ======================================== + [ + 'company_id' => $companyId, + 'code' => 'WC-TEST-01', + 'name' => 'Functional Test Station', + 'description' => 'Machine function and safety testing', + 'work_center_type' => WorkCenterType::LABOR, + 'cost_per_hour' => 75.00, + 'capacity_per_day' => 8, + 'efficiency_percentage' => 95, + 'is_active' => true, + 'created_by' => $userId, + ], + [ + 'company_id' => $companyId, + 'code' => 'WC-QC-FINAL', + 'name' => 'Final Quality Inspection', + 'description' => 'Final quality check and certification', + 'work_center_type' => WorkCenterType::LABOR, + 'cost_per_hour' => 65.00, + 'capacity_per_day' => 8, + 'efficiency_percentage' => 100, + 'is_active' => true, + 'created_by' => $userId, + ], + + // ======================================== + // PDI & SHIPPING + // ======================================== + [ + 'company_id' => $companyId, + 'code' => 'WC-PDI-01', + 'name' => 'Pre-Delivery Inspection', + 'description' => 'Final PDI before customer delivery', + 'work_center_type' => WorkCenterType::LABOR, + 'cost_per_hour' => 55.00, + 'capacity_per_day' => 8, + 'efficiency_percentage' => 98, + 'is_active' => true, + 'created_by' => $userId, + ], + [ + 'company_id' => $companyId, + 'code' => 'WC-PACK-01', + 'name' => 'Packaging & Crating', + 'description' => 'Export packaging and crating for shipping', + 'work_center_type' => WorkCenterType::LABOR, + 'cost_per_hour' => 45.00, + 'capacity_per_day' => 8, + 'efficiency_percentage' => 92, + 'is_active' => true, + 'created_by' => $userId, + ], + ]; + + foreach ($workCenters as $wcData) { + WorkCenter::updateOrCreate( + ['code' => $wcData['code'], 'company_id' => $wcData['company_id']], + $wcData + ); + } + + // Get manufacturable products (tractors and implements) + $manufacturableProducts = Product::whereHas('productType', function ($q) { + $q->where('can_be_manufactured', true); + })->whereHas('categories', function ($q) { + $q->whereIn('slug', [ + 'compact-tractors', 'utility-tractors', 'row-crop-tractors', + 'ploughs', 'disc-harrows', 'cultivators', 'seed-drills', + 'field-sprayers', 'fertilizer-spreaders' + ]); + })->take(6)->get(); + + if ($manufacturableProducts->isEmpty()) { + $this->command->warn('No manufacturable products found. Skipping BOM and Routing creation.'); + return; + } + + // Get component products (spare parts, raw materials) + $componentProducts = Product::whereHas('categories', function ($q) { + $q->whereIn('slug', [ + 'steel-metals', 'fasteners', 'bearings-seals', 'rubber-plastics', + 'hydraulic-components', 'electrical-components', 'engine-parts' + ]); + })->take(30)->get(); + + if ($componentProducts->count() < 5) { + $this->command->warn('Not enough component products found. Skipping BOM creation.'); + return; + } + + $this->command->info('Creating BOMs and Routings for Agricultural Machinery...'); + + // Get work centers + $laserCutting = WorkCenter::where('code', 'WC-LASER-01')->first(); + $bending = WorkCenter::where('code', 'WC-BEND-01')->first(); + $robotWelding = WorkCenter::where('code', 'WC-WELD-01')->first(); + $shotBlasting = WorkCenter::where('code', 'WC-BLAST-01')->first(); + $powderCoating = WorkCenter::where('code', 'WC-PAINT-01')->first(); + $tractorAssembly = WorkCenter::where('code', 'WC-ASM-TRAC')->first(); + $implementAssembly = WorkCenter::where('code', 'WC-ASM-IMPL')->first(); + $hydraulicsAssembly = WorkCenter::where('code', 'WC-ASM-HYD')->first(); + $functionalTest = WorkCenter::where('code', 'WC-TEST-01')->first(); + $finalQC = WorkCenter::where('code', 'WC-QC-FINAL')->first(); + $pdi = WorkCenter::where('code', 'WC-PDI-01')->first(); + + $bomCounter = 1; + $routingCounter = 1; + $createdBoms = []; + $createdRoutings = []; + + foreach ($manufacturableProducts as $index => $product) { + // Create BOM + $bomNumber = 'BOM-' . str_pad($bomCounter++, 5, '0', STR_PAD_LEFT); + + $bom = Bom::updateOrCreate( + ['bom_number' => $bomNumber, 'company_id' => $companyId], + [ + 'product_id' => $product->id, + 'name' => $product->name . ' - Production BOM', + 'description' => 'Manufacturing bill of materials for ' . $product->name, + 'bom_type' => BomType::MANUFACTURING, + 'status' => BomStatus::ACTIVE, + 'quantity' => 1, + 'uom_id' => $uomId, + 'is_default' => true, + 'created_by' => $userId, + ] + ); + + $createdBoms[$product->id] = $bom; + + // Add BOM Items (components) + $componentOffset = $index * 5; + $availableComponents = $componentProducts->skip($componentOffset)->take(5); + + $lineNumber = 1; + foreach ($availableComponents as $component) { + if ($component->id === $product->id) continue; + + BomItem::updateOrCreate( + ['bom_id' => $bom->id, 'component_id' => $component->id], + [ + 'line_number' => $lineNumber++, + 'quantity' => rand(2, 8), + 'uom_id' => $uomId, + 'scrap_percentage' => rand(2, 8), + 'is_optional' => false, + 'is_phantom' => false, + ] + ); + } + + // Determine if this is a tractor or implement + $isTractor = str_contains(strtolower($product->name), 'tractor') || + str_contains(strtolower($product->name), 'at-') || + str_contains(strtolower($product->name), 'ha-'); + + // Create Routing + $routingNumber = 'RTG-' . str_pad($routingCounter++, 5, '0', STR_PAD_LEFT); + + $routing = Routing::updateOrCreate( + ['routing_number' => $routingNumber, 'company_id' => $companyId], + [ + 'product_id' => $product->id, + 'name' => $product->name . ' - Production Routing', + 'description' => 'Manufacturing routing for ' . $product->name, + 'status' => RoutingStatus::ACTIVE, + 'is_default' => true, + 'created_by' => $userId, + ] + ); + + $createdRoutings[$product->id] = $routing; + + // Add Routing Operations based on product type + if ($isTractor) { + $operations = [ + ['work_center_id' => $laserCutting?->id, 'operation_number' => 10, 'name' => 'Laser Cutting Frame Parts', 'description' => 'Cut chassis and frame components', 'setup_time' => 45, 'run_time_per_unit' => 120], + ['work_center_id' => $bending?->id, 'operation_number' => 20, 'name' => 'Bending & Forming', 'description' => 'Form structural components', 'setup_time' => 30, 'run_time_per_unit' => 60], + ['work_center_id' => $robotWelding?->id, 'operation_number' => 30, 'name' => 'Frame Welding', 'description' => 'Weld main chassis frame', 'setup_time' => 60, 'run_time_per_unit' => 180], + ['work_center_id' => $shotBlasting?->id, 'operation_number' => 40, 'name' => 'Shot Blasting', 'description' => 'Surface preparation', 'setup_time' => 15, 'run_time_per_unit' => 45], + ['work_center_id' => $powderCoating?->id, 'operation_number' => 50, 'name' => 'Powder Coating', 'description' => 'Apply protective coating', 'setup_time' => 20, 'run_time_per_unit' => 90], + ['work_center_id' => $tractorAssembly?->id, 'operation_number' => 60, 'name' => 'Tractor Assembly', 'description' => 'Main assembly - engine, transmission, cab', 'setup_time' => 30, 'run_time_per_unit' => 480], + ['work_center_id' => $hydraulicsAssembly?->id, 'operation_number' => 70, 'name' => 'Hydraulics Installation', 'description' => 'Install hydraulic system', 'setup_time' => 15, 'run_time_per_unit' => 120], + ['work_center_id' => $functionalTest?->id, 'operation_number' => 80, 'name' => 'Functional Testing', 'description' => 'Full function and safety test', 'setup_time' => 10, 'run_time_per_unit' => 180], + ['work_center_id' => $finalQC?->id, 'operation_number' => 90, 'name' => 'Final QC Inspection', 'description' => 'Quality certification', 'setup_time' => 5, 'run_time_per_unit' => 60], + ['work_center_id' => $pdi?->id, 'operation_number' => 100, 'name' => 'Pre-Delivery Inspection', 'description' => 'Final PDI before delivery', 'setup_time' => 5, 'run_time_per_unit' => 45], + ]; + } else { + $operations = [ + ['work_center_id' => $laserCutting?->id, 'operation_number' => 10, 'name' => 'Laser Cutting', 'description' => 'Cut implement frame parts', 'setup_time' => 30, 'run_time_per_unit' => 60], + ['work_center_id' => $bending?->id, 'operation_number' => 20, 'name' => 'Bending & Forming', 'description' => 'Form structural parts', 'setup_time' => 20, 'run_time_per_unit' => 45], + ['work_center_id' => $robotWelding?->id, 'operation_number' => 30, 'name' => 'Welding Assembly', 'description' => 'Weld frame and components', 'setup_time' => 30, 'run_time_per_unit' => 120], + ['work_center_id' => $shotBlasting?->id, 'operation_number' => 40, 'name' => 'Shot Blasting', 'description' => 'Surface preparation', 'setup_time' => 10, 'run_time_per_unit' => 30], + ['work_center_id' => $powderCoating?->id, 'operation_number' => 50, 'name' => 'Powder Coating', 'description' => 'Apply protective finish', 'setup_time' => 15, 'run_time_per_unit' => 60], + ['work_center_id' => $implementAssembly?->id, 'operation_number' => 60, 'name' => 'Final Assembly', 'description' => 'Install working parts and hardware', 'setup_time' => 20, 'run_time_per_unit' => 180], + ['work_center_id' => $functionalTest?->id, 'operation_number' => 70, 'name' => 'Functional Testing', 'description' => 'Test all functions', 'setup_time' => 5, 'run_time_per_unit' => 45], + ['work_center_id' => $finalQC?->id, 'operation_number' => 80, 'name' => 'Final QC Inspection', 'description' => 'Quality certification', 'setup_time' => 5, 'run_time_per_unit' => 30], + ]; + } + + foreach ($operations as $opData) { + if ($opData['work_center_id']) { + RoutingOperation::updateOrCreate( + [ + 'routing_id' => $routing->id, + 'operation_number' => $opData['operation_number'], + ], + array_merge($opData, [ + 'routing_id' => $routing->id, + 'queue_time' => 15, + 'move_time' => 10, + ]) + ); + } + } + + $this->command->info("Created BOM and Routing for: {$product->name}"); + } + + // Create Work Orders + $this->command->info('Creating Agricultural Machinery Work Orders...'); + + $workOrdersData = [ + [ + 'product_index' => 0, + 'quantity' => 5, + 'priority' => WorkOrderPriority::NORMAL, + 'status' => WorkOrderStatus::COMPLETED, + 'notes' => 'Production batch completed - Q4 2024', + 'planned_start' => now()->subDays(14), + 'planned_end' => now()->subDays(7), + 'actual_start' => now()->subDays(14), + 'actual_end' => now()->subDays(8), + 'quantity_completed' => 5, + ], + [ + 'product_index' => 1, + 'quantity' => 3, + 'priority' => WorkOrderPriority::HIGH, + 'status' => WorkOrderStatus::IN_PROGRESS, + 'notes' => 'Spring season production - Priority order for dealer', + 'planned_start' => now()->subDays(5), + 'planned_end' => now()->addDays(5), + 'actual_start' => now()->subDays(5), + 'actual_end' => null, + 'quantity_completed' => 1, + ], + [ + 'product_index' => 2, + 'quantity' => 10, + 'priority' => WorkOrderPriority::NORMAL, + 'status' => WorkOrderStatus::RELEASED, + 'notes' => 'Stock replenishment for spring season', + 'planned_start' => now()->addDays(2), + 'planned_end' => now()->addDays(14), + 'actual_start' => null, + 'actual_end' => null, + 'quantity_completed' => 0, + ], + [ + 'product_index' => 3, + 'quantity' => 15, + 'priority' => WorkOrderPriority::LOW, + 'status' => WorkOrderStatus::DRAFT, + 'notes' => 'Planned production for Agritechnica exhibition', + 'planned_start' => now()->addDays(30), + 'planned_end' => now()->addDays(60), + 'actual_start' => null, + 'actual_end' => null, + 'quantity_completed' => 0, + ], + [ + 'product_index' => 4, + 'quantity' => 2, + 'priority' => WorkOrderPriority::URGENT, + 'status' => WorkOrderStatus::ON_HOLD, + 'notes' => 'On hold - Waiting for Grimme hydraulic components', + 'planned_start' => now()->subDays(7), + 'planned_end' => now()->addDays(3), + 'actual_start' => now()->subDays(7), + 'actual_end' => null, + 'quantity_completed' => 0, + ], + [ + 'product_index' => 5, + 'quantity' => 8, + 'priority' => WorkOrderPriority::NORMAL, + 'status' => WorkOrderStatus::RELEASED, + 'notes' => 'Export order - Belgium dealer', + 'planned_start' => now()->addDays(1), + 'planned_end' => now()->addDays(10), + 'actual_start' => null, + 'actual_end' => null, + 'quantity_completed' => 0, + ], + ]; + + $woCounter = 1; + $productsArray = $manufacturableProducts->values(); + + foreach ($workOrdersData as $woData) { + if (!isset($productsArray[$woData['product_index']])) continue; + + $product = $productsArray[$woData['product_index']]; + $bom = $createdBoms[$product->id] ?? null; + $routing = $createdRoutings[$product->id] ?? null; + + if (!$bom || !$routing) continue; + + $woNumber = 'WO-' . now()->format('Ym') . '-' . str_pad($woCounter++, 4, '0', STR_PAD_LEFT); + + $workOrder = WorkOrder::updateOrCreate( + ['work_order_number' => $woNumber, 'company_id' => $companyId], + [ + 'product_id' => $product->id, + 'bom_id' => $bom->id, + 'routing_id' => $routing->id, + 'warehouse_id' => $warehouseId, + 'quantity_ordered' => $woData['quantity'], + 'quantity_completed' => $woData['quantity_completed'], + 'quantity_scrapped' => 0, + 'uom_id' => $uomId, + 'status' => $woData['status'], + 'priority' => $woData['priority'], + 'planned_start_date' => $woData['planned_start'], + 'planned_end_date' => $woData['planned_end'], + 'actual_start_date' => $woData['actual_start'], + 'actual_end_date' => $woData['actual_end'], + 'notes' => $woData['notes'], + 'estimated_cost' => $woData['quantity'] * ($product->cost_price ?? 25000), + 'actual_cost' => $woData['quantity_completed'] * (($product->cost_price ?? 25000) * 0.95), + 'created_by' => $userId, + 'released_at' => in_array($woData['status'], [WorkOrderStatus::RELEASED, WorkOrderStatus::IN_PROGRESS, WorkOrderStatus::COMPLETED, WorkOrderStatus::ON_HOLD]) ? now() : null, + 'released_by' => in_array($woData['status'], [WorkOrderStatus::RELEASED, WorkOrderStatus::IN_PROGRESS, WorkOrderStatus::COMPLETED, WorkOrderStatus::ON_HOLD]) ? $userId : null, + 'completed_at' => $woData['status'] === WorkOrderStatus::COMPLETED ? $woData['actual_end'] : null, + 'completed_by' => $woData['status'] === WorkOrderStatus::COMPLETED ? $userId : null, + ] + ); + + // Create Work Order Operations + $routingOps = RoutingOperation::where('routing_id', $routing->id)->orderBy('operation_number')->get(); + + foreach ($routingOps as $routingOp) { + $opStatus = OperationStatus::PENDING; + $opQuantityCompleted = 0; + $actualStart = null; + $actualEnd = null; + $actualSetup = 0; + $actualRun = 0; + + if ($woData['status'] === WorkOrderStatus::COMPLETED) { + $opStatus = OperationStatus::COMPLETED; + $opQuantityCompleted = $woData['quantity']; + $actualStart = $woData['actual_start']; + $actualEnd = $woData['actual_end']; + $actualSetup = $routingOp->setup_time * 0.95; + $actualRun = $routingOp->run_time_per_unit * $woData['quantity'] * 0.98; + } elseif ($woData['status'] === WorkOrderStatus::IN_PROGRESS) { + if ($routingOp->operation_number <= 30) { + $opStatus = OperationStatus::COMPLETED; + $opQuantityCompleted = $woData['quantity']; + $actualStart = $woData['actual_start']; + $actualEnd = now()->subDays(2); + $actualSetup = $routingOp->setup_time; + $actualRun = $routingOp->run_time_per_unit * $woData['quantity']; + } elseif ($routingOp->operation_number == 40) { + $opStatus = OperationStatus::IN_PROGRESS; + $opQuantityCompleted = $woData['quantity_completed']; + $actualStart = now()->subDays(1); + } + } + + WorkOrderOperation::updateOrCreate( + [ + 'work_order_id' => $workOrder->id, + 'operation_number' => $routingOp->operation_number, + ], + [ + 'routing_operation_id' => $routingOp->id, + 'work_center_id' => $routingOp->work_center_id, + 'name' => $routingOp->name, + 'description' => $routingOp->description, + 'status' => $opStatus, + 'quantity_completed' => $opQuantityCompleted, + 'quantity_scrapped' => 0, + 'planned_start' => $woData['planned_start'], + 'planned_end' => $woData['planned_end'], + 'actual_start' => $actualStart, + 'actual_end' => $actualEnd, + 'actual_setup_time' => $actualSetup, + 'actual_run_time' => $actualRun, + 'actual_cost' => ($actualSetup + $actualRun) / 60 * ($routingOp->workCenter?->cost_per_hour ?? 100), + ] + ); + } + + // Create Work Order Materials from BOM + $bomItems = BomItem::where('bom_id', $bom->id)->get(); + + foreach ($bomItems as $bomItem) { + $requiredQty = $bomItem->quantity * $woData['quantity'] * (1 + $bomItem->scrap_percentage / 100); + $issuedQty = 0; + + if ($woData['status'] === WorkOrderStatus::COMPLETED) { + $issuedQty = $requiredQty; + } elseif (in_array($woData['status'], [WorkOrderStatus::IN_PROGRESS, WorkOrderStatus::ON_HOLD])) { + $issuedQty = $requiredQty * ($woData['quantity_completed'] / max($woData['quantity'], 1)); + } + + $unitCost = $bomItem->component?->cost_price ?? rand(50, 500); + + WorkOrderMaterial::updateOrCreate( + [ + 'work_order_id' => $workOrder->id, + 'product_id' => $bomItem->component_id, + ], + [ + 'bom_item_id' => $bomItem->id, + 'quantity_required' => $requiredQty, + 'quantity_issued' => $issuedQty, + 'quantity_returned' => 0, + 'uom_id' => $bomItem->uom_id, + 'warehouse_id' => $warehouseId, + 'unit_cost' => $unitCost, + 'total_cost' => $issuedQty * $unitCost, + ] + ); + } + + $this->command->info("Created Work Order: {$woNumber} ({$woData['status']->value})"); + } + + $this->command->info('Agricultural Machinery Manufacturing seeding completed!'); + $this->command->info('Summary:'); + $this->command->info(' - Work Centers: ' . WorkCenter::where('company_id', $companyId)->count()); + $this->command->info(' - BOMs: ' . Bom::where('company_id', $companyId)->count()); + $this->command->info(' - Routings: ' . Routing::where('company_id', $companyId)->count()); + $this->command->info(' - Work Orders: ' . WorkOrder::where('company_id', $companyId)->count()); + } +} diff --git a/backend/database/seeders/ProductSeeder.php b/backend/database/seeders/ProductSeeder.php index acc3730..87e90ee 100644 --- a/backend/database/seeders/ProductSeeder.php +++ b/backend/database/seeders/ProductSeeder.php @@ -6,6 +6,7 @@ use App\Models\Company; use App\Models\Product; use App\Models\ProductType; +use App\Models\Bom; use Illuminate\Database\Seeder; use Illuminate\Support\Str; @@ -13,15 +14,34 @@ class ProductSeeder extends Seeder { /** * Run the database seeds. + * + * Agricultural Machinery Products for Netherlands Market */ public function run(): void { - // Get default company - $company = Company::first(); - $companyId = $company?->id; + // Get all companies + $companies = Company::all(); - // Only get subcategories (categories with parent_id) - $categories = Category::whereNotNull('parent_id')->get(); + if ($companies->isEmpty()) { + $this->command->error('No companies found! Please run CompanySeeder first.'); + return; + } + + // Create products for each company + foreach ($companies as $company) { + $this->createProductsForCompany($company); + } + + $totalProducts = Product::count(); + $this->command->info("Products seeded successfully! Total: {$totalProducts} products for " . $companies->count() . " companies"); + } + + private function createProductsForCompany($company): void + { + $companyId = $company->id; + + // Only get subcategories (categories with parent_id) for this company + $categories = Category::where('company_id', $companyId)->whereNotNull('parent_id')->get(); if ($categories->isEmpty()) { $this->command->warn('No categories found! Please run CategorySeeder first.'); @@ -31,410 +51,316 @@ public function run(): void // Get product types $finishedGoodsType = ProductType::where('code', 'FG')->first(); $sparePartsType = ProductType::where('code', 'SP')->first(); - $consumablesType = ProductType::where('code', 'CON')->first(); + $rawMaterialsType = ProductType::where('code', 'RM')->first(); if (!$finishedGoodsType) { $this->command->warn('No product types found! Please run ProductTypeSeeder first.'); return; } - // Product templates for each category + // Agricultural Machinery product templates for each category $productTemplates = [ - 'electronics' => [ - ['name' => 'iPad Pro 12.9"', 'price' => 1099, 'brand' => 'Apple'], - ['name' => 'Samsung Galaxy Tab S9', 'price' => 899, 'brand' => 'Samsung'], - ['name' => 'Kindle Paperwhite', 'price' => 149, 'brand' => 'Amazon'], - ['name' => 'GoPro Hero 12', 'price' => 399, 'brand' => 'GoPro'], - ['name' => 'DJI Mini 4 Pro', 'price' => 759, 'brand' => 'DJI'], - ['name' => 'Bose SoundLink Revolve', 'price' => 219, 'brand' => 'Bose'], - ['name' => 'Anker PowerCore 26800', 'price' => 65, 'brand' => 'Anker'], - ['name' => 'Belkin 3-in-1 Charger', 'price' => 149, 'brand' => 'Belkin'], - ['name' => 'SanDisk 1TB SSD', 'price' => 129, 'brand' => 'SanDisk'], - ['name' => 'Logitech Webcam C920', 'price' => 79, 'brand' => 'Logitech'], - ], - 'tablets' => [ - ['name' => 'iPad Air', 'price' => 599, 'brand' => 'Apple'], - ['name' => 'Galaxy Tab S8', 'price' => 699, 'brand' => 'Samsung'], - ['name' => 'Surface Pro 9', 'price' => 999, 'brand' => 'Microsoft'], - ['name' => 'iPad Mini', 'price' => 499, 'brand' => 'Apple'], - ['name' => 'Lenovo Tab P11', 'price' => 299, 'brand' => 'Lenovo'], - ['name' => 'Amazon Fire HD 10', 'price' => 149, 'brand' => 'Amazon'], - ['name' => 'Huawei MatePad', 'price' => 399, 'brand' => 'Huawei'], - ['name' => 'Xiaomi Pad 6', 'price' => 349, 'brand' => 'Xiaomi'], - ['name' => 'OnePlus Pad', 'price' => 479, 'brand' => 'OnePlus'], - ['name' => 'ASUS ZenPad', 'price' => 279, 'brand' => 'ASUS'], - ], - 'accessories' => [ - ['name' => 'USB-C Hub', 'price' => 49, 'brand' => 'Anker'], - ['name' => 'Wireless Charger', 'price' => 35, 'brand' => 'Belkin'], - ['name' => 'Phone Stand', 'price' => 25, 'brand' => 'Lamicall'], - ['name' => 'Cable Organizer', 'price' => 15, 'brand' => 'JOTO'], - ['name' => 'Screen Protector', 'price' => 12, 'brand' => 'Spigen'], - ['name' => 'Stylus Pen', 'price' => 29, 'brand' => 'Adonit'], - ['name' => 'Laptop Sleeve', 'price' => 35, 'brand' => 'Tomtoc'], - ['name' => 'Cable Pack', 'price' => 22, 'brand' => 'Anker'], - ['name' => 'Webcam Cover', 'price' => 8, 'brand' => 'CloudValley'], - ['name' => 'Cleaning Kit', 'price' => 18, 'brand' => 'OXO'], - ], - 'computers-laptops' => [ - ['name' => 'MacBook Pro 16" M3 Max', 'price' => 3499, 'brand' => 'Apple'], - ['name' => 'Dell XPS 15', 'price' => 1899, 'brand' => 'Dell'], - ['name' => 'HP Spectre x360', 'price' => 1599, 'brand' => 'HP'], - ['name' => 'Lenovo ThinkPad X1', 'price' => 1799, 'brand' => 'Lenovo'], - ['name' => 'ASUS ROG Zephyrus', 'price' => 2299, 'brand' => 'ASUS'], - ['name' => 'Microsoft Surface Laptop', 'price' => 1299, 'brand' => 'Microsoft'], - ['name' => 'Acer Predator Helios', 'price' => 1699, 'brand' => 'Acer'], - ['name' => 'MSI Creator Z16', 'price' => 2199, 'brand' => 'MSI'], - ['name' => 'Razer Blade 15', 'price' => 2499, 'brand' => 'Razer'], - ['name' => 'Samsung Galaxy Book', 'price' => 1399, 'brand' => 'Samsung'], - ], - 'laptops' => [ - ['name' => 'MacBook Air M2', 'price' => 1199, 'brand' => 'Apple'], - ['name' => 'Dell Inspiron 15', 'price' => 799, 'brand' => 'Dell'], - ['name' => 'HP Pavilion 14', 'price' => 699, 'brand' => 'HP'], - ['name' => 'Lenovo IdeaPad', 'price' => 649, 'brand' => 'Lenovo'], - ['name' => 'ASUS VivoBook', 'price' => 599, 'brand' => 'ASUS'], - ['name' => 'Acer Swift 3', 'price' => 749, 'brand' => 'Acer'], - ['name' => 'MSI Modern 14', 'price' => 899, 'brand' => 'MSI'], - ['name' => 'LG Gram 17', 'price' => 1599, 'brand' => 'LG'], - ['name' => 'Huawei MateBook', 'price' => 999, 'brand' => 'Huawei'], - ['name' => 'Microsoft Surface Go', 'price' => 549, 'brand' => 'Microsoft'], - ], - 'desktops' => [ - ['name' => 'iMac 24" M3', 'price' => 1499, 'brand' => 'Apple'], - ['name' => 'Dell OptiPlex', 'price' => 899, 'brand' => 'Dell'], - ['name' => 'HP Pavilion Desktop', 'price' => 749, 'brand' => 'HP'], - ['name' => 'Lenovo ThinkCentre', 'price' => 799, 'brand' => 'Lenovo'], - ['name' => 'ASUS ROG Strix', 'price' => 1899, 'brand' => 'ASUS'], - ['name' => 'Acer Aspire TC', 'price' => 649, 'brand' => 'Acer'], - ['name' => 'MSI Aegis', 'price' => 1699, 'brand' => 'MSI'], - ['name' => 'Alienware Aurora', 'price' => 2499, 'brand' => 'Dell'], - ['name' => 'HP OMEN 45L', 'price' => 1999, 'brand' => 'HP'], - ['name' => 'Corsair Vengeance', 'price' => 1799, 'brand' => 'Corsair'], - ], - 'computer-accessories' => [ - ['name' => 'Magic Mouse', 'price' => 79, 'brand' => 'Apple'], - ['name' => 'MX Keys Keyboard', 'price' => 99, 'brand' => 'Logitech'], - ['name' => 'USB-C Dock', 'price' => 199, 'brand' => 'CalDigit'], - ['name' => 'Webcam Pro', 'price' => 129, 'brand' => 'Logitech'], - ['name' => 'Monitor Arm', 'price' => 149, 'brand' => 'Ergotron'], - ['name' => 'Keyboard Tray', 'price' => 59, 'brand' => '3M'], - ['name' => 'Mouse Pad RGB', 'price' => 39, 'brand' => 'Razer'], - ['name' => 'USB Hub 7-Port', 'price' => 45, 'brand' => 'Anker'], - ['name' => 'Cable Management', 'price' => 25, 'brand' => 'BlueLounge'], - ['name' => 'Laptop Stand', 'price' => 49, 'brand' => 'Rain Design'], - ], - 'mobile-phones' => [ - ['name' => 'iPhone 15 Pro Max', 'price' => 1199, 'brand' => 'Apple'], - ['name' => 'Samsung Galaxy S24 Ultra', 'price' => 1299, 'brand' => 'Samsung'], - ['name' => 'Google Pixel 8 Pro', 'price' => 999, 'brand' => 'Google'], - ['name' => 'OnePlus 12 Pro', 'price' => 899, 'brand' => 'OnePlus'], - ['name' => 'Xiaomi 14 Ultra', 'price' => 1099, 'brand' => 'Xiaomi'], - ['name' => 'OPPO Find X7', 'price' => 849, 'brand' => 'OPPO'], - ['name' => 'Vivo X100 Pro', 'price' => 799, 'brand' => 'Vivo'], - ['name' => 'Motorola Edge 50', 'price' => 699, 'brand' => 'Motorola'], - ['name' => 'Sony Xperia 1 VI', 'price' => 1199, 'brand' => 'Sony'], - ['name' => 'ASUS ROG Phone 8', 'price' => 1099, 'brand' => 'ASUS'], - ], - 'smartphones' => [ - ['name' => 'iPhone 15', 'price' => 999, 'brand' => 'Apple'], - ['name' => 'Galaxy S24', 'price' => 899, 'brand' => 'Samsung'], - ['name' => 'Pixel 8', 'price' => 699, 'brand' => 'Google'], - ['name' => 'OnePlus 12', 'price' => 799, 'brand' => 'OnePlus'], - ['name' => 'Xiaomi 14', 'price' => 699, 'brand' => 'Xiaomi'], - ['name' => 'Nothing Phone 2', 'price' => 599, 'brand' => 'Nothing'], - ['name' => 'Realme GT 5', 'price' => 549, 'brand' => 'Realme'], - ['name' => 'OPPO Reno 11', 'price' => 499, 'brand' => 'OPPO'], - ['name' => 'Vivo V30', 'price' => 449, 'brand' => 'Vivo'], - ['name' => 'Honor Magic 6', 'price' => 699, 'brand' => 'Honor'], - ], - 'mobile-accessories' => [ - ['name' => 'Phone Case Pro', 'price' => 49, 'brand' => 'Spigen'], - ['name' => 'Fast Charger 65W', 'price' => 39, 'brand' => 'Anker'], - ['name' => 'Screen Protector Glass', 'price' => 19, 'brand' => 'Spigen'], - ['name' => 'Car Phone Mount', 'price' => 29, 'brand' => 'iOttie'], - ['name' => 'PopSocket Grip', 'price' => 15, 'brand' => 'PopSockets'], - ['name' => 'Wireless Car Charger', 'price' => 45, 'brand' => 'Belkin'], - ['name' => 'Phone Ring Holder', 'price' => 12, 'brand' => 'Ringke'], - ['name' => 'USB-C Cable 6ft', 'price' => 18, 'brand' => 'Anker'], - ['name' => 'Selfie Stick', 'price' => 25, 'brand' => 'Mpow'], - ['name' => 'Phone Armband', 'price' => 20, 'brand' => 'Tribe'], - ], - 'audio-headphones' => [ - ['name' => 'Sony WH-1000XM5', 'price' => 399, 'brand' => 'Sony'], - ['name' => 'Bose QuietComfort Ultra', 'price' => 429, 'brand' => 'Bose'], - ['name' => 'Apple AirPods Max', 'price' => 549, 'brand' => 'Apple'], - ['name' => 'Sennheiser Momentum 4', 'price' => 379, 'brand' => 'Sennheiser'], - ['name' => 'JBL Live 660NC', 'price' => 199, 'brand' => 'JBL'], - ['name' => 'Audio-Technica ATH-M50x', 'price' => 149, 'brand' => 'Audio-Technica'], - ['name' => 'Beats Studio Pro', 'price' => 349, 'brand' => 'Beats'], - ['name' => 'Jabra Elite 85h', 'price' => 299, 'brand' => 'Jabra'], - ['name' => 'AKG K371', 'price' => 149, 'brand' => 'AKG'], - ['name' => 'Shure AONIC 50', 'price' => 299, 'brand' => 'Shure'], - ], - 'headphones' => [ - ['name' => 'AirPods Pro 2', 'price' => 249, 'brand' => 'Apple'], - ['name' => 'Sony WF-1000XM5', 'price' => 299, 'brand' => 'Sony'], - ['name' => 'Bose QC Earbuds II', 'price' => 279, 'brand' => 'Bose'], - ['name' => 'Samsung Galaxy Buds2 Pro', 'price' => 229, 'brand' => 'Samsung'], - ['name' => 'Sennheiser IE 300', 'price' => 299, 'brand' => 'Sennheiser'], - ['name' => 'Beats Fit Pro', 'price' => 199, 'brand' => 'Beats'], - ['name' => 'Jabra Elite 10', 'price' => 249, 'brand' => 'Jabra'], - ['name' => 'Anker Soundcore Liberty 4', 'price' => 149, 'brand' => 'Anker'], - ['name' => 'JBL Tour Pro 2', 'price' => 249, 'brand' => 'JBL'], - ['name' => 'Nothing Ear 2', 'price' => 149, 'brand' => 'Nothing'], - ], - 'speakers' => [ - ['name' => 'HomePod mini', 'price' => 99, 'brand' => 'Apple'], - ['name' => 'Sonos One', 'price' => 219, 'brand' => 'Sonos'], - ['name' => 'JBL Flip 6', 'price' => 129, 'brand' => 'JBL'], - ['name' => 'Bose SoundLink Mini', 'price' => 199, 'brand' => 'Bose'], - ['name' => 'UE Boom 3', 'price' => 149, 'brand' => 'Ultimate Ears'], - ['name' => 'Marshall Emberton II', 'price' => 169, 'brand' => 'Marshall'], - ['name' => 'Sony SRS-XB43', 'price' => 249, 'brand' => 'Sony'], - ['name' => 'Anker Soundcore 3', 'price' => 79, 'brand' => 'Anker'], - ['name' => 'Bang & Olufsen Beosound A1', 'price' => 299, 'brand' => 'B&O'], - ['name' => 'Harman Kardon Onyx Studio', 'price' => 449, 'brand' => 'Harman Kardon'], - ], - 'cameras-photography' => [ - ['name' => 'Canon EOS R5', 'price' => 3899, 'brand' => 'Canon'], - ['name' => 'Sony A7R V', 'price' => 3899, 'brand' => 'Sony'], - ['name' => 'Nikon Z9', 'price' => 5499, 'brand' => 'Nikon'], - ['name' => 'Fujifilm X-T5', 'price' => 1699, 'brand' => 'Fujifilm'], - ['name' => 'Panasonic Lumix GH6', 'price' => 2199, 'brand' => 'Panasonic'], - ['name' => 'Olympus OM-1', 'price' => 2199, 'brand' => 'Olympus'], - ['name' => 'Leica Q3', 'price' => 5995, 'brand' => 'Leica'], - ['name' => 'Hasselblad X2D', 'price' => 8199, 'brand' => 'Hasselblad'], - ['name' => 'Phase One XF', 'price' => 15990, 'brand' => 'Phase One'], - ['name' => 'DJI Ronin 4D', 'price' => 6499, 'brand' => 'DJI'], - ], - 'dslr-cameras' => [ - ['name' => 'Canon EOS 5D Mark IV', 'price' => 2499, 'brand' => 'Canon'], - ['name' => 'Nikon D850', 'price' => 2999, 'brand' => 'Nikon'], - ['name' => 'Canon EOS 90D', 'price' => 1199, 'brand' => 'Canon'], - ['name' => 'Nikon D780', 'price' => 2299, 'brand' => 'Nikon'], - ['name' => 'Canon EOS Rebel T7i', 'price' => 749, 'brand' => 'Canon'], - ['name' => 'Nikon D7500', 'price' => 1249, 'brand' => 'Nikon'], - ['name' => 'Canon EOS 6D Mark II', 'price' => 1399, 'brand' => 'Canon'], - ['name' => 'Pentax K-1 Mark II', 'price' => 1799, 'brand' => 'Pentax'], - ['name' => 'Nikon D500', 'price' => 1499, 'brand' => 'Nikon'], - ['name' => 'Canon EOS 80D', 'price' => 999, 'brand' => 'Canon'], - ], - 'mirrorless-cameras' => [ - ['name' => 'Sony A7 IV', 'price' => 2499, 'brand' => 'Sony'], - ['name' => 'Canon EOS R6 Mark II', 'price' => 2499, 'brand' => 'Canon'], - ['name' => 'Nikon Z8', 'price' => 3999, 'brand' => 'Nikon'], - ['name' => 'Fujifilm X-H2S', 'price' => 2499, 'brand' => 'Fujifilm'], - ['name' => 'Sony A6700', 'price' => 1399, 'brand' => 'Sony'], - ['name' => 'Canon EOS R8', 'price' => 1499, 'brand' => 'Canon'], - ['name' => 'Nikon Z6 III', 'price' => 2499, 'brand' => 'Nikon'], - ['name' => 'Panasonic S5 II', 'price' => 1999, 'brand' => 'Panasonic'], - ['name' => 'Olympus OM-5', 'price' => 1199, 'brand' => 'Olympus'], - ['name' => 'Fujifilm X-S20', 'price' => 1299, 'brand' => 'Fujifilm'], - ], - 'lenses' => [ - ['name' => '50mm f/1.8 Prime', 'price' => 199, 'brand' => 'Canon'], - ['name' => '24-70mm f/2.8 Zoom', 'price' => 1799, 'brand' => 'Sony'], - ['name' => '70-200mm f/2.8 Telephoto', 'price' => 2599, 'brand' => 'Nikon'], - ['name' => '85mm f/1.4 Portrait', 'price' => 1599, 'brand' => 'Sigma'], - ['name' => '16-35mm f/4 Wide', 'price' => 1099, 'brand' => 'Canon'], - ['name' => '100-400mm f/5.6 Super Tele', 'price' => 1899, 'brand' => 'Sony'], - ['name' => '35mm f/1.4 Art', 'price' => 899, 'brand' => 'Sigma'], - ['name' => '24mm f/1.4 Wide Prime', 'price' => 849, 'brand' => 'Tamron'], - ['name' => '90mm f/2.8 Macro', 'price' => 649, 'brand' => 'Tamron'], - ['name' => '18-135mm f/3.5-5.6 Kit', 'price' => 599, 'brand' => 'Canon'], - ], - 'gaming' => [ - ['name' => 'PlayStation 5', 'price' => 499, 'brand' => 'Sony'], - ['name' => 'Xbox Series X', 'price' => 499, 'brand' => 'Microsoft'], - ['name' => 'Nintendo Switch OLED', 'price' => 349, 'brand' => 'Nintendo'], - ['name' => 'Steam Deck', 'price' => 649, 'brand' => 'Valve'], - ['name' => 'ASUS ROG Ally', 'price' => 699, 'brand' => 'ASUS'], - ['name' => 'Lenovo Legion Go', 'price' => 749, 'brand' => 'Lenovo'], - ['name' => 'Meta Quest 3', 'price' => 499, 'brand' => 'Meta'], - ['name' => 'PlayStation VR2', 'price' => 549, 'brand' => 'Sony'], - ['name' => 'Logitech G Pro X', 'price' => 129, 'brand' => 'Logitech'], - ['name' => 'Razer DeathAdder V3', 'price' => 69, 'brand' => 'Razer'], - ], - 'gaming-consoles' => [ - ['name' => 'PS5 Digital Edition', 'price' => 449, 'brand' => 'Sony'], - ['name' => 'Xbox Series S', 'price' => 299, 'brand' => 'Microsoft'], - ['name' => 'Nintendo Switch Lite', 'price' => 199, 'brand' => 'Nintendo'], - ['name' => 'Steam Deck OLED', 'price' => 549, 'brand' => 'Valve'], - ['name' => 'ASUS ROG Ally Z1', 'price' => 599, 'brand' => 'ASUS'], - ['name' => 'Retro Gaming Console', 'price' => 99, 'brand' => 'Anbernic'], - ['name' => 'Sega Genesis Mini', 'price' => 79, 'brand' => 'Sega'], - ['name' => 'PlayStation Classic', 'price' => 99, 'brand' => 'Sony'], - ['name' => 'NES Classic Edition', 'price' => 59, 'brand' => 'Nintendo'], - ['name' => 'Atari VCS', 'price' => 299, 'brand' => 'Atari'], - ], - 'gaming-accessories' => [ - ['name' => 'DualSense Controller', 'price' => 69, 'brand' => 'Sony'], - ['name' => 'Xbox Elite Controller', 'price' => 179, 'brand' => 'Microsoft'], - ['name' => 'Pro Controller', 'price' => 69, 'brand' => 'Nintendo'], - ['name' => 'Gaming Headset Wireless', 'price' => 149, 'brand' => 'SteelSeries'], - ['name' => 'Racing Wheel', 'price' => 399, 'brand' => 'Logitech'], - ['name' => 'Fight Stick Arcade', 'price' => 199, 'brand' => 'Razer'], - ['name' => 'Gaming Keyboard RGB', 'price' => 129, 'brand' => 'Corsair'], - ['name' => 'Gaming Mouse Pro', 'price' => 79, 'brand' => 'Razer'], - ['name' => 'Controller Charging Dock', 'price' => 29, 'brand' => 'PowerA'], - ['name' => 'Gaming Chair', 'price' => 349, 'brand' => 'Secretlab'], - ], - 'wearables' => [ - ['name' => 'Apple Watch Series 9', 'price' => 429, 'brand' => 'Apple'], - ['name' => 'Samsung Galaxy Watch 6', 'price' => 349, 'brand' => 'Samsung'], - ['name' => 'Garmin Fenix 7', 'price' => 699, 'brand' => 'Garmin'], - ['name' => 'Fitbit Sense 2', 'price' => 299, 'brand' => 'Fitbit'], - ['name' => 'Amazfit GTR 4', 'price' => 199, 'brand' => 'Amazfit'], - ['name' => 'Huawei Watch GT 4', 'price' => 249, 'brand' => 'Huawei'], - ['name' => 'Polar Vantage V3', 'price' => 599, 'brand' => 'Polar'], - ['name' => 'Withings ScanWatch 2', 'price' => 349, 'brand' => 'Withings'], - ['name' => 'Coros Pace 3', 'price' => 229, 'brand' => 'Coros'], - ['name' => 'Suunto 9 Peak Pro', 'price' => 569, 'brand' => 'Suunto'], - ], - 'smartwatches' => [ - ['name' => 'Apple Watch SE', 'price' => 249, 'brand' => 'Apple'], - ['name' => 'Galaxy Watch 5', 'price' => 279, 'brand' => 'Samsung'], - ['name' => 'Pixel Watch 2', 'price' => 349, 'brand' => 'Google'], - ['name' => 'TicWatch Pro 5', 'price' => 349, 'brand' => 'Mobvoi'], - ['name' => 'Garmin Venu 3', 'price' => 449, 'brand' => 'Garmin'], - ['name' => 'Fossil Gen 6', 'price' => 299, 'brand' => 'Fossil'], - ['name' => 'Amazfit GTR 3 Pro', 'price' => 229, 'brand' => 'Amazfit'], - ['name' => 'OnePlus Watch 2', 'price' => 299, 'brand' => 'OnePlus'], - ['name' => 'Huawei Watch GT 3', 'price' => 229, 'brand' => 'Huawei'], - ['name' => 'Withings Steel HR', 'price' => 199, 'brand' => 'Withings'], - ], - 'fitness-trackers' => [ - ['name' => 'Fitbit Charge 6', 'price' => 159, 'brand' => 'Fitbit'], - ['name' => 'Garmin Vivosmart 5', 'price' => 149, 'brand' => 'Garmin'], - ['name' => 'Xiaomi Mi Band 8', 'price' => 49, 'brand' => 'Xiaomi'], - ['name' => 'Whoop 4.0', 'price' => 239, 'brand' => 'Whoop'], - ['name' => 'Amazfit Band 7', 'price' => 49, 'brand' => 'Amazfit'], - ['name' => 'Polar Ignite 3', 'price' => 329, 'brand' => 'Polar'], - ['name' => 'Oura Ring Gen3', 'price' => 299, 'brand' => 'Oura'], - ['name' => 'Garmin Vivoactive 5', 'price' => 299, 'brand' => 'Garmin'], - ['name' => 'Fitbit Inspire 3', 'price' => 99, 'brand' => 'Fitbit'], - ['name' => 'Coros Pace 2', 'price' => 199, 'brand' => 'Coros'], - ], - 'home-appliances' => [ - ['name' => 'LG C3 OLED 65"', 'price' => 1799, 'brand' => 'LG'], - ['name' => 'Samsung QN90C QLED', 'price' => 1999, 'brand' => 'Samsung'], - ['name' => 'Sony Bravia XR A95L', 'price' => 3499, 'brand' => 'Sony'], - ['name' => 'Dyson V15 Detect', 'price' => 749, 'brand' => 'Dyson'], - ['name' => 'iRobot Roomba j9+', 'price' => 899, 'brand' => 'iRobot'], - ['name' => 'Philips Air Fryer XXL', 'price' => 299, 'brand' => 'Philips'], - ['name' => 'Ninja Foodi Max', 'price' => 249, 'brand' => 'Ninja'], - ['name' => 'KitchenAid Stand Mixer', 'price' => 449, 'brand' => 'KitchenAid'], - ['name' => 'Nespresso Vertuo Next', 'price' => 179, 'brand' => 'Nespresso'], - ['name' => 'Breville Barista Express', 'price' => 699, 'brand' => 'Breville'], - ], - 'kitchen-appliances' => [ - ['name' => 'Instant Pot Duo Plus', 'price' => 119, 'brand' => 'Instant Pot'], - ['name' => 'Vitamix E310', 'price' => 349, 'brand' => 'Vitamix'], - ['name' => 'Cuisinart Food Processor', 'price' => 199, 'brand' => 'Cuisinart'], - ['name' => 'Breville Smart Oven', 'price' => 299, 'brand' => 'Breville'], - ['name' => 'Ninja Professional Blender', 'price' => 99, 'brand' => 'Ninja'], - ['name' => 'KitchenAid Hand Mixer', 'price' => 79, 'brand' => 'KitchenAid'], - ['name' => 'Hamilton Beach Slow Cooker', 'price' => 49, 'brand' => 'Hamilton Beach'], - ['name' => 'Breville Espresso Machine', 'price' => 499, 'brand' => 'Breville'], - ['name' => 'Oster Toaster', 'price' => 39, 'brand' => 'Oster'], - ['name' => 'Black+Decker Rice Cooker', 'price' => 29, 'brand' => 'Black+Decker'], - ], - 'cleaning-appliances' => [ - ['name' => 'Dyson V11 Animal', 'price' => 599, 'brand' => 'Dyson'], - ['name' => 'Shark Navigator', 'price' => 199, 'brand' => 'Shark'], - ['name' => 'iRobot Roomba i7+', 'price' => 799, 'brand' => 'iRobot'], - ['name' => 'Bissell CrossWave', 'price' => 299, 'brand' => 'Bissell'], - ['name' => 'Eufy RoboVac', 'price' => 249, 'brand' => 'Eufy'], - ['name' => 'Hoover WindTunnel', 'price' => 149, 'brand' => 'Hoover'], - ['name' => 'Roborock S7', 'price' => 649, 'brand' => 'Roborock'], - ['name' => 'Tineco Floor One', 'price' => 499, 'brand' => 'Tineco'], - ['name' => 'Miele Complete C3', 'price' => 999, 'brand' => 'Miele'], - ['name' => 'Ecovacs Deebot', 'price' => 399, 'brand' => 'Ecovacs'], - ], - 'smart-home' => [ - ['name' => 'Google Nest Hub Max', 'price' => 229, 'brand' => 'Google'], - ['name' => 'Amazon Echo Show 15', 'price' => 279, 'brand' => 'Amazon'], - ['name' => 'Apple HomePod', 'price' => 299, 'brand' => 'Apple'], - ['name' => 'Ring Video Doorbell Pro', 'price' => 249, 'brand' => 'Ring'], - ['name' => 'Arlo Pro 5', 'price' => 249, 'brand' => 'Arlo'], - ['name' => 'Philips Hue Starter Kit', 'price' => 199, 'brand' => 'Philips'], - ['name' => 'Ecobee SmartThermostat', 'price' => 249, 'brand' => 'Ecobee'], - ['name' => 'August Smart Lock Pro', 'price' => 279, 'brand' => 'August'], - ['name' => 'Sonos Beam Gen 2', 'price' => 499, 'brand' => 'Sonos'], - ['name' => 'TP-Link Tapo C200', 'price' => 39, 'brand' => 'TP-Link'], - ], - 'smart-speakers' => [ - ['name' => 'Echo Dot 5th Gen', 'price' => 49, 'brand' => 'Amazon'], - ['name' => 'Google Nest Mini', 'price' => 49, 'brand' => 'Google'], - ['name' => 'Echo Studio', 'price' => 199, 'brand' => 'Amazon'], - ['name' => 'Google Nest Audio', 'price' => 99, 'brand' => 'Google'], - ['name' => 'Sonos One SL', 'price' => 179, 'brand' => 'Sonos'], - ['name' => 'Apple HomePod mini', 'price' => 99, 'brand' => 'Apple'], - ['name' => 'Amazon Echo Show 8', 'price' => 129, 'brand' => 'Amazon'], - ['name' => 'Google Nest Hub', 'price' => 99, 'brand' => 'Google'], - ['name' => 'Bose Home Speaker 500', 'price' => 399, 'brand' => 'Bose'], - ['name' => 'Sonos Roam', 'price' => 179, 'brand' => 'Sonos'], - ], - 'security-cameras' => [ - ['name' => 'Wyze Cam v3', 'price' => 35, 'brand' => 'Wyze'], - ['name' => 'Arlo Pro 4', 'price' => 199, 'brand' => 'Arlo'], - ['name' => 'Ring Stick Up Cam', 'price' => 99, 'brand' => 'Ring'], - ['name' => 'Google Nest Cam', 'price' => 179, 'brand' => 'Google'], - ['name' => 'Blink Outdoor', 'price' => 99, 'brand' => 'Blink'], - ['name' => 'Eufy Solo IndoorCam', 'price' => 39, 'brand' => 'Eufy'], - ['name' => 'Reolink Argus 3 Pro', 'price' => 129, 'brand' => 'Reolink'], - ['name' => 'TP-Link Kasa Spot', 'price' => 39, 'brand' => 'TP-Link'], - ['name' => 'Logitech Circle View', 'price' => 159, 'brand' => 'Logitech'], - ['name' => 'Arlo Essential', 'price' => 129, 'brand' => 'Arlo'], - ], - 'office-supplies' => [ - ['name' => 'Logitech MX Master 3S', 'price' => 99, 'brand' => 'Logitech'], - ['name' => 'Apple Magic Keyboard', 'price' => 149, 'brand' => 'Apple'], - ['name' => 'Herman Miller Aeron', 'price' => 1495, 'brand' => 'Herman Miller'], - ['name' => 'Steelcase Leap', 'price' => 1099, 'brand' => 'Steelcase'], - ['name' => 'Uplift V2 Desk', 'price' => 799, 'brand' => 'Uplift'], - ['name' => 'Epson EcoTank ET-2850', 'price' => 349, 'brand' => 'Epson'], - ['name' => 'Brother HL-L2395DW', 'price' => 149, 'brand' => 'Brother'], - ['name' => 'Fellowes Powershred', 'price' => 179, 'brand' => 'Fellowes'], - ['name' => 'AmazonBasics Monitor Arm', 'price' => 119, 'brand' => 'AmazonBasics'], - ['name' => 'Anker PowerExpand', 'price' => 89, 'brand' => 'Anker'], - ], - 'office-furniture' => [ - ['name' => 'Herman Miller Embody', 'price' => 1795, 'brand' => 'Herman Miller'], - ['name' => 'Steelcase Gesture', 'price' => 1149, 'brand' => 'Steelcase'], - ['name' => 'IKEA Markus', 'price' => 199, 'brand' => 'IKEA'], - ['name' => 'FlexiSpot Standing Desk', 'price' => 449, 'brand' => 'FlexiSpot'], - ['name' => 'Branch Ergonomic Chair', 'price' => 349, 'brand' => 'Branch'], - ['name' => 'VIVO Dual Monitor Desk Mount', 'price' => 34, 'brand' => 'VIVO'], - ['name' => 'Autonomous SmartDesk', 'price' => 499, 'brand' => 'Autonomous'], - ['name' => 'HON Ignition 2.0', 'price' => 399, 'brand' => 'HON'], - ['name' => 'IKEA Bekant', 'price' => 249, 'brand' => 'IKEA'], - ['name' => 'Secretlab Titan Evo', 'price' => 499, 'brand' => 'Secretlab'], - ], - 'printers-scanners' => [ - ['name' => 'HP LaserJet Pro', 'price' => 199, 'brand' => 'HP'], - ['name' => 'Canon PIXMA TR8620', 'price' => 179, 'brand' => 'Canon'], - ['name' => 'Epson WorkForce', 'price' => 149, 'brand' => 'Epson'], - ['name' => 'Brother MFC-L2750DW', 'price' => 299, 'brand' => 'Brother'], - ['name' => 'Canon imageClass', 'price' => 249, 'brand' => 'Canon'], - ['name' => 'HP OfficeJet Pro', 'price' => 229, 'brand' => 'HP'], - ['name' => 'Epson Expression Premium', 'price' => 299, 'brand' => 'Epson'], - ['name' => 'Fujitsu ScanSnap', 'price' => 495, 'brand' => 'Fujitsu'], - ['name' => 'Brother DS-740D', 'price' => 199, 'brand' => 'Brother'], - ['name' => 'Canon CanoScan', 'price' => 99, 'brand' => 'Canon'], + // ======================================== + // TRACTORS + // ======================================== + 'compact-tractors' => [ + ['name' => 'AT-2540 Compact', 'price' => 28500, 'brand' => 'AgriTech NL', 'power' => '25 HP'], + ['name' => 'AT-3045 Orchard', 'price' => 32000, 'brand' => 'AgriTech NL', 'power' => '35 HP'], + ['name' => 'HA-C30 Garden Pro', 'price' => 26500, 'brand' => 'HollandAgro', 'power' => '35 HP'], + ['name' => 'EF-Mini 40', 'price' => 35000, 'brand' => 'EuroFarm', 'power' => '50 HP'], + ['name' => '4E Series 320', 'price' => 29500, 'brand' => 'Deutz-Fahr', 'power' => '35 HP'], + ], + 'utility-tractors' => [ + ['name' => 'AT-5075 Utility', 'price' => 52000, 'brand' => 'AgriTech NL', 'power' => '75 HP'], + ['name' => 'AT-6090 Field Master', 'price' => 68000, 'brand' => 'AgriTech NL', 'power' => '100 HP'], + ['name' => 'HA-U80 Universal', 'price' => 58000, 'brand' => 'HollandAgro', 'power' => '75 HP'], + ['name' => 'EF-Utility 95', 'price' => 72000, 'brand' => 'EuroFarm', 'power' => '100 HP'], + ['name' => '5D TTV 110', 'price' => 85000, 'brand' => 'Deutz-Fahr', 'power' => '100 HP'], + ], + 'row-crop-tractors' => [ + ['name' => 'AT-120 RowMaster', 'price' => 125000, 'brand' => 'AgriTech NL', 'power' => '120 HP'], + ['name' => 'AT-150 PowerLine', 'price' => 165000, 'brand' => 'AgriTech NL', 'power' => '150 HP'], + ['name' => 'HA-RC180', 'price' => 195000, 'brand' => 'HollandAgro', 'power' => '180 HP'], + ['name' => 'EF-180 Pro', 'price' => 185000, 'brand' => 'EuroFarm', 'power' => '180 HP'], + ['name' => '6 Series 6155', 'price' => 175000, 'brand' => 'Deutz-Fahr', 'power' => '150 HP'], + ], + 'specialty-tractors' => [ + ['name' => 'AT-V65 Vineyard', 'price' => 48000, 'brand' => 'AgriTech NL', 'power' => '75 HP'], + ['name' => 'AT-O55 Orchard Narrow', 'price' => 42000, 'brand' => 'AgriTech NL', 'power' => '50 HP'], + ['name' => 'HA-Vine 70', 'price' => 52000, 'brand' => 'HollandAgro', 'power' => '75 HP'], + ['name' => 'EF-Narrow 60', 'price' => 46000, 'brand' => 'EuroFarm', 'power' => '50 HP'], + ['name' => 'Agroplus F Ecoline', 'price' => 55000, 'brand' => 'Deutz-Fahr', 'power' => '75 HP'], + ], + + // ======================================== + // HARVESTING EQUIPMENT + // ======================================== + 'combine-harvesters' => [ + ['name' => 'AT-CH850 Grain Master', 'price' => 385000, 'brand' => 'AgriTech NL', 'power' => '300 HP'], + ['name' => 'AT-CH650 Flex', 'price' => 295000, 'brand' => 'AgriTech NL', 'power' => '250 HP'], + ['name' => 'HA-Combine 7500', 'price' => 425000, 'brand' => 'HollandAgro', 'power' => '400 HP'], + ['name' => 'EF-Harvester 620', 'price' => 365000, 'brand' => 'EuroFarm', 'power' => '300 HP'], + ['name' => 'C7206 TS', 'price' => 345000, 'brand' => 'Deutz-Fahr', 'power' => '250 HP'], + ], + 'forage-harvesters' => [ + ['name' => 'AT-FH450 Silage Pro', 'price' => 285000, 'brand' => 'AgriTech NL', 'power' => '400 HP'], + ['name' => 'AT-FH350 Forage', 'price' => 225000, 'brand' => 'AgriTech NL', 'power' => '300 HP'], + ['name' => 'HA-Forage 550', 'price' => 345000, 'brand' => 'HollandAgro', 'power' => '400 HP'], + ['name' => 'EF-Silage 400', 'price' => 265000, 'brand' => 'EuroFarm', 'power' => '300 HP'], + ['name' => 'Jaguar 850', 'price' => 395000, 'brand' => 'Kverneland', 'power' => '400 HP'], + ], + 'balers' => [ + ['name' => 'AT-RB150 Round Baler', 'price' => 42000, 'brand' => 'AgriTech NL', 'power' => '75 HP'], + ['name' => 'AT-SB250 Square', 'price' => 68000, 'brand' => 'AgriTech NL', 'power' => '100 HP'], + ['name' => 'HA-Baler Pro', 'price' => 55000, 'brand' => 'HollandAgro', 'power' => '75 HP'], + ['name' => 'EF-RoundPack 180', 'price' => 48000, 'brand' => 'EuroFarm', 'power' => '75 HP'], + ['name' => 'Fortima V 1800', 'price' => 52000, 'brand' => 'Kverneland', 'power' => '75 HP'], + ], + 'potato-harvesters' => [ + ['name' => 'GT170 2-Row', 'price' => 185000, 'brand' => 'Grimme', 'power' => '150 HP'], + ['name' => 'SE 150-60 4-Row', 'price' => 325000, 'brand' => 'Grimme', 'power' => '200 HP'], + ['name' => 'AT-PH200 Potato Master', 'price' => 195000, 'brand' => 'AgriTech NL', 'power' => '150 HP'], + ['name' => 'HA-Tuber 300', 'price' => 245000, 'brand' => 'HollandAgro', 'power' => '180 HP'], + ['name' => 'Varitron 470', 'price' => 385000, 'brand' => 'Grimme', 'power' => '250 HP'], + ], + + // ======================================== + // SOIL PREPARATION + // ======================================== + 'ploughs' => [ + ['name' => 'Vari-Titan 10', 'price' => 28500, 'brand' => 'Lemken', 'power' => '150 HP'], + ['name' => 'EurOpal 8', 'price' => 22500, 'brand' => 'Lemken', 'power' => '120 HP'], + ['name' => 'AT-P5 Reversible', 'price' => 18500, 'brand' => 'AgriTech NL', 'power' => '100 HP'], + ['name' => 'HA-Plough Pro 7', 'price' => 24500, 'brand' => 'HollandAgro', 'power' => '120 HP'], + ['name' => '2500 Series 6-Furrow', 'price' => 32000, 'brand' => 'Kverneland', 'power' => '180 HP'], + ], + 'disc-harrows' => [ + ['name' => 'Rubin 12', 'price' => 48500, 'brand' => 'Lemken', 'power' => '180 HP'], + ['name' => 'Heliodor 9', 'price' => 35500, 'brand' => 'Lemken', 'power' => '120 HP'], + ['name' => 'AT-DH400 Disc Pro', 'price' => 32000, 'brand' => 'AgriTech NL', 'power' => '150 HP'], + ['name' => 'Catros 6001-2', 'price' => 42000, 'brand' => 'Amazone', 'power' => '180 HP'], + ['name' => 'HA-Disc 500', 'price' => 38000, 'brand' => 'HollandAgro', 'power' => '150 HP'], + ], + 'cultivators' => [ + ['name' => 'Karat 12', 'price' => 65000, 'brand' => 'Lemken', 'power' => '200 HP'], + ['name' => 'Cenius 4003-2', 'price' => 52000, 'brand' => 'Amazone', 'power' => '180 HP'], + ['name' => 'AT-C600 Chisel', 'price' => 28500, 'brand' => 'AgriTech NL', 'power' => '120 HP'], + ['name' => 'CLC Pro 450', 'price' => 48000, 'brand' => 'Kverneland', 'power' => '180 HP'], + ['name' => 'HA-Culti 500', 'price' => 35000, 'brand' => 'HollandAgro', 'power' => '150 HP'], + ], + 'rotary-tillers' => [ + ['name' => 'Zirkon 12', 'price' => 42000, 'brand' => 'Lemken', 'power' => '150 HP'], + ['name' => 'AT-RT300 Power Tiller', 'price' => 18500, 'brand' => 'AgriTech NL', 'power' => '75 HP'], + ['name' => 'KE 4000 Special', 'price' => 48000, 'brand' => 'Amazone', 'power' => '180 HP'], + ['name' => 'HA-Rotary 400', 'price' => 32000, 'brand' => 'HollandAgro', 'power' => '120 HP'], + ['name' => 'EF-Tiller Pro', 'price' => 24500, 'brand' => 'EuroFarm', 'power' => '100 HP'], + ], + + // ======================================== + // SEEDING & PLANTING + // ======================================== + 'seed-drills' => [ + ['name' => 'ED 6000-2', 'price' => 85000, 'brand' => 'Amazone', 'power' => '150 HP'], + ['name' => 'Solitair 12', 'price' => 95000, 'brand' => 'Lemken', 'power' => '180 HP'], + ['name' => 'AT-SD600 Precision', 'price' => 68000, 'brand' => 'AgriTech NL', 'power' => '120 HP'], + ['name' => 'u-drill 6000', 'price' => 78000, 'brand' => 'Kverneland', 'power' => '150 HP'], + ['name' => 'HA-Seed Pro 800', 'price' => 72000, 'brand' => 'HollandAgro', 'power' => '150 HP'], + ], + 'planters' => [ + ['name' => 'GL 660 Potato Planter', 'price' => 125000, 'brand' => 'Grimme', 'power' => '120 HP'], + ['name' => 'Precea 6000-2', 'price' => 145000, 'brand' => 'Amazone', 'power' => '150 HP'], + ['name' => 'AT-PL8 Precision', 'price' => 95000, 'brand' => 'AgriTech NL', 'power' => '120 HP'], + ['name' => 'Azurit 12', 'price' => 135000, 'brand' => 'Lemken', 'power' => '150 HP'], + ['name' => 'Optima TF Profi', 'price' => 115000, 'brand' => 'Kverneland', 'power' => '120 HP'], + ], + 'transplanters' => [ + ['name' => 'AT-TP4 Transplanter', 'price' => 28500, 'brand' => 'AgriTech NL', 'power' => '50 HP'], + ['name' => 'HA-Plant 6-Row', 'price' => 42000, 'brand' => 'HollandAgro', 'power' => '75 HP'], + ['name' => 'EF-Trans Pro', 'price' => 35000, 'brand' => 'EuroFarm', 'power' => '50 HP'], + ['name' => 'Vegetable Setter 8', 'price' => 48000, 'brand' => 'Grimme', 'power' => '75 HP'], + ['name' => 'AT-TP6 Semi-Auto', 'price' => 38500, 'brand' => 'AgriTech NL', 'power' => '75 HP'], + ], + + // ======================================== + // IRRIGATION SYSTEMS + // ======================================== + 'drip-irrigation' => [ + ['name' => 'DripLine Pro 5000', 'price' => 12500, 'brand' => 'Priva', 'power' => '0 HP'], + ['name' => 'AT-Drip Master', 'price' => 8500, 'brand' => 'AgriTech NL', 'power' => '0 HP'], + ['name' => 'HA-MicroDrip 3000', 'price' => 9800, 'brand' => 'HollandAgro', 'power' => '0 HP'], + ['name' => 'Precision Drip System', 'price' => 15500, 'brand' => 'Priva', 'power' => '0 HP'], + ['name' => 'EF-Drip Economy', 'price' => 6500, 'brand' => 'EuroFarm', 'power' => '0 HP'], + ], + 'sprinkler-systems' => [ + ['name' => 'Center Pivot 800', 'price' => 125000, 'brand' => 'AgriTech NL', 'power' => '25 HP'], + ['name' => 'Linear Move Pro', 'price' => 145000, 'brand' => 'HollandAgro', 'power' => '35 HP'], + ['name' => 'AT-Pivot 1200', 'price' => 165000, 'brand' => 'AgriTech NL', 'power' => '50 HP'], + ['name' => 'HA-Sprinkler 600', 'price' => 85000, 'brand' => 'HollandAgro', 'power' => '25 HP'], + ['name' => 'EF-Irrigation Pro', 'price' => 95000, 'brand' => 'EuroFarm', 'power' => '35 HP'], + ], + 'pumps' => [ + ['name' => 'AT-P100 Irrigation Pump', 'price' => 4500, 'brand' => 'AgriTech NL', 'power' => '25 HP'], + ['name' => 'HA-Pump 200', 'price' => 6800, 'brand' => 'HollandAgro', 'power' => '35 HP'], + ['name' => 'Submersible S500', 'price' => 3200, 'brand' => 'Priva', 'power' => '25 HP'], + ['name' => 'EF-Transfer 150', 'price' => 2800, 'brand' => 'EuroFarm', 'power' => '25 HP'], + ['name' => 'High-Flow 300', 'price' => 8500, 'brand' => 'AgriTech NL', 'power' => '50 HP'], + ], + + // ======================================== + // SPRAYING EQUIPMENT + // ======================================== + 'field-sprayers' => [ + ['name' => 'UX 5201 Super', 'price' => 185000, 'brand' => 'Amazone', 'power' => '180 HP'], + ['name' => 'Pantera 4502', 'price' => 265000, 'brand' => 'Amazone', 'power' => '200 HP'], + ['name' => 'AT-SP3000 Trailed', 'price' => 85000, 'brand' => 'AgriTech NL', 'power' => '100 HP'], + ['name' => 'iXter B 1200', 'price' => 42000, 'brand' => 'Kverneland', 'power' => '75 HP'], + ['name' => 'HA-Spray Master', 'price' => 95000, 'brand' => 'HollandAgro', 'power' => '120 HP'], + ], + 'fertilizer-spreaders' => [ + ['name' => 'ZA-TS 4200', 'price' => 28500, 'brand' => 'Amazone', 'power' => '100 HP'], + ['name' => 'ZG-TS 10001', 'price' => 85000, 'brand' => 'Amazone', 'power' => '150 HP'], + ['name' => 'AT-FS2000 Broadcast', 'price' => 18500, 'brand' => 'AgriTech NL', 'power' => '75 HP'], + ['name' => 'Exacta CL 1500', 'price' => 15500, 'brand' => 'Kverneland', 'power' => '50 HP'], + ['name' => 'HA-Spread Pro', 'price' => 22000, 'brand' => 'HollandAgro', 'power' => '75 HP'], + ], + 'orchard-sprayers' => [ + ['name' => 'AT-OS500 Air Blast', 'price' => 32000, 'brand' => 'AgriTech NL', 'power' => '50 HP'], + ['name' => 'HA-Orchard 800', 'price' => 42000, 'brand' => 'HollandAgro', 'power' => '75 HP'], + ['name' => 'EF-Vineyard Pro', 'price' => 28500, 'brand' => 'EuroFarm', 'power' => '50 HP'], + ['name' => 'Tunnel Sprayer T1000', 'price' => 55000, 'brand' => 'AgriTech NL', 'power' => '75 HP'], + ['name' => 'Low-Drift 600', 'price' => 38000, 'brand' => 'Kverneland', 'power' => '50 HP'], + ], + + // ======================================== + // LIVESTOCK EQUIPMENT + // ======================================== + 'milking-systems' => [ + ['name' => 'Astronaut A5', 'price' => 185000, 'brand' => 'Lely', 'power' => '0 HP'], + ['name' => 'Vector Feeding', 'price' => 125000, 'brand' => 'Lely', 'power' => '0 HP'], + ['name' => 'AT-Milk Pro 8', 'price' => 65000, 'brand' => 'AgriTech NL', 'power' => '0 HP'], + ['name' => 'HA-DairyLine 12', 'price' => 85000, 'brand' => 'HollandAgro', 'power' => '0 HP'], + ['name' => 'Discovery 120', 'price' => 95000, 'brand' => 'Lely', 'power' => '0 HP'], + ], + 'feeding-systems' => [ + ['name' => 'Juno 150', 'price' => 42000, 'brand' => 'Lely', 'power' => '0 HP'], + ['name' => 'AT-Feed Mix 10', 'price' => 48000, 'brand' => 'AgriTech NL', 'power' => '100 HP'], + ['name' => 'HA-Mixer 15', 'price' => 55000, 'brand' => 'HollandAgro', 'power' => '100 HP'], + ['name' => 'TMR Mixer 18', 'price' => 62000, 'brand' => 'EuroFarm', 'power' => '120 HP'], + ['name' => 'Vector 300', 'price' => 165000, 'brand' => 'Lely', 'power' => '0 HP'], + ], + 'manure-handling' => [ + ['name' => 'AT-Slurry 8000', 'price' => 45000, 'brand' => 'AgriTech NL', 'power' => '150 HP'], + ['name' => 'HA-Tanker 12000', 'price' => 65000, 'brand' => 'HollandAgro', 'power' => '180 HP'], + ['name' => 'Manure Spreader Pro', 'price' => 38000, 'brand' => 'EuroFarm', 'power' => '120 HP'], + ['name' => 'Injector 6000', 'price' => 52000, 'brand' => 'AgriTech NL', 'power' => '150 HP'], + ['name' => 'Slurry Pump Station', 'price' => 28000, 'brand' => 'HollandAgro', 'power' => '75 HP'], + ], + + // ======================================== + // GREENHOUSE EQUIPMENT + // ======================================== + 'climate-control' => [ + ['name' => 'Connext Climate', 'price' => 85000, 'brand' => 'Priva', 'power' => '0 HP'], + ['name' => 'AT-Climate Pro', 'price' => 45000, 'brand' => 'AgriTech NL', 'power' => '0 HP'], + ['name' => 'HA-Vent System', 'price' => 32000, 'brand' => 'HollandAgro', 'power' => '0 HP'], + ['name' => 'Heating Control Plus', 'price' => 55000, 'brand' => 'Priva', 'power' => '0 HP'], + ['name' => 'Screen System 4000', 'price' => 42000, 'brand' => 'Priva', 'power' => '0 HP'], + ], + 'greenhouse-irrigation' => [ + ['name' => 'Nutrifit 100', 'price' => 65000, 'brand' => 'Priva', 'power' => '0 HP'], + ['name' => 'AT-Hydro System', 'price' => 42000, 'brand' => 'AgriTech NL', 'power' => '0 HP'], + ['name' => 'Fertigation Pro', 'price' => 55000, 'brand' => 'HollandAgro', 'power' => '0 HP'], + ['name' => 'NFT Growing System', 'price' => 38000, 'brand' => 'Priva', 'power' => '0 HP'], + ['name' => 'EF-Greenhouse Drip', 'price' => 28000, 'brand' => 'EuroFarm', 'power' => '0 HP'], + ], + 'automation-systems' => [ + ['name' => 'Compact 3 Operator', 'price' => 125000, 'brand' => 'Priva', 'power' => '0 HP'], + ['name' => 'AT-Greenhouse Robot', 'price' => 185000, 'brand' => 'AgriTech NL', 'power' => '0 HP'], + ['name' => 'Harvest Automation', 'price' => 145000, 'brand' => 'HollandAgro', 'power' => '0 HP'], + ['name' => 'Sorting Line Pro', 'price' => 95000, 'brand' => 'Priva', 'power' => '0 HP'], + ['name' => 'Pack Station Auto', 'price' => 75000, 'brand' => 'EuroFarm', 'power' => '0 HP'], + ], + + // ======================================== + // SPARE PARTS + // ======================================== + 'engine-parts' => [ + ['name' => 'Fuel Filter Kit', 'price' => 85, 'brand' => 'AgriTech NL', 'power' => '0 HP'], + ['name' => 'Oil Filter Set', 'price' => 45, 'brand' => 'HollandAgro', 'power' => '0 HP'], + ['name' => 'Air Filter Element', 'price' => 125, 'brand' => 'Deutz-Fahr', 'power' => '0 HP'], + ['name' => 'Injector Nozzle', 'price' => 285, 'brand' => 'EuroFarm', 'power' => '0 HP'], + ['name' => 'Fan Belt', 'price' => 65, 'brand' => 'AgriTech NL', 'power' => '0 HP'], + ], + 'hydraulic-components' => [ + ['name' => 'Hydraulic Pump', 'price' => 1850, 'brand' => 'AgriTech NL', 'power' => '0 HP'], + ['name' => 'Cylinder Kit', 'price' => 950, 'brand' => 'HollandAgro', 'power' => '0 HP'], + ['name' => 'Control Valve', 'price' => 680, 'brand' => 'EuroFarm', 'power' => '0 HP'], + ['name' => 'Hose Assembly Set', 'price' => 245, 'brand' => 'AgriTech NL', 'power' => '0 HP'], + ['name' => 'Hydraulic Filter', 'price' => 125, 'brand' => 'Deutz-Fahr', 'power' => '0 HP'], + ], + 'transmission-parts' => [ + ['name' => 'Clutch Plate', 'price' => 485, 'brand' => 'AgriTech NL', 'power' => '0 HP'], + ['name' => 'PTO Shaft', 'price' => 650, 'brand' => 'HollandAgro', 'power' => '0 HP'], + ['name' => 'Gear Set', 'price' => 1250, 'brand' => 'Deutz-Fahr', 'power' => '0 HP'], + ['name' => 'Bearing Kit', 'price' => 185, 'brand' => 'EuroFarm', 'power' => '0 HP'], + ['name' => 'U-Joint Cross', 'price' => 95, 'brand' => 'AgriTech NL', 'power' => '0 HP'], + ], + 'electrical-components' => [ + ['name' => 'Alternator 120A', 'price' => 385, 'brand' => 'AgriTech NL', 'power' => '0 HP'], + ['name' => 'Starter Motor', 'price' => 450, 'brand' => 'HollandAgro', 'power' => '0 HP'], + ['name' => 'ECU Module', 'price' => 1850, 'brand' => 'Deutz-Fahr', 'power' => '0 HP'], + ['name' => 'Wiring Harness', 'price' => 285, 'brand' => 'EuroFarm', 'power' => '0 HP'], + ['name' => 'Sensor Kit', 'price' => 165, 'brand' => 'AgriTech NL', 'power' => '0 HP'], + ], + 'wear-parts' => [ + ['name' => 'Plough Share Set', 'price' => 185, 'brand' => 'Lemken', 'power' => '0 HP'], + ['name' => 'Disc Blade Pack', 'price' => 320, 'brand' => 'AgriTech NL', 'power' => '0 HP'], + ['name' => 'Cultivator Tine', 'price' => 45, 'brand' => 'Amazone', 'power' => '0 HP'], + ['name' => 'Knife Section', 'price' => 8, 'brand' => 'Kverneland', 'power' => '0 HP'], + ['name' => 'Seed Coulter', 'price' => 125, 'brand' => 'HollandAgro', 'power' => '0 HP'], + ], + + // ======================================== + // RAW MATERIALS + // ======================================== + 'steel-metals' => [ + ['name' => 'Steel Sheet 3mm', 'price' => 85, 'brand' => 'Dutch Steel', 'power' => '0 HP'], + ['name' => 'Steel Tube 50x50', 'price' => 42, 'brand' => 'EuroMetal', 'power' => '0 HP'], + ['name' => 'Hardox 450 Plate', 'price' => 285, 'brand' => 'SSAB', 'power' => '0 HP'], + ['name' => 'Aluminum Profile', 'price' => 65, 'brand' => 'AlcoNL', 'power' => '0 HP'], + ['name' => 'Cast Iron Block', 'price' => 125, 'brand' => 'Dutch Steel', 'power' => '0 HP'], + ], + 'fasteners' => [ + ['name' => 'Bolt Set M12', 'price' => 25, 'brand' => 'AgriTech NL', 'power' => '0 HP'], + ['name' => 'Nut Pack M10', 'price' => 18, 'brand' => 'EuroFast', 'power' => '0 HP'], + ['name' => 'Self-Tapping Screws', 'price' => 12, 'brand' => 'FastenPro', 'power' => '0 HP'], + ['name' => 'Lock Washer Set', 'price' => 8, 'brand' => 'AgriTech NL', 'power' => '0 HP'], + ['name' => 'Split Pin Pack', 'price' => 6, 'brand' => 'EuroFast', 'power' => '0 HP'], + ], + 'bearings-seals' => [ + ['name' => 'Ball Bearing 6205', 'price' => 28, 'brand' => 'SKF', 'power' => '0 HP'], + ['name' => 'Roller Bearing 32206', 'price' => 65, 'brand' => 'FAG', 'power' => '0 HP'], + ['name' => 'Oil Seal Set', 'price' => 35, 'brand' => 'AgriTech NL', 'power' => '0 HP'], + ['name' => 'O-Ring Kit', 'price' => 22, 'brand' => 'Simrit', 'power' => '0 HP'], + ['name' => 'Dust Cover Pack', 'price' => 15, 'brand' => 'SKF', 'power' => '0 HP'], + ], + 'rubber-plastics' => [ + ['name' => 'Hydraulic Hose 12mm', 'price' => 45, 'brand' => 'Gates', 'power' => '0 HP'], + ['name' => 'V-Belt A68', 'price' => 28, 'brand' => 'Optibelt', 'power' => '0 HP'], + ['name' => 'Fuel Hose 8mm', 'price' => 18, 'brand' => 'AgriTech NL', 'power' => '0 HP'], + ['name' => 'Plastic Tank 200L', 'price' => 145, 'brand' => 'EuroPlas', 'power' => '0 HP'], + ['name' => 'Rubber Mount', 'price' => 25, 'brand' => 'Vibracoustic', 'power' => '0 HP'], ], ]; - $colors = ['Black', 'White', 'Silver', 'Gray', 'Blue', 'Red', 'Gold', 'Rose Gold', 'Green', 'Purple']; - $conditions = ['New', 'Refurbished', 'Open Box']; - $variants = ['Standard', 'Pro', 'Plus', 'Ultra', 'Max', 'Mini', 'Lite']; + $conditions = ['New', 'Demo Unit', 'Factory Fresh']; + $variants = ['Standard', 'Pro', 'Plus', 'Premium', 'Elite', 'Base', 'Advanced']; // Map category slugs to product types - $accessoryCategories = ['accessories', 'mobile-accessories', 'computer-accessories', 'gaming-accessories']; - $consumableCategories = ['office-supplies']; + $sparePartsCategories = ['engine-parts', 'hydraulic-components', 'transmission-parts', 'electrical-components', 'wear-parts']; + $rawMaterialsCategories = ['steel-metals', 'fasteners', 'bearings-seals', 'rubber-plastics']; - Product::withoutSyncingToSearch(function () use ($categories, $productTemplates, $colors, $conditions, $variants, $companyId, $finishedGoodsType, $sparePartsType, $consumablesType, $accessoryCategories, $consumableCategories) { + Product::withoutSyncingToSearch(function () use ($categories, $productTemplates, $conditions, $variants, $companyId, $finishedGoodsType, $sparePartsType, $rawMaterialsType, $sparePartsCategories, $rawMaterialsCategories) { foreach ($categories as $category) { $templates = $productTemplates[$category->slug] ?? []; @@ -447,49 +373,77 @@ public function run(): void $productsCreated = 0; $templateIndex = 0; - // Create exactly 50 products per subcategory - while ($productsCreated < 50) { + // Create products per subcategory (fewer for spare parts/raw materials) + $maxProducts = in_array($category->slug, array_merge($sparePartsCategories, $rawMaterialsCategories)) ? 20 : 25; + + while ($productsCreated < $maxProducts) { $template = $templates[$templateIndex % count($templates)]; // Add variation to make products unique $variant = $variants[$productsCreated % count($variants)]; - $color = $colors[$productsCreated % count($colors)]; $condition = $conditions[$productsCreated % count($conditions)]; - $productName = "{$template['brand']} {$template['name']} - {$variant} ({$color})"; + $productName = "{$template['brand']} {$template['name']} - {$variant}"; $slug = Str::slug($productName); - $sku = strtoupper(Str::slug($template['brand'])) . '-CAT' . $category->id . '-' . str_pad($productsCreated + 1, 4, '0', STR_PAD_LEFT); + // Use category ID to ensure uniqueness across categories with similar prefixes + $sku = strtoupper(substr(Str::slug($template['brand']), 0, 3)) . '-C' . $category->id . '-' . str_pad($productsCreated + 1, 4, '0', STR_PAD_LEFT); // Price variation $basePrice = $template['price']; - $priceVariation = ($productsCreated % 5) * 50; // Add $0-$200 variation + $priceVariation = ($productsCreated % 5) * ($basePrice * 0.05); // 0-20% variation $price = $basePrice + $priceVariation; $comparePrice = $price + ($price * 0.15); // 15% higher compare price - $costPrice = $price * 0.70; // 70% of selling price + $costPrice = $price * 0.65; // 65% of selling price (machinery has lower margins) // Stock variation - $stock = rand(5, 100); - $lowStockThreshold = rand(3, 10); + $stock = in_array($category->slug, array_merge($sparePartsCategories, $rawMaterialsCategories)) + ? rand(50, 500) // More stock for parts/materials + : rand(2, 15); // Less stock for machinery + + $lowStockThreshold = in_array($category->slug, array_merge($sparePartsCategories, $rawMaterialsCategories)) + ? rand(20, 50) + : rand(1, 3); // Random active/featured status - $isActive = $productsCreated < 25 ? true : (rand(0, 1) === 1); // First 25 active, rest random - $isFeatured = $productsCreated < 5 ? true : (rand(0, 10) > 7); // First 5 featured, rest 30% chance + $isActive = $productsCreated < 15 ? true : (rand(0, 1) === 1); + $isFeatured = $productsCreated < 3 ? true : (rand(0, 10) > 8); // Determine product type based on category - $productTypeId = $finishedGoodsType->id; // Default to Finished Goods - if (in_array($category->slug, $accessoryCategories)) { + $productTypeId = $finishedGoodsType->id; + if (in_array($category->slug, $sparePartsCategories)) { $productTypeId = $sparePartsType?->id ?? $finishedGoodsType->id; - } elseif (in_array($category->slug, $consumableCategories)) { - $productTypeId = $consumablesType?->id ?? $finishedGoodsType->id; + } elseif (in_array($category->slug, $rawMaterialsCategories)) { + $productTypeId = $rawMaterialsType?->id ?? $finishedGoodsType->id; } + // MRP Planning fields based on product type + $isFinishedGoods = $productTypeId === $finishedGoodsType->id; + $isSpareParts = in_array($category->slug, $sparePartsCategories); + $isRawMaterials = in_array($category->slug, $rawMaterialsCategories); + + // Lead time: machinery takes longer, parts/materials are quicker + $leadTimeDays = $isFinishedGoods ? rand(14, 45) : ($isSpareParts ? rand(3, 10) : rand(1, 5)); + + // Safety stock: higher for fast-moving items + $safetyStock = $isRawMaterials ? rand(20, 100) : ($isSpareParts ? rand(5, 25) : rand(1, 3)); + + // Reorder point = safety stock + average daily usage * lead time + $reorderPoint = $safetyStock + ($stock * 0.1 * $leadTimeDays / 30); + + // Make or buy decision + $makeOrBuy = $isFinishedGoods ? 'make' : 'buy'; + + // Minimum order qty and multiple + $minimumOrderQty = $isRawMaterials ? rand(10, 50) : ($isSpareParts ? rand(2, 10) : 1); + $orderMultiple = $isRawMaterials ? rand(5, 25) : ($isSpareParts ? rand(1, 5) : 1); + $product = Product::create([ 'company_id' => $companyId, 'product_type_id' => $productTypeId, 'name' => $productName, 'slug' => $slug, 'sku' => $sku, - 'description' => "High-quality {$template['name']} from {$template['brand']}. Condition: {$condition}. Color: {$color}. Perfect for professional and personal use.", + 'description' => "Professional-grade {$template['name']} from {$template['brand']}. Condition: {$condition}. Designed for Dutch and European agricultural operations. " . ($template['power'] !== '0 HP' ? "Power requirement: {$template['power']}." : ''), 'short_description' => "{$template['brand']} {$template['name']} - {$variant}", 'price' => number_format($price, 2, '.', ''), 'compare_price' => number_format($comparePrice, 2, '.', ''), @@ -498,11 +452,19 @@ public function run(): void 'low_stock_threshold' => $lowStockThreshold, 'is_active' => $isActive, 'is_featured' => $isFeatured, + // MRP Planning fields + 'lead_time_days' => $leadTimeDays, + 'safety_stock' => $safetyStock, + 'reorder_point' => round($reorderPoint, 2), + 'make_or_buy' => $makeOrBuy, + 'minimum_order_qty' => $minimumOrderQty, + 'order_multiple' => $orderMultiple, 'meta_data' => [ 'brand' => $template['brand'], - 'color' => $color, + 'power_requirement' => $template['power'], 'condition' => $condition, 'variant' => $variant, + 'origin' => 'Netherlands/EU', ], ]); @@ -517,7 +479,84 @@ public function run(): void } }); - $totalProducts = Product::count(); - $this->command->info("Products seeded successfully! Total: {$totalProducts} products"); + // Calculate Low-Level Codes for all products + $this->command->info("Calculating Low-Level Codes for products in {$company->name}..."); + $this->calculateLowLevelCodes($companyId); + $this->command->info("✓ Low-Level Codes calculated for {$company->name}!"); + } + + /** + * Calculate Low-Level Codes for all products + * Low-Level Code determines the processing order in MRP: + * - Level 0: Finished goods (not used as components) + * - Level 1+: Components used in higher-level products + */ + protected function calculateLowLevelCodes(?int $companyId): void + { + if (!$companyId) { + return; + } + + // Reset all low-level codes to 0 + Product::where('company_id', $companyId) + ->update(['low_level_code' => 0]); + + $changed = true; + $maxIterations = 100; // Prevent infinite loops + $iteration = 0; + + while ($changed && $iteration < $maxIterations) { + $changed = false; + $iteration++; + + // Get all active BOMs + $boms = Bom::where('company_id', $companyId) + ->where('status', 'active') + ->with('items.component') + ->get(); + + foreach ($boms as $bom) { + $parentProduct = $bom->product; + if (!$parentProduct || !$parentProduct->is_active) { + continue; + } + + $parentLevel = $parentProduct->low_level_code ?? 0; + + // Check all components in this BOM + foreach ($bom->items as $item) { + $component = $item->component; + if (!$component || !$component->is_active) { + continue; + } + + // Component's level should be at least parent's level + 1 + $requiredLevel = $parentLevel + 1; + $currentLevel = $component->low_level_code ?? 0; + + if ($requiredLevel > $currentLevel) { + $component->low_level_code = $requiredLevel; + $component->save(); + $changed = true; + } + } + } + } + + if ($iteration >= $maxIterations) { + $this->command->warn('Low-Level Code calculation reached maximum iterations. Possible circular BOM reference.'); + } + + // Show statistics + $levelStats = Product::where('company_id', $companyId) + ->selectRaw('low_level_code, COUNT(*) as count') + ->groupBy('low_level_code') + ->orderBy('low_level_code') + ->pluck('count', 'low_level_code') + ->toArray(); + + foreach ($levelStats as $level => $count) { + $this->command->info(" Level {$level}: {$count} products"); + } } } diff --git a/backend/database/seeders/ProductUomConversionSeeder.php b/backend/database/seeders/ProductUomConversionSeeder.php new file mode 100644 index 0000000..a8192a0 --- /dev/null +++ b/backend/database/seeders/ProductUomConversionSeeder.php @@ -0,0 +1,166 @@ +id; + + if (!$companyId) { + $this->command->warn('No company found, skipping ProductUomConversionSeeder'); + return; + } + + // Get UOMs + $pcs = UnitOfMeasure::where('company_id', $companyId)->where('code', 'pcs')->first(); + $box = UnitOfMeasure::where('company_id', $companyId)->where('code', 'box')->first(); + $pallet = UnitOfMeasure::where('company_id', $companyId)->where('code', 'pallet')->first(); + $L = UnitOfMeasure::where('company_id', $companyId)->where('code', 'L')->first(); + $drum = UnitOfMeasure::where('company_id', $companyId)->where('code', 'drum')->first(); + $kg = UnitOfMeasure::where('company_id', $companyId)->where('code', 'kg')->first(); + + if (!$pcs || !$box) { + $this->command->warn('Required UOMs not found, skipping ProductUomConversionSeeder'); + return; + } + + $conversionsCreated = 0; + + // Get products from different categories for demo + $products = Product::where('company_id', $companyId) + ->inRandomOrder() + ->limit(20) + ->get(); + + foreach ($products as $index => $product) { + $conversions = []; + + // Vary conversions based on product index for variety + switch ($index % 5) { + case 0: + // Small items: 1 box = 100 pcs, 1 pallet = 50 boxes + $conversions[] = [ + 'from_uom_id' => $box->id, + 'to_uom_id' => $pcs->id, + 'conversion_factor' => 100, + 'is_default' => true, + ]; + if ($pallet) { + $conversions[] = [ + 'from_uom_id' => $pallet->id, + 'to_uom_id' => $box->id, + 'conversion_factor' => 50, + 'is_default' => true, + ]; + } + break; + + case 1: + // Medium items: 1 box = 24 pcs, 1 pallet = 40 boxes + $conversions[] = [ + 'from_uom_id' => $box->id, + 'to_uom_id' => $pcs->id, + 'conversion_factor' => 24, + 'is_default' => true, + ]; + if ($pallet) { + $conversions[] = [ + 'from_uom_id' => $pallet->id, + 'to_uom_id' => $box->id, + 'conversion_factor' => 40, + 'is_default' => true, + ]; + } + break; + + case 2: + // Large items: 1 box = 6 pcs, 1 pallet = 24 boxes + $conversions[] = [ + 'from_uom_id' => $box->id, + 'to_uom_id' => $pcs->id, + 'conversion_factor' => 6, + 'is_default' => true, + ]; + if ($pallet) { + $conversions[] = [ + 'from_uom_id' => $pallet->id, + 'to_uom_id' => $box->id, + 'conversion_factor' => 24, + 'is_default' => true, + ]; + } + break; + + case 3: + // Very large items: 1 pallet = 4 pcs directly + if ($pallet) { + $conversions[] = [ + 'from_uom_id' => $pallet->id, + 'to_uom_id' => $pcs->id, + 'conversion_factor' => 4, + 'is_default' => true, + ]; + } + break; + + case 4: + // Liquids: 1 drum = 200 L + if ($drum && $L) { + $conversions[] = [ + 'from_uom_id' => $drum->id, + 'to_uom_id' => $L->id, + 'conversion_factor' => 200, + 'is_default' => true, + ]; + } + // Also add box conversion + $conversions[] = [ + 'from_uom_id' => $box->id, + 'to_uom_id' => $pcs->id, + 'conversion_factor' => 12, + 'is_default' => true, + ]; + break; + } + + // Create conversions for this product + foreach ($conversions as $conversionData) { + // Check if conversion already exists + $exists = ProductUomConversion::where('product_id', $product->id) + ->where('from_uom_id', $conversionData['from_uom_id']) + ->where('to_uom_id', $conversionData['to_uom_id']) + ->exists(); + + if (!$exists) { + ProductUomConversion::create([ + 'company_id' => $companyId, + 'product_id' => $product->id, + 'from_uom_id' => $conversionData['from_uom_id'], + 'to_uom_id' => $conversionData['to_uom_id'], + 'conversion_factor' => $conversionData['conversion_factor'], + 'is_default' => $conversionData['is_default'], + 'is_active' => true, + ]); + $conversionsCreated++; + } + } + } + + $this->command->info("Product UOM conversions seeded: {$conversionsCreated} conversions for " . $products->count() . " products"); + } +} diff --git a/backend/database/seeders/QualityControlSeeder.php b/backend/database/seeders/QualityControlSeeder.php new file mode 100644 index 0000000..95f17dc --- /dev/null +++ b/backend/database/seeders/QualityControlSeeder.php @@ -0,0 +1,1115 @@ +isEmpty()) { + $this->command->error('No companies found! Please run CompanySeeder first.'); + return; + } + + // Create QC data for each company + foreach ($companies as $company) { + $this->createQcDataForCompany($company); + } + + $this->command->info('Quality Control data seeded successfully for ' . $companies->count() . ' companies'); + } + + private function createQcDataForCompany($company): void + { + $user = User::where('company_id', $company->id)->first(); + $qcManager = User::where('company_id', $company->id)->skip(1)->first() ?? $user; + + $products = Product::where('company_id', $company->id)->take(8)->get(); + $suppliers = Supplier::where('company_id', $company->id)->get(); + $warehouse = Warehouse::where('company_id', $company->id)->first(); + $categories = Category::where('company_id', $company->id)->take(3)->get(); + $uom = UnitOfMeasure::where('company_id', $company->id)->where('code', 'pcs')->first(); + + if ($products->isEmpty()) { + $this->command->warn('No products found. Please run ProductSeeder first.'); + return; + } + + if (!$uom) { + $this->command->warn('No unit of measure found. Please run UnitOfMeasureSeeder first.'); + return; + } + + // Create Acceptance Rules + $rules = $this->createAcceptanceRules($company, $user, $products, $suppliers, $categories); + + // Create test scenarios + $this->createScenario1StandardPass($company, $user, $products[0] ?? null, $suppliers[0] ?? null, $warehouse, $rules['aql'], $uom); + $this->createScenario2PartialFailure($company, $user, $qcManager, $products[1] ?? null, $suppliers[1] ?? null, $warehouse, $rules['visual'], $uom); + $this->createScenario3CriticalQuarantine($company, $user, $qcManager, $products[2] ?? null, $suppliers[2] ?? null, $warehouse, $rules['visual'], $uom); + $this->createScenario4DimensionalRework($company, $user, $qcManager, $products[3] ?? null, $suppliers[0] ?? null, $warehouse, $rules['dimensional'], $uom); + $this->createScenario5UseAsIs($company, $user, $qcManager, $products[4] ?? null, $suppliers[1] ?? null, $warehouse, $rules['visual'], $uom); + $this->createScenario6SupplierReturn($company, $user, $qcManager, $products[5] ?? null, $suppliers[2] ?? null, $warehouse, $rules['documentation'], $uom); + $this->createScenario7SkipLot($company, $user, $products[6] ?? null, $suppliers[4] ?? null, $warehouse, $rules['skip_lot'], $uom); + $this->createScenario8ComplexNcr($company, $user, $qcManager, $products[7] ?? null, $suppliers[3] ?? null, $warehouse, $rules['aql'], $uom); + + $this->command->info("Quality Control scenarios seeded successfully for {$company->name}!"); + $this->command->info('Created:'); + $this->command->info(' - ' . AcceptanceRule::count() . ' acceptance rules'); + $this->command->info(' - ' . GoodsReceivedNote::count() . ' goods received notes'); + $this->command->info(' - ' . ReceivingInspection::count() . ' receiving inspections'); + $this->command->info(' - ' . NonConformanceReport::count() . ' non-conformance reports'); + } + + /** + * Create a PO, GRN with items for test scenarios + */ + private function createGrn( + Company $company, + User $user, + Product $product, + ?Supplier $supplier, + Warehouse $warehouse, + UnitOfMeasure $uom, + float $quantity, + string $status, + string $lotNumber, + ?Carbon $receivedDate = null + ): array { + $receivedDate = $receivedDate ?? now(); + $unitPrice = 25.00; + + // Create Purchase Order first + $poNumber = 'PO-QC-' . str_pad($this->poCounter++, 4, '0', STR_PAD_LEFT); + + $po = PurchaseOrder::create([ + 'company_id' => $company->id, + 'order_number' => $poNumber, + 'supplier_id' => $supplier?->id ?? Supplier::where('company_id', $company->id)->first()->id, + 'warehouse_id' => $warehouse->id, + 'order_date' => $receivedDate->copy()->subDays(7), + 'expected_delivery_date' => $receivedDate, + 'actual_delivery_date' => $receivedDate, + 'status' => PurchaseOrder::STATUS_RECEIVED, + 'currency' => 'USD', + 'exchange_rate' => 1.0, + 'subtotal' => $quantity * $unitPrice, + 'discount_amount' => 0, + 'tax_amount' => 0, + 'shipping_cost' => 0, + 'other_charges' => 0, + 'total_amount' => $quantity * $unitPrice, + 'payment_terms' => 'Net 30', + 'payment_due_days' => 30, + 'notes' => 'QC Test Scenario PO', + 'created_by' => $user->id, + ]); + + $poItem = PurchaseOrderItem::create([ + 'purchase_order_id' => $po->id, + 'product_id' => $product->id, + 'line_number' => 1, + 'description' => $product->name, + 'quantity_ordered' => $quantity, + 'quantity_received' => $quantity, + 'quantity_cancelled' => 0, + 'uom_id' => $uom->id, + 'unit_price' => $unitPrice, + 'discount_percentage' => 0, + 'discount_amount' => 0, + 'tax_percentage' => 0, + 'tax_amount' => 0, + 'line_total' => $quantity * $unitPrice, + 'expected_delivery_date' => $receivedDate, + 'lot_number' => $lotNumber, + ]); + + // Create GRN + $grnNumber = 'GRN-QC-' . str_pad($this->grnCounter++, 4, '0', STR_PAD_LEFT); + + $grn = GoodsReceivedNote::create([ + 'company_id' => $company->id, + 'grn_number' => $grnNumber, + 'purchase_order_id' => $po->id, + 'supplier_id' => $supplier?->id ?? $po->supplier_id, + 'warehouse_id' => $warehouse->id, + 'received_date' => $receivedDate, + 'status' => $status, + 'requires_inspection' => true, + 'notes' => 'QC Test Scenario GRN', + 'received_by' => $user->id, + 'created_by' => $user->id, + ]); + + $grnItem = GoodsReceivedNoteItem::create([ + 'goods_received_note_id' => $grn->id, + 'purchase_order_item_id' => $poItem->id, + 'product_id' => $product->id, + 'line_number' => 1, + 'quantity_received' => $quantity, + 'quantity_accepted' => 0, + 'quantity_rejected' => 0, + 'uom_id' => $uom->id, + 'unit_cost' => $unitPrice, + 'total_cost' => $quantity * $unitPrice, + 'lot_number' => $lotNumber, + 'inspection_status' => 'pending', + ]); + + return [$grn, $grnItem]; + } + + /** + * Create acceptance rules for different scenarios + */ + private function createAcceptanceRules(Company $company, User $user, $products, $suppliers, $categories): array + { + $rules = []; + + // Default AQL rule + $rules['aql'] = AcceptanceRule::firstOrCreate( + ['company_id' => $company->id, 'rule_code' => 'AR-0001'], + [ + 'name' => 'Standard AQL Inspection', + 'description' => 'Default AQL Level II inspection for general merchandise', + 'inspection_type' => 'sampling', + 'sampling_method' => 'aql', + 'aql_level' => 'II', + 'aql_value' => 2.5, + 'criteria' => [ + 'check_quantity' => true, + 'check_packaging' => true, + 'check_labeling' => true, + 'check_documentation' => true, + ], + 'is_default' => true, + 'is_active' => true, + 'priority' => 0, + 'created_by' => $user->id, + ] + ); + + // Visual inspection rule + $rules['visual'] = AcceptanceRule::firstOrCreate( + ['company_id' => $company->id, 'rule_code' => 'AR-0002'], + [ + 'name' => '100% Visual Inspection', + 'description' => 'Complete visual inspection for high-value items', + 'inspection_type' => 'visual', + 'sampling_method' => '100_percent', + 'criteria' => [ + 'check_surface' => true, + 'check_color' => true, + 'check_finish' => true, + 'check_assembly' => true, + 'defect_tolerance' => 0, + ], + 'is_default' => false, + 'is_active' => true, + 'priority' => 10, + 'created_by' => $user->id, + ] + ); + + // Dimensional inspection rule + $rules['dimensional'] = AcceptanceRule::firstOrCreate( + ['company_id' => $company->id, 'rule_code' => 'AR-0003'], + [ + 'name' => 'Dimensional Check', + 'description' => 'Dimensional verification for precision components', + 'inspection_type' => 'dimensional', + 'sampling_method' => 'aql', + 'aql_level' => 'II', + 'aql_value' => 1.0, + 'criteria' => [ + 'tolerance_mm' => 0.5, + 'check_dimensions' => ['length', 'width', 'height', 'diameter'], + 'measurement_tools' => ['caliper', 'micrometer'], + ], + 'is_default' => false, + 'is_active' => true, + 'priority' => 20, + 'created_by' => $user->id, + ] + ); + + // Documentation check rule + $rules['documentation'] = AcceptanceRule::firstOrCreate( + ['company_id' => $company->id, 'rule_code' => 'AR-0004'], + [ + 'name' => 'Documentation Verification', + 'description' => 'Full documentation and certification check', + 'inspection_type' => 'documentation', + 'sampling_method' => '100_percent', + 'criteria' => [ + 'required_documents' => [ + 'packing_list', + 'certificate_of_conformance', + 'test_report', + 'material_certificate', + ], + 'verify_po_match' => true, + 'verify_quantities' => true, + ], + 'is_default' => false, + 'is_active' => true, + 'priority' => 5, + 'created_by' => $user->id, + ] + ); + + // Skip-lot for trusted supplier + if ($suppliers->count() >= 5) { + $rules['skip_lot'] = AcceptanceRule::firstOrCreate( + ['company_id' => $company->id, 'rule_code' => 'AR-0005'], + [ + 'supplier_id' => $suppliers[4]->id, + 'name' => 'Skip-Lot for Trusted Supplier', + 'description' => 'Reduced inspection for Local Supplies Turkey - 5-star rating', + 'inspection_type' => 'documentation', + 'sampling_method' => 'skip_lot', + 'criteria' => [ + 'verify_documentation_only' => true, + 'random_audit_frequency' => 10, // 1 in 10 lots + ], + 'is_default' => false, + 'is_active' => true, + 'priority' => 100, + 'created_by' => $user->id, + ] + ); + } else { + $rules['skip_lot'] = $rules['aql']; + } + + // Category-specific rule (if categories exist) + if ($categories->isNotEmpty()) { + $rules['category'] = AcceptanceRule::firstOrCreate( + ['company_id' => $company->id, 'rule_code' => 'AR-0006'], + [ + 'category_id' => $categories[0]->id, + 'name' => 'Electronics Category Rule', + 'description' => 'Functional testing for electronics category', + 'inspection_type' => 'functional', + 'sampling_method' => 'random', + 'sample_size_percentage' => 10, + 'criteria' => [ + 'power_on_test' => true, + 'functionality_check' => true, + 'safety_test' => true, + ], + 'is_default' => false, + 'is_active' => true, + 'priority' => 50, + 'created_by' => $user->id, + ] + ); + } + + return $rules; + } + + /** + * Scenario 1: Standard Receiving - Successful Pass + * AQL sampling inspection, all samples pass, stock released as available + */ + private function createScenario1StandardPass($company, $user, $product, $supplier, $warehouse, $rule, $uom): void + { + if (!$product) return; + + [$grn, $grnItem] = $this->createGrn( + $company, $user, $product, $supplier, $warehouse, $uom, + 500, GoodsReceivedNote::STATUS_COMPLETED, 'LOT-2024-001', now()->subDays(5) + ); + + // Update GRN item as accepted + $grnItem->update([ + 'quantity_accepted' => 500, + 'inspection_status' => 'passed', + ]); + + $inspection = ReceivingInspection::create([ + 'company_id' => $company->id, + 'goods_received_note_id' => $grn->id, + 'grn_item_id' => $grnItem->id, + 'product_id' => $product->id, + 'acceptance_rule_id' => $rule->id, + 'inspection_number' => 'INS-2024-0001', + 'lot_number' => 'LOT-2024-001', + 'batch_number' => 'BATCH-001', + 'quantity_received' => 500, + 'quantity_inspected' => 50, // AQL sample size + 'quantity_passed' => 50, + 'quantity_failed' => 0, + 'quantity_on_hold' => 0, + 'result' => ReceivingInspection::RESULT_PASSED, + 'disposition' => ReceivingInspection::DISPOSITION_ACCEPT, + 'inspection_data' => [ + 'sampling_method' => 'aql', + 'aql_level' => 'II', + 'sample_size' => 50, + 'accept_number' => 3, + 'reject_number' => 4, + 'defects_found' => 0, + 'measurements' => [ + 'visual_check' => 'pass', + 'packaging_check' => 'pass', + 'documentation_check' => 'pass', + ], + ], + 'notes' => 'All samples passed AQL inspection. Stock released for sale.', + 'inspected_by' => $user->id, + 'inspected_at' => now()->subDays(5), + 'approved_by' => $user->id, + 'approved_at' => now()->subDays(5), + ]); + + // Create available stock + Stock::create([ + 'company_id' => $company->id, + 'product_id' => $product->id, + 'warehouse_id' => $warehouse->id, + 'lot_number' => 'LOT-2024-001', + 'quantity_on_hand' => 500, + 'quantity_reserved' => 0, + 'unit_cost' => 25.00, + 'received_date' => now()->subDays(5), + 'status' => Stock::STATUS_AVAILABLE, + 'quality_status' => Stock::QUALITY_AVAILABLE, + ]); + } + + /** + * Scenario 2: Partial Failure - NCR Required + * Packaging damage found, NCR created, return to supplier disposition + */ + private function createScenario2PartialFailure($company, $user, $qcManager, $product, $supplier, $warehouse, $rule, $uom): void + { + if (!$product) return; + + [$grn, $grnItem] = $this->createGrn( + $company, $user, $product, $supplier, $warehouse, $uom, + 200, GoodsReceivedNote::STATUS_COMPLETED, 'LOT-2024-002', now()->subDays(3) + ); + + // Update GRN item + $grnItem->update([ + 'quantity_accepted' => 170, + 'quantity_rejected' => 30, + 'inspection_status' => 'partial', + 'rejection_reason' => 'Packaging damage - 30 units', + ]); + + $inspection = ReceivingInspection::create([ + 'company_id' => $company->id, + 'goods_received_note_id' => $grn->id, + 'grn_item_id' => $grnItem->id, + 'product_id' => $product->id, + 'acceptance_rule_id' => $rule->id, + 'inspection_number' => 'INS-2024-0002', + 'lot_number' => 'LOT-2024-002', + 'batch_number' => 'BATCH-002', + 'quantity_received' => 200, + 'quantity_inspected' => 200, + 'quantity_passed' => 170, + 'quantity_failed' => 30, + 'quantity_on_hold' => 0, + 'result' => ReceivingInspection::RESULT_PARTIAL, + 'disposition' => ReceivingInspection::DISPOSITION_ACCEPT, // Passed items accepted + 'inspection_data' => [ + 'defects_found' => 30, + 'defect_details' => [ + ['type' => 'packaging_damage', 'count' => 25], + ['type' => 'cosmetic_defect', 'count' => 5], + ], + ], + 'failure_reason' => 'Packaging damage on 30 units during shipping', + 'notes' => 'Passed items accepted. NCR created for failed items.', + 'inspected_by' => $user->id, + 'inspected_at' => now()->subDays(3), + 'approved_by' => $qcManager->id, + 'approved_at' => now()->subDays(3), + ]); + + // Create NCR for failed items + NonConformanceReport::create([ + 'company_id' => $company->id, + 'source_type' => NonConformanceReport::SOURCE_RECEIVING, + 'receiving_inspection_id' => $inspection->id, + 'ncr_number' => 'NCR-2024-0001', + 'title' => 'Packaging Damage - Shipment LOT-2024-002', + 'description' => 'Multiple units received with damaged packaging. 25 units have crushed outer boxes, 5 units have cosmetic damage to product.', + 'product_id' => $product->id, + 'supplier_id' => $supplier?->id, + 'lot_number' => 'LOT-2024-002', + 'batch_number' => 'BATCH-002', + 'quantity_affected' => 30, + 'unit_of_measure' => 'pcs', + 'severity' => NcrSeverity::MAJOR->value, + 'priority' => 'high', + 'defect_type' => 'packaging', + 'root_cause' => 'Improper handling during transit. Evidence of forklift damage on pallets.', + 'disposition' => NcrDisposition::RETURN_TO_SUPPLIER->value, + 'disposition_reason' => 'Units not suitable for sale. Return to supplier for credit.', + 'cost_impact' => 750.00, + 'cost_currency' => 'USD', + 'status' => NcrStatus::CLOSED->value, + 'reported_by' => $user->id, + 'reported_at' => now()->subDays(3), + 'reviewed_by' => $qcManager->id, + 'reviewed_at' => now()->subDays(2), + 'disposition_by' => $qcManager->id, + 'disposition_at' => now()->subDays(2), + 'closed_by' => $qcManager->id, + 'closed_at' => now()->subDays(1), + 'closure_notes' => 'RMA processed. Credit memo #CM-2024-0015 received from supplier.', + ]); + + // Create stock for passed items only + Stock::create([ + 'company_id' => $company->id, + 'product_id' => $product->id, + 'warehouse_id' => $warehouse->id, + 'lot_number' => 'LOT-2024-002', + 'quantity_on_hand' => 170, + 'quantity_reserved' => 0, + 'unit_cost' => 25.00, + 'received_date' => now()->subDays(3), + 'status' => Stock::STATUS_AVAILABLE, + 'quality_status' => Stock::QUALITY_AVAILABLE, + ]); + } + + /** + * Scenario 3: Critical Quality Issue - Quarantine + * Contamination found, stock quarantined, held for lab testing + */ + private function createScenario3CriticalQuarantine($company, $user, $qcManager, $product, $supplier, $warehouse, $rule, $uom): void + { + if (!$product) return; + + [$grn, $grnItem] = $this->createGrn( + $company, $user, $product, $supplier, $warehouse, $uom, + 1000, GoodsReceivedNote::STATUS_PENDING_INSPECTION, 'LOT-2024-003', now()->subHours(12) + ); + + $inspection = ReceivingInspection::create([ + 'company_id' => $company->id, + 'goods_received_note_id' => $grn->id, + 'grn_item_id' => $grnItem->id, + 'product_id' => $product->id, + 'acceptance_rule_id' => $rule->id, + 'inspection_number' => 'INS-2024-0003', + 'lot_number' => 'LOT-2024-003', + 'batch_number' => 'BATCH-003', + 'quantity_received' => 1000, + 'quantity_inspected' => 100, + 'quantity_passed' => 0, + 'quantity_failed' => 100, + 'quantity_on_hold' => 900, + 'result' => ReceivingInspection::RESULT_ON_HOLD, + 'disposition' => ReceivingInspection::DISPOSITION_PENDING, + 'inspection_data' => [ + 'contamination_type' => 'foreign_material', + 'contamination_source' => 'unknown', + 'lab_test_required' => true, + 'lab_test_reference' => 'LAB-2024-0042', + ], + 'failure_reason' => 'Foreign material contamination detected in sampled units', + 'notes' => 'CRITICAL: All stock quarantined pending lab analysis. Do not release.', + 'inspected_by' => $user->id, + 'inspected_at' => now()->subHours(12), + ]); + + // Create NCR for contamination + $ncr = NonConformanceReport::create([ + 'company_id' => $company->id, + 'source_type' => NonConformanceReport::SOURCE_RECEIVING, + 'receiving_inspection_id' => $inspection->id, + 'ncr_number' => 'NCR-2024-0002', + 'title' => 'CRITICAL: Contamination - LOT-2024-003', + 'description' => 'Foreign material detected during visual inspection. Unknown dark particles found inside sealed product packaging. Entire lot quarantined pending laboratory analysis.', + 'product_id' => $product->id, + 'supplier_id' => $supplier?->id, + 'lot_number' => 'LOT-2024-003', + 'batch_number' => 'BATCH-003', + 'quantity_affected' => 1000, + 'unit_of_measure' => 'pcs', + 'severity' => NcrSeverity::CRITICAL->value, + 'priority' => 'urgent', + 'defect_type' => 'contamination', + 'disposition' => NcrDisposition::PENDING->value, + 'status' => NcrStatus::UNDER_REVIEW->value, + 'attachments' => [ + ['name' => 'contamination_photo_1.jpg', 'type' => 'image'], + ['name' => 'contamination_photo_2.jpg', 'type' => 'image'], + ['name' => 'lab_request_form.pdf', 'type' => 'document'], + ], + 'reported_by' => $user->id, + 'reported_at' => now()->subHours(12), + 'reviewed_by' => $qcManager->id, + 'reviewed_at' => now()->subHours(6), + ]); + + // Create quarantined stock + Stock::create([ + 'company_id' => $company->id, + 'product_id' => $product->id, + 'warehouse_id' => $warehouse->id, + 'lot_number' => 'LOT-2024-003', + 'quantity_on_hand' => 1000, + 'quantity_reserved' => 0, + 'unit_cost' => 15.00, + 'received_date' => now()->subHours(12), + 'status' => Stock::STATUS_QUARANTINE, + 'quality_status' => Stock::QUALITY_QUARANTINE, + 'hold_reason' => 'Critical contamination - pending lab analysis', + 'hold_until' => now()->addDays(7), + 'quality_hold_by' => $qcManager->id, + 'quality_hold_at' => now()->subHours(6), + 'quality_reference_type' => NonConformanceReport::class, + 'quality_reference_id' => $ncr->id, + ]); + } + + /** + * Scenario 4: Dimensional Error - Rework + * Out-of-tolerance components, sent for rework then reinspected + */ + private function createScenario4DimensionalRework($company, $user, $qcManager, $product, $supplier, $warehouse, $rule, $uom): void + { + if (!$product) return; + + [$grn, $grnItem] = $this->createGrn( + $company, $user, $product, $supplier, $warehouse, $uom, + 100, GoodsReceivedNote::STATUS_PENDING_INSPECTION, 'LOT-2024-004', now()->subDays(2) + ); + + $inspection = ReceivingInspection::create([ + 'company_id' => $company->id, + 'goods_received_note_id' => $grn->id, + 'grn_item_id' => $grnItem->id, + 'product_id' => $product->id, + 'acceptance_rule_id' => $rule->id, + 'inspection_number' => 'INS-2024-0004', + 'lot_number' => 'LOT-2024-004', + 'batch_number' => 'BATCH-004', + 'quantity_received' => 100, + 'quantity_inspected' => 20, + 'quantity_passed' => 12, + 'quantity_failed' => 8, + 'quantity_on_hold' => 0, + 'result' => ReceivingInspection::RESULT_PARTIAL, + 'disposition' => ReceivingInspection::DISPOSITION_REWORK, + 'inspection_data' => [ + 'measurement_type' => 'dimensional', + 'specification' => ['diameter' => '25.0mm', 'tolerance' => '±0.5mm'], + 'measurements' => [ + ['sample' => 1, 'value' => '25.1mm', 'result' => 'pass'], + ['sample' => 2, 'value' => '24.8mm', 'result' => 'pass'], + ['sample' => 3, 'value' => '25.9mm', 'result' => 'fail'], + ['sample' => 4, 'value' => '26.2mm', 'result' => 'fail'], + ], + 'failure_rate' => '40%', + 'tools_used' => ['digital_caliper', 'go_no_go_gauge'], + ], + 'failure_reason' => 'Diameter out of tolerance - exceeds +0.5mm limit', + 'notes' => 'Failed units sent to rework station for grinding to specification.', + 'inspected_by' => $user->id, + 'inspected_at' => now()->subDays(2), + 'approved_by' => $qcManager->id, + 'approved_at' => now()->subDays(2), + ]); + + // NCR for dimensional issue + NonConformanceReport::create([ + 'company_id' => $company->id, + 'source_type' => NonConformanceReport::SOURCE_RECEIVING, + 'receiving_inspection_id' => $inspection->id, + 'ncr_number' => 'NCR-2024-0003', + 'title' => 'Dimensional Deviation - Oversized Diameter', + 'description' => 'Sample inspection revealed 40% failure rate for diameter tolerance. Measured values exceed upper specification limit of 25.5mm.', + 'product_id' => $product->id, + 'supplier_id' => $supplier?->id, + 'lot_number' => 'LOT-2024-004', + 'batch_number' => 'BATCH-004', + 'quantity_affected' => 40, // Estimated 40% of total + 'unit_of_measure' => 'pcs', + 'severity' => NcrSeverity::MAJOR->value, + 'priority' => 'high', + 'defect_type' => 'dimensional', + 'root_cause' => 'Supplier tool wear - diameter grinding wheel needs replacement', + 'disposition' => NcrDisposition::REWORK->value, + 'disposition_reason' => 'Rework feasible - grind to specification and reinspect', + 'cost_impact' => 200.00, + 'cost_currency' => 'USD', + 'status' => NcrStatus::IN_PROGRESS->value, + 'reported_by' => $user->id, + 'reported_at' => now()->subDays(2), + 'reviewed_by' => $qcManager->id, + 'reviewed_at' => now()->subDays(2), + 'disposition_by' => $qcManager->id, + 'disposition_at' => now()->subDays(1), + ]); + + // Stock on hold for rework + Stock::create([ + 'company_id' => $company->id, + 'product_id' => $product->id, + 'warehouse_id' => $warehouse->id, + 'lot_number' => 'LOT-2024-004', + 'quantity_on_hand' => 100, + 'quantity_reserved' => 0, + 'unit_cost' => 50.00, + 'received_date' => now()->subDays(2), + 'status' => Stock::STATUS_AVAILABLE, + 'quality_status' => Stock::QUALITY_ON_HOLD, + 'hold_reason' => 'Pending rework - dimensional deviation', + 'quality_hold_by' => $qcManager->id, + 'quality_hold_at' => now()->subDays(2), + ]); + } + + /** + * Scenario 5: Use-As-Is Decision - Conditional Acceptance + * Minor cosmetic defect, accepted with restrictions for production only + */ + private function createScenario5UseAsIs($company, $user, $qcManager, $product, $supplier, $warehouse, $rule, $uom): void + { + if (!$product) return; + + [$grn, $grnItem] = $this->createGrn( + $company, $user, $product, $supplier, $warehouse, $uom, + 300, GoodsReceivedNote::STATUS_COMPLETED, 'LOT-2024-005', now()->subDays(4) + ); + + // Update GRN item as conditionally accepted + $grnItem->update([ + 'quantity_accepted' => 300, + 'inspection_status' => 'passed', + 'inspection_notes' => 'Conditional acceptance - production use only', + ]); + + $inspection = ReceivingInspection::create([ + 'company_id' => $company->id, + 'goods_received_note_id' => $grn->id, + 'grn_item_id' => $grnItem->id, + 'product_id' => $product->id, + 'acceptance_rule_id' => $rule->id, + 'inspection_number' => 'INS-2024-0005', + 'lot_number' => 'LOT-2024-005', + 'batch_number' => 'BATCH-005', + 'quantity_received' => 300, + 'quantity_inspected' => 300, + 'quantity_passed' => 250, + 'quantity_failed' => 50, + 'quantity_on_hold' => 0, + 'result' => ReceivingInspection::RESULT_PARTIAL, + 'disposition' => ReceivingInspection::DISPOSITION_USE_AS_IS, + 'inspection_data' => [ + 'defect_type' => 'cosmetic', + 'defect_description' => 'Minor surface scratches on non-visible area', + 'functional_impact' => 'none', + 'engineering_review' => true, + 'engineering_approval' => 'ENG-2024-0123', + ], + 'failure_reason' => 'Minor scratches on internal component surface', + 'notes' => 'Engineering approved use-as-is. Scratches on internal surface, not visible in final assembly.', + 'inspected_by' => $user->id, + 'inspected_at' => now()->subDays(4), + 'approved_by' => $qcManager->id, + 'approved_at' => now()->subDays(4), + ]); + + // NCR with use-as-is disposition + NonConformanceReport::create([ + 'company_id' => $company->id, + 'source_type' => NonConformanceReport::SOURCE_RECEIVING, + 'receiving_inspection_id' => $inspection->id, + 'ncr_number' => 'NCR-2024-0004', + 'title' => 'Cosmetic Defect - Surface Scratches', + 'description' => 'Minor surface scratches detected on internal component surface. Scratches are on the side that faces inward during assembly and will not be visible in final product.', + 'product_id' => $product->id, + 'supplier_id' => $supplier?->id, + 'lot_number' => 'LOT-2024-005', + 'batch_number' => 'BATCH-005', + 'quantity_affected' => 50, + 'unit_of_measure' => 'pcs', + 'severity' => NcrSeverity::MINOR->value, + 'priority' => 'low', + 'defect_type' => 'visual', + 'root_cause' => 'Handling marks during supplier packaging process', + 'disposition' => NcrDisposition::USE_AS_IS->value, + 'disposition_reason' => 'Engineering approval granted. Defect does not affect form, fit, or function. Restricted to production use only - not for direct sale.', + 'cost_impact' => 0.00, + 'cost_currency' => 'USD', + 'status' => NcrStatus::CLOSED->value, + 'reported_by' => $user->id, + 'reported_at' => now()->subDays(4), + 'reviewed_by' => $qcManager->id, + 'reviewed_at' => now()->subDays(4), + 'disposition_by' => $qcManager->id, + 'disposition_at' => now()->subDays(3), + 'closed_by' => $qcManager->id, + 'closed_at' => now()->subDays(3), + 'closure_notes' => 'Accepted per engineering deviation #ENG-2024-0123', + ]); + + // Create conditional stock with restrictions + Stock::create([ + 'company_id' => $company->id, + 'product_id' => $product->id, + 'warehouse_id' => $warehouse->id, + 'lot_number' => 'LOT-2024-005', + 'quantity_on_hand' => 300, + 'quantity_reserved' => 0, + 'unit_cost' => 35.00, + 'received_date' => now()->subDays(4), + 'status' => Stock::STATUS_AVAILABLE, + 'quality_status' => Stock::QUALITY_CONDITIONAL, + 'hold_reason' => 'Use-as-is: Production use only, not for direct sale', + 'quality_restrictions' => [ + 'allowed_operations' => ['production', 'bundle'], + 'blocked_operations' => ['sale'], + 'notes' => 'Engineering approved for internal assembly only', + 'deviation_reference' => 'ENG-2024-0123', + ], + 'quality_hold_by' => $qcManager->id, + 'quality_hold_at' => now()->subDays(3), + ]); + } + + /** + * Scenario 6: Supplier Return - RMA Process + * Wrong product delivered, full rejection and RMA + */ + private function createScenario6SupplierReturn($company, $user, $qcManager, $product, $supplier, $warehouse, $rule, $uom): void + { + if (!$product) return; + + [$grn, $grnItem] = $this->createGrn( + $company, $user, $product, $supplier, $warehouse, $uom, + 150, GoodsReceivedNote::STATUS_COMPLETED, 'LOT-2024-006', now()->subDays(1) + ); + + // Update GRN item as rejected + $grnItem->update([ + 'quantity_rejected' => 150, + 'inspection_status' => 'failed', + 'rejection_reason' => 'Wrong product delivered', + ]); + + $inspection = ReceivingInspection::create([ + 'company_id' => $company->id, + 'goods_received_note_id' => $grn->id, + 'grn_item_id' => $grnItem->id, + 'product_id' => $product->id, + 'acceptance_rule_id' => $rule->id, + 'inspection_number' => 'INS-2024-0006', + 'lot_number' => 'LOT-2024-006', + 'batch_number' => 'BATCH-006', + 'quantity_received' => 150, + 'quantity_inspected' => 150, + 'quantity_passed' => 0, + 'quantity_failed' => 150, + 'quantity_on_hold' => 0, + 'result' => ReceivingInspection::RESULT_FAILED, + 'disposition' => ReceivingInspection::DISPOSITION_RETURN, + 'inspection_data' => [ + 'documentation_match' => false, + 'product_match' => false, + 'po_reference' => 'PO-2024-0789', + 'shipped_product' => 'MODEL-ABC', + 'ordered_product' => 'MODEL-XYZ', + ], + 'failure_reason' => 'Wrong product delivered - MODEL-ABC shipped instead of MODEL-XYZ', + 'notes' => 'Complete shipment rejected. RMA initiated for supplier pickup.', + 'inspected_by' => $user->id, + 'inspected_at' => now()->subDays(1), + 'approved_by' => $qcManager->id, + 'approved_at' => now()->subDays(1), + ]); + + // NCR for wrong item + NonConformanceReport::create([ + 'company_id' => $company->id, + 'source_type' => NonConformanceReport::SOURCE_RECEIVING, + 'receiving_inspection_id' => $inspection->id, + 'ncr_number' => 'NCR-2024-0005', + 'title' => 'Wrong Product Shipped - Complete Rejection', + 'description' => 'Supplier shipped incorrect product. PO-2024-0789 specified MODEL-XYZ but MODEL-ABC was received. Products are not interchangeable.', + 'product_id' => $product->id, + 'supplier_id' => $supplier?->id, + 'lot_number' => 'LOT-2024-006', + 'batch_number' => 'BATCH-006', + 'quantity_affected' => 150, + 'unit_of_measure' => 'pcs', + 'severity' => NcrSeverity::MAJOR->value, + 'priority' => 'urgent', + 'defect_type' => 'wrong_item', + 'root_cause' => 'Supplier shipping error - wrong SKU picked from warehouse', + 'disposition' => NcrDisposition::RETURN_TO_SUPPLIER->value, + 'disposition_reason' => 'Full rejection. RMA #RMA-2024-0033 issued. Supplier to arrange pickup.', + 'cost_impact' => 0.00, // No cost impact - returned + 'cost_currency' => 'USD', + 'status' => NcrStatus::IN_PROGRESS->value, + 'attachments' => [ + ['name' => 'packing_slip.pdf', 'type' => 'document'], + ['name' => 'po_comparison.pdf', 'type' => 'document'], + ], + 'reported_by' => $user->id, + 'reported_at' => now()->subDays(1), + 'reviewed_by' => $qcManager->id, + 'reviewed_at' => now()->subDays(1), + 'disposition_by' => $qcManager->id, + 'disposition_at' => now()->subHours(12), + ]); + + // Rejected stock pending return + Stock::create([ + 'company_id' => $company->id, + 'product_id' => $product->id, + 'warehouse_id' => $warehouse->id, + 'lot_number' => 'LOT-2024-006', + 'quantity_on_hand' => 150, + 'quantity_reserved' => 0, + 'unit_cost' => 45.00, + 'received_date' => now()->subDays(1), + 'status' => Stock::STATUS_QUARANTINE, + 'quality_status' => Stock::QUALITY_REJECTED, + 'hold_reason' => 'Wrong product - pending RMA return to supplier', + 'quality_hold_by' => $qcManager->id, + 'quality_hold_at' => now()->subDays(1), + ]); + } + + /** + * Scenario 7: Skip-Lot Inspection + * Trusted supplier (5-star rating), documentation check only + */ + private function createScenario7SkipLot($company, $user, $product, $supplier, $warehouse, $rule, $uom): void + { + if (!$product) return; + + [$grn, $grnItem] = $this->createGrn( + $company, $user, $product, $supplier, $warehouse, $uom, + 1000, GoodsReceivedNote::STATUS_COMPLETED, 'LOT-2024-007', now()->subHours(4) + ); + + // Update GRN item as accepted + $grnItem->update([ + 'quantity_accepted' => 1000, + 'inspection_status' => 'passed', + 'inspection_notes' => 'Skip-lot inspection - documentation verified', + ]); + + $inspection = ReceivingInspection::create([ + 'company_id' => $company->id, + 'goods_received_note_id' => $grn->id, + 'grn_item_id' => $grnItem->id, + 'product_id' => $product->id, + 'acceptance_rule_id' => $rule->id, + 'inspection_number' => 'INS-2024-0007', + 'lot_number' => 'LOT-2024-007', + 'batch_number' => 'BATCH-007', + 'quantity_received' => 1000, + 'quantity_inspected' => 0, // Skip lot - no physical inspection + 'quantity_passed' => 1000, + 'quantity_failed' => 0, + 'quantity_on_hold' => 0, + 'result' => ReceivingInspection::RESULT_PASSED, + 'disposition' => ReceivingInspection::DISPOSITION_ACCEPT, + 'inspection_data' => [ + 'skip_lot' => true, + 'skip_reason' => 'Trusted supplier with 5-star rating', + 'documentation_verified' => true, + 'documents_checked' => [ + 'packing_list' => 'verified', + 'invoice' => 'verified', + 'certificate_of_conformance' => 'verified', + ], + 'supplier_rating' => 5, + 'consecutive_pass_lots' => 24, + ], + 'notes' => 'Skip-lot applied per AR-0005. Documentation verified, no physical inspection required.', + 'inspected_by' => $user->id, + 'inspected_at' => now()->subHours(4), + 'approved_by' => $user->id, + 'approved_at' => now()->subHours(4), + ]); + + // Available stock - direct release + Stock::create([ + 'company_id' => $company->id, + 'product_id' => $product->id, + 'warehouse_id' => $warehouse->id, + 'lot_number' => 'LOT-2024-007', + 'quantity_on_hand' => 1000, + 'quantity_reserved' => 0, + 'unit_cost' => 12.00, + 'received_date' => now()->subHours(4), + 'status' => Stock::STATUS_AVAILABLE, + 'quality_status' => Stock::QUALITY_AVAILABLE, + 'notes' => 'Skip-lot inspection - released immediately', + ]); + } + + /** + * Scenario 8: Multiple Defects - Complex NCR + * Multiple defect types found, comprehensive NCR with mixed dispositions + */ + private function createScenario8ComplexNcr($company, $user, $qcManager, $product, $supplier, $warehouse, $rule, $uom): void + { + if (!$product) return; + + [$grn, $grnItem] = $this->createGrn( + $company, $user, $product, $supplier, $warehouse, $uom, + 500, GoodsReceivedNote::STATUS_PENDING_INSPECTION, 'LOT-2024-008', now()->subHours(8) + ); + + $inspection = ReceivingInspection::create([ + 'company_id' => $company->id, + 'goods_received_note_id' => $grn->id, + 'grn_item_id' => $grnItem->id, + 'product_id' => $product->id, + 'acceptance_rule_id' => $rule->id, + 'inspection_number' => 'INS-2024-0008', + 'lot_number' => 'LOT-2024-008', + 'batch_number' => 'BATCH-008', + 'quantity_received' => 500, + 'quantity_inspected' => 80, + 'quantity_passed' => 60, + 'quantity_failed' => 15, + 'quantity_on_hold' => 5, + 'result' => ReceivingInspection::RESULT_PARTIAL, + 'disposition' => ReceivingInspection::DISPOSITION_PENDING, + 'inspection_data' => [ + 'defect_summary' => [ + ['type' => 'dimensional', 'count' => 8, 'severity' => 'major'], + ['type' => 'cosmetic', 'count' => 5, 'severity' => 'minor'], + ['type' => 'documentation', 'count' => 2, 'severity' => 'minor'], + ['type' => 'contamination_suspect', 'count' => 5, 'severity' => 'unknown'], + ], + 'aql_result' => 'reject', + 'recommended_action' => 'mixed_disposition', + ], + 'failure_reason' => 'Multiple defect types detected - dimensional, cosmetic, documentation errors, and suspected contamination', + 'notes' => 'Complex NCR required. Material Review Board to evaluate mixed disposition options.', + 'inspected_by' => $user->id, + 'inspected_at' => now()->subHours(8), + ]); + + // Primary NCR - Multiple defects + $ncr = NonConformanceReport::create([ + 'company_id' => $company->id, + 'source_type' => NonConformanceReport::SOURCE_RECEIVING, + 'receiving_inspection_id' => $inspection->id, + 'ncr_number' => 'NCR-2024-0006', + 'title' => 'Multiple Quality Issues - LOT-2024-008', + 'description' => 'Complex quality issues identified across multiple defect categories: +- 8 units with dimensional errors (major) +- 5 units with cosmetic defects (minor) +- 2 units with documentation discrepancies +- 5 units with suspected contamination requiring lab analysis', + 'product_id' => $product->id, + 'supplier_id' => $supplier?->id, + 'lot_number' => 'LOT-2024-008', + 'batch_number' => 'BATCH-008', + 'quantity_affected' => 100, // Estimated based on sample + 'unit_of_measure' => 'pcs', + 'severity' => NcrSeverity::MAJOR->value, + 'priority' => 'urgent', + 'defect_type' => 'other', + 'root_cause' => 'Under investigation - appears to be systemic quality control issue at supplier', + 'disposition' => NcrDisposition::PENDING->value, + 'status' => NcrStatus::PENDING_DISPOSITION->value, + 'attachments' => [ + ['name' => 'dimensional_report.pdf', 'type' => 'document'], + ['name' => 'defect_photos.zip', 'type' => 'archive'], + ['name' => 'supplier_coc.pdf', 'type' => 'document'], + ['name' => 'lab_request.pdf', 'type' => 'document'], + ], + 'reported_by' => $user->id, + 'reported_at' => now()->subHours(8), + 'reviewed_by' => $qcManager->id, + 'reviewed_at' => now()->subHours(4), + ]); + + // Stock pending MRB decision - split into two lots + // Good stock from initial samples + Stock::create([ + 'company_id' => $company->id, + 'product_id' => $product->id, + 'warehouse_id' => $warehouse->id, + 'lot_number' => 'LOT-2024-008-A', + 'quantity_on_hand' => 300, // Estimated good stock + 'quantity_reserved' => 0, + 'unit_cost' => 28.00, + 'received_date' => now()->subHours(8), + 'status' => Stock::STATUS_AVAILABLE, + 'quality_status' => Stock::QUALITY_PENDING_INSPECTION, + 'hold_reason' => 'Pending 100% inspection to segregate good units', + 'quality_hold_by' => $user->id, + 'quality_hold_at' => now()->subHours(8), + ]); + + // Suspect stock requiring further evaluation + Stock::create([ + 'company_id' => $company->id, + 'product_id' => $product->id, + 'warehouse_id' => $warehouse->id, + 'lot_number' => 'LOT-2024-008-B', + 'quantity_on_hand' => 200, // Estimated suspect stock + 'quantity_reserved' => 0, + 'unit_cost' => 28.00, + 'received_date' => now()->subHours(8), + 'status' => Stock::STATUS_QUARANTINE, + 'quality_status' => Stock::QUALITY_ON_HOLD, + 'hold_reason' => 'MRB hold - pending disposition decision for mixed defects', + 'quality_hold_by' => $qcManager->id, + 'quality_hold_at' => now()->subHours(4), + 'quality_reference_type' => NonConformanceReport::class, + 'quality_reference_id' => $ncr->id, + ]); + } +} diff --git a/backend/database/seeders/RolePermissionSeeder.php b/backend/database/seeders/RolePermissionSeeder.php index a8da372..32b8f73 100644 --- a/backend/database/seeders/RolePermissionSeeder.php +++ b/backend/database/seeders/RolePermissionSeeder.php @@ -318,6 +318,114 @@ public function run(): void 'module' => 'qc', 'description' => 'Can approve inspections and set NCR dispositions', ], + + // Manufacturing permissions + [ + 'name' => 'manufacturing.view', + 'display_name' => 'View Manufacturing', + 'module' => 'manufacturing', + 'description' => 'Can view work centers, BOMs, routings, and work orders', + ], + [ + 'name' => 'manufacturing.create', + 'display_name' => 'Create Manufacturing Records', + 'module' => 'manufacturing', + 'description' => 'Can create work centers, BOMs, routings, and work orders', + ], + [ + 'name' => 'manufacturing.edit', + 'display_name' => 'Edit Manufacturing Records', + 'module' => 'manufacturing', + 'description' => 'Can edit work centers, BOMs, routings, and work orders', + ], + [ + 'name' => 'manufacturing.delete', + 'display_name' => 'Delete Manufacturing Records', + 'module' => 'manufacturing', + 'description' => 'Can delete work centers, BOMs, routings, and work orders', + ], + [ + 'name' => 'manufacturing.mrp', + 'display_name' => 'Run MRP', + 'module' => 'manufacturing', + 'description' => 'Can run MRP calculations and manage recommendations', + ], + [ + 'name' => 'manufacturing.release', + 'display_name' => 'Release Work Orders', + 'module' => 'manufacturing', + 'description' => 'Can release work orders for production', + ], + [ + 'name' => 'manufacturing.complete', + 'display_name' => 'Complete Operations', + 'module' => 'manufacturing', + 'description' => 'Can complete work order operations and receive finished goods', + ], + + // Customer management permissions + [ + 'name' => 'customers.view', + 'display_name' => 'View Customers', + 'module' => 'customers', + 'description' => 'Can view customer list and details', + ], + [ + 'name' => 'customers.create', + 'display_name' => 'Create Customers', + 'module' => 'customers', + 'description' => 'Can create new customers', + ], + [ + 'name' => 'customers.edit', + 'display_name' => 'Edit Customers', + 'module' => 'customers', + 'description' => 'Can edit existing customers', + ], + [ + 'name' => 'customers.delete', + 'display_name' => 'Delete Customers', + 'module' => 'customers', + 'description' => 'Can delete customers', + ], + + // Sales permissions + [ + 'name' => 'sales.view', + 'display_name' => 'View Sales', + 'module' => 'sales', + 'description' => 'Can view customers, sales orders, and delivery notes', + ], + [ + 'name' => 'sales.create', + 'display_name' => 'Create Sales Records', + 'module' => 'sales', + 'description' => 'Can create customers and sales orders', + ], + [ + 'name' => 'sales.edit', + 'display_name' => 'Edit Sales Records', + 'module' => 'sales', + 'description' => 'Can edit customers and sales orders', + ], + [ + 'name' => 'sales.delete', + 'display_name' => 'Delete Sales Records', + 'module' => 'sales', + 'description' => 'Can delete customers and sales orders', + ], + [ + 'name' => 'sales.approve', + 'display_name' => 'Approve Sales Orders', + 'module' => 'sales', + 'description' => 'Can approve or reject sales orders', + ], + [ + 'name' => 'sales.ship', + 'display_name' => 'Ship Orders', + 'module' => 'sales', + 'description' => 'Can create delivery notes and ship orders', + ], ]; foreach ($permissions as $permissionData) { @@ -359,6 +467,9 @@ public function run(): void 'producttypes.view', 'inventory.view', 'purchasing.view', + 'qc.view', + 'manufacturing.view', + 'sales.view', 'reports.view', ])->get(); $staffRole->permissions()->sync($staffPermissions->pluck('id')); @@ -452,5 +563,104 @@ public function run(): void 'qc.approve', ])->get(); $qcManagerRole->permissions()->sync($qcManagerPermissions->pluck('id')); + + // Create Production Planner role + $productionPlannerRole = Role::firstOrCreate( + ['name' => 'production_planner'], + [ + 'display_name' => 'Production Planner', + 'description' => 'Can manage BOMs, routings, and plan work orders', + 'is_system_role' => true, + ] + ); + + $productionPlannerPermissions = Permission::whereIn('name', [ + 'products.view', + 'inventory.view', + 'manufacturing.view', + 'manufacturing.create', + 'manufacturing.edit', + 'manufacturing.release', + 'manufacturing.mrp', + ])->get(); + $productionPlannerRole->permissions()->sync($productionPlannerPermissions->pluck('id')); + + // Create Shop Floor Operator role + $shopFloorOperatorRole = Role::firstOrCreate( + ['name' => 'shop_floor_operator'], + [ + 'display_name' => 'Shop Floor Operator', + 'description' => 'Can start and complete work order operations', + 'is_system_role' => true, + ] + ); + + $shopFloorOperatorPermissions = Permission::whereIn('name', [ + 'products.view', + 'inventory.view', + 'manufacturing.view', + 'manufacturing.edit', + 'manufacturing.complete', + ])->get(); + $shopFloorOperatorRole->permissions()->sync($shopFloorOperatorPermissions->pluck('id')); + + // Create Sales Person role + $salesPersonRole = Role::firstOrCreate( + ['name' => 'sales_person'], + [ + 'display_name' => 'Sales Person', + 'description' => 'Can manage customers and create sales orders', + 'is_system_role' => true, + ] + ); + + $salesPersonPermissions = Permission::whereIn('name', [ + 'products.view', + 'inventory.view', + 'sales.view', + 'sales.create', + 'sales.edit', + ])->get(); + $salesPersonRole->permissions()->sync($salesPersonPermissions->pluck('id')); + + // Create Sales Manager role + $salesManagerRole = Role::firstOrCreate( + ['name' => 'sales_manager'], + [ + 'display_name' => 'Sales Manager', + 'description' => 'Full sales access including approvals and shipping', + 'is_system_role' => true, + ] + ); + + $salesManagerPermissions = Permission::whereIn('name', [ + 'products.view', + 'inventory.view', + 'sales.view', + 'sales.create', + 'sales.edit', + 'sales.delete', + 'sales.approve', + 'sales.ship', + 'reports.view', + ])->get(); + $salesManagerRole->permissions()->sync($salesManagerPermissions->pluck('id')); + + // Create Platform Admin role + // Platform admins can access all companies and bypass company isolation + $platformAdminRole = Role::firstOrCreate( + ['name' => 'platform_admin'], + [ + 'display_name' => 'Platform Administrator', + 'description' => 'Platform administrator with access to all companies. Can bypass company isolation.', + 'is_system_role' => true, + ] + ); + + // Platform admin gets all permissions + $allPermissions = Permission::all(); + $platformAdminRole->permissions()->sync($allPermissions->pluck('id')); + + $this->command->info('Platform Admin role created with all permissions'); } } diff --git a/backend/database/seeders/SalesSeeder.php b/backend/database/seeders/SalesSeeder.php new file mode 100644 index 0000000..cb43da6 --- /dev/null +++ b/backend/database/seeders/SalesSeeder.php @@ -0,0 +1,855 @@ +command->error('No user found. Please run UserSeeder first.'); + return; + } + + $companyId = $user->company_id; + $company = Company::find($companyId); + + if (!$company) { + $this->command->error('Company not found for user.'); + return; + } + + $this->command->info('Creating Agricultural Machinery Sales demo data...'); + + DB::transaction(function () use ($companyId) { + // Create customer groups + $customerGroups = $this->createCustomerGroups($companyId); + $this->command->info('Created ' . count($customerGroups) . ' customer groups'); + + // Create customers + $customers = $this->createCustomers($companyId, $customerGroups); + $this->command->info('Created ' . count($customers) . ' customers'); + + // Create group prices for some products + $this->createGroupPrices($companyId, $customerGroups); + $this->command->info('Created group prices'); + + // Create sample sales orders + $salesOrders = $this->createSalesOrders($companyId, $customers); + $this->command->info('Created ' . count($salesOrders) . ' sales orders'); + }); + + $this->command->info("Agricultural Machinery Sales data created successfully for {$company->name}!"); + } + + private function createCustomerGroups(int $companyId): array + { + $groups = [ + [ + 'company_id' => $companyId, + 'name' => 'Authorized Dealers', + 'code' => 'DEALER', + 'description' => 'Authorized agricultural machinery dealers with best pricing', + 'discount_percentage' => 25, + 'payment_terms_days' => 60, + 'credit_limit' => 500000, + 'is_active' => true, + ], + [ + 'company_id' => $companyId, + 'name' => 'Agricultural Cooperatives', + 'code' => 'COOP', + 'description' => 'Farmer cooperatives and agricultural associations', + 'discount_percentage' => 20, + 'payment_terms_days' => 45, + 'credit_limit' => 250000, + 'is_active' => true, + ], + [ + 'company_id' => $companyId, + 'name' => 'Large Farms', + 'code' => 'FARM-L', + 'description' => 'Large-scale agricultural enterprises', + 'discount_percentage' => 15, + 'payment_terms_days' => 30, + 'credit_limit' => 150000, + 'is_active' => true, + ], + [ + 'company_id' => $companyId, + 'name' => 'Small Farms', + 'code' => 'FARM-S', + 'description' => 'Small to medium family farms', + 'discount_percentage' => 10, + 'payment_terms_days' => 30, + 'credit_limit' => 50000, + 'is_active' => true, + ], + [ + 'company_id' => $companyId, + 'name' => 'Contractors', + 'code' => 'CONTR', + 'description' => 'Agricultural service contractors (loonwerkers)', + 'discount_percentage' => 18, + 'payment_terms_days' => 45, + 'credit_limit' => 300000, + 'is_active' => true, + ], + [ + 'company_id' => $companyId, + 'name' => 'Export Customers', + 'code' => 'EXPORT', + 'description' => 'International export customers', + 'discount_percentage' => 12, + 'payment_terms_days' => 90, + 'credit_limit' => 750000, + 'is_active' => true, + ], + ]; + + $result = []; + foreach ($groups as $group) { + $result[] = CustomerGroup::firstOrCreate( + ['company_id' => $group['company_id'], 'code' => $group['code']], + $group + ); + } + + return $result; + } + + private function createCustomers(int $companyId, array $customerGroups): array + { + $customers = [ + // ======================================== + // AUTHORIZED DEALERS (Netherlands) + // ======================================== + [ + 'company_id' => $companyId, + 'customer_group_id' => $customerGroups[0]->id, // Dealer + 'customer_code' => 'CUS-00001', + 'name' => 'AgriDealers Groningen B.V.', + 'email' => 'inkoop@agridealers-groningen.nl', + 'phone' => '+31-50-1234567', + 'tax_id' => 'NL123456789B01', + 'address' => 'Landbouwweg 45', + 'city' => 'Groningen', + 'state' => 'Groningen', + 'postal_code' => '9727 KK', + 'country' => 'Netherlands', + 'contact_person' => 'Jan Huisman', + 'is_active' => true, + ], + [ + 'company_id' => $companyId, + 'customer_group_id' => $customerGroups[0]->id, // Dealer + 'customer_code' => 'CUS-00002', + 'name' => 'Tractoren Centrum Brabant', + 'email' => 'verkoop@tcbrabant.nl', + 'phone' => '+31-13-2345678', + 'tax_id' => 'NL234567890B01', + 'address' => 'Industrieweg 89', + 'city' => 'Tilburg', + 'state' => 'Noord-Brabant', + 'postal_code' => '5038 XM', + 'country' => 'Netherlands', + 'contact_person' => 'Piet van den Berg', + 'is_active' => true, + ], + [ + 'company_id' => $companyId, + 'customer_group_id' => $customerGroups[0]->id, // Dealer + 'customer_code' => 'CUS-00003', + 'name' => 'Friesland Agri Machines', + 'email' => 'orders@frieslandagri.nl', + 'phone' => '+31-58-3456789', + 'tax_id' => 'NL345678901B01', + 'address' => 'Zuiderweg 123', + 'city' => 'Leeuwarden', + 'state' => 'Friesland', + 'postal_code' => '8911 AD', + 'country' => 'Netherlands', + 'contact_person' => 'Sjoerd Hoekstra', + 'is_active' => true, + ], + + // ======================================== + // AGRICULTURAL COOPERATIVES + // ======================================== + [ + 'company_id' => $companyId, + 'customer_group_id' => $customerGroups[1]->id, // Coop + 'customer_code' => 'CUS-00004', + 'name' => 'Coöperatie Flevoland Agrarisch', + 'email' => 'machines@coopflevoland.nl', + 'phone' => '+31-320-456789', + 'tax_id' => 'NL456789012B01', + 'address' => 'Polderweg 567', + 'city' => 'Lelystad', + 'state' => 'Flevoland', + 'postal_code' => '8219 PL', + 'country' => 'Netherlands', + 'contact_person' => 'Hendrik Visser', + 'is_active' => true, + ], + [ + 'company_id' => $companyId, + 'customer_group_id' => $customerGroups[1]->id, // Coop + 'customer_code' => 'CUS-00005', + 'name' => 'ZLTO Werktuigencoöperatie', + 'email' => 'inkoop@zlto-machines.nl', + 'phone' => '+31-73-5678901', + 'tax_id' => 'NL567890123B01', + 'address' => 'Brabantlaan 234', + 'city' => 'Den Bosch', + 'state' => 'Noord-Brabant', + 'postal_code' => '5216 TV', + 'country' => 'Netherlands', + 'contact_person' => 'Maria Jansen', + 'is_active' => true, + ], + + // ======================================== + // LARGE FARMS + // ======================================== + [ + 'company_id' => $companyId, + 'customer_group_id' => $customerGroups[2]->id, // Large Farm + 'customer_code' => 'CUS-00006', + 'name' => 'Akkerbouwbedrijf De Groot', + 'email' => 'bedrijf@degroot-akkerbouw.nl', + 'phone' => '+31-527-678901', + 'tax_id' => 'NL678901234B01', + 'address' => 'Polderkade 78', + 'city' => 'Emmeloord', + 'state' => 'Flevoland', + 'postal_code' => '8302 AD', + 'country' => 'Netherlands', + 'contact_person' => 'Willem de Groot', + 'is_active' => true, + ], + [ + 'company_id' => $companyId, + 'customer_group_id' => $customerGroups[2]->id, // Large Farm + 'customer_code' => 'CUS-00007', + 'name' => 'Melkveebedrijf Hollands Glorie', + 'email' => 'info@hollandsglorie.nl', + 'phone' => '+31-348-789012', + 'tax_id' => 'NL789012345B01', + 'address' => 'Weidezicht 156', + 'city' => 'Woerden', + 'state' => 'Utrecht', + 'postal_code' => '3441 HL', + 'country' => 'Netherlands', + 'contact_person' => 'Kees Bakker', + 'is_active' => true, + ], + [ + 'company_id' => $companyId, + 'customer_group_id' => $customerGroups[2]->id, // Large Farm + 'customer_code' => 'CUS-00008', + 'name' => 'Glastuinbouw Westland BV', + 'email' => 'techniek@glaswestland.nl', + 'phone' => '+31-174-890123', + 'tax_id' => 'NL890123456B01', + 'address' => 'Kassenweg 234', + 'city' => 'Naaldwijk', + 'state' => 'Zuid-Holland', + 'postal_code' => '2671 BK', + 'country' => 'Netherlands', + 'contact_person' => 'Arjan van der Linden', + 'is_active' => true, + ], + + // ======================================== + // SMALL FARMS + // ======================================== + [ + 'company_id' => $companyId, + 'customer_group_id' => $customerGroups[3]->id, // Small Farm + 'customer_code' => 'CUS-00009', + 'name' => 'Boerderij De Zonnehoeve', + 'email' => 'info@dezonnehoeve.nl', + 'phone' => '+31-575-901234', + 'address' => 'Achterweg 12', + 'city' => 'Zutphen', + 'state' => 'Gelderland', + 'postal_code' => '7203 AP', + 'country' => 'Netherlands', + 'contact_person' => 'Familie Mulder', + 'is_active' => true, + ], + [ + 'company_id' => $companyId, + 'customer_group_id' => $customerGroups[3]->id, // Small Farm + 'customer_code' => 'CUS-00010', + 'name' => 'Fruitbedrijf Appelhof', + 'email' => 'contact@appelhof.nl', + 'phone' => '+31-345-012345', + 'address' => 'Boomgaardlaan 89', + 'city' => 'Geldermalsen', + 'state' => 'Gelderland', + 'postal_code' => '4191 LE', + 'country' => 'Netherlands', + 'contact_person' => 'Johan Peters', + 'is_active' => true, + ], + + // ======================================== + // CONTRACTORS (Loonwerkers) + // ======================================== + [ + 'company_id' => $companyId, + 'customer_group_id' => $customerGroups[4]->id, // Contractors + 'customer_code' => 'CUS-00011', + 'name' => 'Loonbedrijf Van der Ploeg', + 'email' => 'planning@vanderploeg-loon.nl', + 'phone' => '+31-594-123456', + 'tax_id' => 'NL901234567B01', + 'address' => 'Machineweg 45', + 'city' => 'Veendam', + 'state' => 'Groningen', + 'postal_code' => '9641 JK', + 'country' => 'Netherlands', + 'contact_person' => 'Gerrit van der Ploeg', + 'is_active' => true, + ], + [ + 'company_id' => $companyId, + 'customer_group_id' => $customerGroups[4]->id, // Contractors + 'customer_code' => 'CUS-00012', + 'name' => 'Agrarisch Loonwerk Zeeland', + 'email' => 'info@loonwerk-zeeland.nl', + 'phone' => '+31-118-234567', + 'tax_id' => 'NL012345678B01', + 'address' => 'Polderstraat 167', + 'city' => 'Goes', + 'state' => 'Zeeland', + 'postal_code' => '4461 HM', + 'country' => 'Netherlands', + 'contact_person' => 'Pieter Leenhouts', + 'is_active' => true, + ], + + // ======================================== + // EXPORT CUSTOMERS (Belgium, Germany) + // ======================================== + [ + 'company_id' => $companyId, + 'customer_group_id' => $customerGroups[5]->id, // Export + 'customer_code' => 'CUS-00013', + 'name' => 'Agri Machines Vlaanderen BVBA', + 'email' => 'aankoop@agri-vlaanderen.be', + 'phone' => '+32-3-4567890', + 'tax_id' => 'BE0123456789', + 'address' => 'Landbouwstraat 234', + 'city' => 'Antwerpen', + 'state' => 'Antwerpen', + 'postal_code' => '2000', + 'country' => 'Belgium', + 'contact_person' => 'Luc Peeters', + 'is_active' => true, + ], + [ + 'company_id' => $companyId, + 'customer_group_id' => $customerGroups[5]->id, // Export + 'customer_code' => 'CUS-00014', + 'name' => 'Landmaschinen Nordrhein GmbH', + 'email' => 'einkauf@landmaschinen-nr.de', + 'phone' => '+49-2151-567890', + 'tax_id' => 'DE123456789', + 'address' => 'Agrarstraße 78', + 'city' => 'Krefeld', + 'state' => 'Nordrhein-Westfalen', + 'postal_code' => '47803', + 'country' => 'Germany', + 'contact_person' => 'Hans Schmidt', + 'is_active' => true, + ], + [ + 'company_id' => $companyId, + 'customer_group_id' => $customerGroups[5]->id, // Export + 'customer_code' => 'CUS-00015', + 'name' => 'Wallonie Agri SPRL', + 'email' => 'commandes@wallonie-agri.be', + 'phone' => '+32-81-678901', + 'tax_id' => 'BE9876543210', + 'address' => 'Route de la Ferme 45', + 'city' => 'Namur', + 'state' => 'Wallonie', + 'postal_code' => '5000', + 'country' => 'Belgium', + 'contact_person' => 'Jean-Marc Dubois', + 'is_active' => true, + ], + ]; + + $result = []; + foreach ($customers as $customer) { + $result[] = Customer::firstOrCreate( + ['company_id' => $customer['company_id'], 'customer_code' => $customer['customer_code']], + $customer + ); + } + + return $result; + } + + private function createGroupPrices(int $companyId, array $customerGroups): void + { + // Get machinery products (tractors, implements) + $products = Product::where('company_id', $companyId) + ->whereHas('categories', function ($q) { + $q->whereIn('slug', [ + 'compact-tractors', 'utility-tractors', 'ploughs', 'disc-harrows' + ]); + }) + ->limit(5)->get(); + + if ($products->isEmpty()) { + $this->command->warn('No machinery products found for group pricing.'); + return; + } + + // Get default currency for company + $defaultCurrency = Currency::where('code', 'EUR')->first() + ?? Currency::where('is_active', true)->first(); + + foreach ($products as $product) { + $basePrice = $product->price ?? $product->cost_price ?? 50000; + + // Dealers get best price + CustomerGroupPrice::firstOrCreate( + [ + 'company_id' => $companyId, + 'customer_group_id' => $customerGroups[0]->id, // Dealer + 'product_id' => $product->id, + 'min_quantity' => 1, + ], + [ + 'price' => $basePrice * 0.75, // 25% off + 'currency_id' => $defaultCurrency?->id, + 'is_active' => true, + ] + ); + + // Cooperatives get good pricing + CustomerGroupPrice::firstOrCreate( + [ + 'company_id' => $companyId, + 'customer_group_id' => $customerGroups[1]->id, // Coop + 'product_id' => $product->id, + 'min_quantity' => 1, + ], + [ + 'price' => $basePrice * 0.80, // 20% off + 'currency_id' => $defaultCurrency?->id, + 'is_active' => true, + ] + ); + + // Contractors volume discount + CustomerGroupPrice::firstOrCreate( + [ + 'company_id' => $companyId, + 'customer_group_id' => $customerGroups[4]->id, // Contractors + 'product_id' => $product->id, + 'min_quantity' => 3, + ], + [ + 'price' => $basePrice * 0.82, // 18% off for 3+ + 'currency_id' => $defaultCurrency?->id, + 'is_active' => true, + ] + ); + } + } + + private function createSalesOrders(int $companyId, array $customers): array + { + $user = User::where('company_id', $companyId)->first(); + $products = Product::where('company_id', $companyId) + ->whereHas('categories', function ($q) { + $q->whereNotIn('slug', ['steel-metals', 'fasteners', 'bearings-seals', 'rubber-plastics']); + }) + ->limit(15)->get(); + $warehouse = Warehouse::where('company_id', $companyId)->where('code', 'WH-MAIN')->first() + ?? Warehouse::where('company_id', $companyId)->first(); + $defaultUom = UnitOfMeasure::first(); + + if ($products->isEmpty()) { + $this->command->warn('No machinery products found for sales orders.'); + return []; + } + + $orders = []; + $deliveryNoteCount = 0; + + if (!$warehouse) { + $this->command->warn('No warehouse found for sales orders.'); + return []; + } + + // Helper function to create order items + $createOrderItems = function ($order, $productList, $minQty = 1, $maxQty = 3) use ($defaultUom) { + $subtotal = 0; + $items = []; + foreach ($productList as $product) { + $qty = rand($minQty, $maxQty); + $price = $product->price ?? $product->cost_price ?? 25000; + $lineTotal = $qty * $price; + $subtotal += $lineTotal; + + $items[] = SalesOrderItem::create([ + 'sales_order_id' => $order->id, + 'product_id' => $product->id, + 'quantity_ordered' => $qty, + 'uom_id' => $product->unit_of_measure_id ?? $defaultUom->id, + 'unit_price' => $price, + 'line_total' => $lineTotal, + ]); + } + $taxAmount = $subtotal * 0.21; // Dutch BTW 21% + $order->update([ + 'subtotal' => $subtotal, + 'tax_amount' => $taxAmount, + 'total_amount' => $subtotal + $taxAmount + ]); + return $items; + }; + + $orderNumber = 1; + + // === DRAFT ORDERS (3) - New quotations === + foreach (array_slice($customers, 0, 3) as $customer) { + $order = SalesOrder::firstOrCreate( + ['company_id' => $companyId, 'order_number' => sprintf('SO-2026-%05d', $orderNumber)], + [ + 'customer_id' => $customer->id, + 'warehouse_id' => $warehouse->id, + 'order_date' => now()->subDays(rand(0, 2)), + 'requested_delivery_date' => now()->addDays(rand(14, 30)), + 'status' => SalesOrderStatus::DRAFT->value, + 'shipping_address' => $customer->address . ', ' . $customer->postal_code . ' ' . $customer->city, + 'subtotal' => 0, + 'tax_amount' => 0, + 'discount_amount' => 0, + 'total_amount' => 0, + 'notes' => 'Spring season machinery inquiry', + 'created_by' => $user->id, + ] + ); + + if ($order->wasRecentlyCreated) { + $createOrderItems($order, $products->random(rand(1, 2)), 1, 2); + } + $orders[] = $order; + $orderNumber++; + } + + // === PENDING APPROVAL ORDERS (2) - Large orders awaiting approval === + foreach (array_slice($customers, 3, 2) as $customer) { + $order = SalesOrder::firstOrCreate( + ['company_id' => $companyId, 'order_number' => sprintf('SO-2026-%05d', $orderNumber)], + [ + 'customer_id' => $customer->id, + 'warehouse_id' => $warehouse->id, + 'order_date' => now()->subDays(rand(1, 3)), + 'requested_delivery_date' => now()->addDays(rand(21, 45)), + 'status' => SalesOrderStatus::PENDING_APPROVAL->value, + 'shipping_address' => $customer->address . ', ' . $customer->postal_code . ' ' . $customer->city, + 'subtotal' => 0, + 'tax_amount' => 0, + 'discount_amount' => 0, + 'total_amount' => 0, + 'notes' => 'Large fleet order - requires management approval', + 'created_by' => $user->id, + ] + ); + + if ($order->wasRecentlyCreated) { + $createOrderItems($order, $products->random(rand(2, 4)), 2, 5); + } + $orders[] = $order; + $orderNumber++; + } + + // === APPROVED ORDERS (2) - Ready for production/shipping === + foreach (array_slice($customers, 5, 2) as $customer) { + $order = SalesOrder::firstOrCreate( + ['company_id' => $companyId, 'order_number' => sprintf('SO-2026-%05d', $orderNumber)], + [ + 'customer_id' => $customer->id, + 'warehouse_id' => $warehouse->id, + 'order_date' => now()->subDays(rand(5, 7)), + 'requested_delivery_date' => now()->addDays(rand(7, 14)), + 'status' => SalesOrderStatus::APPROVED->value, + 'shipping_address' => $customer->address . ', ' . $customer->postal_code . ' ' . $customer->city, + 'subtotal' => 0, + 'tax_amount' => 0, + 'discount_amount' => 0, + 'total_amount' => 0, + 'created_by' => $user->id, + 'approved_by' => $user->id, + 'approved_at' => now()->subDays(rand(2, 3)), + ] + ); + + if ($order->wasRecentlyCreated) { + $createOrderItems($order, $products->random(rand(1, 3)), 1, 2); + } + $orders[] = $order; + $orderNumber++; + } + + // === CONFIRMED ORDERS (3) - Ready for delivery === + foreach (array_slice($customers, 7, 3) as $customer) { + $order = SalesOrder::firstOrCreate( + ['company_id' => $companyId, 'order_number' => sprintf('SO-2026-%05d', $orderNumber)], + [ + 'customer_id' => $customer->id, + 'warehouse_id' => $warehouse->id, + 'order_date' => now()->subDays(rand(10, 14)), + 'requested_delivery_date' => now()->addDays(rand(1, 7)), + 'status' => SalesOrderStatus::CONFIRMED->value, + 'shipping_address' => $customer->address . ', ' . $customer->postal_code . ' ' . $customer->city, + 'subtotal' => 0, + 'tax_amount' => 0, + 'discount_amount' => 0, + 'total_amount' => 0, + 'created_by' => $user->id, + 'approved_by' => $user->id, + 'approved_at' => now()->subDays(rand(7, 10)), + ] + ); + + if ($order->wasRecentlyCreated) { + $createOrderItems($order, $products->random(rand(1, 2)), 1, 3); + } + $orders[] = $order; + $orderNumber++; + } + + // === PARTIALLY SHIPPED ORDERS (2) - With delivery notes === + foreach (array_slice($customers, 10, 2) as $customer) { + $order = SalesOrder::firstOrCreate( + ['company_id' => $companyId, 'order_number' => sprintf('SO-2026-%05d', $orderNumber)], + [ + 'customer_id' => $customer->id, + 'warehouse_id' => $warehouse->id, + 'order_date' => now()->subDays(rand(21, 28)), + 'requested_delivery_date' => now()->addDays(rand(1, 7)), + 'status' => SalesOrderStatus::PARTIALLY_SHIPPED->value, + 'shipping_address' => $customer->address . ', ' . $customer->postal_code . ' ' . $customer->city, + 'subtotal' => 0, + 'tax_amount' => 0, + 'discount_amount' => 0, + 'total_amount' => 0, + 'notes' => 'Partial delivery - remaining items in production', + 'created_by' => $user->id, + 'approved_by' => $user->id, + 'approved_at' => now()->subDays(rand(18, 21)), + ] + ); + + if ($order->wasRecentlyCreated) { + $orderItems = $createOrderItems($order, $products->random(3), 2, 4); + + // Create partial delivery note + $deliveryNoteCount++; + $deliveryNote = DeliveryNote::create([ + 'company_id' => $companyId, + 'sales_order_id' => $order->id, + 'customer_id' => $customer->id, + 'warehouse_id' => $warehouse->id, + 'delivery_number' => sprintf('DN-2026-%05d', $deliveryNoteCount), + 'delivery_date' => now()->subDays(rand(5, 10)), + 'status' => DeliveryNoteStatus::SHIPPED->value, + 'shipping_method' => 'Truck Delivery', + 'tracking_number' => 'NL-TRK-' . str_pad($deliveryNoteCount, 6, '0', STR_PAD_LEFT), + 'created_by' => $user->id, + 'delivered_at' => now()->subDays(rand(5, 10)), + 'notes' => 'First shipment - tractors', + ]); + + // Ship first item fully + if (isset($orderItems[0])) { + $shippedQty = $orderItems[0]->quantity_ordered; + DeliveryNoteItem::create([ + 'delivery_note_id' => $deliveryNote->id, + 'sales_order_item_id' => $orderItems[0]->id, + 'product_id' => $orderItems[0]->product_id, + 'quantity_shipped' => $shippedQty, + ]); + $orderItems[0]->update(['quantity_shipped' => $shippedQty]); + } + } + $orders[] = $order; + $orderNumber++; + } + + // === SHIPPED ORDERS (3) - In transit === + foreach (array_slice($customers, 12, 3) as $customer) { + $order = SalesOrder::firstOrCreate( + ['company_id' => $companyId, 'order_number' => sprintf('SO-2026-%05d', $orderNumber)], + [ + 'customer_id' => $customer->id, + 'warehouse_id' => $warehouse->id, + 'order_date' => now()->subDays(rand(28, 35)), + 'requested_delivery_date' => now()->subDays(rand(1, 5)), + 'status' => SalesOrderStatus::SHIPPED->value, + 'shipping_address' => $customer->address . ', ' . $customer->postal_code . ' ' . $customer->city, + 'subtotal' => 0, + 'tax_amount' => 0, + 'discount_amount' => 0, + 'total_amount' => 0, + 'created_by' => $user->id, + 'approved_by' => $user->id, + 'approved_at' => now()->subDays(rand(25, 30)), + ] + ); + + if ($order->wasRecentlyCreated) { + $orderItems = $createOrderItems($order, $products->random(rand(1, 2)), 1, 2); + + // Full shipment + $deliveryNoteCount++; + $deliveryNote = DeliveryNote::create([ + 'company_id' => $companyId, + 'sales_order_id' => $order->id, + 'customer_id' => $customer->id, + 'warehouse_id' => $warehouse->id, + 'delivery_number' => sprintf('DN-2026-%05d', $deliveryNoteCount), + 'delivery_date' => now()->subDays(rand(2, 5)), + 'status' => DeliveryNoteStatus::SHIPPED->value, + 'shipping_method' => $customer->country === 'Netherlands' ? 'Truck Delivery' : 'International Freight', + 'tracking_number' => 'NL-TRK-' . str_pad($deliveryNoteCount, 6, '0', STR_PAD_LEFT), + 'created_by' => $user->id, + 'delivered_at' => now()->subDays(rand(2, 5)), + ]); + + foreach ($orderItems as $orderItem) { + DeliveryNoteItem::create([ + 'delivery_note_id' => $deliveryNote->id, + 'sales_order_item_id' => $orderItem->id, + 'product_id' => $orderItem->product_id, + 'quantity_shipped' => $orderItem->quantity_ordered, + ]); + $orderItem->update(['quantity_shipped' => $orderItem->quantity_ordered]); + } + } + $orders[] = $order; + $orderNumber++; + } + + // === DELIVERED ORDERS (5) - Completed === + foreach (array_slice($customers, 0, 5) as $customer) { + $daysAgo = rand(45, 90); + $order = SalesOrder::firstOrCreate( + ['company_id' => $companyId, 'order_number' => sprintf('SO-2025-%05d', $orderNumber)], + [ + 'customer_id' => $customer->id, + 'warehouse_id' => $warehouse->id, + 'order_date' => now()->subDays($daysAgo), + 'requested_delivery_date' => now()->subDays($daysAgo - 21), + 'status' => SalesOrderStatus::DELIVERED->value, + 'shipping_address' => $customer->address . ', ' . $customer->postal_code . ' ' . $customer->city, + 'subtotal' => 0, + 'tax_amount' => 0, + 'discount_amount' => 0, + 'total_amount' => 0, + 'created_by' => $user->id, + 'approved_by' => $user->id, + 'approved_at' => now()->subDays($daysAgo - 2), + ] + ); + + if ($order->wasRecentlyCreated) { + $orderItems = $createOrderItems($order, $products->random(rand(1, 3)), 1, 2); + + // Completed delivery + $deliveryNoteCount++; + $deliveryNote = DeliveryNote::create([ + 'company_id' => $companyId, + 'sales_order_id' => $order->id, + 'customer_id' => $customer->id, + 'warehouse_id' => $warehouse->id, + 'delivery_number' => sprintf('DN-2025-%05d', $deliveryNoteCount), + 'delivery_date' => now()->subDays($daysAgo - 14), + 'status' => DeliveryNoteStatus::DELIVERED->value, + 'shipping_method' => 'Truck Delivery', + 'tracking_number' => 'NL-TRK-' . str_pad($deliveryNoteCount, 6, '0', STR_PAD_LEFT), + 'created_by' => $user->id, + 'delivered_by' => $user->id, + 'delivered_at' => now()->subDays($daysAgo - 16), + 'notes' => 'Delivered and installed at customer location', + ]); + + foreach ($orderItems as $orderItem) { + DeliveryNoteItem::create([ + 'delivery_note_id' => $deliveryNote->id, + 'sales_order_item_id' => $orderItem->id, + 'product_id' => $orderItem->product_id, + 'quantity_shipped' => $orderItem->quantity_ordered, + ]); + $orderItem->update(['quantity_shipped' => $orderItem->quantity_ordered]); + } + } + $orders[] = $order; + $orderNumber++; + } + + // === CANCELLED ORDER (1) === + $customer = $customers[5]; + $order = SalesOrder::firstOrCreate( + ['company_id' => $companyId, 'order_number' => sprintf('SO-2025-%05d', $orderNumber)], + [ + 'customer_id' => $customer->id, + 'warehouse_id' => $warehouse->id, + 'order_date' => now()->subDays(rand(60, 90)), + 'requested_delivery_date' => now()->subDays(rand(40, 50)), + 'status' => SalesOrderStatus::CANCELLED->value, + 'shipping_address' => $customer->address . ', ' . $customer->postal_code . ' ' . $customer->city, + 'subtotal' => 0, + 'tax_amount' => 0, + 'discount_amount' => 0, + 'total_amount' => 0, + 'notes' => 'Cancelled - Customer changed requirements', + 'created_by' => $user->id, + ] + ); + + if ($order->wasRecentlyCreated) { + $createOrderItems($order, $products->random(2), 1, 2); + } + $orders[] = $order; + + $this->command->info("Created {$deliveryNoteCount} delivery notes"); + + return $orders; + } +} diff --git a/backend/database/seeders/SettingsSeeder.php b/backend/database/seeders/SettingsSeeder.php index af64bd4..1451453 100644 --- a/backend/database/seeders/SettingsSeeder.php +++ b/backend/database/seeders/SettingsSeeder.php @@ -9,128 +9,172 @@ class SettingsSeeder extends Seeder { /** * Run the database seeds. + * + * NOTE: Enum-like values (inspection_types, sampling_methods, results, statuses, etc.) + * are defined as constants in their respective models (ReceivingInspection, NonConformanceReport, etc.) + * This ensures consistency with database enum constraints and provides type-safety. + * + * Settings table should only contain truly dynamic/configurable values. */ public function run(): void { $settings = [ - // QC - Inspection Types + // =================== + // QC Default Settings + // =================== [ 'group' => 'qc', - 'key' => 'inspection_types', - 'value' => [ - 'visual' => 'Visual Inspection', - 'dimensional' => 'Dimensional Inspection', - 'functional' => 'Functional Test', - 'documentation' => 'Documentation Check', - 'sampling' => 'Sample Testing', - ], - 'description' => 'Available inspection types for acceptance rules', + 'key' => 'default_aql_level', + 'value' => 'II', + 'description' => 'Default AQL inspection level for new acceptance rules', 'is_system' => true, ], - // QC - Sampling Methods [ 'group' => 'qc', - 'key' => 'sampling_methods', - 'value' => [ - '100_percent' => '100% Inspection', - 'aql' => 'AQL Sampling', - 'random' => 'Random Sampling', - 'skip_lot' => 'Skip Lot', - ], - 'description' => 'Available sampling methods for inspections', + 'key' => 'default_sampling_method', + 'value' => 'aql', + 'description' => 'Default sampling method for new acceptance rules', 'is_system' => true, ], - // QC - Inspection Results [ 'group' => 'qc', - 'key' => 'inspection_results', - 'value' => [ - 'pending' => 'Pending', - 'pass' => 'Pass', - 'fail' => 'Fail', - 'conditional_pass' => 'Conditional Pass', - ], - 'description' => 'Possible inspection result statuses', + 'key' => 'auto_create_ncr_on_failure', + 'value' => true, + 'description' => 'Automatically create NCR when inspection fails', 'is_system' => true, ], - // QC - Dispositions [ 'group' => 'qc', - 'key' => 'dispositions', - 'value' => [ - 'accept' => 'Accept', - 'reject' => 'Reject', - 'hold' => 'Hold for Review', - 'rework' => 'Rework Required', - 'return_to_supplier' => 'Return to Supplier', - 'use_as_is' => 'Use As-Is', - ], - 'description' => 'Disposition options for inspected items', + 'key' => 'require_approval_for_use_as_is', + 'value' => true, + 'description' => 'Require manager approval for use-as-is disposition', + 'is_system' => true, + ], + [ + 'group' => 'qc', + 'key' => 'quarantine_critical_ncr', + 'value' => true, + 'description' => 'Automatically quarantine stock for critical NCRs', 'is_system' => true, ], - // QC - Defect Types + + // =================== + // AQL Reference Tables + // =================== [ 'group' => 'qc', - 'key' => 'defect_types', + 'key' => 'aql_levels', 'value' => [ - 'physical_damage' => 'Physical Damage', - 'dimensional_error' => 'Dimensional Error', - 'cosmetic_defect' => 'Cosmetic Defect', - 'functional_failure' => 'Functional Failure', - 'material_defect' => 'Material Defect', - 'contamination' => 'Contamination', - 'packaging_damage' => 'Packaging Damage', - 'documentation_error' => 'Documentation Error', - 'labeling_error' => 'Labeling Error', - 'quantity_discrepancy' => 'Quantity Discrepancy', - 'quality_deviation' => 'Quality Deviation', - 'other' => 'Other', + 'S-1' => 'Special Level S-1', + 'S-2' => 'Special Level S-2', + 'S-3' => 'Special Level S-3', + 'S-4' => 'Special Level S-4', + 'I' => 'General Level I', + 'II' => 'General Level II (Standard)', + 'III' => 'General Level III', ], - 'description' => 'Types of defects for NCR classification', + 'description' => 'AQL inspection levels (ANSI/ASQ Z1.4)', 'is_system' => true, ], - // QC - NCR Severities [ 'group' => 'qc', - 'key' => 'ncr_severities', + 'key' => 'aql_values', 'value' => [ - 'critical' => 'Critical', - 'major' => 'Major', - 'minor' => 'Minor', + '0.065' => '0.065%', + '0.10' => '0.10%', + '0.15' => '0.15%', + '0.25' => '0.25%', + '0.40' => '0.40%', + '0.65' => '0.65%', + '1.0' => '1.0%', + '1.5' => '1.5%', + '2.5' => '2.5%', + '4.0' => '4.0%', + '6.5' => '6.5%', ], - 'description' => 'Severity levels for non-conformance reports', + 'description' => 'Acceptable Quality Level percentages', + 'is_system' => true, + ], + + // =================== + // Notification Settings + // =================== + [ + 'group' => 'qc', + 'key' => 'notify_on_critical_ncr', + 'value' => true, + 'description' => 'Send email notification for critical NCRs', 'is_system' => true, ], - // QC - NCR Statuses [ 'group' => 'qc', - 'key' => 'ncr_statuses', + 'key' => 'critical_ncr_notify_emails', + 'value' => [], + 'description' => 'Email addresses to notify for critical NCRs', + 'is_system' => false, + ], + + // =================== + // General App Settings + // =================== + [ + 'group' => 'app', + 'key' => 'items_per_page', + 'value' => 15, + 'description' => 'Default number of items per page in listings', + 'is_system' => true, + ], + [ + 'group' => 'app', + 'key' => 'date_format', + 'value' => 'Y-m-d', + 'description' => 'Default date format for display', + 'is_system' => true, + ], + [ + 'group' => 'app', + 'key' => 'datetime_format', + 'value' => 'Y-m-d H:i:s', + 'description' => 'Default datetime format for display', + 'is_system' => true, + ], + + // =================== + // MRP Settings + // =================== + [ + 'group' => 'mrp', + 'key' => 'working_days', + 'value' => [1, 2, 3, 4, 5], // Monday to Friday (0=Sunday, 1=Monday, ..., 6=Saturday) + 'description' => 'Standard working days for MRP calculations. Array of day numbers: 0=Sunday, 1=Monday, ..., 6=Saturday', + 'is_system' => true, // Only admin can modify + ], + [ + 'group' => 'mrp', + 'key' => 'default_shift', 'value' => [ - 'draft' => 'Draft', - 'open' => 'Open', - 'under_review' => 'Under Review', - 'pending_disposition' => 'Pending Disposition', - 'in_progress' => 'In Progress', - 'closed' => 'Closed', - 'cancelled' => 'Cancelled', + 'name' => 'default', + 'start_time' => '08:00:00', + 'end_time' => '17:00:00', + 'break_hours' => 1.0, + 'working_hours' => 8.0, ], - 'description' => 'Status workflow for NCR', + 'description' => 'Default shift configuration for MRP calculations', 'is_system' => true, ], - // QC - AQL Levels [ - 'group' => 'qc', - 'key' => 'aql_levels', + 'group' => 'mrp', + 'key' => 'shifts', 'value' => [ - 'S-1' => 'Special Level S-1', - 'S-2' => 'Special Level S-2', - 'S-3' => 'Special Level S-3', - 'S-4' => 'Special Level S-4', - 'I' => 'General Level I', - 'II' => 'General Level II', - 'III' => 'General Level III', + 'default' => [ + 'name' => 'default', + 'start_time' => '08:00:00', + 'end_time' => '17:00:00', + 'break_hours' => 1.0, + 'working_hours' => 8.0, + ], ], - 'description' => 'AQL inspection levels (ANSI/ASQ Z1.4)', + 'description' => 'Available shifts for MRP calculations. Can define multiple shifts (morning, afternoon, night, etc.)', 'is_system' => true, ], ]; diff --git a/backend/database/seeders/StockSeeder.php b/backend/database/seeders/StockSeeder.php index 50d90a2..8b06842 100644 --- a/backend/database/seeders/StockSeeder.php +++ b/backend/database/seeders/StockSeeder.php @@ -17,8 +17,27 @@ class StockSeeder extends Seeder */ public function run(): void { - $company = Company::first(); - $companyId = $company?->id; + // Get all companies + $companies = Company::all(); + + if ($companies->isEmpty()) { + $this->command->error('No companies found! Please run CompanySeeder first.'); + return; + } + + // Create stock for each company + foreach ($companies as $company) { + $this->createStockForCompany($company); + } + + $totalStock = Stock::count(); + $totalMovements = StockMovement::count(); + $this->command->info("Stock seeded successfully! Total: {$totalStock} stock records, {$totalMovements} movements for " . $companies->count() . " companies"); + } + + private function createStockForCompany($company): void + { + $companyId = $company->id; $warehouses = Warehouse::where('company_id', $companyId)->get(); $products = Product::where('company_id', $companyId)->limit(200)->get(); // First 200 products @@ -56,6 +75,8 @@ public function run(): void 'expiry_date' => rand(0, 1) ? now()->addMonths(rand(3, 24)) : null, 'received_date' => now()->subDays(rand(30, 90)), 'status' => 'available', + // QC fields (from migration: add_qc_fields_to_warehouses_and_stock) + 'quality_status' => 'available', // available, pending_inspection, on_hold, conditional, rejected, quarantine 'notes' => 'Initial stock setup', ]); $stockCount++; @@ -97,6 +118,8 @@ public function run(): void 'expiry_date' => rand(0, 1) ? now()->addMonths(rand(3, 24)) : null, 'received_date' => now()->subDays(rand(1, 30)), 'status' => 'available', + // QC fields (from migration: add_qc_fields_to_warehouses_and_stock) + 'quality_status' => 'available', // available, pending_inspection, on_hold, conditional, rejected, quarantine 'notes' => 'Stock transfer from main warehouse', ]); $stockCount++; @@ -154,7 +177,6 @@ public function run(): void } } - $this->command->info("Stock seeded: {$stockCount} stock records"); - $this->command->info("Stock movements seeded: {$movementCount} movements"); + $this->command->info("Stock seeded for {$company->name}: {$stockCount} stock records, {$movementCount} movements"); } } diff --git a/backend/database/seeders/SupplierSeeder.php b/backend/database/seeders/SupplierSeeder.php index ee0c938..f6855fb 100644 --- a/backend/database/seeders/SupplierSeeder.php +++ b/backend/database/seeders/SupplierSeeder.php @@ -12,143 +12,368 @@ class SupplierSeeder extends Seeder { /** * Run the database seeds. + * + * Agricultural Machinery Suppliers for Netherlands/EU Market */ public function run(): void { - $company = Company::first(); - $user = User::where('company_id', $company->id)->first(); + // Get all companies + $companies = Company::all(); + + if ($companies->isEmpty()) { + $this->command->error('No companies found! Please run CompanySeeder first.'); + return; + } + + // Create suppliers for each company + foreach ($companies as $company) { + $this->createSuppliersForCompany($company); + } + + $totalSuppliers = \App\Models\Supplier::count(); + $this->command->info("Suppliers seeded successfully! Total: {$totalSuppliers} suppliers for " . $companies->count() . " companies"); + } + + private function createSuppliersForCompany($company): void + { + $companyId = $company->id; + $user = User::where('company_id', $companyId)->first(); $suppliers = [ + // ======================================== + // DUTCH SUPPLIERS + // ======================================== [ 'company_id' => $company->id, 'supplier_code' => 'SUP-00001', - 'name' => 'Tech Components Ltd.', - 'legal_name' => 'Tech Components Limited', - 'tax_id' => 'TC123456789', - 'email' => 'orders@techcomponents.com', - 'phone' => '+1-555-0101', - 'website' => 'https://techcomponents.com', - 'address' => '123 Industrial Park', - 'city' => 'San Francisco', - 'state' => 'California', - 'country' => 'USA', - 'postal_code' => '94102', - 'contact_person' => 'John Smith', - 'contact_email' => 'john.smith@techcomponents.com', - 'contact_phone' => '+1-555-0102', - 'currency' => 'USD', + 'name' => 'Dutch Steel Industries B.V.', + 'legal_name' => 'Dutch Steel Industries Besloten Vennootschap', + 'tax_id' => 'NL123456789B01', + 'email' => 'orders@dutchsteel.nl', + 'phone' => '+31-20-1234567', + 'website' => 'https://dutchsteel.nl', + 'address' => 'Industrieweg 45', + 'city' => 'Rotterdam', + 'state' => 'Zuid-Holland', + 'country' => 'Netherlands', + 'postal_code' => '3044 BC', + 'contact_person' => 'Jan van der Berg', + 'contact_email' => 'j.vanderberg@dutchsteel.nl', + 'contact_phone' => '+31-6-12345678', + 'currency' => 'EUR', 'payment_terms_days' => 30, - 'credit_limit' => 50000.00, - 'lead_time_days' => 7, - 'minimum_order_amount' => 500.00, + 'credit_limit' => 250000.00, + 'lead_time_days' => 5, + 'minimum_order_amount' => 1000.00, 'rating' => 5, 'is_active' => true, 'created_by' => $user->id, + 'notes' => 'Primary steel supplier. Hardox certified. ISO 9001.', ], [ 'company_id' => $company->id, 'supplier_code' => 'SUP-00002', - 'name' => 'Global Electronics Inc.', - 'legal_name' => 'Global Electronics Incorporated', - 'tax_id' => 'GE987654321', - 'email' => 'sales@globalelectronics.com', - 'phone' => '+1-555-0201', - 'website' => 'https://globalelectronics.com', - 'address' => '456 Commerce Way', - 'city' => 'New York', - 'state' => 'New York', - 'country' => 'USA', - 'postal_code' => '10001', - 'contact_person' => 'Jane Doe', - 'contact_email' => 'jane.doe@globalelectronics.com', - 'contact_phone' => '+1-555-0202', - 'currency' => 'USD', + 'name' => 'Hydrauliek Centrum Nederland', + 'legal_name' => 'Hydrauliek Centrum Nederland B.V.', + 'tax_id' => 'NL987654321B01', + 'email' => 'verkoop@hydrauliekcentrum.nl', + 'phone' => '+31-40-2345678', + 'website' => 'https://hydrauliekcentrum.nl', + 'address' => 'Technoweg 12', + 'city' => 'Eindhoven', + 'state' => 'Noord-Brabant', + 'country' => 'Netherlands', + 'postal_code' => '5611 AB', + 'contact_person' => 'Pieter de Jong', + 'contact_email' => 'p.dejong@hydrauliekcentrum.nl', + 'contact_phone' => '+31-6-23456789', + 'currency' => 'EUR', + 'payment_terms_days' => 30, + 'credit_limit' => 150000.00, + 'lead_time_days' => 3, + 'minimum_order_amount' => 500.00, + 'rating' => 5, + 'is_active' => true, + 'created_by' => $user->id, + 'notes' => 'Hydraulic systems specialist. Same-day delivery available.', + ], + [ + 'company_id' => $company->id, + 'supplier_code' => 'SUP-00003', + 'name' => 'Lely Parts & Service', + 'legal_name' => 'Lely Industries N.V.', + 'tax_id' => 'NL001234567B01', + 'email' => 'parts@lely.com', + 'phone' => '+31-33-4567890', + 'website' => 'https://lely.com', + 'address' => 'Cornelis van der Lelylaan 1', + 'city' => 'Maassluis', + 'state' => 'Zuid-Holland', + 'country' => 'Netherlands', + 'postal_code' => '3147 PB', + 'contact_person' => 'Annemieke Bakker', + 'contact_email' => 'a.bakker@lely.com', + 'contact_phone' => '+31-6-34567890', + 'currency' => 'EUR', 'payment_terms_days' => 45, - 'credit_limit' => 100000.00, + 'credit_limit' => 500000.00, + 'lead_time_days' => 7, + 'minimum_order_amount' => 2500.00, + 'rating' => 5, + 'is_active' => true, + 'created_by' => $user->id, + 'notes' => 'Dairy and livestock equipment. OEM parts.', + ], + [ + 'company_id' => $company->id, + 'supplier_code' => 'SUP-00004', + 'name' => 'Priva B.V.', + 'legal_name' => 'Priva Holding B.V.', + 'tax_id' => 'NL002345678B01', + 'email' => 'sales@priva.nl', + 'phone' => '+31-174-522600', + 'website' => 'https://priva.nl', + 'address' => 'Zijlweg 3', + 'city' => 'De Lier', + 'state' => 'Zuid-Holland', + 'country' => 'Netherlands', + 'postal_code' => '2678 LC', + 'contact_person' => 'Saskia Vermeer', + 'contact_email' => 's.vermeer@priva.nl', + 'contact_phone' => '+31-6-45678901', + 'currency' => 'EUR', + 'payment_terms_days' => 30, + 'credit_limit' => 350000.00, 'lead_time_days' => 14, - 'minimum_order_amount' => 1000.00, - 'rating' => 4, + 'minimum_order_amount' => 5000.00, + 'rating' => 5, 'is_active' => true, 'created_by' => $user->id, + 'notes' => 'Greenhouse automation. Climate control systems.', ], + + // ======================================== + // GERMAN SUPPLIERS + // ======================================== [ 'company_id' => $company->id, - 'supplier_code' => 'SUP-00003', - 'name' => 'Euro Parts GmbH', - 'legal_name' => 'Euro Parts GmbH', + 'supplier_code' => 'SUP-00005', + 'name' => 'Lemken GmbH & Co. KG', + 'legal_name' => 'Lemken GmbH & Co. KG', 'tax_id' => 'DE123456789', - 'email' => 'orders@europarts.de', - 'phone' => '+49-30-12345678', - 'website' => 'https://europarts.de', - 'address' => 'Industriestrasse 42', - 'city' => 'Berlin', - 'state' => 'Berlin', + 'email' => 'parts@lemken.com', + 'phone' => '+49-2838-2040', + 'website' => 'https://lemken.com', + 'address' => 'Weseler Straße 5', + 'city' => 'Alpen', + 'state' => 'Nordrhein-Westfalen', + 'country' => 'Germany', + 'postal_code' => '46519', + 'contact_person' => 'Klaus Schmidt', + 'contact_email' => 'k.schmidt@lemken.com', + 'contact_phone' => '+49-171-1234567', + 'currency' => 'EUR', + 'payment_terms_days' => 30, + 'credit_limit' => 400000.00, + 'lead_time_days' => 10, + 'minimum_order_amount' => 3000.00, + 'rating' => 5, + 'is_active' => true, + 'created_by' => $user->id, + 'notes' => 'Tillage equipment. Ploughs, cultivators, disc harrows.', + ], + [ + 'company_id' => $company->id, + 'supplier_code' => 'SUP-00006', + 'name' => 'Amazone Werke', + 'legal_name' => 'Amazonen-Werke H. Dreyer SE & Co. KG', + 'tax_id' => 'DE234567890', + 'email' => 'export@amazone.de', + 'phone' => '+49-5405-5010', + 'website' => 'https://amazone.de', + 'address' => 'Am Amazonenwerk 9-13', + 'city' => 'Hasbergen', + 'state' => 'Niedersachsen', + 'country' => 'Germany', + 'postal_code' => '49205', + 'contact_person' => 'Heinrich Weber', + 'contact_email' => 'h.weber@amazone.de', + 'contact_phone' => '+49-172-2345678', + 'currency' => 'EUR', + 'payment_terms_days' => 45, + 'credit_limit' => 600000.00, + 'lead_time_days' => 14, + 'minimum_order_amount' => 5000.00, + 'rating' => 5, + 'is_active' => true, + 'created_by' => $user->id, + 'notes' => 'Sprayers, spreaders, seed drills. Premium quality.', + ], + [ + 'company_id' => $company->id, + 'supplier_code' => 'SUP-00007', + 'name' => 'Grimme Landmaschinenfabrik', + 'legal_name' => 'Grimme Landmaschinenfabrik GmbH & Co. KG', + 'tax_id' => 'DE345678901', + 'email' => 'ersatzteile@grimme.de', + 'phone' => '+49-5561-880', + 'website' => 'https://grimme.com', + 'address' => 'Hunteburger Straße 32', + 'city' => 'Damme', + 'state' => 'Niedersachsen', 'country' => 'Germany', - 'postal_code' => '10115', - 'contact_person' => 'Hans Mueller', - 'contact_email' => 'h.mueller@europarts.de', - 'contact_phone' => '+49-30-12345679', + 'postal_code' => '49401', + 'contact_person' => 'Dieter Hoffmann', + 'contact_email' => 'd.hoffmann@grimme.de', + 'contact_phone' => '+49-173-3456789', 'currency' => 'EUR', 'payment_terms_days' => 30, - 'credit_limit' => 75000.00, - 'lead_time_days' => 21, + 'credit_limit' => 450000.00, + 'lead_time_days' => 7, 'minimum_order_amount' => 2000.00, + 'rating' => 5, + 'is_active' => true, + 'created_by' => $user->id, + 'notes' => 'Potato technology specialist. Harvesters, planters.', + ], + [ + 'company_id' => $company->id, + 'supplier_code' => 'SUP-00008', + 'name' => 'Deutz-Fahr Parts', + 'legal_name' => 'SAME DEUTZ-FAHR Deutschland GmbH', + 'tax_id' => 'DE456789012', + 'email' => 'parts@deutz-fahr.de', + 'phone' => '+49-8331-8020', + 'website' => 'https://deutz-fahr.com', + 'address' => 'Deutz-Fahr-Straße 1', + 'city' => 'Lauingen', + 'state' => 'Bayern', + 'country' => 'Germany', + 'postal_code' => '89415', + 'contact_person' => 'Franz Müller', + 'contact_email' => 'f.mueller@deutz-fahr.de', + 'contact_phone' => '+49-174-4567890', + 'currency' => 'EUR', + 'payment_terms_days' => 30, + 'credit_limit' => 300000.00, + 'lead_time_days' => 5, + 'minimum_order_amount' => 1500.00, 'rating' => 4, 'is_active' => true, 'created_by' => $user->id, + 'notes' => 'Tractor parts. Engines, transmissions, electronics.', ], [ 'company_id' => $company->id, - 'supplier_code' => 'SUP-00004', - 'name' => 'Asia Manufacturing Co.', - 'legal_name' => 'Asia Manufacturing Company Ltd.', - 'tax_id' => 'CN987654321', - 'email' => 'export@asiamfg.cn', - 'phone' => '+86-21-12345678', - 'website' => 'https://asiamfg.cn', - 'address' => '789 Export Zone', - 'city' => 'Shanghai', - 'state' => 'Shanghai', - 'country' => 'China', - 'postal_code' => '200000', - 'contact_person' => 'Li Wei', - 'contact_email' => 'li.wei@asiamfg.cn', - 'contact_phone' => '+86-21-12345679', - 'currency' => 'USD', - 'payment_terms_days' => 60, + 'supplier_code' => 'SUP-00009', + 'name' => 'SKF Deutschland GmbH', + 'legal_name' => 'SKF GmbH', + 'tax_id' => 'DE567890123', + 'email' => 'bearings@skf.de', + 'phone' => '+49-9721-560', + 'website' => 'https://skf.com/de', + 'address' => 'Gunnar-Wester-Straße 12', + 'city' => 'Schweinfurt', + 'state' => 'Bayern', + 'country' => 'Germany', + 'postal_code' => '97421', + 'contact_person' => 'Wolfgang Braun', + 'contact_email' => 'w.braun@skf.com', + 'contact_phone' => '+49-175-5678901', + 'currency' => 'EUR', + 'payment_terms_days' => 30, 'credit_limit' => 200000.00, - 'lead_time_days' => 45, - 'minimum_order_amount' => 5000.00, - 'shipping_method' => 'Sea Freight', - 'rating' => 3, + 'lead_time_days' => 3, + 'minimum_order_amount' => 500.00, + 'rating' => 5, 'is_active' => true, 'created_by' => $user->id, + 'notes' => 'Bearings, seals, lubrication systems.', ], + + // ======================================== + // OTHER EU SUPPLIERS + // ======================================== [ 'company_id' => $company->id, - 'supplier_code' => 'SUP-00005', - 'name' => 'Local Supplies Turkey', - 'legal_name' => 'Local Supplies Ticaret A.S.', - 'tax_id' => 'TR1234567890', - 'email' => 'info@localsupplies.com.tr', - 'phone' => '+90-212-1234567', - 'website' => 'https://localsupplies.com.tr', - 'address' => 'Organize Sanayi Bolgesi No:15', - 'city' => 'Istanbul', - 'state' => 'Marmara', - 'country' => 'Turkey', - 'postal_code' => '34100', - 'contact_person' => 'Ahmet Yilmaz', - 'contact_email' => 'ahmet@localsupplies.com.tr', - 'contact_phone' => '+90-532-1234567', - 'currency' => 'TRY', - 'payment_terms_days' => 15, - 'credit_limit' => 500000.00, - 'lead_time_days' => 3, - 'minimum_order_amount' => 1000.00, + 'supplier_code' => 'SUP-00010', + 'name' => 'Kverneland Group Italia', + 'legal_name' => 'Kverneland Group Italia S.p.A.', + 'tax_id' => 'IT12345678901', + 'email' => 'ricambi@kverneland.it', + 'phone' => '+39-0442-632111', + 'website' => 'https://kvernelandgroup.com', + 'address' => 'Via Masotto 122', + 'city' => 'Legnago', + 'state' => 'Veneto', + 'country' => 'Italy', + 'postal_code' => '37045', + 'contact_person' => 'Marco Rossi', + 'contact_email' => 'm.rossi@kverneland.it', + 'contact_phone' => '+39-335-1234567', + 'currency' => 'EUR', + 'payment_terms_days' => 45, + 'credit_limit' => 350000.00, + 'lead_time_days' => 12, + 'minimum_order_amount' => 2500.00, + 'rating' => 4, + 'is_active' => true, + 'created_by' => $user->id, + 'notes' => 'Forage equipment, balers, mowers. Kubota group.', + ], + [ + 'company_id' => $company->id, + 'supplier_code' => 'SUP-00011', + 'name' => 'Gates Europe', + 'legal_name' => 'Gates Europe BVBA', + 'tax_id' => 'BE0420449669', + 'email' => 'agri@gates.com', + 'phone' => '+32-2-5560211', + 'website' => 'https://gates.com/eu', + 'address' => 'Dr. Carlierlaan 30', + 'city' => 'Erembodegem', + 'state' => 'Oost-Vlaanderen', + 'country' => 'Belgium', + 'postal_code' => '9320', + 'contact_person' => 'Philippe Dubois', + 'contact_email' => 'p.dubois@gates.com', + 'contact_phone' => '+32-475-123456', + 'currency' => 'EUR', + 'payment_terms_days' => 30, + 'credit_limit' => 100000.00, + 'lead_time_days' => 5, + 'minimum_order_amount' => 750.00, + 'rating' => 5, + 'is_active' => true, + 'created_by' => $user->id, + 'notes' => 'Belts, hoses, hydraulic fittings. Fast delivery.', + ], + [ + 'company_id' => $company->id, + 'supplier_code' => 'SUP-00012', + 'name' => 'SSAB Europe Oy', + 'legal_name' => 'SSAB Europe Oy', + 'tax_id' => 'FI01234567', + 'email' => 'hardox@ssab.com', + 'phone' => '+358-20-5931000', + 'website' => 'https://ssab.com', + 'address' => 'Harvialantie 420', + 'city' => 'Hämeenlinna', + 'state' => 'Kanta-Häme', + 'country' => 'Finland', + 'postal_code' => '13300', + 'contact_person' => 'Mikko Virtanen', + 'contact_email' => 'm.virtanen@ssab.com', + 'contact_phone' => '+358-40-1234567', + 'currency' => 'EUR', + 'payment_terms_days' => 30, + 'credit_limit' => 300000.00, + 'lead_time_days' => 14, + 'minimum_order_amount' => 5000.00, 'rating' => 5, 'is_active' => true, 'created_by' => $user->id, + 'notes' => 'Hardox, Strenx, Domex steel. Wear-resistant materials.', ], ]; @@ -156,48 +381,77 @@ public function run(): void Supplier::create($supplierData); } - // Attach some products to suppliers - $products = Product::where('company_id', $company->id)->limit(5)->get(); - $suppliers = Supplier::where('company_id', $company->id)->get(); - - if ($products->count() > 0 && $suppliers->count() > 0) { - // Attach first 3 products to first supplier - $suppliers[0]->products()->attach($products[0]->id, [ - 'supplier_sku' => 'TC-' . $products[0]->sku, - 'unit_price' => 10.50, - 'currency' => 'USD', - 'minimum_order_qty' => 100, - 'lead_time_days' => 5, - 'is_preferred' => true, - 'is_active' => true, - ]); + // Attach products to suppliers based on their specialty + $this->attachProductsToSuppliers($company); - if (isset($products[1])) { - $suppliers[0]->products()->attach($products[1]->id, [ - 'supplier_sku' => 'TC-' . $products[1]->sku, - 'unit_price' => 25.00, - 'currency' => 'USD', + // Info message moved to createSuppliersForCompany method + } + + /** + * Attach products to suppliers based on category/specialty + */ + private function attachProductsToSuppliers(Company $company): void + { + $suppliers = Supplier::where('company_id', $company->id)->get()->keyBy('supplier_code'); + + // Get products by category slugs + $steelProducts = Product::where('company_id', $company->id) + ->whereHas('categories', fn($q) => $q->where('slug', 'steel-metals')) + ->limit(10)->get(); + + $hydraulicProducts = Product::where('company_id', $company->id) + ->whereHas('categories', fn($q) => $q->where('slug', 'hydraulic-components')) + ->limit(10)->get(); + + $bearingProducts = Product::where('company_id', $company->id) + ->whereHas('categories', fn($q) => $q->where('slug', 'bearings-seals')) + ->limit(10)->get(); + + // Dutch Steel Industries - Steel products + if (isset($suppliers['SUP-00001']) && $steelProducts->isNotEmpty()) { + foreach ($steelProducts as $index => $product) { + $suppliers['SUP-00001']->products()->attach($product->id, [ + 'supplier_sku' => 'DSI-' . $product->sku, + 'unit_price' => $product->cost_price * 0.95, + 'currency' => 'EUR', 'minimum_order_qty' => 50, - 'lead_time_days' => 7, + 'lead_time_days' => 5, + 'is_preferred' => $index === 0, + 'is_active' => true, + ]); + } + } + + // Hydrauliek Centrum - Hydraulic components + if (isset($suppliers['SUP-00002']) && $hydraulicProducts->isNotEmpty()) { + foreach ($hydraulicProducts as $index => $product) { + $suppliers['SUP-00002']->products()->attach($product->id, [ + 'supplier_sku' => 'HCN-' . $product->sku, + 'unit_price' => $product->cost_price * 0.92, + 'currency' => 'EUR', + 'minimum_order_qty' => 10, + 'lead_time_days' => 3, 'is_preferred' => true, 'is_active' => true, ]); } + } - // Attach same products to second supplier with different prices - if (isset($suppliers[1])) { - $suppliers[1]->products()->attach($products[0]->id, [ - 'supplier_sku' => 'GE-' . $products[0]->sku, - 'unit_price' => 11.00, - 'currency' => 'USD', - 'minimum_order_qty' => 200, - 'lead_time_days' => 10, - 'is_preferred' => false, + // SKF - Bearings + if (isset($suppliers['SUP-00009']) && $bearingProducts->isNotEmpty()) { + foreach ($bearingProducts as $index => $product) { + $suppliers['SUP-00009']->products()->attach($product->id, [ + 'supplier_sku' => 'SKF-' . $product->sku, + 'unit_price' => $product->cost_price * 0.90, + 'currency' => 'EUR', + 'minimum_order_qty' => 25, + 'lead_time_days' => 3, + 'is_preferred' => true, 'is_active' => true, ]); } } - $this->command->info('Suppliers seeded successfully!'); + $this->command->info("Suppliers seeded for {$company->name}: " . count($suppliers) . " suppliers"); } } diff --git a/backend/database/seeders/Traits/SeederModeTrait.php b/backend/database/seeders/Traits/SeederModeTrait.php new file mode 100644 index 0000000..0a52069 --- /dev/null +++ b/backend/database/seeders/Traits/SeederModeTrait.php @@ -0,0 +1,43 @@ +runningInConsole()) { + // Check if --demo flag was passed + $argv = $_SERVER['argv'] ?? []; + if (in_array('--demo', $argv)) { + return true; + } + } + + return env('SEED_MODE', 'minimal') === 'demo'; + } + + /** + * Check if running in minimal mode (system essentials only) + */ + protected function isMinimalMode(): bool + { + return !$this->isDemoMode(); + } + + /** + * Output mode information + */ + protected function outputModeInfo(): void + { + $mode = $this->isDemoMode() ? 'DEMO' : 'MINIMAL'; + $this->command?->info("Running in {$mode} mode"); + } +} diff --git a/backend/database/seeders/UnitOfMeasureSeeder.php b/backend/database/seeders/UnitOfMeasureSeeder.php index 556e6f5..322673c 100644 --- a/backend/database/seeders/UnitOfMeasureSeeder.php +++ b/backend/database/seeders/UnitOfMeasureSeeder.php @@ -235,6 +235,114 @@ public function run(): void 'precision' => 2, 'is_active' => true, ], + [ + 'company_id' => $companyId, + 'code' => 'ha', + 'name' => 'Hectare', + 'uom_type' => 'area', + 'base_unit_id' => $createdUnits['m2'], + 'conversion_factor' => 10000, + 'precision' => 2, + 'is_active' => true, + ], + + // Power units (for agricultural machinery) + [ + 'company_id' => $companyId, + 'code' => 'hp', + 'name' => 'Horsepower', + 'uom_type' => 'power', + 'base_unit_id' => null, + 'conversion_factor' => 1, + 'precision' => 0, + 'is_active' => true, + ], + [ + 'company_id' => $companyId, + 'code' => 'kW', + 'name' => 'Kilowatt', + 'uom_type' => 'power', + 'base_unit_id' => null, + 'conversion_factor' => 1.341, // 1 kW = 1.341 HP + 'precision' => 2, + 'is_active' => true, + ], + + // Speed/Flow units (for machinery) + [ + 'company_id' => $companyId, + 'code' => 'rpm', + 'name' => 'Revolutions per Minute', + 'uom_type' => 'speed', + 'base_unit_id' => null, + 'conversion_factor' => 1, + 'precision' => 0, + 'is_active' => true, + ], + [ + 'company_id' => $companyId, + 'code' => 'L/min', + 'name' => 'Liters per Minute', + 'uom_type' => 'flow', + 'base_unit_id' => null, + 'conversion_factor' => 1, + 'precision' => 1, + 'is_active' => true, + ], + + // Time units (for service/warranty) + [ + 'company_id' => $companyId, + 'code' => 'hr', + 'name' => 'Hour', + 'uom_type' => 'time', + 'base_unit_id' => null, + 'conversion_factor' => 1, + 'precision' => 0, + 'is_active' => true, + ], + + // Set units (for machinery sets/kits) + [ + 'company_id' => $companyId, + 'code' => 'set', + 'name' => 'Set', + 'uom_type' => 'quantity', + 'base_unit_id' => $createdUnits['pcs'], + 'conversion_factor' => 1, + 'precision' => 0, + 'is_active' => true, + ], + [ + 'company_id' => $companyId, + 'code' => 'pair', + 'name' => 'Pair', + 'uom_type' => 'quantity', + 'base_unit_id' => $createdUnits['pcs'], + 'conversion_factor' => 2, + 'precision' => 0, + 'is_active' => true, + ], + [ + 'company_id' => $companyId, + 'code' => 'pallet', + 'name' => 'Pallet', + 'uom_type' => 'quantity', + 'base_unit_id' => $createdUnits['pcs'], + 'conversion_factor' => 1, // Product-specific, default 1 + 'precision' => 0, + 'is_active' => true, + ], + [ + 'company_id' => $companyId, + 'code' => 'drum', + 'name' => 'Drum', + 'uom_type' => 'quantity', + 'base_unit_id' => $createdUnits['pcs'], + 'conversion_factor' => 1, // Product-specific, default 1 + 'precision' => 0, + 'is_active' => true, + ], ]; foreach ($derivedUnits as $unitData) { diff --git a/backend/database/seeders/UserSeeder.php b/backend/database/seeders/UserSeeder.php index 2984712..112716f 100644 --- a/backend/database/seeders/UserSeeder.php +++ b/backend/database/seeders/UserSeeder.php @@ -15,67 +15,161 @@ class UserSeeder extends Seeder */ public function run(): void { - // Get default company - $company = Company::first(); + // Get all companies + $companies = Company::all(); - // Create Admin User - $admin = User::firstOrCreate( - ['email' => 'admin@example.com'], - [ - 'company_id' => $company?->id, - 'first_name' => 'Admin', - 'last_name' => 'User', - 'password' => Hash::make('password'), - 'email_verified_at' => now(), - ] - ); + if ($companies->isEmpty()) { + $this->command->error('No companies found! Please run CompanySeeder first.'); + return; + } - // Assign Admin role + // Get roles $adminRole = Role::where('name', 'admin')->first(); - if ($adminRole && !$admin->roles->contains($adminRole->id)) { - $admin->roles()->attach($adminRole); - } + $managerRole = Role::where('name', 'manager')->first(); + $staffRole = Role::where('name', 'staff')->first(); - $this->command->info('Admin user created: admin@example.com / password'); + // Create users for each company + foreach ($companies as $index => $company) { + $companyNumber = $index + 1; - // Create Manager User - $manager = User::firstOrCreate( - ['email' => 'manager@example.com'], - [ - 'company_id' => $company?->id, - 'first_name' => 'Manager', - 'last_name' => 'User', - 'password' => Hash::make('password'), - 'email_verified_at' => now(), - ] - ); + // Create Admin User for this company + $admin = User::firstOrCreate( + ['email' => "admin{$companyNumber}@example.com"], + [ + 'company_id' => $company->id, + 'first_name' => 'Admin', + 'last_name' => $company->name, + 'password' => Hash::make('password'), + 'email_verified_at' => now(), + ] + ); - // Assign Manager role (if exists) - $managerRole = Role::where('name', 'manager')->first(); - if ($managerRole && !$manager->roles->contains($managerRole->id)) { - $manager->roles()->attach($managerRole); + // Assign Admin role + if ($adminRole && !$admin->roles->contains($adminRole->id)) { + $admin->roles()->attach($adminRole); + } + + $this->command->info("Admin user created for {$company->name}: admin{$companyNumber}@example.com / password"); + + // Create Manager User for this company + if ($managerRole) { + $manager = User::firstOrCreate( + ['email' => "manager{$companyNumber}@example.com"], + [ + 'company_id' => $company->id, + 'first_name' => 'Manager', + 'last_name' => $company->name, + 'password' => Hash::make('password'), + 'email_verified_at' => now(), + ] + ); + + if (!$manager->roles->contains($managerRole->id)) { + $manager->roles()->attach($managerRole); + } + + $this->command->info("Manager user created for {$company->name}: manager{$companyNumber}@example.com / password"); + } + + // Create Staff User for this company + if ($staffRole) { + $staff = User::firstOrCreate( + ['email' => "staff{$companyNumber}@example.com"], + [ + 'company_id' => $company->id, + 'first_name' => 'Staff', + 'last_name' => $company->name, + 'password' => Hash::make('password'), + 'email_verified_at' => now(), + ] + ); + + if (!$staff->roles->contains($staffRole->id)) { + $staff->roles()->attach($staffRole); + } + + $this->command->info("Staff user created for {$company->name}: staff{$companyNumber}@example.com / password"); + } } - $this->command->info('Manager user created: manager@example.com / password'); + // Keep backward compatibility: Create users with original emails for first company + $firstCompany = $companies->first(); + if ($firstCompany) { + // Admin User (original email) + $admin = User::firstOrCreate( + ['email' => 'admin@example.com'], + [ + 'company_id' => $firstCompany->id, + 'first_name' => 'Admin', + 'last_name' => 'User', + 'password' => Hash::make('password'), + 'email_verified_at' => now(), + ] + ); - // Create Staff User - $staff = User::firstOrCreate( - ['email' => 'staff@example.com'], + if ($adminRole && !$admin->roles->contains($adminRole->id)) { + $admin->roles()->attach($adminRole); + } + + // Manager User (original email) + if ($managerRole) { + $manager = User::firstOrCreate( + ['email' => 'manager@example.com'], + [ + 'company_id' => $firstCompany->id, + 'first_name' => 'Manager', + 'last_name' => 'User', + 'password' => Hash::make('password'), + 'email_verified_at' => now(), + ] + ); + + if (!$manager->roles->contains($managerRole->id)) { + $manager->roles()->attach($managerRole); + } + } + + // Staff User (original email) + if ($staffRole) { + $staff = User::firstOrCreate( + ['email' => 'staff@example.com'], + [ + 'company_id' => $firstCompany->id, + 'first_name' => 'Staff', + 'last_name' => 'User', + 'password' => Hash::make('password'), + 'email_verified_at' => now(), + ] + ); + + if (!$staff->roles->contains($staffRole->id)) { + $staff->roles()->attach($staffRole); + } + } + } + + // Create Platform Admin User + // Platform admin has company_id = null (not tied to any company) + // This allows them to see and manage all companies + $platformAdmin = User::firstOrCreate( + ['email' => 'platform@example.com'], [ - 'company_id' => $company?->id, - 'first_name' => 'Staff', - 'last_name' => 'User', + 'company_id' => null, // Platform admin is not tied to any company + 'first_name' => 'Platform', + 'last_name' => 'Administrator', 'password' => Hash::make('password'), 'email_verified_at' => now(), + 'is_active' => true, ] ); - // Assign Staff role - $staffRole = Role::where('name', 'staff')->first(); - if ($staffRole && !$staff->roles->contains($staffRole->id)) { - $staff->roles()->attach($staffRole); + // Assign Platform Admin role + $platformAdminRole = Role::where('name', 'platform_admin')->first(); + if ($platformAdminRole && !$platformAdmin->roles->contains($platformAdminRole->id)) { + $platformAdmin->roles()->attach($platformAdminRole); } - $this->command->info('Staff user created: staff@example.com / password'); + $this->command->info('Platform Admin user created: platform@example.com / password'); + $this->command->warn('⚠️ Platform Admin has access to ALL companies. Use with caution!'); } } diff --git a/backend/database/seeders/WarehouseSeeder.php b/backend/database/seeders/WarehouseSeeder.php index 6fa93de..12ecaa3 100644 --- a/backend/database/seeders/WarehouseSeeder.php +++ b/backend/database/seeders/WarehouseSeeder.php @@ -10,118 +10,322 @@ class WarehouseSeeder extends Seeder { /** * Run the database seeds. + * + * Agricultural Machinery Warehouses in Netherlands */ public function run(): void { - $company = Company::first(); - $companyId = $company?->id; + // Get all companies + $companies = Company::all(); + + if ($companies->isEmpty()) { + $this->command->error('No companies found! Please run CompanySeeder first.'); + return; + } + + // Create warehouses for each company + foreach ($companies as $company) { + $this->createWarehousesForCompany($company); + } + + $this->command->info('Warehouses seeded for ' . $companies->count() . ' companies'); + } + + private function createWarehousesForCompany($company): void + { + $companyId = $company->id; $warehouses = [ + // ======================================== + // MAIN FACILITIES + // ======================================== [ 'company_id' => $companyId, - 'name' => 'Main Warehouse', + 'name' => 'Hoofdmagazijn Rotterdam', 'code' => 'WH-MAIN', 'warehouse_type' => 'finished_goods', - 'address' => '123 Industrial Park, Building A', - 'city' => 'New York', - 'country' => 'USA', - 'postal_code' => '10001', - 'contact_phone' => '+1-555-0101', - 'contact_email' => 'main-warehouse@demo-company.com', - 'contact_person' => 'John Smith', + 'address' => 'Europaweg 245', + 'city' => 'Rotterdam', + 'country' => 'Netherlands', + 'postal_code' => '3199 LC', + 'contact_phone' => '+31-10-1234567', + 'contact_email' => 'hoofdmagazijn@agritech-nl.com', + 'contact_person' => 'Willem van der Berg', 'is_active' => true, 'is_default' => true, - 'settings' => ['capacity' => 10000], + 'settings' => [ + 'capacity' => 15000, + 'area_sqm' => 8500, + 'loading_docks' => 12, + 'forklift_capacity' => '10 ton', + ], + ], + [ + 'company_id' => $companyId, + 'name' => 'Production Plant Eindhoven', + 'code' => 'WH-PROD', + 'warehouse_type' => 'wip', + 'address' => 'Industrielaan 78', + 'city' => 'Eindhoven', + 'country' => 'Netherlands', + 'postal_code' => '5651 GH', + 'contact_phone' => '+31-40-2345678', + 'contact_email' => 'productie@agritech-nl.com', + 'contact_person' => 'Pieter de Vries', + 'is_active' => true, + 'is_default' => false, + 'settings' => [ + 'capacity' => 8000, + 'area_sqm' => 12000, + 'assembly_lines' => 4, + 'paint_booth' => true, + ], + ], + + // ======================================== + // RAW MATERIALS STORAGE + // ======================================== + [ + 'company_id' => $companyId, + 'name' => 'Steel & Metals Storage', + 'code' => 'WH-STEEL', + 'warehouse_type' => 'raw_materials', + 'address' => 'Havenweg 156', + 'city' => 'Rotterdam', + 'country' => 'Netherlands', + 'postal_code' => '3089 JK', + 'contact_phone' => '+31-10-3456789', + 'contact_email' => 'staal@agritech-nl.com', + 'contact_person' => 'Henk Janssen', + 'is_active' => true, + 'is_default' => false, + 'settings' => [ + 'capacity' => 5000, + 'area_sqm' => 4000, + 'crane_capacity' => '20 ton', + 'covered' => true, + ], + ], + [ + 'company_id' => $companyId, + 'name' => 'Components Warehouse', + 'code' => 'WH-COMP', + 'warehouse_type' => 'raw_materials', + 'address' => 'Techniekweg 45', + 'city' => 'Tilburg', + 'country' => 'Netherlands', + 'postal_code' => '5026 RM', + 'contact_phone' => '+31-13-4567890', + 'contact_email' => 'componenten@agritech-nl.com', + 'contact_person' => 'Johan Bakker', + 'is_active' => true, + 'is_default' => false, + 'settings' => [ + 'capacity' => 6000, + 'area_sqm' => 3500, + 'climate_controlled' => true, + 'shelving_racks' => 450, + ], ], + + // ======================================== + // DISTRIBUTION CENTERS + // ======================================== [ 'company_id' => $companyId, - 'name' => 'Distribution Center East', - 'code' => 'DC-EAST', + 'name' => 'Distribution Center North', + 'code' => 'DC-NORTH', 'warehouse_type' => 'finished_goods', - 'address' => '456 Logistics Avenue', - 'city' => 'Boston', - 'country' => 'USA', - 'postal_code' => '02101', - 'contact_phone' => '+1-555-0102', - 'contact_email' => 'dc-east@demo-company.com', - 'contact_person' => 'Sarah Johnson', + 'address' => 'Noorderweg 89', + 'city' => 'Groningen', + 'country' => 'Netherlands', + 'postal_code' => '9723 CK', + 'contact_phone' => '+31-50-5678901', + 'contact_email' => 'dc-noord@agritech-nl.com', + 'contact_person' => 'Klaas Hoekstra', 'is_active' => true, 'is_default' => false, - 'settings' => ['capacity' => 5000], + 'settings' => [ + 'capacity' => 4000, + 'area_sqm' => 2500, + 'loading_docks' => 6, + 'region' => 'North Netherlands', + ], ], [ 'company_id' => $companyId, - 'name' => 'Distribution Center West', - 'code' => 'DC-WEST', + 'name' => 'Distribution Center South', + 'code' => 'DC-SOUTH', 'warehouse_type' => 'finished_goods', - 'address' => '789 Commerce Street', - 'city' => 'Los Angeles', - 'country' => 'USA', - 'postal_code' => '90001', - 'contact_phone' => '+1-555-0103', - 'contact_email' => 'dc-west@demo-company.com', - 'contact_person' => 'Mike Davis', + 'address' => 'Maasweg 234', + 'city' => 'Maastricht', + 'country' => 'Netherlands', + 'postal_code' => '6214 PP', + 'contact_phone' => '+31-43-6789012', + 'contact_email' => 'dc-zuid@agritech-nl.com', + 'contact_person' => 'Frans Willems', 'is_active' => true, 'is_default' => false, - 'settings' => ['capacity' => 5000], + 'settings' => [ + 'capacity' => 3500, + 'area_sqm' => 2000, + 'loading_docks' => 4, + 'region' => 'South Netherlands / Belgium', + ], ], + + // ======================================== + // SPARE PARTS CENTER + // ======================================== [ 'company_id' => $companyId, - 'name' => 'Raw Materials Storage', - 'code' => 'WH-RAW', + 'name' => 'Spare Parts Center', + 'code' => 'WH-PARTS', + 'warehouse_type' => 'finished_goods', + 'address' => 'Serviceweg 12', + 'city' => 'Utrecht', + 'country' => 'Netherlands', + 'postal_code' => '3542 AD', + 'contact_phone' => '+31-30-7890123', + 'contact_email' => 'onderdelen@agritech-nl.com', + 'contact_person' => 'Marianne Smit', + 'is_active' => true, + 'is_default' => false, + 'settings' => [ + 'capacity' => 25000, + 'area_sqm' => 3000, + 'bin_locations' => 15000, + 'automated_picking' => true, + ], + ], + + // ======================================== + // QUALITY CONTROL ZONES + // ======================================== + [ + 'company_id' => $companyId, + 'name' => 'Incoming Inspection Zone', + 'code' => 'QZ-INCOMING', 'warehouse_type' => 'raw_materials', - 'address' => '321 Supply Chain Road', - 'city' => 'Chicago', - 'country' => 'USA', - 'postal_code' => '60601', - 'contact_phone' => '+1-555-0104', - 'contact_email' => 'raw-materials@demo-company.com', - 'contact_person' => 'Emily Brown', + 'address' => 'Kwaliteitsweg 1', + 'city' => 'Eindhoven', + 'country' => 'Netherlands', + 'postal_code' => '5651 GJ', + 'contact_phone' => '+31-40-8901234', + 'contact_email' => 'qc-incoming@agritech-nl.com', + 'contact_person' => 'QC Team', 'is_active' => true, 'is_default' => false, - 'settings' => ['capacity' => 3000], + 'is_quarantine_zone' => true, + 'is_rejection_zone' => false, + 'settings' => [ + 'capacity' => 1500, + 'purpose' => 'Hold incoming materials pending QC inspection', + 'inspection_bays' => 4, + ], ], [ 'company_id' => $companyId, - 'name' => 'Returns Processing Center', - 'code' => 'WH-RET', + 'name' => 'Final Inspection Zone', + 'code' => 'QZ-FINAL', + 'warehouse_type' => 'finished_goods', + 'address' => 'Kwaliteitsweg 2', + 'city' => 'Eindhoven', + 'country' => 'Netherlands', + 'postal_code' => '5651 GJ', + 'contact_phone' => '+31-40-8901235', + 'contact_email' => 'qc-final@agritech-nl.com', + 'contact_person' => 'QC Team', + 'is_active' => true, + 'is_default' => false, + 'is_quarantine_zone' => true, + 'is_rejection_zone' => false, + 'settings' => [ + 'capacity' => 500, + 'purpose' => 'Hold finished machinery pending final inspection', + 'test_area' => true, + ], + ], + [ + 'company_id' => $companyId, + 'name' => 'Rejection & NCR Zone', + 'code' => 'RZ-NCR', 'warehouse_type' => 'returns', - 'address' => '555 Return Lane', - 'city' => 'Dallas', - 'country' => 'USA', - 'postal_code' => '75201', - 'contact_phone' => '+1-555-0105', - 'contact_email' => 'returns@demo-company.com', - 'contact_person' => 'Tom Wilson', + 'address' => 'Kwaliteitsweg 3', + 'city' => 'Eindhoven', + 'country' => 'Netherlands', + 'postal_code' => '5651 GK', + 'contact_phone' => '+31-40-8901236', + 'contact_email' => 'ncr@agritech-nl.com', + 'contact_person' => 'QC Team', 'is_active' => true, 'is_default' => false, - 'settings' => ['capacity' => 2000], + 'is_quarantine_zone' => false, + 'is_rejection_zone' => true, + 'settings' => [ + 'capacity' => 800, + 'purpose' => 'Hold non-conforming items for disposition', + 'segregation_zones' => 3, + ], ], + + // ======================================== + // SERVICE & RETURNS + // ======================================== [ 'company_id' => $companyId, - 'name' => 'Work in Progress Facility', - 'code' => 'WH-WIP', - 'warehouse_type' => 'wip', - 'address' => '777 Manufacturing Blvd', - 'city' => 'Detroit', - 'country' => 'USA', - 'postal_code' => '48201', - 'contact_phone' => '+1-555-0106', - 'contact_email' => 'wip@demo-company.com', - 'contact_person' => 'Lisa Anderson', + 'name' => 'Service & Returns Center', + 'code' => 'WH-SERVICE', + 'warehouse_type' => 'returns', + 'address' => 'Servicepark 56', + 'city' => 'Amersfoort', + 'country' => 'Netherlands', + 'postal_code' => '3824 MP', + 'contact_phone' => '+31-33-9012345', + 'contact_email' => 'service@agritech-nl.com', + 'contact_person' => 'Erik van Dam', 'is_active' => true, 'is_default' => false, - 'settings' => ['capacity' => 4000], + 'settings' => [ + 'capacity' => 2000, + 'area_sqm' => 1500, + 'repair_bays' => 8, + 'purpose' => 'Warranty repairs and customer returns', + ], ], ]; foreach ($warehouses as $warehouseData) { + // Make code unique per company + $uniqueCode = $warehouseData['code'] . '-' . $company->id; Warehouse::firstOrCreate( - ['code' => $warehouseData['code'], 'company_id' => $companyId], - $warehouseData + ['code' => $uniqueCode, 'company_id' => $companyId], + array_merge($warehouseData, ['code' => $uniqueCode]) ); } - $this->command->info('Warehouses seeded: ' . count($warehouses) . ' warehouses'); + // Link QC zones to production warehouse + $prodWarehouse = Warehouse::where('code', 'WH-PROD-' . $company->id)->where('company_id', $companyId)->first(); + $incomingQZ = Warehouse::where('code', 'QZ-INCOMING-' . $company->id)->where('company_id', $companyId)->first(); + $finalQZ = Warehouse::where('code', 'QZ-FINAL-' . $company->id)->where('company_id', $companyId)->first(); + $rejectionZone = Warehouse::where('code', 'RZ-NCR-' . $company->id)->where('company_id', $companyId)->first(); + + if ($prodWarehouse && $incomingQZ && $rejectionZone) { + $prodWarehouse->update([ + 'linked_quarantine_warehouse_id' => $incomingQZ->id, + 'linked_rejection_warehouse_id' => $rejectionZone->id, + ]); + } + + // Link QC zones to main warehouse + $mainWarehouse = Warehouse::where('code', 'WH-MAIN-' . $company->id)->where('company_id', $companyId)->first(); + if ($mainWarehouse && $finalQZ && $rejectionZone) { + $mainWarehouse->update([ + 'linked_quarantine_warehouse_id' => $finalQZ->id, + 'linked_rejection_warehouse_id' => $rejectionZone->id, + ]); + } + + $this->command->info("Warehouses seeded for {$company->name}: " . count($warehouses) . " locations (including QC zones)"); } } diff --git a/backend/resources/views/emails/user-invitation.blade.php b/backend/resources/views/emails/user-invitation.blade.php new file mode 100644 index 0000000..8a52af3 --- /dev/null +++ b/backend/resources/views/emails/user-invitation.blade.php @@ -0,0 +1,124 @@ + + + + + + Invitation to Join {{ $companyName }} + + + +
+
+

You're Invited!

+
+ +
+

Hello,

+ +

{{ $inviterName }} has invited you to join {{ $companyName }} on our Smart Stock Management platform.

+ +
+

Email: {{ $email }}

+

Company: {{ $companyName }}

+

Invited by: {{ $inviterName }}

+
+ +

To accept this invitation, please click the button below. You will need to provide:

+ +
+

📝 Required Information:

+

✓ First Name

+

✓ Last Name

+

✓ Password (minimum 8 characters)

+

✓ Password Confirmation

+
+ + + +

Or copy and paste this link into your browser:

+

{{ $acceptUrl }}

+ +
+

⚠️ Important: This invitation will expire on {{ $expiresAt }}.

+

If you don't accept the invitation before it expires, you'll need to request a new one.

+
+ +

If you didn't expect this invitation, you can safely ignore this email.

+
+ + +
+ + diff --git a/backend/routes/api.php b/backend/routes/api.php index 34fd481..7abe02b 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -1,27 +1,29 @@ json([ "message" => "API is working", @@ -37,326 +39,60 @@ ); }); -// Public Authentication Routes -Route::prefix('auth')->group(function () { - Route::post('/register', [AuthController::class, 'register']); - Route::post('/login', [AuthController::class, 'login']); - Route::post('/forgot-password', [AuthController::class, 'forgotPassword']); - Route::post('/reset-password', [AuthController::class, 'resetPassword']); -}); - -// Protected Routes (require authentication) -Route::middleware('auth:sanctum')->group(function () { - // Auth routes - Route::prefix('auth')->group(function () { - Route::post('/logout', [AuthController::class, 'logout']); - Route::get('/me', [AuthController::class, 'me']); - Route::post('/refresh', [AuthController::class, 'refresh']); - }); - - // User management routes (permission-based) - Route::prefix('users')->group(function () { - Route::get('/', [UserController::class, 'index'])->middleware('permission:users.view'); - Route::post('/', [UserController::class, 'store'])->middleware('permission:users.create'); - Route::get('/{user}', [UserController::class, 'show'])->middleware('permission:users.view'); - Route::put('/{user}', [UserController::class, 'update'])->middleware('permission:users.edit'); - Route::delete('/{user}', [UserController::class, 'destroy'])->middleware('permission:users.delete'); - Route::post('/{id}/restore', [UserController::class, 'restore'])->middleware('permission:users.delete'); - Route::delete('/{id}/force', [UserController::class, 'forceDelete'])->middleware('role:admin'); - }); - - // Role management routes (Admin only) - Route::middleware('role:admin')->group(function () { - Route::apiResource('roles', RoleController::class); - Route::post('roles/{role}/permissions/assign', [RoleController::class, 'assignPermissions']); - Route::post('roles/{role}/permissions/revoke', [RoleController::class, 'revokePermissions']); - - // Permission management routes (Admin only) - Route::apiResource('permissions', PermissionController::class); - Route::get('permissions/modules/list', [PermissionController::class, 'modules']); - }); - - // Category routes (permission-based) - Route::prefix('categories')->group(function () { - Route::get('/', [CategoryController::class, 'index'])->middleware('permission:categories.view'); - Route::post('/', [CategoryController::class, 'store'])->middleware('permission:categories.create'); - Route::get('/{category}', [CategoryController::class, 'show'])->middleware('permission:categories.view'); - Route::put('/{category}', [CategoryController::class, 'update'])->middleware('permission:categories.edit'); - Route::delete('/{category}', [CategoryController::class, 'destroy'])->middleware('permission:categories.delete'); - - // Category attribute management - Route::get('/{category}/attributes', [CategoryController::class, 'getAttributes'])->middleware('permission:categories.view'); - Route::post('/{category}/attributes', [CategoryController::class, 'assignAttributes'])->middleware('permission:categories.edit'); - Route::put('/{category}/attributes/{attribute}', [CategoryController::class, 'updateAttribute'])->middleware('permission:categories.edit'); - Route::delete('/{category}/attributes/{attribute}', [CategoryController::class, 'removeAttribute'])->middleware('permission:categories.edit'); - }); - - // Product routes (permission-based) - Route::prefix('products')->group(function () { - Route::get('/', [ProductController::class, 'index'])->middleware('permission:products.view'); - Route::post('/', [ProductController::class, 'store'])->middleware('permission:products.create'); - - // Search endpoints - multiple URL options for flexibility - // All accept: ?search=, ?query=, or ?q= parameters - Route::get('/search', [ProductController::class, 'search'])->middleware('permission:products.view'); - Route::get('/query', [ProductController::class, 'search'])->middleware('permission:products.view'); - Route::get('/find', [ProductController::class, 'search'])->middleware('permission:products.view'); - - Route::get('/{product}', [ProductController::class, 'show'])->middleware('permission:products.view'); - Route::put('/{product}', [ProductController::class, 'update'])->middleware('permission:products.edit'); - Route::delete('/{product}', [ProductController::class, 'destroy'])->middleware('permission:products.delete'); - Route::post('/{id}/restore', [ProductController::class, 'restore'])->middleware('permission:products.delete'); - - // Product image routes - Route::post('/{product}/images', [ProductImageController::class, 'upload'])->middleware('permission:products.edit'); - Route::put('/{product}/images/{image}', [ProductImageController::class, 'update'])->middleware('permission:products.edit'); - Route::delete('/{product}/images/{image}', [ProductImageController::class, 'destroy'])->middleware('permission:products.edit'); - Route::post('/{product}/images/reorder', [ProductImageController::class, 'reorder'])->middleware('permission:products.edit'); - - // Product attribute management - Route::get('/{product}/attributes', [ProductController::class, 'getAttributes'])->middleware('permission:products.view'); - Route::post('/{product}/attributes', [ProductController::class, 'assignAttributes'])->middleware('permission:products.edit'); - Route::put('/{product}/attributes/{attribute}', [ProductController::class, 'updateAttribute'])->middleware('permission:products.edit'); - Route::delete('/{product}/attributes/{attribute}', [ProductController::class, 'removeAttribute'])->middleware('permission:products.edit'); - - // Product variant management - Route::get('/{product}/variants', [ProductController::class, 'getVariants'])->middleware('permission:products.view'); - Route::post('/{product}/variants', [ProductController::class, 'createVariant'])->middleware('permission:products.edit'); - - // Automatic variant generation (rate limited: 10 requests per minute) - // NOTE: These specific routes MUST come BEFORE the {variant} parameter routes - Route::post('/{product}/variants/generate', [AttributeController::class, 'generateVariants']) - ->middleware(['permission:products.edit', 'throttle:variant-generate']); - Route::post('/{product}/variants/expand', [AttributeController::class, 'expandVariants']) - ->middleware(['permission:products.edit', 'throttle:variant-generate']); - Route::delete('/{product}/variants/clear', [AttributeController::class, 'clearVariants']) - ->middleware('permission:products.edit'); - - // Variant CRUD with ID parameter (must come AFTER specific routes like /generate and /clear) - Route::put('/{product}/variants/{variant}', [ProductController::class, 'updateVariant'])->middleware('permission:products.edit'); - Route::delete('/{product}/variants/{variant}', [ProductController::class, 'deleteVariant'])->middleware('permission:products.edit'); - - // Force delete variants (Admin only - permanent deletion) - Route::delete('/{product}/variants/{variant}/force', [ProductController::class, 'forceDeleteVariant'])->middleware('role:admin'); - Route::delete('/{product}/variants/force-clear', [AttributeController::class, 'forceClearVariants'])->middleware('role:admin'); - }); - - // Product types routes (permission-based) - Route::prefix('producttypes')->group(function () { - Route::get('/', [ProductTypeController::class, 'index'])->middleware('permission:producttypes.view'); - Route::get('/{productType}', [ProductTypeController::class, 'show'])->middleware('permission:producttypes.view'); - Route::post('/', [ProductTypeController::class, 'store'])->middleware('permission:producttypes.create'); - Route::put('/{productType}', [ProductTypeController::class, 'update'])->middleware('permission:producttypes.edit'); - Route::delete('/{productType}', [ProductTypeController::class, 'destroy'])->middleware('permission:producttypes.delete'); - }); - // Attribute routes (permission-based) - Route::prefix('attributes')->group(function () { - Route::get('/', [AttributeController::class, 'index'])->middleware('permission:products.view'); - Route::post('/', [AttributeController::class, 'store'])->middleware('permission:products.create'); - Route::get('/{attribute}', [AttributeController::class, 'show'])->middleware('permission:products.view'); - Route::put('/{attribute}', [AttributeController::class, 'update'])->middleware('permission:products.edit'); - Route::delete('/{attribute}', [AttributeController::class, 'destroy'])->middleware('permission:products.delete'); - - // Attribute value management - Route::post('/{attribute}/values', [AttributeController::class, 'addValues'])->middleware('permission:products.edit'); - Route::put('/{attribute}/values/{value}', [AttributeController::class, 'updateValue'])->middleware('permission:products.edit'); - Route::delete('/{attribute}/values/{value}', [AttributeController::class, 'destroyValue'])->middleware('permission:products.edit'); - }); - - // Bulk variant generation (rate limited: 5 requests per minute - heavy operation) - Route::post('/variants/bulk-generate', [AttributeController::class, 'bulkGenerateVariants']) - ->middleware(['permission:products.edit', 'throttle:bulk-variant-generate']); - - // Settings routes (lookup values, system config) - Route::prefix('settings')->group(function () { - Route::get('/', [SettingController::class, 'index'])->middleware('permission:settings.view'); - Route::get('/groups', [SettingController::class, 'groups'])->middleware('permission:settings.view'); - Route::get('/group/{group}', [SettingController::class, 'group'])->middleware('permission:settings.view'); - Route::get('/{group}/{key}', [SettingController::class, 'show'])->middleware('permission:settings.view'); - Route::post('/', [SettingController::class, 'store'])->middleware('permission:settings.edit'); - Route::put('/{group}/{key}', [SettingController::class, 'update'])->middleware('permission:settings.edit'); - Route::delete('/{group}/{key}', [SettingController::class, 'destroy'])->middleware('permission:settings.edit'); - }); +// Clear module cache endpoint (useful after .env changes) +Route::post("/modules/clear-cache", function () { + app(\App\Services\ModuleService::class)->clearCache(); + return response()->json([ + 'message' => 'Module cache cleared successfully', + 'modules' => app(\App\Services\ModuleService::class)->getModuleStatus(), + ]); +})->middleware('auth:sanctum'); - // Currency routes - Route::prefix('currencies')->group(function () { - Route::get('/', [CurrencyController::class, 'index'])->middleware('permission:settings.view'); - Route::get('/active', [CurrencyController::class, 'active'])->middleware('permission:settings.view'); - Route::post('/', [CurrencyController::class, 'store'])->middleware('permission:settings.edit'); - Route::get('/{currency}', [CurrencyController::class, 'show'])->middleware('permission:settings.view'); - Route::put('/{currency}', [CurrencyController::class, 'update'])->middleware('permission:settings.edit'); - Route::delete('/{currency}', [CurrencyController::class, 'destroy'])->middleware('permission:settings.edit'); - Route::post('/{currency}/toggle-active', [CurrencyController::class, 'toggleActive'])->middleware('permission:settings.edit'); +// ============================================================================ +// AUTHENTICATION ROUTES (Public + Protected) +// ============================================================================ +require __DIR__ . '/api/auth.php'; - // Exchange rate management - Route::get('/exchange-rate/get', [CurrencyController::class, 'getExchangeRate'])->middleware('permission:settings.view'); - Route::post('/exchange-rate/set', [CurrencyController::class, 'setExchangeRate'])->middleware('permission:settings.edit'); - Route::get('/exchange-rate/history', [CurrencyController::class, 'exchangeRateHistory'])->middleware('permission:settings.view'); - Route::post('/convert', [CurrencyController::class, 'convert'])->middleware('permission:settings.view'); - }); +// ============================================================================ +// PROTECTED ROUTES (Require Authentication) +// ============================================================================ +Route::middleware('auth:sanctum')->group(function () { - // Warehouse routes - Route::prefix('warehouses')->group(function () { - Route::get('/', [WarehouseController::class, 'index'])->middleware('permission:inventory.view'); - Route::get('/list', [WarehouseController::class, 'list'])->middleware('permission:inventory.view'); - Route::post('/', [WarehouseController::class, 'store'])->middleware('permission:inventory.create'); - Route::get('/{warehouse}', [WarehouseController::class, 'show'])->middleware('permission:inventory.view'); - Route::put('/{warehouse}', [WarehouseController::class, 'update'])->middleware('permission:inventory.edit'); - Route::delete('/{warehouse}', [WarehouseController::class, 'destroy'])->middleware('permission:inventory.delete'); - Route::post('/{warehouse}/toggle-active', [WarehouseController::class, 'toggleActive'])->middleware('permission:inventory.edit'); - Route::post('/{warehouse}/set-default', [WarehouseController::class, 'setDefault'])->middleware('permission:inventory.edit'); - Route::get('/{warehouse}/stock-summary', [WarehouseController::class, 'stockSummary'])->middleware('permission:inventory.view'); - }); + // Core system routes (users, roles, permissions, settings, currencies, UoM) + require __DIR__ . '/api/core.php'; - // Stock routes - Route::prefix('stock')->group(function () { - Route::get('/', [StockController::class, 'index'])->middleware('permission:inventory.view'); - Route::get('/low-stock', [StockController::class, 'lowStock'])->middleware('permission:inventory.view'); - Route::get('/expiring', [StockController::class, 'expiring'])->middleware('permission:inventory.view'); - Route::get('/product/{productId}', [StockController::class, 'productStock'])->middleware('permission:inventory.view'); - Route::get('/warehouse/{warehouseId}', [StockController::class, 'warehouseStock'])->middleware('permission:inventory.view'); + // Product management routes (products, categories, attributes, variants, UOM conversions) + require __DIR__ . '/api/products.php'; - // Stock operations - Route::post('/receive', [StockController::class, 'receive'])->middleware('permission:inventory.create'); - Route::post('/issue', [StockController::class, 'issue'])->middleware('permission:inventory.edit'); - Route::post('/transfer', [StockController::class, 'transfer'])->middleware('permission:inventory.edit'); - Route::post('/adjust', [StockController::class, 'adjust'])->middleware('permission:inventory.edit'); - Route::post('/reserve', [StockController::class, 'reserve'])->middleware('permission:inventory.edit'); - Route::post('/release-reservation', [StockController::class, 'releaseReservation'])->middleware('permission:inventory.edit'); - }); + // Inventory management routes (warehouses, stock, movements) + require __DIR__ . '/api/inventory.php'; - // Stock Movement routes - Route::prefix('stock-movements')->group(function () { - Route::get('/', [StockMovementController::class, 'index'])->middleware('permission:inventory.view'); - Route::get('/summary', [StockMovementController::class, 'summary'])->middleware('permission:inventory.view'); - Route::get('/daily-report', [StockMovementController::class, 'dailyReport'])->middleware('permission:inventory.view'); - Route::get('/audit-trail', [StockMovementController::class, 'auditTrail'])->middleware('permission:inventory.view'); - Route::get('/product/{productId}', [StockMovementController::class, 'productMovements'])->middleware('permission:inventory.view'); - Route::get('/warehouse/{warehouseId}', [StockMovementController::class, 'warehouseMovements'])->middleware('permission:inventory.view'); - Route::get('/types/movement', [StockMovementController::class, 'movementTypes'])->middleware('permission:inventory.view'); - Route::get('/types/transaction', [StockMovementController::class, 'transactionTypes'])->middleware('permission:inventory.view'); - }); + // ======================================================================== + // MODULE-BASED ROUTES + // ======================================================================== - // =================================================== - // PROCUREMENT MODULE (Phase 3) + // Procurement Module (suppliers, purchase orders, goods received notes) // Requires: MODULE_PROCUREMENT_ENABLED=true - // =================================================== Route::middleware('module:procurement')->group(function () { - - // Supplier routes - Route::prefix('suppliers')->group(function () { - Route::get('/', [SupplierController::class, 'index'])->middleware('permission:purchasing.view'); - Route::get('/list', [SupplierController::class, 'list'])->middleware('permission:purchasing.view'); - Route::post('/', [SupplierController::class, 'store'])->middleware('permission:purchasing.create'); - Route::get('/for-product/{productId}', [SupplierController::class, 'forProduct'])->middleware('permission:purchasing.view'); - Route::get('/{supplier}', [SupplierController::class, 'show'])->middleware('permission:purchasing.view'); - Route::put('/{supplier}', [SupplierController::class, 'update'])->middleware('permission:purchasing.edit'); - Route::delete('/{supplier}', [SupplierController::class, 'destroy'])->middleware('permission:purchasing.delete'); - Route::post('/{supplier}/toggle-active', [SupplierController::class, 'toggleActive'])->middleware('permission:purchasing.edit'); - Route::get('/{supplier}/statistics', [SupplierController::class, 'statistics'])->middleware('permission:purchasing.view'); - - // Supplier product management - Route::post('/{supplier}/products', [SupplierController::class, 'attachProducts'])->middleware('permission:purchasing.edit'); - Route::put('/{supplier}/products/{productId}', [SupplierController::class, 'updateProduct'])->middleware('permission:purchasing.edit'); - Route::delete('/{supplier}/products/{productId}', [SupplierController::class, 'detachProduct'])->middleware('permission:purchasing.edit'); + require __DIR__ . '/api/procurement.php'; }); - // Purchase Order routes - Route::prefix('purchase-orders')->group(function () { - Route::get('/', [PurchaseOrderController::class, 'index'])->middleware('permission:purchasing.view'); - Route::post('/', [PurchaseOrderController::class, 'store'])->middleware('permission:purchasing.create'); - Route::get('/statistics', [PurchaseOrderController::class, 'statistics'])->middleware('permission:purchasing.view'); - Route::get('/overdue', [PurchaseOrderController::class, 'overdue'])->middleware('permission:purchasing.view'); - Route::get('/{purchaseOrder}', [PurchaseOrderController::class, 'show'])->middleware('permission:purchasing.view'); - Route::put('/{purchaseOrder}', [PurchaseOrderController::class, 'update'])->middleware('permission:purchasing.edit'); - Route::delete('/{purchaseOrder}', [PurchaseOrderController::class, 'destroy'])->middleware('permission:purchasing.delete'); - - // PO Item management - Route::post('/{purchaseOrder}/items', [PurchaseOrderController::class, 'addItems'])->middleware('permission:purchasing.edit'); - Route::put('/{purchaseOrder}/items/{item}', [PurchaseOrderController::class, 'updateItem'])->middleware('permission:purchasing.edit'); - Route::delete('/{purchaseOrder}/items/{item}', [PurchaseOrderController::class, 'deleteItem'])->middleware('permission:purchasing.edit'); - - // PO Workflow actions - Route::post('/{purchaseOrder}/submit', [PurchaseOrderController::class, 'submitForApproval'])->middleware('permission:purchasing.edit'); - Route::post('/{purchaseOrder}/approve', [PurchaseOrderController::class, 'approve'])->middleware('permission:purchasing.approve'); - Route::post('/{purchaseOrder}/reject', [PurchaseOrderController::class, 'reject'])->middleware('permission:purchasing.approve'); - Route::post('/{purchaseOrder}/send', [PurchaseOrderController::class, 'markAsSent'])->middleware('permission:purchasing.edit'); - Route::post('/{purchaseOrder}/cancel', [PurchaseOrderController::class, 'cancel'])->middleware('permission:purchasing.edit'); - Route::post('/{purchaseOrder}/close', [PurchaseOrderController::class, 'close'])->middleware('permission:purchasing.edit'); + // Quality Control Module (acceptance rules, inspections, NCRs) + // Requires: MODULE_QC_ENABLED=true + Route::middleware('module:qc')->group(function () { + require __DIR__ . '/api/qc.php'; }); - // Goods Received Note (GRN) routes - Route::prefix('goods-received-notes')->group(function () { - Route::get('/', [GoodsReceivedNoteController::class, 'index'])->middleware('permission:purchasing.view'); - Route::post('/', [GoodsReceivedNoteController::class, 'store'])->middleware('permission:purchasing.receive'); - Route::get('/pending-inspection', [GoodsReceivedNoteController::class, 'pendingInspection'])->middleware('permission:purchasing.view'); - Route::get('/for-purchase-order/{purchaseOrderId}', [GoodsReceivedNoteController::class, 'forPurchaseOrder'])->middleware('permission:purchasing.view'); - Route::get('/{goodsReceivedNote}', [GoodsReceivedNoteController::class, 'show'])->middleware('permission:purchasing.view'); - Route::put('/{goodsReceivedNote}', [GoodsReceivedNoteController::class, 'update'])->middleware('permission:purchasing.receive'); - Route::delete('/{goodsReceivedNote}', [GoodsReceivedNoteController::class, 'destroy'])->middleware('permission:purchasing.receive'); - - // GRN Workflow actions - Route::post('/{goodsReceivedNote}/submit-inspection', [GoodsReceivedNoteController::class, 'submitForInspection'])->middleware('permission:purchasing.receive'); - Route::post('/{goodsReceivedNote}/record-inspection', [GoodsReceivedNoteController::class, 'recordInspection'])->middleware('permission:purchasing.inspect'); - Route::post('/{goodsReceivedNote}/complete', [GoodsReceivedNoteController::class, 'complete'])->middleware('permission:purchasing.receive'); - Route::post('/{goodsReceivedNote}/cancel', [GoodsReceivedNoteController::class, 'cancel'])->middleware('permission:purchasing.receive'); + // Manufacturing Module (work centers, BOMs, routings, work orders, MRP, CRP) + // Requires: MODULE_MANUFACTURING_ENABLED=true + Route::middleware('module:manufacturing')->group(function () { + require __DIR__ . '/api/manufacturing.php'; }); - // =================================================== - // QUALITY CONTROL (QC) - Standard Level - // Part of Procurement Module - // =================================================== - - // Acceptance Rules routes - Route::prefix('acceptance-rules')->group(function () { - Route::get('/', [AcceptanceRuleController::class, 'index'])->middleware('permission:qc.view'); - Route::get('/list', [AcceptanceRuleController::class, 'list'])->middleware('permission:qc.view'); - Route::post('/', [AcceptanceRuleController::class, 'store'])->middleware('permission:qc.create'); - Route::get('/inspection-types', [AcceptanceRuleController::class, 'inspectionTypes'])->middleware('permission:qc.view'); - Route::get('/sampling-methods', [AcceptanceRuleController::class, 'samplingMethods'])->middleware('permission:qc.view'); - Route::post('/find-applicable', [AcceptanceRuleController::class, 'findApplicable'])->middleware('permission:qc.view'); - Route::get('/{acceptanceRule}', [AcceptanceRuleController::class, 'show'])->middleware('permission:qc.view'); - Route::put('/{acceptanceRule}', [AcceptanceRuleController::class, 'update'])->middleware('permission:qc.edit'); - Route::delete('/{acceptanceRule}', [AcceptanceRuleController::class, 'destroy'])->middleware('permission:qc.delete'); - }); - - // Receiving Inspections routes - Route::prefix('receiving-inspections')->group(function () { - Route::get('/', [ReceivingInspectionController::class, 'index'])->middleware('permission:qc.view'); - Route::get('/statistics', [ReceivingInspectionController::class, 'statistics'])->middleware('permission:qc.view'); - Route::get('/results', [ReceivingInspectionController::class, 'results'])->middleware('permission:qc.view'); - Route::get('/dispositions', [ReceivingInspectionController::class, 'dispositions'])->middleware('permission:qc.view'); - Route::get('/for-grn/{goodsReceivedNote}', [ReceivingInspectionController::class, 'forGrn'])->middleware('permission:qc.view'); - Route::post('/create-for-grn/{goodsReceivedNote}', [ReceivingInspectionController::class, 'createForGrn'])->middleware('permission:qc.inspect'); - Route::get('/{receivingInspection}', [ReceivingInspectionController::class, 'show'])->middleware('permission:qc.view'); - Route::post('/{receivingInspection}/record-result', [ReceivingInspectionController::class, 'recordResult'])->middleware('permission:qc.inspect'); - Route::post('/{receivingInspection}/approve', [ReceivingInspectionController::class, 'approve'])->middleware('permission:qc.approve'); - Route::put('/{receivingInspection}/disposition', [ReceivingInspectionController::class, 'updateDisposition'])->middleware('permission:qc.edit'); - }); - - // Non-Conformance Reports (NCR) routes - Route::prefix('ncrs')->group(function () { - Route::get('/', [NonConformanceReportController::class, 'index'])->middleware('permission:qc.view'); - Route::post('/', [NonConformanceReportController::class, 'store'])->middleware('permission:qc.create'); - Route::get('/statistics', [NonConformanceReportController::class, 'statistics'])->middleware('permission:qc.view'); - Route::get('/statuses', [NonConformanceReportController::class, 'statuses'])->middleware('permission:qc.view'); - Route::get('/severities', [NonConformanceReportController::class, 'severities'])->middleware('permission:qc.view'); - Route::get('/defect-types', [NonConformanceReportController::class, 'defectTypes'])->middleware('permission:qc.view'); - Route::get('/dispositions', [NonConformanceReportController::class, 'dispositions'])->middleware('permission:qc.view'); - Route::get('/supplier/{supplierId}/summary', [NonConformanceReportController::class, 'supplierSummary'])->middleware('permission:qc.view'); - Route::post('/from-inspection/{receivingInspection}', [NonConformanceReportController::class, 'createFromInspection'])->middleware('permission:qc.create'); - Route::get('/{nonConformanceReport}', [NonConformanceReportController::class, 'show'])->middleware('permission:qc.view'); - Route::put('/{nonConformanceReport}', [NonConformanceReportController::class, 'update'])->middleware('permission:qc.edit'); - Route::delete('/{nonConformanceReport}', [NonConformanceReportController::class, 'destroy'])->middleware('permission:qc.delete'); - - // NCR Workflow actions - Route::post('/{nonConformanceReport}/submit-review', [NonConformanceReportController::class, 'submitForReview'])->middleware('permission:qc.edit'); - Route::post('/{nonConformanceReport}/complete-review', [NonConformanceReportController::class, 'completeReview'])->middleware('permission:qc.review'); - Route::post('/{nonConformanceReport}/set-disposition', [NonConformanceReportController::class, 'setDisposition'])->middleware('permission:qc.approve'); - Route::post('/{nonConformanceReport}/start-progress', [NonConformanceReportController::class, 'startProgress'])->middleware('permission:qc.edit'); - Route::post('/{nonConformanceReport}/close', [NonConformanceReportController::class, 'close'])->middleware('permission:qc.approve'); - Route::post('/{nonConformanceReport}/cancel', [NonConformanceReportController::class, 'cancel'])->middleware('permission:qc.edit'); + // Sales Module (customer groups, customers, sales orders, delivery notes) + // Requires: MODULE_SALES_ENABLED=true + Route::middleware('module:sales')->group(function () { + require __DIR__ . '/api/sales.php'; }); - }); // End of procurement module }); diff --git a/backend/routes/api/auth.php b/backend/routes/api/auth.php new file mode 100644 index 0000000..11b11d2 --- /dev/null +++ b/backend/routes/api/auth.php @@ -0,0 +1,25 @@ +withoutMiddleware('auth:sanctum')->group(function () { + Route::post('/register', [AuthController::class, 'register']); + Route::post('/login', [AuthController::class, 'login']); + Route::post('/forgot-password', [AuthController::class, 'forgotPassword']); + Route::post('/reset-password', [AuthController::class, 'resetPassword']); +}); + +// Protected routes (require authentication) +Route::prefix('auth')->middleware('auth:sanctum')->group(function () { + Route::post('/logout', [AuthController::class, 'logout']); + Route::get('/me', [AuthController::class, 'me']); + Route::post('/refresh', [AuthController::class, 'refresh']); +}); diff --git a/backend/routes/api/core.php b/backend/routes/api/core.php new file mode 100644 index 0000000..b656d71 --- /dev/null +++ b/backend/routes/api/core.php @@ -0,0 +1,142 @@ +group(function () { + Route::middleware('permission:users.view')->group(function () { + Route::get('/', [UserController::class, 'index']); + Route::get('/{user}', [UserController::class, 'show']); + }); + + Route::post('/', [UserController::class, 'store'])->middleware('permission:users.create'); + Route::put('/{user}', [UserController::class, 'update'])->middleware('permission:users.edit'); + Route::delete('/{user}', [UserController::class, 'destroy'])->middleware('permission:users.delete'); + Route::post('/{id}/restore', [UserController::class, 'restore'])->middleware('permission:users.delete'); + Route::delete('/{id}/force', [UserController::class, 'forceDelete'])->middleware('role:admin'); +}); + +// User Invitations +Route::prefix('invitations')->group(function () { + // Public routes (no auth required) + Route::withoutMiddleware('auth:sanctum')->group(function () { + // Support both path parameter and query parameter for flexibility + Route::get('/accept/{token?}', [InvitationController::class, 'show']); + Route::post('/accept/{token?}', [InvitationController::class, 'accept']); + }); + + // Protected routes (require authentication) + Route::middleware('permission:users.view')->group(function () { + Route::get('/', [InvitationController::class, 'index']); + }); + + Route::post('/', [InvitationController::class, 'store'])->middleware('permission:users.create'); + Route::post('/{id}/resend', [InvitationController::class, 'resend']); // Permission check in service (allows inviter) + Route::delete('/{id}', [InvitationController::class, 'destroy'])->middleware('permission:users.delete'); +}); + +// Role & Permission Management (Admin only) +Route::middleware('role:admin')->group(function () { + Route::apiResource('roles', RoleController::class); + Route::post('roles/{role}/permissions/assign', [RoleController::class, 'assignPermissions']); + Route::post('roles/{role}/permissions/revoke', [RoleController::class, 'revokePermissions']); + + Route::apiResource('permissions', PermissionController::class); + Route::get('permissions/modules/list', [PermissionController::class, 'modules']); +}); + +// Settings +Route::prefix('settings')->group(function () { + Route::middleware('permission:settings.view')->group(function () { + Route::get('/', [SettingController::class, 'index']); + Route::get('/groups', [SettingController::class, 'groups']); + Route::get('/group/{group}', [SettingController::class, 'group']); + Route::get('/{group}/{key}', [SettingController::class, 'show']); + }); + + Route::middleware('permission:settings.edit')->group(function () { + Route::post('/', [SettingController::class, 'store']); + Route::put('/{group}/{key}', [SettingController::class, 'update']); + Route::delete('/{group}/{key}', [SettingController::class, 'destroy']); + }); +}); + +// Over-Delivery Tolerance (Company-specific, Admin only) +Route::prefix('over-delivery-tolerance')->group(function () { + Route::middleware(['permission:settings.view', 'role:admin'])->group(function () { + Route::get('/', [OverDeliveryToleranceController::class, 'show']); + Route::get('/levels', [OverDeliveryToleranceController::class, 'levels']); + }); + + Route::middleware(['permission:settings.edit', 'role:admin'])->group(function () { + Route::put('/', [OverDeliveryToleranceController::class, 'update']); + }); +}); + +// Company Calendar (for MRP working days) +Route::prefix('company-calendar')->group(function () { + Route::middleware('permission:settings.view')->group(function () { + Route::get('/', [CompanyCalendarController::class, 'index']); + Route::get('/date-range', [CompanyCalendarController::class, 'getDateRange']); + Route::get('/{calendar}', [CompanyCalendarController::class, 'show']); + }); + + Route::middleware('permission:settings.edit')->group(function () { + Route::post('/', [CompanyCalendarController::class, 'store']); + Route::post('/bulk', [CompanyCalendarController::class, 'bulkStore']); + Route::put('/{calendar}', [CompanyCalendarController::class, 'update']); + Route::delete('/{calendar}', [CompanyCalendarController::class, 'destroy']); + }); +}); + +// Currencies +Route::prefix('currencies')->group(function () { + Route::middleware('permission:settings.view')->group(function () { + Route::get('/', [CurrencyController::class, 'index']); + Route::get('/active', [CurrencyController::class, 'active']); + Route::get('/{currency}', [CurrencyController::class, 'show']); + Route::get('/exchange-rate/get', [CurrencyController::class, 'getExchangeRate']); + Route::get('/exchange-rate/history', [CurrencyController::class, 'exchangeRateHistory']); + Route::post('/convert', [CurrencyController::class, 'convert']); + }); + + Route::middleware('permission:settings.edit')->group(function () { + Route::post('/', [CurrencyController::class, 'store']); + Route::put('/{currency}', [CurrencyController::class, 'update']); + Route::delete('/{currency}', [CurrencyController::class, 'destroy']); + Route::post('/{currency}/toggle-active', [CurrencyController::class, 'toggleActive']); + Route::post('/exchange-rate/set', [CurrencyController::class, 'setExchangeRate']); + }); +}); + +// Units of Measure +Route::prefix('units-of-measure')->group(function () { + Route::middleware('permission:settings.view')->group(function () { + Route::get('/', [UnitOfMeasureController::class, 'index']); + Route::get('/list', [UnitOfMeasureController::class, 'list']); + Route::get('/types', [UnitOfMeasureController::class, 'types']); + Route::get('/{unitOfMeasure}', [UnitOfMeasureController::class, 'show']); + }); + + Route::middleware('permission:settings.edit')->group(function () { + Route::post('/', [UnitOfMeasureController::class, 'store']); + Route::put('/{unitOfMeasure}', [UnitOfMeasureController::class, 'update']); + Route::delete('/{unitOfMeasure}', [UnitOfMeasureController::class, 'destroy']); + }); +}); diff --git a/backend/routes/api/inventory.php b/backend/routes/api/inventory.php new file mode 100644 index 0000000..f0619d2 --- /dev/null +++ b/backend/routes/api/inventory.php @@ -0,0 +1,79 @@ +group(function () { + Route::middleware('permission:inventory.view')->group(function () { + Route::get('/', [WarehouseController::class, 'index']); + Route::get('/list', [WarehouseController::class, 'list']); + Route::get('/quarantine-zones', [WarehouseController::class, 'quarantineZones']); + Route::get('/rejection-zones', [WarehouseController::class, 'rejectionZones']); + Route::get('/qc-zones', [WarehouseController::class, 'qcZones']); + Route::get('/{warehouse}', [WarehouseController::class, 'show']); + Route::get('/{warehouse}/stock-summary', [WarehouseController::class, 'stockSummary']); + }); + + Route::post('/', [WarehouseController::class, 'store'])->middleware('permission:inventory.create'); + + Route::middleware('permission:inventory.edit')->group(function () { + Route::put('/{warehouse}', [WarehouseController::class, 'update']); + Route::post('/{warehouse}/toggle-active', [WarehouseController::class, 'toggleActive']); + Route::post('/{warehouse}/set-default', [WarehouseController::class, 'setDefault']); + }); + + Route::delete('/{warehouse}', [WarehouseController::class, 'destroy'])->middleware('permission:inventory.delete'); +}); + +// Stock +Route::prefix('stock')->group(function () { + Route::middleware('permission:inventory.view')->group(function () { + Route::get('/', [StockController::class, 'index']); + Route::get('/low-stock', [StockController::class, 'lowStock']); + Route::get('/expiring', [StockController::class, 'expiring']); + Route::get('/product/{productId}', [StockController::class, 'productStock']); + Route::get('/warehouse/{warehouseId}', [StockController::class, 'warehouseStock']); + }); + + Route::post('/receive', [StockController::class, 'receive'])->middleware('permission:inventory.create'); + + Route::middleware('permission:inventory.edit')->group(function () { + Route::post('/issue', [StockController::class, 'issue']); + Route::post('/transfer', [StockController::class, 'transfer']); + Route::post('/adjust', [StockController::class, 'adjust']); + Route::post('/reserve', [StockController::class, 'reserve']); + Route::post('/release-reservation', [StockController::class, 'releaseReservation']); + }); +}); + +// Stock Movements +Route::prefix('stock-movements')->middleware('permission:inventory.view')->group(function () { + Route::get('/', [StockMovementController::class, 'index']); + Route::get('/summary', [StockMovementController::class, 'summary']); + Route::get('/daily-report', [StockMovementController::class, 'dailyReport']); + Route::get('/audit-trail', [StockMovementController::class, 'auditTrail']); + Route::get('/product/{productId}', [StockMovementController::class, 'productMovements']); + Route::get('/warehouse/{warehouseId}', [StockMovementController::class, 'warehouseMovements']); + Route::get('/types/movement', [StockMovementController::class, 'movementTypes']); + Route::get('/types/transaction', [StockMovementController::class, 'transactionTypes']); +}); + +// Stock Debts (Negative Stock Management) +Route::prefix('stock-debts')->middleware('permission:inventory.view')->group(function () { + Route::get('/', [StockDebtController::class, 'index']); + Route::get('/alerts', [StockDebtController::class, 'alerts']); + Route::get('/weekly-report', [StockDebtController::class, 'weeklyReport']); + Route::get('/long-term', [StockDebtController::class, 'longTerm']); + Route::get('/{stockDebt}', [StockDebtController::class, 'show']); +}); diff --git a/backend/routes/api/manufacturing.php b/backend/routes/api/manufacturing.php new file mode 100644 index 0000000..e24106c --- /dev/null +++ b/backend/routes/api/manufacturing.php @@ -0,0 +1,175 @@ +group(function () { + Route::middleware('permission:manufacturing.view')->group(function () { + Route::get('/', [WorkCenterController::class, 'index']); + Route::get('/list', [WorkCenterController::class, 'list']); + Route::get('/types', [WorkCenterController::class, 'types']); + Route::get('/{workCenter}', [WorkCenterController::class, 'show']); + Route::get('/{workCenter}/availability', [WorkCenterController::class, 'availability']); + }); + + Route::post('/', [WorkCenterController::class, 'store'])->middleware('permission:manufacturing.create'); + + Route::middleware('permission:manufacturing.edit')->group(function () { + Route::put('/{workCenter}', [WorkCenterController::class, 'update']); + Route::post('/{workCenter}/toggle-active', [WorkCenterController::class, 'toggleActive']); + }); + + Route::delete('/{workCenter}', [WorkCenterController::class, 'destroy'])->middleware('permission:manufacturing.delete'); +}); + +// BOMs (Bill of Materials) +Route::prefix('boms')->group(function () { + Route::middleware('permission:manufacturing.view')->group(function () { + Route::get('/', [BomController::class, 'index']); + Route::get('/list', [BomController::class, 'list']); + Route::get('/types', [BomController::class, 'types']); + Route::get('/statuses', [BomController::class, 'statuses']); + Route::get('/for-product/{productId}', [BomController::class, 'forProduct']); + Route::get('/{bom}', [BomController::class, 'show']); + Route::match(['get', 'post'], '/{bom}/explode', [BomController::class, 'explode']); + }); + + Route::post('/', [BomController::class, 'store'])->middleware('permission:manufacturing.create'); + Route::post('/{bom}/copy', [BomController::class, 'copy'])->middleware('permission:manufacturing.create'); + + Route::middleware('permission:manufacturing.edit')->group(function () { + Route::put('/{bom}', [BomController::class, 'update']); + Route::post('/{bom}/items', [BomController::class, 'addItem']); + Route::put('/{bom}/items/{itemId}', [BomController::class, 'updateItem']); + Route::delete('/{bom}/items/{itemId}', [BomController::class, 'removeItem']); + Route::post('/{bom}/activate', [BomController::class, 'activate']); + Route::post('/{bom}/obsolete', [BomController::class, 'obsolete']); + Route::post('/{bom}/set-default', [BomController::class, 'setDefault']); + }); + + Route::delete('/{bom}', [BomController::class, 'destroy'])->middleware('permission:manufacturing.delete'); +}); + +// Routings +Route::prefix('routings')->group(function () { + Route::middleware('permission:manufacturing.view')->group(function () { + Route::get('/', [RoutingController::class, 'index']); + Route::get('/list', [RoutingController::class, 'list']); + Route::get('/statuses', [RoutingController::class, 'statuses']); + Route::get('/for-product/{productId}', [RoutingController::class, 'forProduct']); + Route::get('/{routing}', [RoutingController::class, 'show']); + Route::post('/{routing}/calculate-lead-time', [RoutingController::class, 'calculateLeadTime']); + }); + + Route::post('/', [RoutingController::class, 'store'])->middleware('permission:manufacturing.create'); + Route::post('/{routing}/copy', [RoutingController::class, 'copy'])->middleware('permission:manufacturing.create'); + + Route::middleware('permission:manufacturing.edit')->group(function () { + Route::put('/{routing}', [RoutingController::class, 'update']); + Route::post('/{routing}/operations', [RoutingController::class, 'addOperation']); + Route::put('/{routing}/operations/{operationId}', [RoutingController::class, 'updateOperation']); + Route::delete('/{routing}/operations/{operationId}', [RoutingController::class, 'removeOperation']); + Route::post('/{routing}/operations/reorder', [RoutingController::class, 'reorderOperations']); + Route::post('/{routing}/activate', [RoutingController::class, 'activate']); + Route::post('/{routing}/obsolete', [RoutingController::class, 'obsolete']); + Route::post('/{routing}/set-default', [RoutingController::class, 'setDefault']); + }); + + Route::delete('/{routing}', [RoutingController::class, 'destroy'])->middleware('permission:manufacturing.delete'); +}); + +// Work Orders +Route::prefix('work-orders')->group(function () { + Route::middleware('permission:manufacturing.view')->group(function () { + Route::get('/', [WorkOrderController::class, 'index']); + Route::get('/statistics', [WorkOrderController::class, 'statistics']); + Route::get('/statuses', [WorkOrderController::class, 'statuses']); + Route::get('/priorities', [WorkOrderController::class, 'priorities']); + Route::get('/{workOrder}', [WorkOrderController::class, 'show']); + Route::get('/{workOrder}/material-requirements', [WorkOrderController::class, 'materialRequirements']); + Route::get('/{workOrder}/check-capacity', [CapacityController::class, 'checkWorkOrderCapacity']); + }); + + Route::post('/', [WorkOrderController::class, 'store'])->middleware('permission:manufacturing.create'); + + Route::middleware('permission:manufacturing.edit')->group(function () { + Route::put('/{workOrder}', [WorkOrderController::class, 'update']); + Route::post('/{workOrder}/start', [WorkOrderController::class, 'start']); + Route::post('/{workOrder}/cancel', [WorkOrderController::class, 'cancel']); + Route::post('/{workOrder}/hold', [WorkOrderController::class, 'hold']); + Route::post('/{workOrder}/resume', [WorkOrderController::class, 'resume']); + Route::post('/{workOrder}/operations/{operationId}/start', [WorkOrderController::class, 'startOperation']); + Route::post('/{workOrder}/issue-materials', [WorkOrderController::class, 'issueMaterials']); + }); + + Route::post('/{workOrder}/release', [WorkOrderController::class, 'release'])->middleware('permission:manufacturing.release'); + + Route::middleware('permission:manufacturing.complete')->group(function () { + Route::post('/{workOrder}/complete', [WorkOrderController::class, 'complete']); + Route::post('/{workOrder}/operations/{operationId}/complete', [WorkOrderController::class, 'completeOperation']); + Route::post('/{workOrder}/receive-finished-goods', [WorkOrderController::class, 'receiveFinishedGoods']); + }); + + Route::delete('/{workOrder}', [WorkOrderController::class, 'destroy'])->middleware('permission:manufacturing.delete'); +}); + +// MRP (Material Requirements Planning) +Route::prefix('mrp')->group(function () { + Route::middleware('permission:manufacturing.view')->group(function () { + Route::get('/', [MrpController::class, 'index']); + Route::get('/statistics', [MrpController::class, 'statistics']); + Route::get('/products-needing-attention', [MrpController::class, 'productsNeedingAttention']); + Route::get('/statuses', [MrpController::class, 'statuses']); + Route::get('/recommendation-types', [MrpController::class, 'recommendationTypes']); + Route::get('/recommendation-statuses', [MrpController::class, 'recommendationStatuses']); + Route::get('/priorities', [MrpController::class, 'priorities']); + Route::get('/{mrpRun}', [MrpController::class, 'show']); + Route::get('/{mrpRun}/progress', [MrpController::class, 'progress']); + Route::get('/{mrpRun}/recommendations', [MrpController::class, 'recommendations']); + }); + + Route::middleware('permission:manufacturing.mrp')->group(function () { + Route::post('/', [MrpController::class, 'store']); + Route::post('/invalidate-cache', [MrpController::class, 'invalidateCache']); + Route::post('/{mrpRun}/cancel', [MrpController::class, 'cancel']); + Route::post('/recommendations/bulk-approve', [MrpController::class, 'bulkApprove']); + Route::post('/recommendations/bulk-reject', [MrpController::class, 'bulkReject']); + Route::post('/recommendations/{recommendation}/approve', [MrpController::class, 'approveRecommendation']); + Route::post('/recommendations/{recommendation}/reject', [MrpController::class, 'rejectRecommendation']); + }); +}); + +// Capacity Planning (CRP) +Route::prefix('capacity')->group(function () { + Route::middleware('permission:manufacturing.view')->group(function () { + Route::get('/overview', [CapacityController::class, 'overview']); + Route::get('/load-report', [CapacityController::class, 'loadReport']); + Route::get('/bottleneck-analysis', [CapacityController::class, 'bottleneckAnalysis']); + Route::get('/day-types', [CapacityController::class, 'dayTypes']); + Route::get('/work-center/{workCenter}', [CapacityController::class, 'workCenterCapacity']); + Route::get('/work-center/{workCenter}/daily', [CapacityController::class, 'dailyCapacity']); + Route::get('/work-center/{workCenter}/find-slot', [CapacityController::class, 'findSlot']); + Route::get('/work-center/{workCenter}/calendar', [CapacityController::class, 'calendar']); + }); + + Route::middleware('permission:manufacturing.edit')->group(function () { + Route::post('/generate-calendar', [CapacityController::class, 'generateCalendar']); + Route::post('/work-center/{workCenter}/set-holiday', [CapacityController::class, 'setHoliday']); + Route::post('/work-center/{workCenter}/set-maintenance', [CapacityController::class, 'setMaintenance']); + Route::put('/calendar/{calendar}', [CapacityController::class, 'updateCalendarEntry']); + }); +}); diff --git a/backend/routes/api/procurement.php b/backend/routes/api/procurement.php new file mode 100644 index 0000000..44e4afd --- /dev/null +++ b/backend/routes/api/procurement.php @@ -0,0 +1,101 @@ +group(function () { + Route::middleware('permission:purchasing.view')->group(function () { + Route::get('/', [SupplierController::class, 'index']); + Route::get('/list', [SupplierController::class, 'list']); + Route::get('/for-product/{productId}', [SupplierController::class, 'forProduct']); + + // Supplier Quality routes (requires QC module) - MUST be before /{supplier} route + Route::middleware('module:qc')->group(function () { + Route::get('/quality-ranking', [SupplierController::class, 'qualityRanking'])->middleware('permission:qc.view'); + }); + + Route::get('/{supplier}', [SupplierController::class, 'show']); + Route::get('/{supplier}/statistics', [SupplierController::class, 'statistics']); + }); + + // Supplier Quality routes (requires QC module) - per supplier routes + Route::middleware('module:qc')->group(function () { + Route::get('/{supplier}/quality-score', [SupplierController::class, 'qualityScore'])->middleware('permission:qc.view'); + Route::get('/{supplier}/quality-statistics', [SupplierController::class, 'qualityStatistics'])->middleware('permission:qc.view'); + }); + + Route::post('/', [SupplierController::class, 'store'])->middleware('permission:purchasing.create'); + + Route::middleware('permission:purchasing.edit')->group(function () { + Route::put('/{supplier}', [SupplierController::class, 'update']); + Route::post('/{supplier}/toggle-active', [SupplierController::class, 'toggleActive']); + Route::post('/{supplier}/products', [SupplierController::class, 'attachProducts']); + Route::put('/{supplier}/products/{productId}', [SupplierController::class, 'updateProduct']); + Route::delete('/{supplier}/products/{productId}', [SupplierController::class, 'detachProduct']); + }); + + Route::delete('/{supplier}', [SupplierController::class, 'destroy'])->middleware('permission:purchasing.delete'); +}); + +// Purchase Orders +Route::prefix('purchase-orders')->group(function () { + Route::middleware('permission:purchasing.view')->group(function () { + Route::get('/', [PurchaseOrderController::class, 'index']); + Route::get('/statistics', [PurchaseOrderController::class, 'statistics']); + Route::get('/overdue', [PurchaseOrderController::class, 'overdue']); + Route::get('/{purchaseOrder}', [PurchaseOrderController::class, 'show']); + }); + + Route::post('/', [PurchaseOrderController::class, 'store'])->middleware('permission:purchasing.create'); + + Route::middleware('permission:purchasing.edit')->group(function () { + Route::put('/{purchaseOrder}', [PurchaseOrderController::class, 'update']); + Route::post('/{purchaseOrder}/items', [PurchaseOrderController::class, 'addItems']); + Route::put('/{purchaseOrder}/items/{item}', [PurchaseOrderController::class, 'updateItem']); + Route::delete('/{purchaseOrder}/items/{item}', [PurchaseOrderController::class, 'deleteItem']); + Route::post('/{purchaseOrder}/submit', [PurchaseOrderController::class, 'submitForApproval']); + Route::post('/{purchaseOrder}/send', [PurchaseOrderController::class, 'markAsSent']); + Route::post('/{purchaseOrder}/cancel', [PurchaseOrderController::class, 'cancel']); + Route::post('/{purchaseOrder}/close', [PurchaseOrderController::class, 'close']); + }); + + Route::middleware('permission:purchasing.approve')->group(function () { + Route::post('/{purchaseOrder}/approve', [PurchaseOrderController::class, 'approve']); + Route::post('/{purchaseOrder}/reject', [PurchaseOrderController::class, 'reject']); + }); + + Route::delete('/{purchaseOrder}', [PurchaseOrderController::class, 'destroy'])->middleware('permission:purchasing.delete'); +}); + +// Goods Received Notes (GRN) +Route::prefix('goods-received-notes')->group(function () { + Route::middleware('permission:purchasing.view')->group(function () { + Route::get('/', [GoodsReceivedNoteController::class, 'index']); + Route::get('/pending-inspection', [GoodsReceivedNoteController::class, 'pendingInspection']); + Route::get('/for-purchase-order/{purchaseOrderId}', [GoodsReceivedNoteController::class, 'forPurchaseOrder']); + Route::get('/{goodsReceivedNote}', [GoodsReceivedNoteController::class, 'show']); + }); + + Route::middleware('permission:purchasing.receive')->group(function () { + Route::post('/', [GoodsReceivedNoteController::class, 'store']); + Route::put('/{goodsReceivedNote}', [GoodsReceivedNoteController::class, 'update']); + Route::delete('/{goodsReceivedNote}', [GoodsReceivedNoteController::class, 'destroy']); + Route::post('/{goodsReceivedNote}/submit-inspection', [GoodsReceivedNoteController::class, 'submitForInspection']); + Route::post('/{goodsReceivedNote}/complete', [GoodsReceivedNoteController::class, 'complete']); + Route::post('/{goodsReceivedNote}/cancel', [GoodsReceivedNoteController::class, 'cancel']); + }); + + Route::post('/{goodsReceivedNote}/record-inspection', [GoodsReceivedNoteController::class, 'recordInspection']) + ->middleware('permission:purchasing.inspect'); +}); diff --git a/backend/routes/api/products.php b/backend/routes/api/products.php new file mode 100644 index 0000000..87a20ed --- /dev/null +++ b/backend/routes/api/products.php @@ -0,0 +1,135 @@ +group(function () { + Route::middleware('permission:categories.view')->group(function () { + Route::get('/', [CategoryController::class, 'index']); + Route::get('/{category}', [CategoryController::class, 'show']); + Route::get('/{category}/attributes', [CategoryController::class, 'getAttributes']); + }); + + Route::middleware('permission:categories.create')->post('/', [CategoryController::class, 'store']); + + Route::middleware('permission:categories.edit')->group(function () { + Route::put('/{category}', [CategoryController::class, 'update']); + Route::post('/{category}/attributes', [CategoryController::class, 'assignAttributes']); + Route::put('/{category}/attributes/{attribute}', [CategoryController::class, 'updateAttribute']); + Route::delete('/{category}/attributes/{attribute}', [CategoryController::class, 'removeAttribute']); + }); + + Route::middleware('permission:categories.delete')->delete('/{category}', [CategoryController::class, 'destroy']); +}); + +// Product Types +Route::prefix('producttypes')->group(function () { + Route::middleware('permission:producttypes.view')->group(function () { + Route::get('/', [ProductTypeController::class, 'index']); + Route::get('/{productType}', [ProductTypeController::class, 'show']); + }); + + Route::post('/', [ProductTypeController::class, 'store'])->middleware('permission:producttypes.create'); + Route::put('/{productType}', [ProductTypeController::class, 'update'])->middleware('permission:producttypes.edit'); + Route::delete('/{productType}', [ProductTypeController::class, 'destroy'])->middleware('permission:producttypes.delete'); +}); + +// Attributes +Route::prefix('attributes')->group(function () { + Route::middleware('permission:products.view')->group(function () { + Route::get('/', [AttributeController::class, 'index']); + Route::get('/{attribute}', [AttributeController::class, 'show']); + }); + + Route::post('/', [AttributeController::class, 'store'])->middleware('permission:products.create'); + + Route::middleware('permission:products.edit')->group(function () { + Route::put('/{attribute}', [AttributeController::class, 'update']); + Route::post('/{attribute}/values', [AttributeController::class, 'addValues']); + Route::put('/{attribute}/values/{value}', [AttributeController::class, 'updateValue']); + Route::delete('/{attribute}/values/{value}', [AttributeController::class, 'destroyValue']); + }); + + Route::delete('/{attribute}', [AttributeController::class, 'destroy'])->middleware('permission:products.delete'); +}); + +// Bulk variant generation +Route::post('/variants/bulk-generate', [AttributeController::class, 'bulkGenerateVariants']) + ->middleware(['permission:products.edit', 'throttle:bulk-variant-generate']); + +// Products +Route::prefix('products')->group(function () { + Route::middleware('permission:products.view')->group(function () { + Route::get('/', [ProductController::class, 'index']); + Route::get('/search', [ProductController::class, 'search']); + Route::get('/query', [ProductController::class, 'search']); + Route::get('/find', [ProductController::class, 'search']); + Route::get('/{product}', [ProductController::class, 'show']); + Route::get('/{product}/attributes', [ProductController::class, 'getAttributes']); + Route::get('/{product}/variants', [ProductController::class, 'getVariants']); + Route::get('/{product}/uom-conversions', [ProductUomConversionController::class, 'index']); + Route::get('/{product}/uom-conversions/{conversion}', [ProductUomConversionController::class, 'show']); + Route::post('/{product}/uom-conversions/convert', [ProductUomConversionController::class, 'convert']); + }); + + Route::post('/', [ProductController::class, 'store'])->middleware('permission:products.create'); + + Route::middleware('permission:products.edit')->group(function () { + Route::put('/{product}', [ProductController::class, 'update']); + + // Product images + Route::post('/{product}/images', [ProductImageController::class, 'upload']); + Route::put('/{product}/images/{image}', [ProductImageController::class, 'update']); + Route::delete('/{product}/images/{image}', [ProductImageController::class, 'destroy']); + Route::post('/{product}/images/reorder', [ProductImageController::class, 'reorder']); + + // Product attributes + Route::post('/{product}/attributes', [ProductController::class, 'assignAttributes']); + Route::put('/{product}/attributes/{attribute}', [ProductController::class, 'updateAttribute']); + Route::delete('/{product}/attributes/{attribute}', [ProductController::class, 'removeAttribute']); + + // Product variants + Route::post('/{product}/variants', [ProductController::class, 'createVariant']); + Route::put('/{product}/variants/{variant}', [ProductController::class, 'updateVariant']); + Route::delete('/{product}/variants/{variant}', [ProductController::class, 'deleteVariant']); + + // Variant generation (rate limited) + Route::post('/{product}/variants/generate', [AttributeController::class, 'generateVariants']) + ->middleware('throttle:variant-generate'); + Route::post('/{product}/variants/expand', [AttributeController::class, 'expandVariants']) + ->middleware('throttle:variant-generate'); + Route::delete('/{product}/variants/clear', [AttributeController::class, 'clearVariants']); + + // Product UOM conversions + Route::post('/{product}/uom-conversions', [ProductUomConversionController::class, 'store']); + Route::post('/{product}/uom-conversions/bulk', [ProductUomConversionController::class, 'bulkStore']); + Route::post('/{product}/uom-conversions/copy-from', [ProductUomConversionController::class, 'copyFrom']); + Route::put('/{product}/uom-conversions/{conversion}', [ProductUomConversionController::class, 'update']); + Route::delete('/{product}/uom-conversions/{conversion}', [ProductUomConversionController::class, 'destroy']); + Route::post('/{product}/uom-conversions/{conversion}/toggle-active', [ProductUomConversionController::class, 'toggleActive']); + }); + + Route::middleware('permission:products.delete')->group(function () { + Route::delete('/{product}', [ProductController::class, 'destroy']); + Route::post('/{id}/restore', [ProductController::class, 'restore']); + }); + + // Admin only + Route::middleware('role:admin')->group(function () { + Route::delete('/{product}/variants/{variant}/force', [ProductController::class, 'forceDeleteVariant']); + Route::delete('/{product}/variants/force-clear', [AttributeController::class, 'forceClearVariants']); + }); +}); diff --git a/backend/routes/api/qc.php b/backend/routes/api/qc.php new file mode 100644 index 0000000..4f028a5 --- /dev/null +++ b/backend/routes/api/qc.php @@ -0,0 +1,90 @@ +group(function () { + Route::middleware('permission:qc.view')->group(function () { + Route::get('/', [AcceptanceRuleController::class, 'index']); + Route::get('/list', [AcceptanceRuleController::class, 'list']); + Route::get('/inspection-types', [AcceptanceRuleController::class, 'inspectionTypes']); + Route::get('/sampling-methods', [AcceptanceRuleController::class, 'samplingMethods']); + Route::post('/find-applicable', [AcceptanceRuleController::class, 'findApplicable']); + Route::get('/{acceptanceRule}', [AcceptanceRuleController::class, 'show']); + }); + + Route::post('/', [AcceptanceRuleController::class, 'store'])->middleware('permission:qc.create'); + Route::put('/{acceptanceRule}', [AcceptanceRuleController::class, 'update'])->middleware('permission:qc.edit'); + Route::delete('/{acceptanceRule}', [AcceptanceRuleController::class, 'destroy'])->middleware('permission:qc.delete'); +}); + +// Receiving Inspections +Route::prefix('receiving-inspections')->group(function () { + Route::middleware('permission:qc.view')->group(function () { + Route::get('/', [ReceivingInspectionController::class, 'index']); + Route::get('/statistics', [ReceivingInspectionController::class, 'statistics']); + Route::get('/results', [ReceivingInspectionController::class, 'results']); + Route::get('/dispositions', [ReceivingInspectionController::class, 'dispositions']); + Route::get('/for-grn/{goodsReceivedNote}', [ReceivingInspectionController::class, 'forGrn']); + Route::get('/{receivingInspection}', [ReceivingInspectionController::class, 'show']); + }); + + Route::middleware('permission:qc.inspect')->group(function () { + Route::post('/create-for-grn/{goodsReceivedNote}', [ReceivingInspectionController::class, 'createForGrn']); + Route::post('/{receivingInspection}/record-result', [ReceivingInspectionController::class, 'recordResult']); + }); + + Route::post('/{receivingInspection}/approve', [ReceivingInspectionController::class, 'approve']) + ->middleware('permission:qc.approve'); + + Route::middleware('permission:qc.edit')->group(function () { + Route::put('/{receivingInspection}/disposition', [ReceivingInspectionController::class, 'updateDisposition']); + Route::post('/{receivingInspection}/transfer-to-qc', [ReceivingInspectionController::class, 'transferToQcZone']); + }); +}); + +// Non-Conformance Reports (NCR) +Route::prefix('ncrs')->group(function () { + Route::middleware('permission:qc.view')->group(function () { + Route::get('/', [NonConformanceReportController::class, 'index']); + Route::get('/statistics', [NonConformanceReportController::class, 'statistics']); + Route::get('/statuses', [NonConformanceReportController::class, 'statuses']); + Route::get('/severities', [NonConformanceReportController::class, 'severities']); + Route::get('/defect-types', [NonConformanceReportController::class, 'defectTypes']); + Route::get('/dispositions', [NonConformanceReportController::class, 'dispositions']); + Route::get('/supplier/{supplierId}/summary', [NonConformanceReportController::class, 'supplierSummary']); + Route::get('/{nonConformanceReport}', [NonConformanceReportController::class, 'show']); + }); + + Route::middleware('permission:qc.create')->group(function () { + Route::post('/', [NonConformanceReportController::class, 'store']); + Route::post('/from-inspection/{receivingInspection}', [NonConformanceReportController::class, 'createFromInspection']); + }); + + Route::middleware('permission:qc.edit')->group(function () { + Route::put('/{nonConformanceReport}', [NonConformanceReportController::class, 'update']); + Route::post('/{nonConformanceReport}/submit-review', [NonConformanceReportController::class, 'submitForReview']); + Route::post('/{nonConformanceReport}/start-progress', [NonConformanceReportController::class, 'startProgress']); + Route::post('/{nonConformanceReport}/cancel', [NonConformanceReportController::class, 'cancel']); + }); + + Route::middleware('permission:qc.review')->post('/{nonConformanceReport}/complete-review', [NonConformanceReportController::class, 'completeReview']); + + Route::middleware('permission:qc.approve')->group(function () { + Route::post('/{nonConformanceReport}/set-disposition', [NonConformanceReportController::class, 'setDisposition']); + Route::post('/{nonConformanceReport}/close', [NonConformanceReportController::class, 'close']); + }); + + Route::delete('/{nonConformanceReport}', [NonConformanceReportController::class, 'destroy'])->middleware('permission:qc.delete'); +}); diff --git a/backend/routes/api/sales.php b/backend/routes/api/sales.php new file mode 100644 index 0000000..2ed95f3 --- /dev/null +++ b/backend/routes/api/sales.php @@ -0,0 +1,103 @@ +group(function () { + Route::middleware('permission:customers.view')->group(function () { + Route::get('/', [CustomerGroupController::class, 'index']); + Route::get('/list', [CustomerGroupController::class, 'list']); + Route::get('/{customerGroup}', [CustomerGroupController::class, 'show']); + Route::get('/{customerGroup}/prices', [CustomerGroupController::class, 'prices']); + }); + + Route::post('/', [CustomerGroupController::class, 'store'])->middleware('permission:customers.create'); + + Route::middleware('permission:customers.edit')->group(function () { + Route::put('/{customerGroup}', [CustomerGroupController::class, 'update']); + Route::post('/{customerGroup}/prices', [CustomerGroupController::class, 'setPrice']); + Route::post('/{customerGroup}/prices/bulk', [CustomerGroupController::class, 'bulkSetPrices']); + Route::delete('/{customerGroup}/prices/{priceId}', [CustomerGroupController::class, 'deletePrice']); + }); + + Route::delete('/{customerGroup}', [CustomerGroupController::class, 'destroy'])->middleware('permission:customers.delete'); +}); + +// Customers +Route::prefix('customers')->group(function () { + Route::middleware('permission:customers.view')->group(function () { + Route::get('/', [CustomerController::class, 'index']); + Route::get('/list', [CustomerController::class, 'list']); + Route::get('/{customer}', [CustomerController::class, 'show']); + Route::get('/{customer}/statistics', [CustomerController::class, 'statistics']); + }); + + Route::post('/', [CustomerController::class, 'store'])->middleware('permission:customers.create'); + Route::put('/{customer}', [CustomerController::class, 'update'])->middleware('permission:customers.edit'); + Route::delete('/{customer}', [CustomerController::class, 'destroy'])->middleware('permission:customers.delete'); +}); + +// Sales Orders +Route::prefix('sales-orders')->group(function () { + Route::middleware('permission:sales.view')->group(function () { + Route::get('/', [SalesOrderController::class, 'index']); + Route::get('/statistics', [SalesOrderController::class, 'statistics']); + Route::get('/statuses', [SalesOrderController::class, 'statuses']); + Route::get('/{salesOrder}', [SalesOrderController::class, 'show']); + }); + + Route::post('/', [SalesOrderController::class, 'store'])->middleware('permission:sales.create'); + + Route::middleware('permission:sales.edit')->group(function () { + Route::put('/{salesOrder}', [SalesOrderController::class, 'update']); + Route::post('/{salesOrder}/submit', [SalesOrderController::class, 'submitForApproval']); + Route::post('/{salesOrder}/confirm', [SalesOrderController::class, 'confirm']); + Route::post('/{salesOrder}/cancel', [SalesOrderController::class, 'cancel']); + }); + + Route::middleware('permission:sales.approve')->group(function () { + Route::post('/{salesOrder}/approve', [SalesOrderController::class, 'approve']); + Route::post('/{salesOrder}/reject', [SalesOrderController::class, 'reject']); + }); + + Route::middleware('permission:sales.ship')->group(function () { + Route::post('/{salesOrder}/mark-as-shipped', [SalesOrderController::class, 'markAsShipped']); + Route::post('/{salesOrder}/mark-as-delivered', [SalesOrderController::class, 'markAsDelivered']); + }); + + Route::delete('/{salesOrder}', [SalesOrderController::class, 'destroy'])->middleware('permission:sales.delete'); +}); + +// Delivery Notes +Route::prefix('delivery-notes')->group(function () { + Route::middleware('permission:sales.view')->group(function () { + Route::get('/', [DeliveryNoteController::class, 'index']); + Route::get('/statuses', [DeliveryNoteController::class, 'statuses']); + Route::get('/for-sales-order/{salesOrder}', [DeliveryNoteController::class, 'forSalesOrder']); + Route::get('/{deliveryNote}', [DeliveryNoteController::class, 'show']); + }); + + Route::post('/', [DeliveryNoteController::class, 'store'])->middleware('permission:sales.create'); + Route::put('/{deliveryNote}', [DeliveryNoteController::class, 'update'])->middleware('permission:sales.edit'); + + Route::middleware('permission:sales.ship')->group(function () { + Route::post('/{deliveryNote}/confirm', [DeliveryNoteController::class, 'confirm']); + Route::post('/{deliveryNote}/mark-as-shipped', [DeliveryNoteController::class, 'ship']); + Route::post('/{deliveryNote}/mark-as-delivered', [DeliveryNoteController::class, 'markAsDelivered']); + Route::post('/{deliveryNote}/cancel', [DeliveryNoteController::class, 'cancel']); + }); + + Route::delete('/{deliveryNote}', [DeliveryNoteController::class, 'destroy'])->middleware('permission:sales.delete'); +}); diff --git a/backend/tests/Unit/Services/MrpServiceTest.php b/backend/tests/Unit/Services/MrpServiceTest.php new file mode 100644 index 0000000..8972d9d --- /dev/null +++ b/backend/tests/Unit/Services/MrpServiceTest.php @@ -0,0 +1,531 @@ +company = Company::create([ + 'name' => 'Test Company', + 'code' => 'TEST', + 'tax_id' => '1234567890', + ]); + + $this->user = User::create([ + 'company_id' => $this->company->id, + 'first_name' => 'Test', + 'last_name' => 'User', + 'email' => 'test@example.com', + 'password' => bcrypt('password'), + ]); + + $this->warehouse = Warehouse::create([ + 'company_id' => $this->company->id, + 'code' => 'WH-001', + 'name' => 'Test Warehouse', + 'warehouse_type' => 'finished_goods', + 'is_active' => true, + ]); + + // Set up MRP settings + Setting::create([ + 'group' => 'mrp', + 'key' => 'working_days', + 'value' => [1, 2, 3, 4, 5], // Monday to Friday + 'is_system' => true, + ]); + + // Create default UOM + $this->uom = UnitOfMeasure::firstOrCreate( + ['code' => 'pcs', 'company_id' => $this->company->id], + [ + 'name' => 'Piece', + 'uom_type' => 'quantity', + 'is_active' => true, + ] + ); + + // Create service instances + $this->mrpService = app(MrpService::class); + + // Authenticate user + Auth::login($this->user); + } + + /** @test */ + public function it_can_create_and_execute_an_mrp_run() + { + // Arrange: Create a product with stock + $product = Product::create([ + 'company_id' => $this->company->id, + 'name' => 'Test Product', + 'slug' => 'test-product', + 'sku' => 'TEST-001', + 'price' => 100.00, + 'cost_price' => 50.00, + 'stock' => 0, + 'lead_time_days' => 5, + 'safety_stock' => 10, + 'reorder_point' => 20, + 'make_or_buy' => 'buy', + 'is_active' => true, + ]); + + Stock::create([ + 'company_id' => $this->company->id, + 'product_id' => $product->id, + 'warehouse_id' => $this->warehouse->id, + 'quantity_on_hand' => 15, + 'quantity_reserved' => 0, + 'unit_cost' => 50.00, + 'status' => 'available', + 'quality_status' => 'available', + ]); + + // Create a customer + $customer = Customer::create([ + 'company_id' => $this->company->id, + 'customer_code' => 'CUST-001', + 'name' => 'Test Customer', + 'email' => 'customer@example.com', + ]); + + // Create a sales order + $salesOrder = SalesOrder::create([ + 'company_id' => $this->company->id, + 'customer_id' => $customer->id, + 'warehouse_id' => $this->warehouse->id, + 'order_number' => 'SO-001', + 'order_date' => now(), + 'status' => SalesOrderStatus::CONFIRMED, + 'total_amount' => 5000.00, + ]); + + SalesOrderItem::create([ + 'sales_order_id' => $salesOrder->id, + 'product_id' => $product->id, + 'uom_id' => $this->uom->id, + 'quantity_ordered' => 50, + 'quantity' => 50, + 'unit_price' => 100.00, + 'required_date' => now()->addDays(10), + ]); + + // Act: Run MRP + $run = $this->mrpService->runMrp([ + 'name' => 'Test MRP Run', + 'planning_horizon_start' => now(), + 'planning_horizon_end' => now()->addDays(30), + ]); + + // Assert: MRP run was created and completed + $this->assertInstanceOf(MrpRun::class, $run); + $this->assertEquals(MrpRunStatus::COMPLETED, $run->status); + $this->assertGreaterThan(0, $run->products_processed); + } + + /** @test */ + public function it_generates_purchase_order_recommendations_for_low_stock() + { + // Arrange: Product with low stock + $product = Product::create([ + 'company_id' => $this->company->id, + 'name' => 'Low Stock Product', + 'slug' => 'low-stock-product', + 'sku' => 'LS-001', + 'price' => 100.00, + 'lead_time_days' => 7, + 'safety_stock' => 20, + 'reorder_point' => 30, + 'make_or_buy' => 'buy', + 'minimum_order_qty' => 50, + 'is_active' => true, + ]); + + Stock::create([ + 'company_id' => $this->company->id, + 'product_id' => $product->id, + 'warehouse_id' => $this->warehouse->id, + 'quantity_on_hand' => 10, // Below reorder point + 'quantity_reserved' => 0, + 'unit_cost' => 50.00, + 'status' => 'available', + 'quality_status' => 'available', + ]); + + // Act: Run MRP + $run = $this->mrpService->runMrp([ + 'planning_horizon_start' => now(), + 'planning_horizon_end' => now()->addDays(30), + ]); + + // Assert: Purchase order recommendation generated + $recommendations = $run->recommendations; + $this->assertGreaterThan(0, $recommendations->count()); + + $poRecommendation = $recommendations->firstWhere('type', 'PURCHASE_ORDER'); + $this->assertNotNull($poRecommendation); + $this->assertEquals($product->id, $poRecommendation->product_id); + } + + /** @test */ + public function it_calculates_low_level_codes_correctly() + { + // Arrange: Create a multi-level BOM structure + // Level 0: Finished product + $finishedProduct = Product::create([ + 'company_id' => $this->company->id, + 'name' => 'Finished Product LLC', + 'slug' => 'finished-product-llc-test', + 'sku' => 'FP-LLC-TEST-001', + 'price' => 300.00, + 'make_or_buy' => 'make', + 'is_active' => true, + ]); + + // Level 1: Sub-assembly + $subAssembly = Product::create([ + 'company_id' => $this->company->id, + 'name' => 'Sub Assembly LLC', + 'slug' => 'sub-assembly-llc-test', + 'sku' => 'SUB-LLC-TEST-001', + 'price' => 150.00, + 'make_or_buy' => 'make', + 'is_active' => true, + ]); + + // Level 2: Raw material + $rawMaterial = Product::create([ + 'company_id' => $this->company->id, + 'name' => 'Raw Material LLC', + 'slug' => 'raw-material-llc-test', + 'sku' => 'RAW-LLC-TEST-001', + 'price' => 50.00, + 'make_or_buy' => 'buy', + 'is_active' => true, + ]); + + // Create BOMs + $finishedBom = Bom::create([ + 'company_id' => $this->company->id, + 'product_id' => $finishedProduct->id, + 'bom_number' => 'BOM-FIN', + 'name' => 'Finished Product BOM', + 'status' => \App\Enums\BomStatus::ACTIVE, + 'is_default' => true, + ]); + + BomItem::create([ + 'bom_id' => $finishedBom->id, + 'component_id' => $subAssembly->id, + 'line_number' => 1, + 'quantity' => 1, + 'uom_id' => $this->uom->id, + ]); + + $subBom = Bom::create([ + 'company_id' => $this->company->id, + 'product_id' => $subAssembly->id, + 'bom_number' => 'BOM-SUB', + 'name' => 'Sub Assembly BOM', + 'status' => \App\Enums\BomStatus::ACTIVE, + 'is_default' => true, + ]); + + BomItem::create([ + 'bom_id' => $subBom->id, + 'component_id' => $rawMaterial->id, + 'line_number' => 1, + 'quantity' => 2, + 'uom_id' => $this->uom->id, + ]); + + // Act: Run MRP (this will calculate LLC) + $run = $this->mrpService->runMrp([ + 'planning_horizon_start' => now(), + 'planning_horizon_end' => now()->addDays(30), + ]); + + // Assert: Low-level codes are correct + $finishedProduct->refresh(); + $subAssembly->refresh(); + $rawMaterial->refresh(); + + $this->assertEquals(0, $finishedProduct->low_level_code); + $this->assertEquals(1, $subAssembly->low_level_code); + $this->assertEquals(2, $rawMaterial->low_level_code); + } + + /** @test */ + public function it_handles_dependent_demand_from_bom_explosion() + { + // Arrange: Create finished product with BOM + $finishedProduct = Product::create([ + 'company_id' => $this->company->id, + 'name' => 'Finished Product BOM', + 'slug' => 'finished-product-bom', + 'sku' => 'FP-BOM-001', + 'price' => 200.00, + 'make_or_buy' => 'make', + 'lead_time_days' => 5, + 'is_active' => true, + ]); + + $component = Product::create([ + 'company_id' => $this->company->id, + 'name' => 'Component Product', + 'slug' => 'component-product', + 'sku' => 'COMP-BOM-001', + 'price' => 50.00, + 'make_or_buy' => 'buy', + 'lead_time_days' => 3, + 'is_active' => true, + ]); + + $bom = Bom::create([ + 'company_id' => $this->company->id, + 'product_id' => $finishedProduct->id, + 'bom_number' => 'BOM-002', + 'name' => 'Finished Product BOM', + 'status' => \App\Enums\BomStatus::ACTIVE, + 'is_default' => true, + ]); + + BomItem::create([ + 'bom_id' => $bom->id, + 'component_id' => $component->id, + 'line_number' => 1, + 'quantity' => 2, // 2 components per finished product + 'uom_id' => $this->uom->id, + ]); + + // Create a customer + $customer = Customer::create([ + 'company_id' => $this->company->id, + 'customer_code' => 'CUST-003', + 'name' => 'Test Customer 3', + 'email' => 'customer3@example.com', + ]); + + // Create sales order for finished product + $salesOrder = SalesOrder::create([ + 'company_id' => $this->company->id, + 'customer_id' => $customer->id, + 'warehouse_id' => $this->warehouse->id, + 'order_number' => 'SO-003', + 'order_date' => now(), + 'status' => SalesOrderStatus::CONFIRMED, + 'total_amount' => 2000.00, + ]); + + SalesOrderItem::create([ + 'sales_order_id' => $salesOrder->id, + 'product_id' => $finishedProduct->id, + 'uom_id' => $this->uom->id, + 'quantity_ordered' => 10, + 'quantity' => 10, // Need 10 finished products + 'unit_price' => 200.00, + 'required_date' => now()->addDays(10), + ]); + + // Act: Run MRP + $run = $this->mrpService->runMrp([ + 'planning_horizon_start' => now(), + 'planning_horizon_end' => now()->addDays(30), + ]); + + // Assert: Component has dependent demand + $componentRecommendations = $run->recommendations + ->where('product_id', $component->id); + + $this->assertGreaterThan(0, $componentRecommendations->count()); + + // Should need 20 components (10 finished × 2 per finished) + $totalComponentQty = $componentRecommendations->sum('quantity'); + $this->assertGreaterThanOrEqual(20, $totalComponentQty); + } + + /** @test */ + public function it_respects_lead_times_in_order_dates() + { + // Arrange: Product with lead time + $product = Product::factory()->create([ + 'company_id' => $this->company->id, + 'lead_time_days' => 10, + 'make_or_buy' => 'buy', + ]); + + $salesOrder = SalesOrder::factory()->create([ + 'company_id' => $this->company->id, + 'order_date' => now(), + 'status' => 'pending', + ]); + + SalesOrderItem::factory()->create([ + 'sales_order_id' => $salesOrder->id, + 'product_id' => $product->id, + 'quantity_ordered' => 100, + 'quantity' => 100, + 'required_date' => now()->addDays(15), // Need in 15 days + ]); + + // Act: Run MRP + $run = $this->mrpService->runMrp([ + 'planning_horizon_start' => now(), + 'planning_horizon_end' => now()->addDays(30), + 'respect_lead_times' => true, + ]); + + // Assert: Order date is before required date (considering lead time) + $recommendation = $run->recommendations->firstWhere('product_id', $product->id); + $this->assertNotNull($recommendation); + + $orderDate = \Carbon\Carbon::parse($recommendation->order_date); + $requiredDate = \Carbon\Carbon::parse($recommendation->required_date); + + // Order date should be at least lead_time_days before required date + $this->assertLessThanOrEqual( + $requiredDate->subDays(10), + $orderDate + ); + } + + /** @test */ + public function it_validates_mrp_run_data() + { + // Arrange: No products + // (products are created in setUp, but we can test with empty state) + + // Act & Assert: Should throw exception for invalid planning horizon + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Planning horizon start date must be before end date'); + + $this->mrpService->runMrp([ + 'planning_horizon_start' => now()->addDays(10), + 'planning_horizon_end' => now(), // End before start + ]); + } + + /** @test */ + public function it_uses_cache_for_low_level_codes() + { + // Arrange: Create products with BOM + $product = Product::create([ + 'company_id' => $this->company->id, + 'name' => 'Finished Product', + 'slug' => 'finished-product', + 'sku' => 'FP-001', + 'price' => 200.00, + 'make_or_buy' => 'make', + 'is_active' => true, + ]); + + $component = Product::create([ + 'company_id' => $this->company->id, + 'name' => 'Component', + 'slug' => 'component', + 'sku' => 'COMP-001', + 'price' => 50.00, + 'make_or_buy' => 'buy', + 'is_active' => true, + ]); + + $bom = Bom::create([ + 'company_id' => $this->company->id, + 'product_id' => $product->id, + 'bom_number' => 'BOM-001', + 'name' => 'Test BOM', + 'status' => \App\Enums\BomStatus::ACTIVE, + 'is_default' => true, + ]); + + BomItem::create([ + 'bom_id' => $bom->id, + 'component_id' => $component->id, + 'line_number' => 1, + 'quantity' => 1, + 'uom_id' => $this->uom->id, + ]); + + // Act: Run MRP twice + $run1 = $this->mrpService->runMrp([ + 'planning_horizon_start' => now(), + 'planning_horizon_end' => now()->addDays(30), + ]); + + // Second run should use cache + $run2 = $this->mrpService->runMrp([ + 'planning_horizon_start' => now(), + 'planning_horizon_end' => now()->addDays(30), + ]); + + // Assert: Both runs completed successfully + $this->assertEquals(MrpRunStatus::COMPLETED, $run1->status); + $this->assertEquals(MrpRunStatus::COMPLETED, $run2->status); + + // LLC should be cached (second run should be faster) + $component->refresh(); + $this->assertEquals(1, $component->low_level_code); + } + + /** @test */ + public function it_prevents_concurrent_mrp_runs() + { + // Arrange: Create a product + Product::create([ + 'company_id' => $this->company->id, + 'name' => 'Test Product', + 'slug' => 'test-product-concurrent', + 'sku' => 'TEST-CONC-001', + 'price' => 100.00, + 'is_active' => true, + ]); + + // Act: Try to run two MRP runs simultaneously + // (In real scenario, second would be blocked by lock) + $run1 = $this->mrpService->runMrp([ + 'planning_horizon_start' => now(), + 'planning_horizon_end' => now()->addDays(30), + ]); + + // Second run should either wait or fail + // (Lock mechanism prevents concurrent runs) + $this->assertEquals(MrpRunStatus::COMPLETED, $run1->status); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index c7ea6ae..5e4595d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,9 @@ services: working_dir: /var/www/backend volumes: - ./backend:/var/www/backend + depends_on: + - postgres + - redis environment: - DB_CONNECTION=${DB_CONNECTION:-pgsql} - DB_HOST=${DB_HOST:-postgres} @@ -36,8 +39,18 @@ services: - REDIS_HOST=redis - REDIS_PASSWORD=null - REDIS_PORT=6379 + - CACHE_STORE=${CACHE_STORE:-redis} + - QUEUE_CONNECTION=${QUEUE_CONNECTION:-redis} - ELASTICSEARCH_HOST=elasticsearch - ELASTICSEARCH_PORT=9200 + - MAIL_MAILER=${MAIL_MAILER:-smtp} + - MAIL_HOST=${MAIL_HOST:-mailpit} + - MAIL_PORT=${MAIL_PORT:-1025} + - MAIL_USERNAME=${MAIL_USERNAME:-} + - MAIL_PASSWORD=${MAIL_PASSWORD:-} + - MAIL_ENCRYPTION=${MAIL_ENCRYPTION:-} + - MAIL_FROM_ADDRESS=${MAIL_FROM_ADDRESS:-noreply@smartstock.local} + - MAIL_FROM_NAME=${MAIL_FROM_NAME:-Smart Stock Management} networks: - sms_network @@ -57,10 +70,6 @@ services: - postgres_data:/var/lib/postgresql/data networks: - sms_network - profiles: - - postgres - - database - - all # MySQL Database (Optional) mysql: @@ -94,8 +103,25 @@ services: - redis_data:/data networks: - sms_network + + # Redis Commander - Redis GUI Tool (Development) + redis-commander: + image: rediscommander/redis-commander:latest + container_name: sms_redis_commander + restart: unless-stopped + ports: + - "8081:8081" + environment: + - REDIS_HOSTS=local:redis:6379 + - HTTP_USER=admin + - HTTP_PASSWORD=${REDIS_COMMANDER_PASSWORD:-admin} + depends_on: + - redis + networks: + - sms_network profiles: - redis + - dev - all # Node.js for React Development (Optional - Full-stack mode only) @@ -183,21 +209,19 @@ services: - database - all - # Redis Commander (Redis GUI - Optional) - redis-commander: - image: rediscommander/redis-commander:latest - container_name: sms_redis_commander + # Mailpit - Email Testing Tool (Development) + mailpit: + image: axllent/mailpit:latest + container_name: sms_mailpit restart: unless-stopped ports: - - "8081:8081" - environment: - - REDIS_HOSTS=local:redis:6379 - depends_on: - - redis + - "1025:1025" # SMTP port + - "8025:8025" # Web UI port networks: - sms_network profiles: - - redis + - mail + - dev - all networks: