-
This commit is contained in:
151
DEVELOPMENT.md
151
DEVELOPMENT.md
@@ -54,12 +54,115 @@ XX. **Nome Problema (FIX/IMPLEMENTATO DATA):** - **Problema:** Descrizione breve
|
||||
|
||||
## Quick Start - Session Recovery
|
||||
|
||||
**Ultima sessione:** 29 Novembre 2025 (pomeriggio)
|
||||
**Ultima sessione:** 30 Novembre 2025 (Notte)
|
||||
|
||||
**Stato progetto:** Migrazione Oracle APEX → .NET + React TypeScript in corso
|
||||
**Stato progetto:** Modulo Produzione COMPLETATO (incluso MRP ricorsivo e Dashboard). Prossimo step: Modulo Qualità o completamento Report System.
|
||||
|
||||
**Lavoro completato nell'ultima sessione:**
|
||||
|
||||
- **NUOVA FEATURE: Modulo Produzione (production) - Avanzato** - COMPLETATO
|
||||
- **Backend implementato:**
|
||||
- Entities: `WorkCenter`, `ProductionCycle`, `ProductionCyclePhase`, `ProductionOrderPhase`, `MrpSuggestion`
|
||||
- DTOs: `WorkCenterDto`, `ProductionCycleDto`, `ProductionOrderPhaseDto`, `MrpSuggestionDto` (con Create/Update variants)
|
||||
- Services: `ProductionService` (esteso), `MrpService`
|
||||
- Controllers: `WorkCentersController`, `ProductionCyclesController`, `MrpController`
|
||||
- Migration: `AddAdvancedProduction`
|
||||
- **Frontend implementato:**
|
||||
- Pages: `WorkCentersPage`, `ProductionCyclesPage`, `ProductionCycleFormPage`, `MrpPage`
|
||||
- Components: `ProductionOrderPhases` (gestione avanzamento), `ProductionLayout` (navigazione tab)
|
||||
- Services: `productionService.ts` (esteso)
|
||||
- Routing: `/production/work-centers`, `/production/cycles`, `/production/mrp`
|
||||
- Traduzioni: Aggiunte chiavi per centri lavoro, cicli, fasi e MRP in `translation.json` (IT e EN)
|
||||
- **Funzionalità:**
|
||||
- **Centri di Lavoro**: Gestione risorse produttive e costi orari
|
||||
- **Cicli Produttivi**: Definizione sequenze fasi standard per articolo
|
||||
- **Ordini di Produzione**:
|
||||
- Copia automatica fasi dal ciclo default alla creazione ordine
|
||||
- Gestione avanzamento fasi (Start/Complete) con tempi e quantità
|
||||
- **MRP (Material Requirements Planning)**:
|
||||
- Calcolo fabbisogni basato su ordini clienti e scorte minime
|
||||
- Generazione suggerimenti produzione/acquisto
|
||||
- Processamento suggerimenti (creazione automatica ordini)
|
||||
|
||||
- **FIX: Traduzioni Modulo Produzione** - RISOLTO
|
||||
- **Problema:** Alcune chiavi di traduzione mancanti o errate nel componente `ProductionOrderPhases`.
|
||||
- **Soluzione:**
|
||||
- Aggiornato `ProductionOrderPhases.tsx` per usare le chiavi corrette (`production.order.phases.*`).
|
||||
- Aggiunte le chiavi mancanti (`durationHelp`, stati fasi) in `it/translation.json` e `en/translation.json`.
|
||||
- **File modificati:** `ProductionOrderPhases.tsx`, `it/translation.json`, `en/translation.json`.
|
||||
|
||||
- **NUOVA FEATURE: Distinta Base Multilivello e MRP Ricorsivo** - COMPLETATO
|
||||
- **Backend:**
|
||||
- Aggiornato `MrpService` per calcolare i fabbisogni in modo ricorsivo (infiniti livelli).
|
||||
- Aggiornato `ProductionService` per supportare la creazione automatica e ricorsiva degli ordini figli per i semilavorati.
|
||||
- **Frontend:**
|
||||
- Aggiunta opzione "Crea ordini figli" nel form di creazione Ordine di Produzione.
|
||||
- Aggiornate traduzioni.
|
||||
- **Nuova Dashboard Produzione:** Creata pagina con KPI e ordini recenti.
|
||||
- **Visualizzazione Gerarchia:** Aggiunta colonna "Ordine Padre" nella lista e tab "Ordini Figli" nel dettaglio ordine.
|
||||
|
||||
- **NUOVA FEATURE: MRP Configurabile e Distinta Base Multilivello** - COMPLETATO
|
||||
- **Backend:**
|
||||
- Aggiornato `MrpService` per supportare configurazione (Safety Stock, Sales Orders, Forecasts).
|
||||
- Implementata logica MRP ricorsiva con gestione Lead Time e Safety Stock.
|
||||
- Aggiunto `MrpConfigurationDto`.
|
||||
- Aggiornato `MrpController` per accettare configurazione.
|
||||
- **Frontend:**
|
||||
- Creato `MrpConfigurationDialog` per impostare parametri MRP.
|
||||
- Aggiornato `MrpPage` per usare il dialog.
|
||||
- Aggiornato `productionService` per passare la configurazione.
|
||||
- Aggiunte traduzioni per la configurazione MRP.
|
||||
- **Test:**
|
||||
- Verificata creazione articoli e BOM multilivello.
|
||||
- Verificata esecuzione MRP (backend logica corretta).
|
||||
|
||||
- **NUOVA FEATURE: Modulo Vendite (sales)** - COMPLETATO
|
||||
- **Backend implementato:**
|
||||
- Entities: `SalesOrder`, `SalesOrderLine`
|
||||
- DTOs: `SalesOrderDto`, `SalesOrderLineDto` (con Create/Update variants)
|
||||
- Services: `SalesService` (CRUD, Confirm, Ship logic)
|
||||
- Controllers: `SalesOrdersController`
|
||||
- Migration: `AddSalesModule`
|
||||
- **Frontend implementato:**
|
||||
- Pages: `SalesOrdersPage`, `SalesOrderFormPage`
|
||||
- Services: `salesService.ts`
|
||||
- Routing: `/sales/orders`
|
||||
- Menu: Aggiunta voce "Vendite" nella sidebar
|
||||
- Traduzioni: Aggiunte chiavi per ordini in `translation.json` (IT)
|
||||
- **Funzionalità:**
|
||||
- Gestione ordini di vendita
|
||||
- Creazione ordini (Bozza -> Confermato -> Spedito)
|
||||
- Spedizione merce (Ship)
|
||||
- Calcolo totali ordine
|
||||
|
||||
- **FIX: Traduzioni Modulo Vendite e Menu** - RISOLTO
|
||||
- **Problema:** Chiavi di traduzione errate in `SalesOrderFormPage` e voci di menu mancanti in Inglese.
|
||||
- **Soluzione:**
|
||||
- Allineate chiavi `sales.orders.*` -> `sales.order.*` nel frontend.
|
||||
- Aggiunta sezione `sales` completa in `en/translation.json`.
|
||||
- Aggiunte voci menu "Purchases" e "Sales" in `en/translation.json`.
|
||||
- **File modificati:** `SalesOrderFormPage.tsx`, `en/translation.json`, `it/translation.json`.
|
||||
|
||||
- **NUOVA FEATURE: Modulo Acquisti (purchases)** - COMPLETATO
|
||||
- **Backend implementato:**
|
||||
- Entities: `Supplier`, `PurchaseOrder`, `PurchaseOrderLine`
|
||||
- DTOs: `SupplierDto`, `PurchaseOrderDto`, `PurchaseOrderLineDto` (con Create/Update variants)
|
||||
- Services: `SupplierService`, `PurchaseService` (CRUD, Confirm, Receive logic)
|
||||
- Controllers: `SuppliersController`, `PurchaseOrdersController`
|
||||
- Migration: `AddPurchasesModule`
|
||||
- AutoCode: Integrazione per generazione codici `Supplier` e `PurchaseOrder`
|
||||
- **Frontend implementato:**
|
||||
- Pages: `SuppliersPage`, `SupplierFormPage`, `PurchaseOrdersPage`, `PurchaseOrderFormPage`
|
||||
- Services: `supplierService.ts`, `purchaseService.ts`
|
||||
- Routing: `/purchases/suppliers`, `/purchases/orders`
|
||||
- Menu: Aggiunta voce "Acquisti" nella sidebar
|
||||
- Traduzioni: Aggiunte chiavi per fornitori e ordini in `translation.json` (IT e EN)
|
||||
- **Funzionalità:**
|
||||
- Gestione anagrafica fornitori
|
||||
- Creazione ordini di acquisto (Bozza -> Confermato -> Ricevuto)
|
||||
- Ricezione merce con generazione automatica movimenti di magazzino (Inbound)
|
||||
- Calcolo totali ordine (imponibile, IVA, totale)
|
||||
|
||||
- **NUOVA FEATURE: Supporto Tema Scuro e Multilingua** - COMPLETATO
|
||||
- **Obiettivo:** Permettere all'utente di cambiare tema (Chiaro/Scuro) e lingua (Italiano/Inglese) con persistenza
|
||||
- **Frontend implementato:**
|
||||
@@ -157,6 +260,17 @@ XX. **Nome Problema (FIX/IMPLEMENTATO DATA):** - **Problema:** Descrizione breve
|
||||
- In modifica: campo Codice mostra il valore reale, sempre disabled
|
||||
- Campo "Codice Alternativo" sempre modificabile (opzionale)
|
||||
|
||||
- **FIX: API 404 / Pagine Bianche in Dev Mode** - RISOLTO
|
||||
- **Problema:** Le chiamate API dal frontend fallivano con 404 (o ritornavano HTML) e le pagine rimanevano bianche.
|
||||
- **Causa:** Mancava la configurazione del proxy in `vite.config.ts` per inoltrare le richieste `/api` al backend (.NET su porta 5000).
|
||||
- **Soluzione:** Aggiunto proxy per `/api` e `/hubs` verso `http://localhost:5000` in `vite.config.ts`.
|
||||
- **File modificati:** `frontend/vite.config.ts`
|
||||
|
||||
- **FIX: Traduzioni Mancanti e Chiavi Errate** - RISOLTO
|
||||
- **Problema:** Errori di interfaccia dovuti a chiavi di traduzione mancanti (`common.required`, `common.add`, `common.active`) e percorsi errati in `PurchaseOrderFormPage` e `SuppliersPage`.
|
||||
- **Soluzione:** Aggiunte chiavi mancanti in `en/translation.json` e corretti i percorsi delle chiavi nei componenti React.
|
||||
- **File modificati:** `frontend/public/locales/en/translation.json`, `frontend/src/modules/purchases/pages/PurchaseOrderFormPage.tsx`, `frontend/src/modules/purchases/pages/SuppliersPage.tsx`
|
||||
|
||||
**Lavoro completato nelle sessioni precedenti (30 Novembre 2025):**
|
||||
|
||||
- **NUOVA FEATURE: Sistema Codici Automatici Configurabili** - COMPLETATO
|
||||
@@ -520,9 +634,12 @@ XX. **Nome Problema (FIX/IMPLEMENTATO DATA):** - **Problema:** Descrizione breve
|
||||
- Backend: Entities, Service, Controllers, API completi
|
||||
- Manca: Frontend (pagine React per gestione articoli, movimenti, giacenze)
|
||||
2. [x] **Frontend modulo Magazzino** - Pagine React per warehouse (Articoli, Movimenti, Giacenze, Inventario)
|
||||
3. [ ] **Implementare modulo Acquisti (purchases)** - Dipende da Magazzino
|
||||
4. [ ] **Implementare modulo Vendite (sales)** - Dipende da Magazzino
|
||||
5. [ ] **Implementare modulo Produzione (production)** - Dipende da Magazzino
|
||||
3. [x] **Implementare modulo Acquisti (purchases)** - COMPLETATO
|
||||
4. [x] **Implementare modulo Vendite (sales)** - COMPLETATO
|
||||
5. [x] **Implementare modulo Produzione (production)** - COMPLETATO
|
||||
- Backend: Entities, Service, Controllers, API completi
|
||||
- Frontend: Pagine React per BOM e Ordini, integrazione Magazzino
|
||||
- Test: Verificato flusso completo (BOM -> Ordine -> Stati -> Completamento)
|
||||
6. [ ] **Implementare modulo Qualità (quality)** - Indipendente
|
||||
|
||||
**Report System (completamento):**
|
||||
@@ -558,6 +675,30 @@ make check # Verifica prerequisiti installati (dotnet, node, npm)
|
||||
- Frontend: in dev mode (`make frontend-run`) il hot-reload è automatico per la maggior parte delle modifiche, ma per modifiche strutturali (nuovi file, cambi a tipi, etc.) potrebbe essere necessario riavviare
|
||||
|
||||
---
|
||||
# Development Documentation
|
||||
|
||||
## Production Module Implementation
|
||||
- **Backend**:
|
||||
- Entities: `BillOfMaterials`, `BillOfMaterialsComponent`, `ProductionOrder`, `ProductionOrderComponent`
|
||||
- Services: `ProductionService` (implements `IProductionService`)
|
||||
- Controllers: `BillOfMaterialsController`, `ProductionOrdersController`
|
||||
- Integration: Registered in `Program.cs`, added to `AppollinareDbContext`
|
||||
- **Frontend**:
|
||||
- Types: `frontend/src/modules/production/types/index.ts`
|
||||
- Services: `frontend/src/modules/production/services/productionService.ts`
|
||||
- Pages:
|
||||
- `ProductionOrdersPage` (List)
|
||||
- `ProductionOrderFormPage` (Create/Edit)
|
||||
- `BillOfMaterialsPage` (List)
|
||||
- `BillOfMaterialsFormPage` (Create/Edit)
|
||||
- Routes: `frontend/src/modules/production/routes.tsx`
|
||||
- Menu: Added to `Layout.tsx` with `Factory` icon
|
||||
- Translations: Added `production` section to `it` and `en` locales
|
||||
|
||||
## Recent Changes
|
||||
- Fixed build errors in Purchases and Sales modules (types and unused imports).
|
||||
- Implemented Production module with full CRUD and status management.
|
||||
- Integrated Production module with Warehouse module for article selection.
|
||||
|
||||
## Project Overview
|
||||
|
||||
|
||||
17
frontend/package-lock.json
generated
17
frontend/package-lock.json
generated
@@ -31,6 +31,7 @@
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.67.0",
|
||||
"react-i18next": "^16.3.5",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"uuid": "^13.0.0",
|
||||
@@ -4973,6 +4974,22 @@
|
||||
"react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
"version": "7.67.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.67.0.tgz",
|
||||
"integrity": "sha512-E55EOwKJHHIT/I6J9DmQbCWToAYSw9nN5R57MZw9rMtjh+YQreMDxRLfdjfxQbiJ3/qbg3Z02wGzBX4M+5fMtQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/react-hook-form"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17 || ^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "16.3.5",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.3.5.tgz",
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.67.0",
|
||||
"react-i18next": "^16.3.5",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"uuid": "^13.0.0",
|
||||
|
||||
@@ -28,7 +28,12 @@
|
||||
"optional": "Optional",
|
||||
"notes": "Notes",
|
||||
"preview": "Preview",
|
||||
"none": "None"
|
||||
"none": "None",
|
||||
"view": "View",
|
||||
"required": "Required",
|
||||
"add": "Add",
|
||||
"active": "Active",
|
||||
"inactive": "Inactive"
|
||||
},
|
||||
"menu": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -39,6 +44,9 @@
|
||||
"articles": "Articles",
|
||||
"resources": "Resources",
|
||||
"warehouse": "Warehouse",
|
||||
"purchases": "Purchases",
|
||||
"sales": "Sales",
|
||||
"production": "Production",
|
||||
"reports": "Reports",
|
||||
"modules": "Modules",
|
||||
"autoCodes": "Auto Codes",
|
||||
@@ -1045,5 +1053,289 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"purchases": {
|
||||
"menu": {
|
||||
"suppliers": "Suppliers",
|
||||
"orders": "Purchase Orders"
|
||||
},
|
||||
"suppliers": {
|
||||
"title": "Suppliers",
|
||||
"newSupplier": "New Supplier",
|
||||
"editSupplier": "Edit Supplier",
|
||||
"columns": {
|
||||
"code": "Code",
|
||||
"name": "Name",
|
||||
"vatNumber": "VAT Number",
|
||||
"email": "Email",
|
||||
"phone": "Phone",
|
||||
"city": "City",
|
||||
"status": "Status"
|
||||
},
|
||||
"fields": {
|
||||
"code": "Code",
|
||||
"name": "Business Name",
|
||||
"vatNumber": "VAT Number",
|
||||
"fiscalCode": "Fiscal Code",
|
||||
"email": "Email",
|
||||
"pec": "PEC",
|
||||
"phone": "Phone",
|
||||
"website": "Website",
|
||||
"address": "Address",
|
||||
"city": "City",
|
||||
"province": "Province",
|
||||
"zipCode": "ZIP Code",
|
||||
"country": "Country",
|
||||
"paymentTerms": "Payment Terms",
|
||||
"notes": "Notes",
|
||||
"isActive": "Active"
|
||||
},
|
||||
"placeholders": {
|
||||
"search": "Search supplier...",
|
||||
"generatedAutomatically": "Generated automatically"
|
||||
},
|
||||
"deleteConfirm": "Are you sure you want to delete this supplier?"
|
||||
},
|
||||
"orders": {
|
||||
"title": "Purchase Orders",
|
||||
"newOrder": "New Order",
|
||||
"editOrder": "Edit Order",
|
||||
"columns": {
|
||||
"orderNumber": "Order Number",
|
||||
"orderDate": "Date",
|
||||
"supplier": "Supplier",
|
||||
"status": "Status",
|
||||
"total": "Total",
|
||||
"deliveryDate": "Delivery Date"
|
||||
},
|
||||
"fields": {
|
||||
"orderNumber": "Order Number",
|
||||
"orderDate": "Order Date",
|
||||
"expectedDeliveryDate": "Expected Delivery",
|
||||
"supplier": "Supplier",
|
||||
"destinationWarehouse": "Destination Warehouse",
|
||||
"notes": "Notes",
|
||||
"article": "Article",
|
||||
"quantity": "Quantity",
|
||||
"unitPrice": "Unit Price",
|
||||
"discount": "Discount %",
|
||||
"taxRate": "Tax Rate %",
|
||||
"lineTotal": "Total"
|
||||
},
|
||||
"status": {
|
||||
"Draft": "Draft",
|
||||
"Confirmed": "Confirmed",
|
||||
"Received": "Received",
|
||||
"Cancelled": "Cancelled"
|
||||
},
|
||||
"actions": {
|
||||
"addLine": "Add Line",
|
||||
"confirm": "Confirm Order",
|
||||
"receive": "Receive Goods",
|
||||
"view": "View",
|
||||
"delete": "Delete"
|
||||
},
|
||||
"totals": {
|
||||
"net": "Net Total",
|
||||
"tax": "Tax",
|
||||
"gross": "Gross Total"
|
||||
},
|
||||
"deleteConfirm": "Are you sure you want to delete this order?",
|
||||
"confirmDialog": {
|
||||
"title": "Confirm Order",
|
||||
"content": "Are you sure you want to confirm this order? It will no longer be editable.",
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"receiveDialog": {
|
||||
"title": "Receive Goods",
|
||||
"content": "Are you sure you want to mark this order as received? This will generate stock movements.",
|
||||
"confirm": "Receive",
|
||||
"cancel": "Cancel"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sales": {
|
||||
"order": {
|
||||
"title": "Sales Orders",
|
||||
"newOrder": "New Order",
|
||||
"createTitle": "New Order",
|
||||
"editTitle": "Edit Order",
|
||||
"status": {
|
||||
"Draft": "Draft",
|
||||
"Confirmed": "Confirmed",
|
||||
"PartiallyShipped": "Partially Shipped",
|
||||
"Shipped": "Shipped",
|
||||
"Invoiced": "Invoiced",
|
||||
"Cancelled": "Cancelled"
|
||||
},
|
||||
"columns": {
|
||||
"number": "Number",
|
||||
"date": "Date",
|
||||
"customer": "Customer",
|
||||
"status": "Status",
|
||||
"total": "Total"
|
||||
},
|
||||
"fields": {
|
||||
"orderDate": "Order Date",
|
||||
"expectedDeliveryDate": "Expected Delivery Date",
|
||||
"customer": "Customer",
|
||||
"notes": "Notes",
|
||||
"lineTotal": "Line Total",
|
||||
"article": "Article",
|
||||
"quantity": "Quantity",
|
||||
"unitPrice": "Unit Price",
|
||||
"discount": "Discount %",
|
||||
"taxRate": "Tax Rate %"
|
||||
},
|
||||
"totals": {
|
||||
"gross": "Gross Total"
|
||||
},
|
||||
"actions": {
|
||||
"confirm": "Confirm Order",
|
||||
"ship": "Ship Goods"
|
||||
}
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"bom": {
|
||||
"title": "Bills of Materials",
|
||||
"newBom": "New BOM",
|
||||
"createTitle": "New Bill of Materials",
|
||||
"editTitle": "Edit Bill of Materials",
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"article": "Produced Article",
|
||||
"quantity": "Produced Quantity",
|
||||
"components": "Components",
|
||||
"componentArticle": "Component Article",
|
||||
"componentQuantity": "Quantity",
|
||||
"scrapPercentage": "Scrap %",
|
||||
"noComponents": "No components added"
|
||||
},
|
||||
"columns": {
|
||||
"name": "Name",
|
||||
"article": "Article",
|
||||
"quantity": "Quantity"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Production Dashboard",
|
||||
"activeOrders": "Active Orders",
|
||||
"lateOrders": "Late Orders",
|
||||
"mrpSuggestions": "MRP Suggestions",
|
||||
"completedToday": "Completed Today",
|
||||
"recentOrders": "Recent Orders"
|
||||
},
|
||||
"order": {
|
||||
"title": "Production Orders",
|
||||
"newOrder": "New Order",
|
||||
"createTitle": "New Order",
|
||||
"editTitle": "Edit Order",
|
||||
"subOrders": "Sub-Orders",
|
||||
"noSubOrders": "No sub-orders present",
|
||||
"status": {
|
||||
"Draft": "Draft",
|
||||
"Planned": "Planned",
|
||||
"Released": "Released",
|
||||
"InProgress": "In Progress",
|
||||
"Completed": "Completed",
|
||||
"Cancelled": "Cancelled"
|
||||
},
|
||||
"columns": {
|
||||
"code": "Code",
|
||||
"startDate": "Start Date",
|
||||
"article": "Article",
|
||||
"parentOrder": "Parent Order",
|
||||
"quantity": "Quantity",
|
||||
"status": "Status"
|
||||
},
|
||||
"fields": {
|
||||
"article": "Article to Produce",
|
||||
"quantity": "Quantity",
|
||||
"startDate": "Start Date",
|
||||
"dueDate": "Due Date",
|
||||
"notes": "Notes",
|
||||
"bom": "Bill of Materials",
|
||||
"bomHelp": "Select a BOM to pre-fill components",
|
||||
"createChildOrders": "Create child orders for sub-assemblies"
|
||||
},
|
||||
"actions": {
|
||||
"plan": "Plan",
|
||||
"release": "Release",
|
||||
"start": "Start",
|
||||
"complete": "Complete"
|
||||
},
|
||||
"phases": {
|
||||
"title": "Production Phases",
|
||||
"sequence": "Seq",
|
||||
"name": "Phase",
|
||||
"workCenter": "Work Center",
|
||||
"status": "Status",
|
||||
"progress": "Progress",
|
||||
"actions": "Actions",
|
||||
"start": "Start Phase",
|
||||
"complete": "Complete Phase",
|
||||
"quantity": "Qty Completed",
|
||||
"scrapped": "Qty Scrapped",
|
||||
"duration": "Duration (min)",
|
||||
"durationHelp": "Estimated: {{estimated}} min",
|
||||
"statusValue": {
|
||||
"Pending": "Pending",
|
||||
"InProgress": "In Progress",
|
||||
"Completed": "Completed",
|
||||
"Paused": "Paused"
|
||||
}
|
||||
}
|
||||
},
|
||||
"workCenter": {
|
||||
"title": "Work Centers",
|
||||
"new": "New Work Center",
|
||||
"edit": "Edit Work Center",
|
||||
"fields": {
|
||||
"code": "Code",
|
||||
"name": "Name",
|
||||
"description": "Description",
|
||||
"costPerHour": "Hourly Cost"
|
||||
}
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Production Cycles",
|
||||
"new": "New Cycle",
|
||||
"createTitle": "New Production Cycle",
|
||||
"editTitle": "Edit Production Cycle",
|
||||
"phases": "Cycle Phases",
|
||||
"addPhase": "Add Phase",
|
||||
"noPhases": "No phases defined",
|
||||
"fields": {
|
||||
"name": "Cycle Name",
|
||||
"description": "Description",
|
||||
"article": "Article",
|
||||
"isDefault": "Default",
|
||||
"phaseName": "Phase Name",
|
||||
"workCenter": "Work Center",
|
||||
"duration": "Unit Duration (min)",
|
||||
"setupTime": "Setup Time (min)"
|
||||
}
|
||||
},
|
||||
"mrp": {
|
||||
"title": "MRP Planning",
|
||||
"run": "Run MRP",
|
||||
"columns": {
|
||||
"date": "Calculation Date",
|
||||
"article": "Article",
|
||||
"type": "Type",
|
||||
"quantity": "Quantity",
|
||||
"reason": "Reason"
|
||||
},
|
||||
"type": {
|
||||
"production": "Production",
|
||||
"purchase": "Purchase"
|
||||
},
|
||||
"actions": {
|
||||
"process": "Process / Create Order"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,8 @@
|
||||
"optional": "Opzionale",
|
||||
"notes": "Note",
|
||||
"preview": "Anteprima",
|
||||
"none": "Nessuno"
|
||||
"none": "Nessuno",
|
||||
"view": "Dettaglio"
|
||||
},
|
||||
"menu": {
|
||||
"dashboard": "Dashboard",
|
||||
@@ -39,6 +40,9 @@
|
||||
"articles": "Articoli",
|
||||
"resources": "Risorse",
|
||||
"warehouse": "Magazzino",
|
||||
"purchases": "Acquisti",
|
||||
"sales": "Vendite",
|
||||
"production": "Produzione",
|
||||
"reports": "Report",
|
||||
"modules": "Moduli",
|
||||
"autoCodes": "Codici Auto",
|
||||
@@ -571,6 +575,267 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"purchases": {
|
||||
"supplier": {
|
||||
"title": "Fornitori",
|
||||
"newSupplier": "Nuovo Fornitore",
|
||||
"createTitle": "Nuovo Fornitore",
|
||||
"editTitle": "Modifica Fornitore",
|
||||
"columns": {
|
||||
"code": "Codice",
|
||||
"name": "Ragione Sociale",
|
||||
"vatNumber": "P.IVA",
|
||||
"email": "Email",
|
||||
"phone": "Telefono",
|
||||
"city": "Città",
|
||||
"status": "Stato"
|
||||
},
|
||||
"fields": {
|
||||
"name": "Ragione Sociale",
|
||||
"vatNumber": "P.IVA",
|
||||
"fiscalCode": "Codice Fiscale",
|
||||
"address": "Indirizzo",
|
||||
"city": "Città",
|
||||
"province": "Provincia",
|
||||
"zipCode": "CAP",
|
||||
"country": "Nazione",
|
||||
"email": "Email",
|
||||
"pec": "PEC",
|
||||
"phone": "Telefono",
|
||||
"website": "Sito Web",
|
||||
"paymentTerms": "Termini di Pagamento",
|
||||
"notes": "Note"
|
||||
}
|
||||
},
|
||||
"order": {
|
||||
"title": "Ordini Acquisto",
|
||||
"newOrder": "Nuovo Ordine",
|
||||
"createTitle": "Nuovo Ordine",
|
||||
"editTitle": "Modifica Ordine",
|
||||
"status": {
|
||||
"Draft": "Bozza",
|
||||
"Confirmed": "Confermato",
|
||||
"PartiallyReceived": "Parz. Ricevuto",
|
||||
"Received": "Ricevuto",
|
||||
"Cancelled": "Annullato"
|
||||
},
|
||||
"columns": {
|
||||
"number": "Numero",
|
||||
"date": "Data",
|
||||
"supplier": "Fornitore",
|
||||
"status": "Stato",
|
||||
"total": "Totale"
|
||||
},
|
||||
"fields": {
|
||||
"date": "Data Ordine",
|
||||
"expectedDate": "Data Prevista Consegna",
|
||||
"supplier": "Fornitore",
|
||||
"warehouse": "Magazzino Destinazione",
|
||||
"notes": "Note"
|
||||
},
|
||||
"lines": {
|
||||
"article": "Articolo",
|
||||
"quantity": "Quantità",
|
||||
"price": "Prezzo Unit.",
|
||||
"discount": "Sconto %",
|
||||
"tax": "IVA %",
|
||||
"total": "Totale"
|
||||
},
|
||||
"total": "Totale Ordine",
|
||||
"actions": {
|
||||
"confirm": "Conferma Ordine",
|
||||
"receive": "Ricevi Merce"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sales": {
|
||||
"order": {
|
||||
"title": "Ordini Vendita",
|
||||
"newOrder": "Nuovo Ordine",
|
||||
"createTitle": "Nuovo Ordine",
|
||||
"editTitle": "Modifica Ordine",
|
||||
"status": {
|
||||
"Draft": "Bozza",
|
||||
"Confirmed": "Confermato",
|
||||
"PartiallyShipped": "Parz. Spedito",
|
||||
"Shipped": "Spedito",
|
||||
"Invoiced": "Fatturato",
|
||||
"Cancelled": "Annullato"
|
||||
},
|
||||
"columns": {
|
||||
"number": "Numero",
|
||||
"date": "Data",
|
||||
"customer": "Cliente",
|
||||
"status": "Stato",
|
||||
"total": "Totale"
|
||||
},
|
||||
"fields": {
|
||||
"orderDate": "Data Ordine",
|
||||
"expectedDeliveryDate": "Data Prevista Consegna",
|
||||
"customer": "Cliente",
|
||||
"notes": "Note",
|
||||
"lineTotal": "Totale Riga",
|
||||
"article": "Articolo",
|
||||
"quantity": "Quantità",
|
||||
"unitPrice": "Prezzo Unit.",
|
||||
"discount": "Sconto %",
|
||||
"taxRate": "IVA %"
|
||||
},
|
||||
"totals": {
|
||||
"gross": "Totale Lordo"
|
||||
},
|
||||
"actions": {
|
||||
"confirm": "Conferma Ordine",
|
||||
"ship": "Spedisci Merce"
|
||||
}
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"bom": {
|
||||
"title": "Distinte Base",
|
||||
"newBom": "Nuova Distinta Base",
|
||||
"createTitle": "Nuova Distinta Base",
|
||||
"editTitle": "Modifica Distinta Base",
|
||||
"fields": {
|
||||
"name": "Nome",
|
||||
"description": "Descrizione",
|
||||
"article": "Articolo Prodotto",
|
||||
"quantity": "Quantità Prodotta",
|
||||
"components": "Componenti",
|
||||
"componentArticle": "Articolo Componente",
|
||||
"componentQuantity": "Quantità",
|
||||
"scrapPercentage": "Scarto %",
|
||||
"noComponents": "Nessun componente aggiunto"
|
||||
},
|
||||
"columns": {
|
||||
"name": "Nome",
|
||||
"article": "Articolo",
|
||||
"quantity": "Quantità"
|
||||
}
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard Produzione",
|
||||
"activeOrders": "Ordini Attivi",
|
||||
"lateOrders": "Ordini in Ritardo",
|
||||
"mrpSuggestions": "Suggerimenti MRP",
|
||||
"completedToday": "Completati Oggi",
|
||||
"recentOrders": "Ordini Recenti"
|
||||
},
|
||||
"order": {
|
||||
"title": "Ordini di Produzione",
|
||||
"newOrder": "Nuovo Ordine",
|
||||
"createTitle": "Nuovo Ordine",
|
||||
"editTitle": "Modifica Ordine",
|
||||
"subOrders": "Ordini Figli",
|
||||
"noSubOrders": "Nessun ordine figlio presente",
|
||||
"status": {
|
||||
"Draft": "Bozza",
|
||||
"Planned": "Pianificato",
|
||||
"Released": "Rilasciato",
|
||||
"InProgress": "In Corso",
|
||||
"Completed": "Completato",
|
||||
"Cancelled": "Annullato"
|
||||
},
|
||||
"columns": {
|
||||
"code": "Codice",
|
||||
"startDate": "Data Inizio",
|
||||
"article": "Articolo",
|
||||
"parentOrder": "Ordine Padre",
|
||||
"quantity": "Quantità",
|
||||
"status": "Stato"
|
||||
},
|
||||
"fields": {
|
||||
"article": "Articolo da Produrre",
|
||||
"quantity": "Quantità",
|
||||
"startDate": "Data Inizio",
|
||||
"dueDate": "Data Scadenza",
|
||||
"notes": "Note",
|
||||
"bom": "Distinta Base",
|
||||
"bomHelp": "Seleziona una DiBa per precompilare i componenti",
|
||||
"createChildOrders": "Crea ordini figli per semilavorati automaticamente"
|
||||
},
|
||||
"actions": {
|
||||
"plan": "Pianifica",
|
||||
"release": "Rilascia",
|
||||
"start": "Avvia",
|
||||
"complete": "Completa"
|
||||
},
|
||||
"phases": {
|
||||
"title": "Fasi di Produzione",
|
||||
"sequence": "Seq",
|
||||
"name": "Fase",
|
||||
"workCenter": "Centro di Lavoro",
|
||||
"status": "Stato",
|
||||
"progress": "Avanzamento",
|
||||
"actions": "Azioni",
|
||||
"start": "Avvia Fase",
|
||||
"complete": "Completa Fase",
|
||||
"quantity": "Qta Completata",
|
||||
"scrapped": "Qta Scartata",
|
||||
"duration": "Durata (min)",
|
||||
"durationHelp": "Stimata: {{estimated}} min",
|
||||
"statusValue": {
|
||||
"Pending": "In Attesa",
|
||||
"InProgress": "In Corso",
|
||||
"Completed": "Completata",
|
||||
"Paused": "In Pausa"
|
||||
}
|
||||
}
|
||||
},
|
||||
"workCenter": {
|
||||
"title": "Centri di Lavoro",
|
||||
"new": "Nuovo Centro",
|
||||
"edit": "Modifica Centro",
|
||||
"fields": {
|
||||
"code": "Codice",
|
||||
"name": "Nome",
|
||||
"description": "Descrizione",
|
||||
"costPerHour": "Costo Orario"
|
||||
}
|
||||
},
|
||||
"cycle": {
|
||||
"title": "Cicli Produttivi",
|
||||
"new": "Nuovo Ciclo",
|
||||
"createTitle": "Nuovo Ciclo Produttivo",
|
||||
"editTitle": "Modifica Ciclo Produttivo",
|
||||
"phases": "Fasi del Ciclo",
|
||||
"addPhase": "Aggiungi Fase",
|
||||
"noPhases": "Nessuna fase definita",
|
||||
"fields": {
|
||||
"name": "Nome Ciclo",
|
||||
"description": "Descrizione",
|
||||
"article": "Articolo",
|
||||
"isDefault": "Predefinito",
|
||||
"phaseName": "Nome Fase",
|
||||
"workCenter": "Centro di Lavoro",
|
||||
"duration": "Durata Unit. (min)",
|
||||
"setupTime": "Tempo Setup (min)"
|
||||
}
|
||||
},
|
||||
"mrp": {
|
||||
"title": "Pianificazione MRP",
|
||||
"run": "Esegui MRP",
|
||||
"configurationTitle": "Configurazione MRP",
|
||||
"configurationDescription": "Seleziona le opzioni per l'esecuzione del calcolo MRP.",
|
||||
"includeSafetyStock": "Includi Scorta di Sicurezza",
|
||||
"includeSalesOrders": "Includi Ordini di Vendita",
|
||||
"includeForecasts": "Includi Previsioni",
|
||||
"columns": {
|
||||
"date": "Data Calcolo",
|
||||
"article": "Articolo",
|
||||
"type": "Tipo",
|
||||
"quantity": "Quantità",
|
||||
"reason": "Motivo"
|
||||
},
|
||||
"type": {
|
||||
"production": "Produzione",
|
||||
"purchase": "Acquisto"
|
||||
},
|
||||
"actions": {
|
||||
"process": "Processa / Crea Ordine"
|
||||
}
|
||||
}
|
||||
},
|
||||
"warehouse": {
|
||||
"dashboard": {
|
||||
"newInbound": "Nuovo Carico",
|
||||
|
||||
@@ -21,6 +21,9 @@ import ModulePurchasePage from "./pages/ModulePurchasePage";
|
||||
import AutoCodesAdminPage from "./pages/AutoCodesAdminPage";
|
||||
import CustomFieldsAdminPage from "./pages/CustomFieldsAdminPage";
|
||||
import WarehouseRoutes from "./modules/warehouse/routes";
|
||||
import PurchasesRoutes from "./modules/purchases/routes";
|
||||
import SalesRoutes from "./modules/sales/routes";
|
||||
import ProductionRoutes from "./modules/production/routes";
|
||||
import { ModuleGuard } from "./components/ModuleGuard";
|
||||
import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates";
|
||||
import { CollaborationProvider } from "./contexts/CollaborationContext";
|
||||
@@ -99,6 +102,33 @@ function App() {
|
||||
</ModuleGuard>
|
||||
}
|
||||
/>
|
||||
{/* Purchases Module */}
|
||||
<Route
|
||||
path="purchases/*"
|
||||
element={
|
||||
<ModuleGuard moduleCode="purchases">
|
||||
<PurchasesRoutes />
|
||||
</ModuleGuard>
|
||||
}
|
||||
/>
|
||||
{/* Sales Module */}
|
||||
<Route
|
||||
path="sales/*"
|
||||
element={
|
||||
<ModuleGuard moduleCode="sales">
|
||||
<SalesRoutes />
|
||||
</ModuleGuard>
|
||||
}
|
||||
/>
|
||||
{/* Production Module */}
|
||||
<Route
|
||||
path="production/*"
|
||||
element={
|
||||
<ModuleGuard moduleCode="production">
|
||||
<ProductionRoutes />
|
||||
</ModuleGuard>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</RealTimeProvider>
|
||||
|
||||
@@ -30,6 +30,9 @@ import {
|
||||
Extension as ModulesIcon,
|
||||
Warehouse as WarehouseIcon,
|
||||
Code as AutoCodeIcon,
|
||||
ShoppingCart as ShoppingCartIcon,
|
||||
Sell as SellIcon,
|
||||
Factory as ProductionIcon,
|
||||
} from "@mui/icons-material";
|
||||
import CollaborationIndicator from "./collaboration/CollaborationIndicator";
|
||||
import { useModules } from "../contexts/ModuleContext";
|
||||
@@ -68,6 +71,24 @@ export default function Layout() {
|
||||
path: "/warehouse",
|
||||
moduleCode: "warehouse",
|
||||
},
|
||||
{
|
||||
text: t('menu.purchases'),
|
||||
icon: <ShoppingCartIcon />,
|
||||
path: "/purchases/orders",
|
||||
moduleCode: "purchases",
|
||||
},
|
||||
{
|
||||
text: t('menu.sales'),
|
||||
icon: <SellIcon />,
|
||||
path: "/sales/orders",
|
||||
moduleCode: "sales",
|
||||
},
|
||||
{
|
||||
text: t('menu.production'),
|
||||
icon: <ProductionIcon />,
|
||||
path: "/production/orders",
|
||||
moduleCode: "production",
|
||||
},
|
||||
{ text: t('menu.reports'), icon: <PrintIcon />, path: "/report-templates" },
|
||||
{ text: t('menu.modules'), icon: <ModulesIcon />, path: "/modules" },
|
||||
{ text: t('menu.autoCodes'), icon: <AutoCodeIcon />, path: "/admin/auto-codes" },
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Button,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
Box,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MrpConfigurationDto } from '../types';
|
||||
|
||||
interface MrpConfigurationDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onRun: (config: MrpConfigurationDto) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export default function MrpConfigurationDialog({ open, onClose, onRun, isLoading }: MrpConfigurationDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [config, setConfig] = useState<MrpConfigurationDto>({
|
||||
includeSafetyStock: true,
|
||||
includeSalesOrders: true,
|
||||
includeForecasts: false,
|
||||
warehouseIds: [] // Empty means all
|
||||
});
|
||||
|
||||
const handleChange = (field: keyof MrpConfigurationDto) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setConfig({ ...config, [field]: event.target.checked });
|
||||
};
|
||||
|
||||
const handleRun = () => {
|
||||
onRun(config);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{t('production.mrp.configurationTitle')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary">
|
||||
{t('production.mrp.configurationDescription')}
|
||||
</Typography>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={config.includeSafetyStock}
|
||||
onChange={handleChange('includeSafetyStock')}
|
||||
/>
|
||||
}
|
||||
label={t('production.mrp.includeSafetyStock')}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={config.includeSalesOrders}
|
||||
onChange={handleChange('includeSalesOrders')}
|
||||
/>
|
||||
}
|
||||
label={t('production.mrp.includeSalesOrders')}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={config.includeForecasts}
|
||||
onChange={handleChange('includeForecasts')}
|
||||
/>
|
||||
}
|
||||
label={t('production.mrp.includeForecasts')}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={isLoading}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleRun} variant="contained" disabled={isLoading}>
|
||||
{t('production.mrp.run')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { Box, Paper, Tab, Tabs } from "@mui/material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function ProductionLayout() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// Determine active tab based on current path
|
||||
const getActiveTab = () => {
|
||||
const path = location.pathname;
|
||||
if (path.includes("/production/bom")) return "/production/bom";
|
||||
if (path.includes("/production/work-centers")) return "/production/work-centers";
|
||||
if (path.includes("/production/cycles")) return "/production/cycles";
|
||||
if (path.includes("/production/mrp")) return "/production/mrp";
|
||||
return "/production/orders";
|
||||
};
|
||||
|
||||
const handleChange = (_event: React.SyntheticEvent, newValue: string) => {
|
||||
navigate(newValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<Paper sx={{ mb: 2 }}>
|
||||
<Tabs
|
||||
value={getActiveTab()}
|
||||
onChange={handleChange}
|
||||
indicatorColor="primary"
|
||||
textColor="primary"
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
>
|
||||
<Tab label={t("production.order.title")} value="/production/orders" />
|
||||
<Tab label={t("production.bom.title")} value="/production/bom" />
|
||||
<Tab label={t("production.workCenter.title")} value="/production/work-centers" />
|
||||
<Tab label={t("production.cycle.title")} value="/production/cycles" />
|
||||
<Tab label={t("production.mrp.title")} value="/production/mrp" />
|
||||
</Tabs>
|
||||
</Paper>
|
||||
<Box sx={{ flex: 1, overflow: "auto" }}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Chip,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
PlayArrow as StartIcon,
|
||||
Check as CompleteIcon,
|
||||
Edit as EditIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { productionService } from "../services/productionService";
|
||||
import {
|
||||
ProductionOrderDto,
|
||||
ProductionOrderPhaseDto,
|
||||
ProductionPhaseStatus,
|
||||
UpdateProductionOrderPhaseDto,
|
||||
} from "../types";
|
||||
|
||||
interface Props {
|
||||
order: ProductionOrderDto;
|
||||
isReadOnly?: boolean;
|
||||
}
|
||||
|
||||
export default function ProductionOrderPhases({ order, isReadOnly }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [editingPhase, setEditingPhase] = useState<ProductionOrderPhaseDto | null>(null);
|
||||
const [quantityCompleted, setQuantityCompleted] = useState(0);
|
||||
const [quantityScrapped, setQuantityScrapped] = useState(0);
|
||||
const [actualDuration, setActualDuration] = useState(0);
|
||||
|
||||
const updatePhaseMutation = useMutation({
|
||||
mutationFn: ({
|
||||
phaseId,
|
||||
data,
|
||||
}: {
|
||||
phaseId: number;
|
||||
data: UpdateProductionOrderPhaseDto;
|
||||
}) => productionService.updateProductionOrderPhase(order.id, phaseId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["production-order", order.id.toString()] });
|
||||
handleClose();
|
||||
},
|
||||
});
|
||||
|
||||
const handleStartPhase = (phase: ProductionOrderPhaseDto) => {
|
||||
updatePhaseMutation.mutate({
|
||||
phaseId: phase.id,
|
||||
data: {
|
||||
status: ProductionPhaseStatus.InProgress,
|
||||
quantityCompleted: phase.quantityCompleted,
|
||||
quantityScrapped: phase.quantityScrapped,
|
||||
actualDurationMinutes: phase.actualDurationMinutes,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCompletePhase = (phase: ProductionOrderPhaseDto) => {
|
||||
setEditingPhase(phase);
|
||||
setQuantityCompleted(phase.quantityCompleted);
|
||||
setQuantityScrapped(phase.quantityScrapped);
|
||||
setActualDuration(phase.actualDurationMinutes || phase.estimatedDurationMinutes);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setEditingPhase(null);
|
||||
};
|
||||
|
||||
const handleSubmitCompletion = () => {
|
||||
if (!editingPhase) return;
|
||||
|
||||
updatePhaseMutation.mutate({
|
||||
phaseId: editingPhase.id,
|
||||
data: {
|
||||
status: ProductionPhaseStatus.Completed,
|
||||
quantityCompleted: Number(quantityCompleted),
|
||||
quantityScrapped: Number(quantityScrapped),
|
||||
actualDurationMinutes: Number(actualDuration),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusColor = (status: ProductionPhaseStatus) => {
|
||||
switch (status) {
|
||||
case ProductionPhaseStatus.Pending:
|
||||
return "default";
|
||||
case ProductionPhaseStatus.InProgress:
|
||||
return "warning";
|
||||
case ProductionPhaseStatus.Completed:
|
||||
return "success";
|
||||
case ProductionPhaseStatus.Paused:
|
||||
return "error";
|
||||
default:
|
||||
return "default";
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t("production.order.phases.title")}
|
||||
</Typography>
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t("production.order.phases.sequence")}</TableCell>
|
||||
<TableCell>{t("production.order.phases.name")}</TableCell>
|
||||
<TableCell>{t("production.order.phases.workCenter")}</TableCell>
|
||||
<TableCell>{t("production.order.phases.status")}</TableCell>
|
||||
<TableCell align="right">{t("production.order.phases.quantity")}</TableCell>
|
||||
<TableCell align="right">{t("production.order.phases.scrapped")}</TableCell>
|
||||
<TableCell align="right">{t("production.order.phases.duration")}</TableCell>
|
||||
<TableCell align="center">{t("production.order.phases.actions")}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{order.phases?.map((phase) => (
|
||||
<TableRow key={phase.id}>
|
||||
<TableCell>{phase.sequence}</TableCell>
|
||||
<TableCell>{phase.name}</TableCell>
|
||||
<TableCell>{phase.workCenterName}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={t(`production.order.phases.statusValue.${ProductionPhaseStatus[phase.status]}`)}
|
||||
color={getStatusColor(phase.status)}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">{phase.quantityCompleted}</TableCell>
|
||||
<TableCell align="right">{phase.quantityScrapped}</TableCell>
|
||||
<TableCell align="right">
|
||||
{phase.actualDurationMinutes} / {phase.estimatedDurationMinutes}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{!isReadOnly && (
|
||||
<Box sx={{ display: "flex", justifyContent: "center", gap: 1 }}>
|
||||
{phase.status === ProductionPhaseStatus.Pending && (
|
||||
<IconButton
|
||||
size="small"
|
||||
color="primary"
|
||||
onClick={() => handleStartPhase(phase)}
|
||||
title={t("production.order.phases.start")}
|
||||
>
|
||||
<StartIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
{(phase.status === ProductionPhaseStatus.InProgress ||
|
||||
phase.status === ProductionPhaseStatus.Pending) && (
|
||||
<IconButton
|
||||
size="small"
|
||||
color="success"
|
||||
onClick={() => handleCompletePhase(phase)}
|
||||
title={t("production.order.phases.complete")}
|
||||
>
|
||||
<CompleteIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
{phase.status === ProductionPhaseStatus.Completed && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handleCompletePhase(phase)} // Re-open dialog to edit
|
||||
title={t("common.edit")}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{(!order.phases || order.phases.length === 0) && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} align="center">
|
||||
{t("production.cycle.noPhases")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<Dialog open={!!editingPhase} onClose={handleClose}>
|
||||
<DialogTitle>{t("production.order.phases.complete")}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2, mt: 1, minWidth: 300 }}>
|
||||
<TextField
|
||||
label={t("production.order.phases.quantity")}
|
||||
type="number"
|
||||
value={quantityCompleted}
|
||||
onChange={(e) => setQuantityCompleted(Number(e.target.value))}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label={t("production.order.phases.scrapped")}
|
||||
type="number"
|
||||
value={quantityScrapped}
|
||||
onChange={(e) => setQuantityScrapped(Number(e.target.value))}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label={t("production.order.phases.duration")}
|
||||
type="number"
|
||||
value={actualDuration}
|
||||
onChange={(e) => setActualDuration(Number(e.target.value))}
|
||||
fullWidth
|
||||
helperText={t("production.order.phases.durationHelp", { estimated: editingPhase?.estimatedDurationMinutes })}
|
||||
/>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>{t("common.cancel")}</Button>
|
||||
<Button onClick={handleSubmitCompletion} variant="contained" color="primary">
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import { useEffect } from "react";
|
||||
import { useForm, Controller, useFieldArray } from "react-hook-form";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Paper,
|
||||
Typography,
|
||||
Grid,
|
||||
TextField,
|
||||
Autocomplete,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
IconButton,
|
||||
Alert,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
Save as SaveIcon,
|
||||
ArrowBack as BackIcon,
|
||||
Add as AddIcon,
|
||||
Delete as DeleteIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { productionService } from "../services/productionService";
|
||||
import { articleService } from "../../warehouse/services/warehouseService";
|
||||
import { CreateBillOfMaterialsDto, UpdateBillOfMaterialsDto } from "../types";
|
||||
|
||||
export default function BillOfMaterialsFormPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const isEdit = Boolean(id);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { control, handleSubmit, reset, setValue, formState: { errors } } = useForm<CreateBillOfMaterialsDto & { isActive?: boolean }>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
articleId: 0,
|
||||
quantity: 1,
|
||||
isActive: true,
|
||||
components: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "components",
|
||||
});
|
||||
|
||||
const { data: bom, isLoading } = useQuery({
|
||||
queryKey: ["bom", id],
|
||||
queryFn: () => productionService.getBillOfMaterialsById(Number(id)),
|
||||
enabled: isEdit,
|
||||
});
|
||||
|
||||
const { data: articles = [] } = useQuery({
|
||||
queryKey: ["articles"],
|
||||
queryFn: () => articleService.getAll(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (bom) {
|
||||
reset({
|
||||
name: bom.name,
|
||||
description: bom.description,
|
||||
articleId: bom.articleId,
|
||||
quantity: bom.quantity,
|
||||
isActive: bom.isActive,
|
||||
components: bom.components.map(c => ({
|
||||
componentArticleId: c.componentArticleId,
|
||||
quantity: c.quantity,
|
||||
scrapPercentage: c.scrapPercentage,
|
||||
id: c.id // Keep ID for updates
|
||||
})),
|
||||
});
|
||||
}
|
||||
}, [bom, reset]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateBillOfMaterialsDto) => productionService.createBillOfMaterials(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["boms"] });
|
||||
navigate("/production/bom");
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: UpdateBillOfMaterialsDto) => productionService.updateBillOfMaterials(Number(id), data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["boms"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["bom", id] });
|
||||
navigate("/production/bom");
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: any) => {
|
||||
if (isEdit) {
|
||||
const updateData: UpdateBillOfMaterialsDto = {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
quantity: data.quantity,
|
||||
isActive: data.isActive,
|
||||
components: data.components.map((c: any) => ({
|
||||
id: c.id,
|
||||
componentArticleId: c.componentArticleId,
|
||||
quantity: c.quantity,
|
||||
scrapPercentage: c.scrapPercentage,
|
||||
isDeleted: false
|
||||
}))
|
||||
};
|
||||
updateMutation.mutate(updateData);
|
||||
} else {
|
||||
createMutation.mutate(data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComponentArticleChange = (index: number, articleId: number | null) => {
|
||||
if (!articleId) return;
|
||||
setValue(`components.${index}.componentArticleId`, articleId);
|
||||
};
|
||||
|
||||
if (isEdit && isLoading) return <Typography>Loading...</Typography>;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 1200, mx: "auto" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 3 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
<Button
|
||||
startIcon={<BackIcon />}
|
||||
onClick={() => navigate("/production/bom")}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
<Typography variant="h4">
|
||||
{isEdit ? `${t("production.bom.editTitle")} ${bom?.name}` : t("production.bom.createTitle")}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{(createMutation.isError || updateMutation.isError) && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{t("common.error")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
rules={{ required: t("common.required") }}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label={t("production.bom.fields.name")}
|
||||
fullWidth
|
||||
error={!!errors.name}
|
||||
helperText={errors.name?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Controller
|
||||
name="articleId"
|
||||
control={control}
|
||||
rules={{ required: t("common.required") }}
|
||||
render={({ field }: { field: any }) => (
|
||||
<Autocomplete
|
||||
options={articles}
|
||||
getOptionLabel={(option) => `${option.code} - ${option.description}`}
|
||||
value={articles.find(a => a.id === field.value) || null}
|
||||
onChange={(_, newValue) => field.onChange(newValue?.id)}
|
||||
disabled={isEdit} // Cannot change article of existing BOM
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={t("production.bom.fields.article")}
|
||||
error={!!errors.articleId}
|
||||
helperText={errors.articleId?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Controller
|
||||
name="quantity"
|
||||
control={control}
|
||||
rules={{ required: t("common.required"), min: 0.0001 }}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label={t("production.bom.fields.quantity")}
|
||||
type="number"
|
||||
fullWidth
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
error={!!errors.quantity}
|
||||
helperText={errors.quantity?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 8 }}>
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label={t("production.bom.fields.description")}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{isEdit && (
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Controller
|
||||
name="isActive"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={field.value} onChange={field.onChange} />}
|
||||
label={t("common.active")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 2 }}>
|
||||
<Typography variant="h6">{t("production.bom.fields.components")}</Typography>
|
||||
<Button startIcon={<AddIcon />} onClick={() => append({
|
||||
componentArticleId: 0,
|
||||
quantity: 1,
|
||||
scrapPercentage: 0
|
||||
})}>
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell width="40%">{t("production.bom.fields.componentArticle")}</TableCell>
|
||||
<TableCell width="20%">{t("production.bom.fields.componentQuantity")}</TableCell>
|
||||
<TableCell width="20%">{t("production.bom.fields.scrapPercentage")}</TableCell>
|
||||
<TableCell width="10%"></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{fields.map((field: any, index: number) => (
|
||||
<TableRow key={field.id}>
|
||||
<TableCell>
|
||||
<Controller
|
||||
name={`components.${index}.componentArticleId`}
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
render={({ field: articleField }: { field: any }) => (
|
||||
<Autocomplete
|
||||
options={articles}
|
||||
getOptionLabel={(option) => `${option.code} - ${option.description}`}
|
||||
value={articles.find(a => a.id === articleField.value) || null}
|
||||
onChange={(_, newValue) => handleComponentArticleChange(index, newValue?.id || null)}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} size="small" variant="standard" error={!articleField.value} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Controller
|
||||
name={`components.${index}.quantity`}
|
||||
control={control}
|
||||
rules={{ required: true, min: 0.0001 }}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="number"
|
||||
size="small"
|
||||
variant="standard"
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Controller
|
||||
name={`components.${index}.scrapPercentage`}
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="number"
|
||||
size="small"
|
||||
variant="standard"
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<IconButton size="small" color="error" onClick={() => remove(index)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{fields.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} align="center">
|
||||
<Typography color="text.secondary">{t("production.bom.noComponents")}</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
123
frontend/src/modules/production/pages/BillOfMaterialsPage.tsx
Normal file
123
frontend/src/modules/production/pages/BillOfMaterialsPage.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
DataGrid,
|
||||
GridColDef,
|
||||
GridRenderCellParams,
|
||||
GridToolbar,
|
||||
} from "@mui/x-data-grid";
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Visibility as ViewIcon,
|
||||
Delete as DeleteIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { productionService } from "../services/productionService";
|
||||
import { BillOfMaterialsDto } from "../types";
|
||||
|
||||
export default function BillOfMaterialsPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: boms = [], isLoading } = useQuery({
|
||||
queryKey: ["boms"],
|
||||
queryFn: () => productionService.getBillOfMaterials(),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => productionService.deleteBillOfMaterials(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["boms"] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = () => {
|
||||
navigate("/production/bom/new");
|
||||
};
|
||||
|
||||
const handleView = (id: number) => {
|
||||
navigate(`/production/bom/${id}`);
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (confirm(t("common.confirmDelete"))) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{ field: "name", headerName: t("production.bom.columns.name"), flex: 1, minWidth: 200 },
|
||||
{ field: "articleName", headerName: t("production.bom.columns.article"), flex: 1, minWidth: 200 },
|
||||
{ field: "quantity", headerName: t("production.bom.columns.quantity"), width: 100, type: 'number' },
|
||||
{
|
||||
field: "actions",
|
||||
headerName: t("common.actions"),
|
||||
width: 120,
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams<BillOfMaterialsDto>) => (
|
||||
<Box>
|
||||
<Tooltip title={t("common.view")}>
|
||||
<IconButton size="small" onClick={() => handleView(params.row.id)}>
|
||||
<ViewIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("common.delete")}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDelete(params.row.id)}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4">{t("production.bom.title")}</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
{t("production.bom.newBom")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ height: 600, width: "100%" }}>
|
||||
<DataGrid
|
||||
rows={boms}
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
slots={{ toolbar: GridToolbar }}
|
||||
slotProps={{
|
||||
toolbar: {
|
||||
showQuickFilter: true,
|
||||
},
|
||||
}}
|
||||
disableRowSelectionOnClick
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
154
frontend/src/modules/production/pages/MrpPage.tsx
Normal file
154
frontend/src/modules/production/pages/MrpPage.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
DataGrid,
|
||||
GridColDef,
|
||||
GridRenderCellParams,
|
||||
GridToolbar,
|
||||
} from "@mui/x-data-grid";
|
||||
import {
|
||||
PlayArrow as RunIcon,
|
||||
Check as CheckIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { productionService } from "../services/productionService";
|
||||
import MrpConfigurationDialog from "../components/MrpConfigurationDialog";
|
||||
import { MrpSuggestionDto, MrpSuggestionType, MrpConfigurationDto } from "../types";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export default function MrpPage() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [isConfigOpen, setIsConfigOpen] = useState(false);
|
||||
|
||||
const { data: suggestions = [], isLoading } = useQuery({
|
||||
queryKey: ["mrpSuggestions"],
|
||||
queryFn: () => productionService.getMrpSuggestions(false),
|
||||
});
|
||||
|
||||
const runMutation = useMutation({
|
||||
mutationFn: (config: MrpConfigurationDto) => productionService.runMrp(config),
|
||||
onMutate: () => {
|
||||
setIsRunning(true);
|
||||
setIsConfigOpen(false);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["mrpSuggestions"] });
|
||||
setIsRunning(false);
|
||||
},
|
||||
onError: () => setIsRunning(false),
|
||||
});
|
||||
|
||||
const processMutation = useMutation({
|
||||
mutationFn: (id: number) => productionService.processMrpSuggestion(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["mrpSuggestions"] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleRunClick = () => {
|
||||
setIsConfigOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmRun = (config: MrpConfigurationDto) => {
|
||||
runMutation.mutate(config);
|
||||
};
|
||||
|
||||
const handleProcess = (id: number) => {
|
||||
processMutation.mutate(id);
|
||||
};
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{
|
||||
field: "calculationDate",
|
||||
headerName: t("production.mrp.columns.date"),
|
||||
width: 150,
|
||||
valueFormatter: (value: string) => value ? dayjs(value).format('DD/MM/YYYY HH:mm') : ''
|
||||
},
|
||||
{
|
||||
field: "article",
|
||||
headerName: t("production.mrp.columns.article"),
|
||||
flex: 1,
|
||||
minWidth: 200,
|
||||
valueGetter: (_value: any, row: MrpSuggestionDto) => row.article?.description || ''
|
||||
},
|
||||
{
|
||||
field: "type",
|
||||
headerName: t("production.mrp.columns.type"),
|
||||
width: 120,
|
||||
renderCell: (params: GridRenderCellParams<MrpSuggestionDto>) => (
|
||||
<Chip
|
||||
label={params.value === MrpSuggestionType.Production ? t("production.mrp.type.production") : t("production.mrp.type.purchase")}
|
||||
color={params.value === MrpSuggestionType.Production ? "primary" : "secondary"}
|
||||
size="small"
|
||||
/>
|
||||
)
|
||||
},
|
||||
{ field: "quantity", headerName: t("production.mrp.columns.quantity"), width: 120, type: 'number' },
|
||||
{ field: "reason", headerName: t("production.mrp.columns.reason"), flex: 1, minWidth: 200 },
|
||||
{
|
||||
field: "actions",
|
||||
headerName: t("common.actions"),
|
||||
width: 120,
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams<MrpSuggestionDto>) => (
|
||||
<Box>
|
||||
<Tooltip title={t("production.mrp.actions.process")}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="success"
|
||||
onClick={() => handleProcess(params.row.id)}
|
||||
>
|
||||
<CheckIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 3 }}>
|
||||
<Typography variant="h4">{t("production.mrp.title")}</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={isRunning ? <CircularProgress size={20} color="inherit" /> : <RunIcon />}
|
||||
onClick={handleRunClick}
|
||||
disabled={isRunning}
|
||||
>
|
||||
{t("production.mrp.run")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ height: 600, width: "100%" }}>
|
||||
<DataGrid
|
||||
rows={suggestions}
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
slots={{ toolbar: GridToolbar }}
|
||||
slotProps={{ toolbar: { showQuickFilter: true } }}
|
||||
disableRowSelectionOnClick
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<MrpConfigurationDialog
|
||||
open={isConfigOpen}
|
||||
onClose={() => setIsConfigOpen(false)}
|
||||
onRun={handleConfirmRun}
|
||||
isLoading={isRunning}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { useEffect } from "react";
|
||||
import { useForm, useFieldArray, Controller } from "react-hook-form";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
Grid,
|
||||
TextField,
|
||||
MenuItem,
|
||||
IconButton,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
Save as SaveIcon,
|
||||
ArrowBack as BackIcon,
|
||||
Add as AddIcon,
|
||||
Delete as DeleteIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { productionService } from "../services/productionService";
|
||||
import warehouseService from "../../warehouse/services/warehouseService";
|
||||
import { CreateProductionCycleDto, UpdateProductionCycleDto } from "../types";
|
||||
|
||||
export default function ProductionCycleFormPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const isEdit = !!id;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { control, handleSubmit, reset } = useForm<any>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
articleId: "",
|
||||
isDefault: false,
|
||||
isActive: true,
|
||||
phases: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "phases",
|
||||
});
|
||||
|
||||
const { data: articles = [] } = useQuery({
|
||||
queryKey: ["articles"],
|
||||
queryFn: () => warehouseService.articles.getAll(),
|
||||
});
|
||||
|
||||
const { data: workCenters = [] } = useQuery({
|
||||
queryKey: ["workCenters"],
|
||||
queryFn: () => productionService.getWorkCenters(),
|
||||
});
|
||||
|
||||
const { data: cycle } = useQuery({
|
||||
queryKey: ["productionCycle", id],
|
||||
queryFn: () => productionService.getProductionCycleById(Number(id)),
|
||||
enabled: isEdit,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (cycle) {
|
||||
reset({
|
||||
name: cycle.name,
|
||||
description: cycle.description,
|
||||
articleId: cycle.articleId,
|
||||
isDefault: cycle.isDefault,
|
||||
isActive: cycle.isActive,
|
||||
phases: cycle.phases.map((p) => ({
|
||||
...p,
|
||||
isDeleted: false,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}, [cycle, reset]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateProductionCycleDto) =>
|
||||
productionService.createProductionCycle(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["productionCycles"] });
|
||||
navigate("/production/cycles");
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: UpdateProductionCycleDto) =>
|
||||
productionService.updateProductionCycle(Number(id), data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["productionCycles"] });
|
||||
navigate("/production/cycles");
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: any) => {
|
||||
const formattedPhases = data.phases.map((p: any, index: number) => ({
|
||||
id: p.id,
|
||||
sequence: (index + 1) * 10, // Auto-sequence
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
workCenterId: p.workCenterId,
|
||||
durationPerUnitMinutes: Number(p.durationPerUnitMinutes),
|
||||
setupTimeMinutes: Number(p.setupTimeMinutes),
|
||||
isDeleted: p.isDeleted || false,
|
||||
}));
|
||||
|
||||
if (isEdit) {
|
||||
updateMutation.mutate({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
isDefault: data.isDefault,
|
||||
isActive: data.isActive,
|
||||
phases: formattedPhases,
|
||||
});
|
||||
} else {
|
||||
createMutation.mutate({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
articleId: Number(data.articleId),
|
||||
isDefault: data.isDefault,
|
||||
phases: formattedPhases,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", mb: 3 }}>
|
||||
<IconButton onClick={() => navigate("/production/cycles")} sx={{ mr: 2 }}>
|
||||
<BackIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h4">
|
||||
{isEdit
|
||||
? t("production.cycle.editTitle")
|
||||
: t("production.cycle.createTitle")}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label={t("production.cycle.fields.name")}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Controller
|
||||
name="articleId"
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
select
|
||||
label={t("production.cycle.fields.article")}
|
||||
fullWidth
|
||||
disabled={isEdit}
|
||||
>
|
||||
{articles.map((article: any) => (
|
||||
<MenuItem key={article.id} value={article.id}>
|
||||
{article.code} - {article.description}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label={t("production.cycle.fields.description")}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Controller
|
||||
name="isDefault"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={field.value} onChange={field.onChange} />}
|
||||
label={t("production.cycle.fields.isDefault")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
{isEdit && (
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Controller
|
||||
name="isActive"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={field.value} onChange={field.onChange} />}
|
||||
label={t("common.active")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 2 }}>
|
||||
<Typography variant="h6">{t("production.cycle.phases")}</Typography>
|
||||
<Button
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() =>
|
||||
append({
|
||||
name: "",
|
||||
description: "",
|
||||
workCenterId: "",
|
||||
durationPerUnitMinutes: 0,
|
||||
setupTimeMinutes: 0,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("production.cycle.addPhase")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t("production.cycle.fields.phaseName")}</TableCell>
|
||||
<TableCell>{t("production.cycle.fields.workCenter")}</TableCell>
|
||||
<TableCell width={150}>{t("production.cycle.fields.duration")}</TableCell>
|
||||
<TableCell width={150}>{t("production.cycle.fields.setupTime")}</TableCell>
|
||||
<TableCell width={50}></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{fields.map((field, index) => (
|
||||
<TableRow key={field.id}>
|
||||
<TableCell>
|
||||
<Controller
|
||||
name={`phases.${index}.name`}
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
render={({ field }) => (
|
||||
<TextField {...field} fullWidth size="small" />
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Controller
|
||||
name={`phases.${index}.workCenterId`}
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
render={({ field }) => (
|
||||
<TextField {...field} select fullWidth size="small">
|
||||
{workCenters.map((wc) => (
|
||||
<MenuItem key={wc.id} value={wc.id}>
|
||||
{wc.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Controller
|
||||
name={`phases.${index}.durationPerUnitMinutes`}
|
||||
control={control}
|
||||
rules={{ required: true, min: 0 }}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="number"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Controller
|
||||
name={`phases.${index}.setupTimeMinutes`}
|
||||
control={control}
|
||||
rules={{ required: true, min: 0 }}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="number"
|
||||
fullWidth
|
||||
size="small"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{fields.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} align="center">
|
||||
{t("production.cycle.noPhases")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
|
||||
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 2 }}>
|
||||
<Button onClick={() => navigate("/production/cycles")}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</Box>
|
||||
</form>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
119
frontend/src/modules/production/pages/ProductionCyclesPage.tsx
Normal file
119
frontend/src/modules/production/pages/ProductionCyclesPage.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
DataGrid,
|
||||
GridColDef,
|
||||
GridRenderCellParams,
|
||||
GridToolbar,
|
||||
} from "@mui/x-data-grid";
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Edit as EditIcon,
|
||||
Delete as DeleteIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { productionService } from "../services/productionService";
|
||||
import { ProductionCycleDto } from "../types";
|
||||
|
||||
export default function ProductionCyclesPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: cycles = [], isLoading } = useQuery({
|
||||
queryKey: ["productionCycles"],
|
||||
queryFn: () => productionService.getProductionCycles(),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => productionService.deleteProductionCycle(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["productionCycles"] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = () => {
|
||||
navigate("/production/cycles/new");
|
||||
};
|
||||
|
||||
const handleEdit = (id: number) => {
|
||||
navigate(`/production/cycles/${id}`);
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (confirm(t("common.confirmDelete"))) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{ field: "name", headerName: t("production.cycle.fields.name"), flex: 1, minWidth: 200 },
|
||||
{ field: "articleName", headerName: t("production.cycle.fields.article"), flex: 1, minWidth: 200 },
|
||||
{
|
||||
field: "isDefault",
|
||||
headerName: t("production.cycle.fields.isDefault"),
|
||||
width: 100,
|
||||
type: 'boolean'
|
||||
},
|
||||
{
|
||||
field: "isActive",
|
||||
headerName: t("common.active"),
|
||||
width: 100,
|
||||
type: 'boolean'
|
||||
},
|
||||
{
|
||||
field: "actions",
|
||||
headerName: t("common.actions"),
|
||||
width: 120,
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams<ProductionCycleDto>) => (
|
||||
<Box>
|
||||
<Tooltip title={t("common.edit")}>
|
||||
<IconButton size="small" onClick={() => handleEdit(params.row.id)}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("common.delete")}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDelete(params.row.id)}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 3 }}>
|
||||
<Typography variant="h4">{t("production.cycle.title")}</Typography>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={handleCreate}>
|
||||
{t("production.cycle.new")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ height: 600, width: "100%" }}>
|
||||
<DataGrid
|
||||
rows={cycles}
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
slots={{ toolbar: GridToolbar }}
|
||||
slotProps={{ toolbar: { showQuickFilter: true } }}
|
||||
disableRowSelectionOnClick
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Box, Grid, Paper, Typography, Card, CardContent, LinearProgress } from "@mui/material";
|
||||
import {
|
||||
Assignment as OrderIcon,
|
||||
Warning as AlertIcon,
|
||||
PrecisionManufacturing as MrpIcon,
|
||||
CheckCircle as CompletedIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { productionService } from "../services/productionService";
|
||||
import { ProductionOrderStatus } from "../types";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export default function ProductionDashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { data: orders = [] } = useQuery({
|
||||
queryKey: ["production-orders"],
|
||||
queryFn: () => productionService.getProductionOrders(),
|
||||
});
|
||||
|
||||
const { data: mrpSuggestions = [] } = useQuery({
|
||||
queryKey: ["mrpSuggestions"],
|
||||
queryFn: () => productionService.getMrpSuggestions(false),
|
||||
});
|
||||
|
||||
// KPI Calculations
|
||||
const activeOrders = orders.filter(
|
||||
(o) =>
|
||||
o.status === ProductionOrderStatus.Released ||
|
||||
o.status === ProductionOrderStatus.InProgress
|
||||
).length;
|
||||
|
||||
const lateOrders = orders.filter(
|
||||
(o) =>
|
||||
dayjs(o.dueDate).isBefore(dayjs()) &&
|
||||
o.status !== ProductionOrderStatus.Completed &&
|
||||
o.status !== ProductionOrderStatus.Cancelled
|
||||
).length;
|
||||
|
||||
const completedToday = orders.filter(
|
||||
(o) =>
|
||||
o.status === ProductionOrderStatus.Completed &&
|
||||
dayjs(o.endDate).isSame(dayjs(), "day")
|
||||
).length;
|
||||
|
||||
const pendingSuggestions = mrpSuggestions.filter((s) => !s.isProcessed).length;
|
||||
|
||||
const StatCard = ({ title, value, icon, color }: any) => (
|
||||
<Card sx={{ height: "100%" }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2 }}>
|
||||
<Typography color="textSecondary" variant="subtitle1">
|
||||
{title}
|
||||
</Typography>
|
||||
<Box sx={{ color: `${color}.main` }}>{icon}</Box>
|
||||
</Box>
|
||||
<Typography variant="h4">{value}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography variant="h4" sx={{ mb: 3 }}>
|
||||
{t("production.dashboard.title")}
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3} sx={{ mb: 4 }}>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatCard
|
||||
title={t("production.dashboard.activeOrders")}
|
||||
value={activeOrders}
|
||||
icon={<OrderIcon fontSize="large" />}
|
||||
color="primary"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatCard
|
||||
title={t("production.dashboard.lateOrders")}
|
||||
value={lateOrders}
|
||||
icon={<AlertIcon fontSize="large" />}
|
||||
color="error"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatCard
|
||||
title={t("production.dashboard.mrpSuggestions")}
|
||||
value={pendingSuggestions}
|
||||
icon={<MrpIcon fontSize="large" />}
|
||||
color="warning"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||
<StatCard
|
||||
title={t("production.dashboard.completedToday")}
|
||||
value={completedToday}
|
||||
icon={<CompletedIcon fontSize="large" />}
|
||||
color="success"
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs: 12, md: 8 }}>
|
||||
<Paper sx={{ p: 2 }}>
|
||||
<Typography variant="h6" gutterBottom>{t("production.dashboard.recentOrders")}</Typography>
|
||||
{/* Simple list of recent orders */}
|
||||
{orders.slice(0, 5).map(order => (
|
||||
<Box key={order.id} sx={{ mb: 2, p: 1, borderBottom: '1px solid #eee' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography variant="subtitle2">{order.code}</Typography>
|
||||
<Typography variant="caption">{dayjs(order.dueDate).format('DD/MM/YYYY')}</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2">{order.articleName}</Typography>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={order.status === ProductionOrderStatus.Completed ? 100 : (order.status === ProductionOrderStatus.InProgress ? 50 : 0)}
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
{/* Placeholder for other widgets */}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Paper,
|
||||
Typography,
|
||||
Grid,
|
||||
TextField,
|
||||
Autocomplete,
|
||||
Alert,
|
||||
Chip,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
Tabs,
|
||||
Tab,
|
||||
IconButton,
|
||||
} from "@mui/material";
|
||||
import { DataGrid } from "@mui/x-data-grid";
|
||||
import {
|
||||
Save as SaveIcon,
|
||||
ArrowBack as BackIcon,
|
||||
Check as CheckIcon,
|
||||
PlayArrow as StartIcon,
|
||||
Done as CompleteIcon,
|
||||
Visibility as ViewIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { productionService } from "../services/productionService";
|
||||
import { articleService } from "../../warehouse/services/warehouseService";
|
||||
import { CreateProductionOrderDto, UpdateProductionOrderDto, ProductionOrderStatus } from "../types";
|
||||
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
||||
import dayjs from "dayjs";
|
||||
import ProductionOrderPhases from "../components/ProductionOrderPhases";
|
||||
|
||||
export default function ProductionOrderFormPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const isEdit = Boolean(id);
|
||||
const queryClient = useQueryClient();
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
|
||||
const { control, handleSubmit, reset, watch, setValue, formState: { errors } } = useForm<CreateProductionOrderDto>({
|
||||
defaultValues: {
|
||||
articleId: 0,
|
||||
quantity: 1,
|
||||
startDate: new Date().toISOString(),
|
||||
dueDate: new Date().toISOString(),
|
||||
notes: "",
|
||||
billOfMaterialsId: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const { data: order, isLoading } = useQuery({
|
||||
queryKey: ["production-order", id],
|
||||
queryFn: () => productionService.getProductionOrderById(Number(id)),
|
||||
enabled: isEdit,
|
||||
});
|
||||
|
||||
const { data: articles = [] } = useQuery({
|
||||
queryKey: ["articles"],
|
||||
queryFn: () => articleService.getAll(),
|
||||
});
|
||||
|
||||
const { data: boms = [] } = useQuery({
|
||||
queryKey: ["boms"],
|
||||
queryFn: () => productionService.getBillOfMaterials(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (order) {
|
||||
reset({
|
||||
articleId: order.articleId,
|
||||
quantity: order.quantity,
|
||||
startDate: order.startDate,
|
||||
dueDate: order.dueDate,
|
||||
notes: order.notes,
|
||||
});
|
||||
}
|
||||
}, [order, reset]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateProductionOrderDto) => productionService.createProductionOrder(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["production-orders"] });
|
||||
navigate("/production/orders");
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: UpdateProductionOrderDto) => productionService.updateProductionOrder(Number(id), data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["production-orders"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["production-order", id] });
|
||||
navigate("/production/orders");
|
||||
},
|
||||
});
|
||||
|
||||
const changeStatusMutation = useMutation({
|
||||
mutationFn: (status: ProductionOrderStatus) => productionService.changeProductionOrderStatus(Number(id), status),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["production-orders"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["production-order", id] });
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: CreateProductionOrderDto) => {
|
||||
if (isEdit) {
|
||||
// For update we need to pass status as well, but form doesn't have it.
|
||||
// We assume status doesn't change via form save, only via actions.
|
||||
const updateData: UpdateProductionOrderDto = {
|
||||
quantity: data.quantity,
|
||||
startDate: data.startDate,
|
||||
dueDate: data.dueDate,
|
||||
notes: data.notes,
|
||||
status: order!.status // Keep existing status
|
||||
};
|
||||
updateMutation.mutate(updateData);
|
||||
} else {
|
||||
createMutation.mutate(data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArticleChange = (articleId: number | null) => {
|
||||
if (!articleId) return;
|
||||
setValue('articleId', articleId);
|
||||
|
||||
// Try to find a BOM for this article to auto-select
|
||||
const bom = boms.find(b => b.articleId === articleId);
|
||||
if (bom) {
|
||||
setValue('billOfMaterialsId', bom.id);
|
||||
}
|
||||
};
|
||||
|
||||
const isReadOnly = isEdit && order?.status === ProductionOrderStatus.Completed;
|
||||
|
||||
if (isEdit && isLoading) return <Typography>Loading...</Typography>;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 800, mx: "auto" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 3 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
<Button
|
||||
startIcon={<BackIcon />}
|
||||
onClick={() => navigate("/production/orders")}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
<Box>
|
||||
<Typography variant="h4">
|
||||
{isEdit ? `${t("production.order.editTitle")} ${order?.code}` : t("production.order.createTitle")}
|
||||
</Typography>
|
||||
{isEdit && order && (
|
||||
<Chip
|
||||
label={t(`production.order.status.${ProductionOrderStatus[order.status]}`)}
|
||||
color={order.status === ProductionOrderStatus.Completed ? "success" : "default"}
|
||||
size="small"
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
{isEdit && order?.status === ProductionOrderStatus.Draft && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="info"
|
||||
startIcon={<CheckIcon />}
|
||||
onClick={() => changeStatusMutation.mutate(ProductionOrderStatus.Planned)}
|
||||
>
|
||||
{t("production.order.actions.plan")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isEdit && order?.status === ProductionOrderStatus.Planned && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<StartIcon />}
|
||||
onClick={() => changeStatusMutation.mutate(ProductionOrderStatus.Released)}
|
||||
>
|
||||
{t("production.order.actions.release")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isEdit && order?.status === ProductionOrderStatus.Released && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="warning"
|
||||
startIcon={<StartIcon />}
|
||||
onClick={() => changeStatusMutation.mutate(ProductionOrderStatus.InProgress)}
|
||||
>
|
||||
{t("production.order.actions.start")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isEdit && order?.status === ProductionOrderStatus.InProgress && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
startIcon={<CompleteIcon />}
|
||||
onClick={() => changeStatusMutation.mutate(ProductionOrderStatus.Completed)}
|
||||
>
|
||||
{t("production.order.actions.complete")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isReadOnly && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{(createMutation.isError || updateMutation.isError || changeStatusMutation.isError) && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{t("common.error")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Controller
|
||||
name="articleId"
|
||||
control={control}
|
||||
rules={{ required: t("common.required") }}
|
||||
render={({ field }: { field: any }) => (
|
||||
<Autocomplete
|
||||
options={articles}
|
||||
getOptionLabel={(option) => `${option.code} - ${option.description}`}
|
||||
value={articles.find(a => a.id === field.value) || null}
|
||||
onChange={(_, newValue) => handleArticleChange(newValue?.id || null)}
|
||||
disabled={isEdit || isReadOnly} // Cannot change article after creation
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={t("production.order.fields.article")}
|
||||
error={!!errors.articleId}
|
||||
helperText={errors.articleId?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{!isEdit && (
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Controller
|
||||
name="billOfMaterialsId"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<Autocomplete
|
||||
options={boms.filter(b => b.articleId === watch('articleId'))}
|
||||
getOptionLabel={(option) => option.name}
|
||||
value={boms.find(b => b.id === field.value) || null}
|
||||
onChange={(_, newValue) => field.onChange(newValue?.id)}
|
||||
disabled={isReadOnly}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={t("production.order.fields.bom")}
|
||||
helperText={t("production.order.fields.bomHelp")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{!isEdit && (
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Controller
|
||||
name="createChildOrders"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={t("production.order.fields.createChildOrders")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Controller
|
||||
name="quantity"
|
||||
control={control}
|
||||
rules={{ required: t("common.required"), min: 0.0001 }}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label={t("production.order.fields.quantity")}
|
||||
type="number"
|
||||
fullWidth
|
||||
disabled={isReadOnly}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
error={!!errors.quantity}
|
||||
helperText={errors.quantity?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Controller
|
||||
name="startDate"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<DatePicker
|
||||
label={t("production.order.fields.startDate")}
|
||||
value={field.value ? dayjs(field.value) : null}
|
||||
onChange={(date) => field.onChange(date?.toISOString())}
|
||||
disabled={isReadOnly}
|
||||
slotProps={{ textField: { fullWidth: true } }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Controller
|
||||
name="dueDate"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<DatePicker
|
||||
label={t("production.order.fields.dueDate")}
|
||||
value={field.value ? dayjs(field.value) : null}
|
||||
onChange={(date) => field.onChange(date?.toISOString())}
|
||||
disabled={isReadOnly}
|
||||
slotProps={{ textField: { fullWidth: true } }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Controller
|
||||
name="notes"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label={t("production.order.fields.notes")}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
{isEdit && order && (
|
||||
<Box sx={{ width: '100%' }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={tabValue} onChange={(_, val) => setTabValue(val)}>
|
||||
<Tab label={t("production.order.phases.title")} />
|
||||
<Tab label={t("production.order.subOrders")} />
|
||||
</Tabs>
|
||||
</Box>
|
||||
<TabPanel value={tabValue} index={0}>
|
||||
<ProductionOrderPhases order={order} isReadOnly={isReadOnly} />
|
||||
</TabPanel>
|
||||
<TabPanel value={tabValue} index={1}>
|
||||
{order.childOrders && order.childOrders.length > 0 ? (
|
||||
<Paper sx={{ width: '100%' }}>
|
||||
<DataGrid
|
||||
rows={order.childOrders}
|
||||
columns={[
|
||||
{ field: "code", headerName: t("production.order.columns.code"), width: 150 },
|
||||
{ field: "articleName", headerName: t("production.order.columns.article"), flex: 1 },
|
||||
{ field: "quantity", headerName: t("production.order.columns.quantity"), width: 100 },
|
||||
{ field: "status", headerName: t("production.order.columns.status"), width: 150, valueGetter: (_value, row: any) => t(`production.order.status.${ProductionOrderStatus[row.status]}`) },
|
||||
{
|
||||
field: "actions",
|
||||
headerName: t("common.actions"),
|
||||
width: 100,
|
||||
renderCell: (params: any) => (
|
||||
<IconButton size="small" onClick={() => navigate(`/production/orders/${params.row.id}`)}>
|
||||
<ViewIcon />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
]}
|
||||
hideFooter
|
||||
autoHeight
|
||||
/>
|
||||
</Paper>
|
||||
) : (
|
||||
<Typography sx={{ p: 2 }}>{t("production.order.noSubOrders")}</Typography>
|
||||
)}
|
||||
</TabPanel>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function TabPanel(props: { children?: React.ReactNode; index: number; value: number }) {
|
||||
const { children, value, index, ...other } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tabpanel"
|
||||
hidden={value !== index}
|
||||
id={`simple-tabpanel-${index}`}
|
||||
aria-labelledby={`simple-tab-${index}`}
|
||||
{...other}
|
||||
>
|
||||
{value === index && (
|
||||
<Box sx={{ p: 3 }}>
|
||||
{children}
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
167
frontend/src/modules/production/pages/ProductionOrdersPage.tsx
Normal file
167
frontend/src/modules/production/pages/ProductionOrdersPage.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
DataGrid,
|
||||
GridColDef,
|
||||
GridRenderCellParams,
|
||||
GridToolbar,
|
||||
} from "@mui/x-data-grid";
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Visibility as ViewIcon,
|
||||
Delete as DeleteIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { productionService } from "../services/productionService";
|
||||
import { ProductionOrderDto, ProductionOrderStatus } from "../types";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export default function ProductionOrdersPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: orders = [], isLoading } = useQuery({
|
||||
queryKey: ["production-orders"],
|
||||
queryFn: () => productionService.getProductionOrders(),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => productionService.deleteProductionOrder(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["production-orders"] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = () => {
|
||||
navigate("/production/orders/new");
|
||||
};
|
||||
|
||||
const handleView = (id: number) => {
|
||||
navigate(`/production/orders/${id}`);
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (confirm(t("common.confirmDelete"))) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusChip = (status: ProductionOrderStatus) => {
|
||||
const label = t(`production.order.status.${ProductionOrderStatus[status]}`);
|
||||
switch (status) {
|
||||
case ProductionOrderStatus.Draft:
|
||||
return <Chip label={label} size="small" />;
|
||||
case ProductionOrderStatus.Planned:
|
||||
return <Chip label={label} color="info" size="small" />;
|
||||
case ProductionOrderStatus.Released:
|
||||
return <Chip label={label} color="primary" size="small" />;
|
||||
case ProductionOrderStatus.InProgress:
|
||||
return <Chip label={label} color="warning" size="small" />;
|
||||
case ProductionOrderStatus.Completed:
|
||||
return <Chip label={label} color="success" size="small" />;
|
||||
case ProductionOrderStatus.Cancelled:
|
||||
return <Chip label={label} color="error" size="small" />;
|
||||
default:
|
||||
return <Chip label={label} size="small" />;
|
||||
}
|
||||
};
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{ field: "code", headerName: t("production.order.columns.code"), width: 150 },
|
||||
{
|
||||
field: "startDate",
|
||||
headerName: t("production.order.columns.startDate"),
|
||||
width: 120,
|
||||
valueFormatter: (value) => dayjs(value).format("DD/MM/YYYY"),
|
||||
},
|
||||
{ field: "articleName", headerName: t("production.order.columns.article"), flex: 1, minWidth: 200 },
|
||||
{ field: "parentProductionOrderCode", headerName: t("production.order.columns.parentOrder"), width: 150 },
|
||||
{ field: "quantity", headerName: t("production.order.columns.quantity"), width: 100, type: 'number' },
|
||||
{
|
||||
field: "status",
|
||||
headerName: t("production.order.columns.status"),
|
||||
width: 150,
|
||||
renderCell: (params: GridRenderCellParams<ProductionOrderDto>) =>
|
||||
getStatusChip(params.row.status),
|
||||
},
|
||||
{
|
||||
field: "actions",
|
||||
headerName: t("common.actions"),
|
||||
width: 120,
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams<ProductionOrderDto>) => (
|
||||
<Box>
|
||||
<Tooltip title={t("common.view")}>
|
||||
<IconButton size="small" onClick={() => handleView(params.row.id)}>
|
||||
<ViewIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{(params.row.status === ProductionOrderStatus.Draft) && (
|
||||
<Tooltip title={t("common.delete")}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDelete(params.row.id)}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4">{t("production.order.title")}</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
{t("production.order.newOrder")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ height: 600, width: "100%" }}>
|
||||
<DataGrid
|
||||
rows={orders}
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
slots={{ toolbar: GridToolbar }}
|
||||
slotProps={{
|
||||
toolbar: {
|
||||
showQuickFilter: true,
|
||||
},
|
||||
}}
|
||||
disableRowSelectionOnClick
|
||||
initialState={{
|
||||
pagination: { paginationModel: { pageSize: 25 } },
|
||||
sorting: {
|
||||
sortModel: [{ field: "startDate", sort: "desc" }],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
252
frontend/src/modules/production/pages/WorkCentersPage.tsx
Normal file
252
frontend/src/modules/production/pages/WorkCentersPage.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
DataGrid,
|
||||
GridColDef,
|
||||
GridRenderCellParams,
|
||||
GridToolbar,
|
||||
} from "@mui/x-data-grid";
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Edit as EditIcon,
|
||||
Delete as DeleteIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { productionService } from "../services/productionService";
|
||||
import { WorkCenterDto, CreateWorkCenterDto, UpdateWorkCenterDto } from "../types";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
|
||||
export default function WorkCentersPage() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
|
||||
const { data: workCenters = [], isLoading } = useQuery({
|
||||
queryKey: ["workCenters"],
|
||||
queryFn: () => productionService.getWorkCenters(),
|
||||
});
|
||||
|
||||
const { control, handleSubmit, reset, setValue } = useForm<CreateWorkCenterDto & { isActive: boolean }>();
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateWorkCenterDto) => productionService.createWorkCenter(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["workCenters"] });
|
||||
handleClose();
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: UpdateWorkCenterDto }) =>
|
||||
productionService.updateWorkCenter(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["workCenters"] });
|
||||
handleClose();
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => productionService.deleteWorkCenter(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["workCenters"] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleOpen = (workCenter?: WorkCenterDto) => {
|
||||
if (workCenter) {
|
||||
setEditingId(workCenter.id);
|
||||
setValue("code", workCenter.code);
|
||||
setValue("name", workCenter.name);
|
||||
setValue("description", workCenter.description);
|
||||
setValue("costPerHour", workCenter.costPerHour);
|
||||
setValue("isActive", workCenter.isActive);
|
||||
} else {
|
||||
setEditingId(null);
|
||||
reset({ code: "", name: "", description: "", costPerHour: 0, isActive: true });
|
||||
}
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
setEditingId(null);
|
||||
reset();
|
||||
};
|
||||
|
||||
const onSubmit = (data: CreateWorkCenterDto & { isActive: boolean }) => {
|
||||
if (editingId) {
|
||||
updateMutation.mutate({
|
||||
id: editingId,
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
costPerHour: data.costPerHour,
|
||||
isActive: data.isActive,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
createMutation.mutate(data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (confirm(t("common.confirmDelete"))) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{ field: "code", headerName: t("production.workCenter.fields.code"), width: 150 },
|
||||
{ field: "name", headerName: t("production.workCenter.fields.name"), flex: 1, minWidth: 200 },
|
||||
{ field: "costPerHour", headerName: t("production.workCenter.fields.costPerHour"), width: 150, type: 'number' },
|
||||
{
|
||||
field: "isActive",
|
||||
headerName: t("common.active"),
|
||||
width: 100,
|
||||
type: 'boolean'
|
||||
},
|
||||
{
|
||||
field: "actions",
|
||||
headerName: t("common.actions"),
|
||||
width: 120,
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams<WorkCenterDto>) => (
|
||||
<Box>
|
||||
<Tooltip title={t("common.edit")}>
|
||||
<IconButton size="small" onClick={() => handleOpen(params.row)}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("common.delete")}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDelete(params.row.id)}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 3 }}>
|
||||
<Typography variant="h4">{t("production.workCenter.title")}</Typography>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => handleOpen()}>
|
||||
{t("production.workCenter.new")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ height: 600, width: "100%" }}>
|
||||
<DataGrid
|
||||
rows={workCenters}
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
slots={{ toolbar: GridToolbar }}
|
||||
slotProps={{ toolbar: { showQuickFilter: true } }}
|
||||
disableRowSelectionOnClick
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<DialogTitle>
|
||||
{editingId ? t("production.workCenter.edit") : t("production.workCenter.new")}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||
<Controller
|
||||
name="code"
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label={t("production.workCenter.fields.code")}
|
||||
disabled={!!editingId}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label={t("production.workCenter.fields.name")}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label={t("production.workCenter.fields.description")}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="costPerHour"
|
||||
control={control}
|
||||
rules={{ required: true, min: 0 }}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label={t("production.workCenter.fields.costPerHour")}
|
||||
type="number"
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{editingId && (
|
||||
<Controller
|
||||
name="isActive"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControlLabel
|
||||
control={<Switch checked={field.value} onChange={field.onChange} />}
|
||||
label={t("common.active")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>{t("common.cancel")}</Button>
|
||||
<Button type="submit" variant="contained">
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</form>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
41
frontend/src/modules/production/routes.tsx
Normal file
41
frontend/src/modules/production/routes.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import ProductionDashboardPage from "./pages/ProductionDashboardPage";
|
||||
import ProductionOrdersPage from "./pages/ProductionOrdersPage";
|
||||
import ProductionOrderFormPage from "./pages/ProductionOrderFormPage";
|
||||
import BillOfMaterialsPage from "./pages/BillOfMaterialsPage";
|
||||
import BillOfMaterialsFormPage from "./pages/BillOfMaterialsFormPage";
|
||||
import WorkCentersPage from "./pages/WorkCentersPage";
|
||||
import ProductionCyclesPage from "./pages/ProductionCyclesPage";
|
||||
import ProductionCycleFormPage from "./pages/ProductionCycleFormPage";
|
||||
import MrpPage from "./pages/MrpPage";
|
||||
import ProductionLayout from "./components/ProductionLayout";
|
||||
|
||||
export default function ProductionRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route element={<ProductionLayout />}>
|
||||
<Route index element={<ProductionDashboardPage />} />
|
||||
{/* Production Orders */}
|
||||
<Route path="orders" element={<ProductionOrdersPage />} />
|
||||
<Route path="orders/new" element={<ProductionOrderFormPage />} />
|
||||
<Route path="orders/:id" element={<ProductionOrderFormPage />} />
|
||||
|
||||
{/* Bill of Materials */}
|
||||
<Route path="bom" element={<BillOfMaterialsPage />} />
|
||||
<Route path="bom/new" element={<BillOfMaterialsFormPage />} />
|
||||
<Route path="bom/:id" element={<BillOfMaterialsFormPage />} />
|
||||
|
||||
{/* Work Centers */}
|
||||
<Route path="work-centers" element={<WorkCentersPage />} />
|
||||
|
||||
{/* Production Cycles */}
|
||||
<Route path="cycles" element={<ProductionCyclesPage />} />
|
||||
<Route path="cycles/new" element={<ProductionCycleFormPage />} />
|
||||
<Route path="cycles/:id" element={<ProductionCycleFormPage />} />
|
||||
|
||||
{/* MRP */}
|
||||
<Route path="mrp" element={<MrpPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
150
frontend/src/modules/production/services/productionService.ts
Normal file
150
frontend/src/modules/production/services/productionService.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import axios from 'axios';
|
||||
import {
|
||||
BillOfMaterialsDto,
|
||||
CreateBillOfMaterialsDto,
|
||||
UpdateBillOfMaterialsDto,
|
||||
ProductionOrderDto,
|
||||
CreateProductionOrderDto,
|
||||
UpdateProductionOrderDto,
|
||||
ProductionOrderStatus,
|
||||
WorkCenterDto,
|
||||
CreateWorkCenterDto,
|
||||
UpdateWorkCenterDto,
|
||||
ProductionCycleDto,
|
||||
CreateProductionCycleDto,
|
||||
UpdateProductionCycleDto,
|
||||
MrpSuggestionDto,
|
||||
UpdateProductionOrderPhaseDto,
|
||||
MrpConfigurationDto
|
||||
} from '../types';
|
||||
|
||||
const BOM_API_URL = '/api/production/bom';
|
||||
const ORDERS_API_URL = '/api/production/orders';
|
||||
|
||||
export const productionService = {
|
||||
// Bill of Materials
|
||||
getBillOfMaterials: async (): Promise<BillOfMaterialsDto[]> => {
|
||||
const response = await axios.get(BOM_API_URL);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getBillOfMaterialsById: async (id: number): Promise<BillOfMaterialsDto> => {
|
||||
const response = await axios.get(`${BOM_API_URL}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createBillOfMaterials: async (dto: CreateBillOfMaterialsDto): Promise<BillOfMaterialsDto> => {
|
||||
const response = await axios.post(BOM_API_URL, dto);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateBillOfMaterials: async (id: number, dto: UpdateBillOfMaterialsDto): Promise<BillOfMaterialsDto> => {
|
||||
const response = await axios.put(`${BOM_API_URL}/${id}`, dto);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteBillOfMaterials: async (id: number): Promise<void> => {
|
||||
await axios.delete(`${BOM_API_URL}/${id}`);
|
||||
},
|
||||
|
||||
// Production Orders
|
||||
getProductionOrders: async (): Promise<ProductionOrderDto[]> => {
|
||||
const response = await axios.get(ORDERS_API_URL);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getProductionOrderById: async (id: number): Promise<ProductionOrderDto> => {
|
||||
const response = await axios.get(`${ORDERS_API_URL}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createProductionOrder: async (dto: CreateProductionOrderDto): Promise<ProductionOrderDto> => {
|
||||
const response = await axios.post(ORDERS_API_URL, dto);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateProductionOrder: async (id: number, dto: UpdateProductionOrderDto): Promise<ProductionOrderDto> => {
|
||||
const response = await axios.put(`${ORDERS_API_URL}/${id}`, dto);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
changeProductionOrderStatus: async (id: number, status: ProductionOrderStatus): Promise<ProductionOrderDto> => {
|
||||
const response = await axios.put(`${ORDERS_API_URL}/${id}/status`, status, {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateProductionOrderPhase: async (orderId: number, phaseId: number, dto: UpdateProductionOrderPhaseDto): Promise<ProductionOrderDto> => {
|
||||
const response = await axios.put(`${ORDERS_API_URL}/${orderId}/phases/${phaseId}`, dto);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteProductionOrder: async (id: number): Promise<void> => {
|
||||
await axios.delete(`${ORDERS_API_URL}/${id}`);
|
||||
},
|
||||
|
||||
// Work Centers
|
||||
getWorkCenters: async (): Promise<WorkCenterDto[]> => {
|
||||
const response = await axios.get('/api/production/work-centers');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getWorkCenterById: async (id: number): Promise<WorkCenterDto> => {
|
||||
const response = await axios.get(`/api/production/work-centers/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createWorkCenter: async (dto: CreateWorkCenterDto): Promise<WorkCenterDto> => {
|
||||
const response = await axios.post('/api/production/work-centers', dto);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateWorkCenter: async (id: number, dto: UpdateWorkCenterDto): Promise<WorkCenterDto> => {
|
||||
const response = await axios.put(`/api/production/work-centers/${id}`, dto);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteWorkCenter: async (id: number): Promise<void> => {
|
||||
await axios.delete(`/api/production/work-centers/${id}`);
|
||||
},
|
||||
|
||||
// Production Cycles
|
||||
getProductionCycles: async (): Promise<ProductionCycleDto[]> => {
|
||||
const response = await axios.get('/api/production/cycles');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getProductionCycleById: async (id: number): Promise<ProductionCycleDto> => {
|
||||
const response = await axios.get(`/api/production/cycles/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createProductionCycle: async (dto: CreateProductionCycleDto): Promise<ProductionCycleDto> => {
|
||||
const response = await axios.post('/api/production/cycles', dto);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateProductionCycle: async (id: number, dto: UpdateProductionCycleDto): Promise<ProductionCycleDto> => {
|
||||
const response = await axios.put(`/api/production/cycles/${id}`, dto);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteProductionCycle: async (id: number): Promise<void> => {
|
||||
await axios.delete(`/api/production/cycles/${id}`);
|
||||
},
|
||||
|
||||
// MRP
|
||||
runMrp: async (config: MrpConfigurationDto): Promise<void> => {
|
||||
await axios.post('/api/production/mrp/run', config);
|
||||
},
|
||||
|
||||
getMrpSuggestions: async (includeProcessed = false): Promise<MrpSuggestionDto[]> => {
|
||||
const response = await axios.get(`/api/production/mrp/suggestions?includeProcessed=${includeProcessed}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
processMrpSuggestion: async (id: number): Promise<void> => {
|
||||
await axios.post(`/api/production/mrp/suggestions/${id}/process`);
|
||||
}
|
||||
};
|
||||
248
frontend/src/modules/production/types/index.ts
Normal file
248
frontend/src/modules/production/types/index.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
export interface BillOfMaterialsDto {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
articleId: number;
|
||||
articleName: string;
|
||||
quantity: number;
|
||||
isActive: boolean;
|
||||
components: BillOfMaterialsComponentDto[];
|
||||
}
|
||||
|
||||
export interface BillOfMaterialsComponentDto {
|
||||
id: number;
|
||||
componentArticleId: number;
|
||||
componentArticleName: string;
|
||||
quantity: number;
|
||||
scrapPercentage: number;
|
||||
}
|
||||
|
||||
export interface CreateBillOfMaterialsDto {
|
||||
name: string;
|
||||
description: string;
|
||||
articleId: number;
|
||||
quantity: number;
|
||||
components: CreateBillOfMaterialsComponentDto[];
|
||||
}
|
||||
|
||||
export interface CreateBillOfMaterialsComponentDto {
|
||||
componentArticleId: number;
|
||||
quantity: number;
|
||||
scrapPercentage: number;
|
||||
}
|
||||
|
||||
export interface UpdateBillOfMaterialsDto {
|
||||
name: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
isActive: boolean;
|
||||
components: UpdateBillOfMaterialsComponentDto[];
|
||||
}
|
||||
|
||||
export interface UpdateBillOfMaterialsComponentDto {
|
||||
id?: number;
|
||||
componentArticleId: number;
|
||||
quantity: number;
|
||||
scrapPercentage: number;
|
||||
isDeleted: boolean;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface ProductionOrderComponentDto {
|
||||
id: number;
|
||||
articleId: number;
|
||||
articleName: string;
|
||||
requiredQuantity: number;
|
||||
consumedQuantity: number;
|
||||
}
|
||||
|
||||
export interface CreateProductionOrderDto {
|
||||
articleId: number;
|
||||
quantity: number;
|
||||
startDate: string;
|
||||
dueDate: string;
|
||||
notes?: string;
|
||||
billOfMaterialsId?: number;
|
||||
createChildOrders?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateProductionOrderDto {
|
||||
quantity: number;
|
||||
startDate: string;
|
||||
dueDate: string;
|
||||
notes?: string;
|
||||
status: ProductionOrderStatus;
|
||||
}
|
||||
|
||||
export enum ProductionOrderStatus {
|
||||
Draft = 0,
|
||||
Planned = 1,
|
||||
Released = 2,
|
||||
InProgress = 3,
|
||||
Completed = 4,
|
||||
Cancelled = 5
|
||||
}
|
||||
|
||||
// Work Centers
|
||||
export interface WorkCenterDto {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
costPerHour: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface CreateWorkCenterDto {
|
||||
code: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
costPerHour: number;
|
||||
}
|
||||
|
||||
export interface UpdateWorkCenterDto {
|
||||
name: string;
|
||||
description?: string;
|
||||
costPerHour: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
// Production Cycles
|
||||
export interface ProductionCycleDto {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
articleId: number;
|
||||
articleName: string;
|
||||
isDefault: boolean;
|
||||
isActive: boolean;
|
||||
phases: ProductionCyclePhaseDto[];
|
||||
}
|
||||
|
||||
export interface ProductionCyclePhaseDto {
|
||||
id: number;
|
||||
sequence: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
workCenterId: number;
|
||||
workCenterName: string;
|
||||
durationPerUnitMinutes: number;
|
||||
setupTimeMinutes: number;
|
||||
}
|
||||
|
||||
export interface CreateProductionCycleDto {
|
||||
name: string;
|
||||
description?: string;
|
||||
articleId: number;
|
||||
isDefault: boolean;
|
||||
phases: CreateProductionCyclePhaseDto[];
|
||||
}
|
||||
|
||||
export interface CreateProductionCyclePhaseDto {
|
||||
sequence: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
workCenterId: number;
|
||||
durationPerUnitMinutes: number;
|
||||
setupTimeMinutes: number;
|
||||
}
|
||||
|
||||
export interface UpdateProductionCycleDto {
|
||||
name: string;
|
||||
description?: string;
|
||||
isDefault: boolean;
|
||||
isActive: boolean;
|
||||
phases: UpdateProductionCyclePhaseDto[];
|
||||
}
|
||||
|
||||
export interface UpdateProductionCyclePhaseDto {
|
||||
id?: number;
|
||||
sequence: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
workCenterId: number;
|
||||
durationPerUnitMinutes: number;
|
||||
setupTimeMinutes: number;
|
||||
isDeleted: boolean;
|
||||
}
|
||||
|
||||
// Production Order Phases
|
||||
export interface ProductionOrderPhaseDto {
|
||||
id: number;
|
||||
sequence: number;
|
||||
name: string;
|
||||
workCenterId: number;
|
||||
workCenterName: string;
|
||||
status: ProductionPhaseStatus;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
quantityCompleted: number;
|
||||
quantityScrapped: number;
|
||||
estimatedDurationMinutes: number;
|
||||
actualDurationMinutes: number;
|
||||
}
|
||||
|
||||
export enum ProductionPhaseStatus {
|
||||
Pending = 0,
|
||||
InProgress = 1,
|
||||
Completed = 2,
|
||||
Paused = 3
|
||||
}
|
||||
|
||||
export interface UpdateProductionOrderPhaseDto {
|
||||
status: ProductionPhaseStatus;
|
||||
quantityCompleted: number;
|
||||
quantityScrapped: number;
|
||||
actualDurationMinutes: number;
|
||||
}
|
||||
|
||||
// Update ProductionOrderDto to include phases
|
||||
export interface ProductionOrderDto {
|
||||
id: number;
|
||||
code: string;
|
||||
articleId: number;
|
||||
articleName: string;
|
||||
quantity: number;
|
||||
startDate: string;
|
||||
endDate?: string;
|
||||
dueDate: string;
|
||||
status: ProductionOrderStatus;
|
||||
notes?: string;
|
||||
components: ProductionOrderComponentDto[];
|
||||
phases: ProductionOrderPhaseDto[];
|
||||
parentProductionOrderId?: number;
|
||||
parentProductionOrderCode?: string;
|
||||
childOrders?: ProductionOrderDto[];
|
||||
}
|
||||
|
||||
// MRP
|
||||
export interface MrpSuggestionDto {
|
||||
id: number;
|
||||
calculationDate: string;
|
||||
articleId: number;
|
||||
article: {
|
||||
id: number;
|
||||
code: string;
|
||||
description: string;
|
||||
};
|
||||
type: MrpSuggestionType;
|
||||
quantity: number;
|
||||
suggestionDate: string;
|
||||
reason: string;
|
||||
isProcessed: boolean;
|
||||
}
|
||||
|
||||
export enum MrpSuggestionType {
|
||||
Production = 0,
|
||||
Purchase = 1
|
||||
}
|
||||
|
||||
export interface MrpConfigurationDto {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
includeSafetyStock: boolean;
|
||||
includeSalesOrders: boolean;
|
||||
includeForecasts: boolean;
|
||||
warehouseIds?: number[];
|
||||
}
|
||||
482
frontend/src/modules/purchases/pages/PurchaseOrderFormPage.tsx
Normal file
482
frontend/src/modules/purchases/pages/PurchaseOrderFormPage.tsx
Normal file
@@ -0,0 +1,482 @@
|
||||
import { useEffect } from "react";
|
||||
import { useForm, Controller, useFieldArray } from "react-hook-form";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Paper,
|
||||
Typography,
|
||||
Grid,
|
||||
TextField,
|
||||
Autocomplete,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
IconButton,
|
||||
Alert,
|
||||
Chip,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
Save as SaveIcon,
|
||||
ArrowBack as BackIcon,
|
||||
Add as AddIcon,
|
||||
Delete as DeleteIcon,
|
||||
Check as CheckIcon,
|
||||
LocalShipping as ReceiveIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { purchaseService } from "../services/purchaseService";
|
||||
import { supplierService } from "../services/supplierService";
|
||||
import { articleService, warehouseLocationService } from "../../warehouse/services/warehouseService";
|
||||
import { CreatePurchaseOrderDto, UpdatePurchaseOrderDto, PurchaseOrderStatus } from "../types";
|
||||
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export default function PurchaseOrderFormPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const isEdit = Boolean(id);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { control, handleSubmit, reset, watch, setValue, formState: { errors } } = useForm<CreatePurchaseOrderDto>({
|
||||
defaultValues: {
|
||||
orderDate: new Date().toISOString(),
|
||||
expectedDeliveryDate: undefined,
|
||||
supplierId: 0,
|
||||
destinationWarehouseId: undefined,
|
||||
notes: "",
|
||||
lines: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "lines",
|
||||
});
|
||||
|
||||
const { data: order, isLoading } = useQuery({
|
||||
queryKey: ["purchase-order", id],
|
||||
queryFn: () => purchaseService.getById(Number(id)),
|
||||
enabled: isEdit,
|
||||
});
|
||||
|
||||
const { data: suppliers = [] } = useQuery({
|
||||
queryKey: ["suppliers"],
|
||||
queryFn: () => supplierService.getAll(),
|
||||
});
|
||||
|
||||
const { data: articles = [] } = useQuery({
|
||||
queryKey: ["articles"],
|
||||
queryFn: () => articleService.getAll(),
|
||||
});
|
||||
|
||||
const { data: warehouses = [] } = useQuery({
|
||||
queryKey: ["warehouses"],
|
||||
queryFn: () => warehouseLocationService.getAll(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (order) {
|
||||
reset({
|
||||
orderDate: order.orderDate,
|
||||
expectedDeliveryDate: order.expectedDeliveryDate,
|
||||
supplierId: order.supplierId,
|
||||
destinationWarehouseId: order.destinationWarehouseId,
|
||||
notes: order.notes,
|
||||
lines: order.lines.map(l => ({
|
||||
warehouseArticleId: l.warehouseArticleId,
|
||||
description: l.description,
|
||||
quantity: l.quantity,
|
||||
unitPrice: l.unitPrice,
|
||||
taxRate: l.taxRate,
|
||||
discountPercent: l.discountPercent,
|
||||
// Store existing ID for updates
|
||||
id: l.id
|
||||
})),
|
||||
});
|
||||
}
|
||||
}, [order, reset]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreatePurchaseOrderDto) => purchaseService.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["purchase-orders"] });
|
||||
navigate("/purchases/orders");
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: UpdatePurchaseOrderDto) => purchaseService.update(Number(id), data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["purchase-orders"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["purchase-order", id] });
|
||||
navigate("/purchases/orders");
|
||||
},
|
||||
});
|
||||
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: (id: number) => purchaseService.confirm(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["purchase-orders"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["purchase-order", id] });
|
||||
},
|
||||
});
|
||||
|
||||
const receiveMutation = useMutation({
|
||||
mutationFn: (id: number) => purchaseService.receive(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["purchase-orders"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["purchase-order", id] });
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: CreatePurchaseOrderDto) => {
|
||||
if (isEdit) {
|
||||
updateMutation.mutate(data as UpdatePurchaseOrderDto);
|
||||
} else {
|
||||
createMutation.mutate(data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArticleChange = (index: number, articleId: number | null) => {
|
||||
if (!articleId) return;
|
||||
const article = articles.find(a => a.id === articleId);
|
||||
if (article) {
|
||||
setValue(`lines.${index}.warehouseArticleId`, article.id);
|
||||
setValue(`lines.${index}.description`, article.description);
|
||||
setValue(`lines.${index}.unitPrice`, article.lastPurchaseCost || 0);
|
||||
setValue(`lines.${index}.taxRate`, 22); // Default tax rate
|
||||
}
|
||||
};
|
||||
|
||||
const calculateLineTotal = (index: number) => {
|
||||
const lines = watch("lines");
|
||||
const line = lines[index];
|
||||
if (!line) return 0;
|
||||
const netPrice = line.unitPrice * (1 - (line.discountPercent || 0) / 100);
|
||||
return netPrice * line.quantity;
|
||||
};
|
||||
|
||||
const calculateTotal = () => {
|
||||
const lines = watch("lines");
|
||||
return lines.reduce((acc: number, _line: any, index: number) => acc + calculateLineTotal(index), 0);
|
||||
};
|
||||
|
||||
const isReadOnly = isEdit && order?.status !== PurchaseOrderStatus.Draft;
|
||||
|
||||
if (isEdit && isLoading) return <Typography>Loading...</Typography>;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 1200, mx: "auto" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 3 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
<Button
|
||||
startIcon={<BackIcon />}
|
||||
onClick={() => navigate("/purchases/orders")}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
<Box>
|
||||
<Typography variant="h4">
|
||||
{isEdit ? `${t("purchases.orders.editOrder")} ${order?.orderNumber}` : t("purchases.orders.newOrder")}
|
||||
</Typography>
|
||||
{isEdit && order && (
|
||||
<Chip
|
||||
label={t(`purchases.orders.status.${PurchaseOrderStatus[order.status]}`)}
|
||||
color={order.status === PurchaseOrderStatus.Confirmed ? "primary" : order.status === PurchaseOrderStatus.Received ? "success" : "default"}
|
||||
size="small"
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
{isEdit && order?.status === PurchaseOrderStatus.Draft && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<CheckIcon />}
|
||||
onClick={() => confirmMutation.mutate(Number(id))}
|
||||
disabled={confirmMutation.isPending}
|
||||
>
|
||||
{t("purchases.orders.actions.confirm")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isEdit && (order?.status === PurchaseOrderStatus.Confirmed || order?.status === PurchaseOrderStatus.PartiallyReceived) && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
startIcon={<ReceiveIcon />}
|
||||
onClick={() => receiveMutation.mutate(Number(id))}
|
||||
disabled={receiveMutation.isPending}
|
||||
>
|
||||
{t("purchases.orders.actions.receive")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isReadOnly && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{(createMutation.isError || updateMutation.isError || confirmMutation.isError || receiveMutation.isError) && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{t("common.error")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Controller
|
||||
name="orderDate"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<DatePicker
|
||||
label={t("purchases.orders.fields.orderDate")}
|
||||
value={field.value ? dayjs(field.value) : null}
|
||||
onChange={(date) => field.onChange(date?.toISOString())}
|
||||
disabled={isReadOnly}
|
||||
slotProps={{ textField: { fullWidth: true } }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Controller
|
||||
name="expectedDeliveryDate"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<DatePicker
|
||||
label={t("purchases.orders.fields.expectedDeliveryDate")}
|
||||
value={field.value ? dayjs(field.value) : null}
|
||||
onChange={(date) => field.onChange(date?.toISOString())}
|
||||
disabled={isReadOnly}
|
||||
slotProps={{ textField: { fullWidth: true } }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Controller
|
||||
name="supplierId"
|
||||
control={control}
|
||||
rules={{ required: t("common.required") }}
|
||||
render={({ field }: { field: any }) => (
|
||||
<Autocomplete
|
||||
options={suppliers}
|
||||
getOptionLabel={(option) => option.name}
|
||||
value={suppliers.find(s => s.id === field.value) || null}
|
||||
onChange={(_, newValue) => field.onChange(newValue?.id)}
|
||||
disabled={isReadOnly}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={t("purchases.orders.fields.supplier")}
|
||||
error={!!errors.supplierId}
|
||||
helperText={errors.supplierId?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Controller
|
||||
name="destinationWarehouseId"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<Autocomplete
|
||||
options={warehouses}
|
||||
getOptionLabel={(option) => option.name}
|
||||
value={warehouses.find(w => w.id === field.value) || null}
|
||||
onChange={(_, newValue) => field.onChange(newValue?.id)}
|
||||
disabled={isReadOnly}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={t("purchases.orders.fields.destinationWarehouse")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Controller
|
||||
name="notes"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label={t("purchases.orders.fields.notes")}
|
||||
fullWidth
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 2 }}>
|
||||
<Typography variant="h6">{t("purchases.orders.fields.lineTotal")}</Typography>
|
||||
{!isReadOnly && (
|
||||
<Button startIcon={<AddIcon />} onClick={() => append({
|
||||
warehouseArticleId: 0,
|
||||
quantity: 1,
|
||||
unitPrice: 0,
|
||||
taxRate: 22,
|
||||
discountPercent: 0,
|
||||
description: ""
|
||||
})}>
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell width="30%">{t("purchases.orders.fields.article")}</TableCell>
|
||||
<TableCell width="10%">{t("purchases.orders.fields.quantity")}</TableCell>
|
||||
<TableCell width="15%">{t("purchases.orders.fields.unitPrice")}</TableCell>
|
||||
<TableCell width="10%">{t("purchases.orders.fields.discount")}</TableCell>
|
||||
<TableCell width="10%">{t("purchases.orders.fields.taxRate")}</TableCell>
|
||||
<TableCell width="15%" align="right">{t("purchases.orders.fields.lineTotal")}</TableCell>
|
||||
<TableCell width="10%"></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{fields.map((field: any, index: number) => (
|
||||
<TableRow key={field.id}>
|
||||
<TableCell>
|
||||
<Controller
|
||||
name={`lines.${index}.warehouseArticleId`}
|
||||
control={control}
|
||||
render={({ field: articleField }: { field: any }) => (
|
||||
<Autocomplete
|
||||
options={articles}
|
||||
getOptionLabel={(option) => `${option.code} - ${option.description}`}
|
||||
value={articles.find(a => a.id === articleField.value) || null}
|
||||
onChange={(_, newValue) => handleArticleChange(index, newValue?.id || null)}
|
||||
disabled={isReadOnly}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} size="small" variant="standard" />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Controller
|
||||
name={`lines.${index}.quantity`}
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="number"
|
||||
size="small"
|
||||
variant="standard"
|
||||
disabled={isReadOnly}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Controller
|
||||
name={`lines.${index}.unitPrice`}
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="number"
|
||||
size="small"
|
||||
variant="standard"
|
||||
disabled={isReadOnly}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Controller
|
||||
name={`lines.${index}.discountPercent`}
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="number"
|
||||
size="small"
|
||||
variant="standard"
|
||||
disabled={isReadOnly}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Controller
|
||||
name={`lines.${index}.taxRate`}
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="number"
|
||||
size="small"
|
||||
variant="standard"
|
||||
disabled={isReadOnly}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{new Intl.NumberFormat("it-IT", { style: "currency", currency: "EUR" }).format(calculateLineTotal(index))}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{!isReadOnly && (
|
||||
<IconButton size="small" color="error" onClick={() => remove(index)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} align="right">
|
||||
<Typography fontWeight="bold">{t("purchases.orders.totals.gross")}</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Typography fontWeight="bold">
|
||||
{new Intl.NumberFormat("it-IT", { style: "currency", currency: "EUR" }).format(calculateTotal())}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
174
frontend/src/modules/purchases/pages/PurchaseOrdersPage.tsx
Normal file
174
frontend/src/modules/purchases/pages/PurchaseOrdersPage.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
DataGrid,
|
||||
GridColDef,
|
||||
GridRenderCellParams,
|
||||
GridToolbar,
|
||||
} from "@mui/x-data-grid";
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Visibility as ViewIcon,
|
||||
Delete as DeleteIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { purchaseService } from "../services/purchaseService";
|
||||
import { PurchaseOrderDto, PurchaseOrderStatus } from "../types";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export default function PurchaseOrdersPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: orders = [], isLoading } = useQuery({
|
||||
queryKey: ["purchase-orders"],
|
||||
queryFn: () => purchaseService.getAll(),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => purchaseService.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["purchase-orders"] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = () => {
|
||||
navigate("/purchases/orders/new");
|
||||
};
|
||||
|
||||
const handleView = (id: number) => {
|
||||
navigate(`/purchases/orders/${id}`);
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (confirm(t("common.confirmDelete"))) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusChip = (status: PurchaseOrderStatus) => {
|
||||
const label = t(`purchases.order.status.${PurchaseOrderStatus[status]}`);
|
||||
switch (status) {
|
||||
case PurchaseOrderStatus.Draft:
|
||||
return <Chip label={label} size="small" />;
|
||||
case PurchaseOrderStatus.Confirmed:
|
||||
return <Chip label={label} color="primary" size="small" />;
|
||||
case PurchaseOrderStatus.PartiallyReceived:
|
||||
return <Chip label={label} color="warning" size="small" />;
|
||||
case PurchaseOrderStatus.Received:
|
||||
return <Chip label={label} color="success" size="small" />;
|
||||
case PurchaseOrderStatus.Cancelled:
|
||||
return <Chip label={label} color="error" size="small" />;
|
||||
default:
|
||||
return <Chip label={label} size="small" />;
|
||||
}
|
||||
};
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{ field: "orderNumber", headerName: t("purchases.order.columns.number"), width: 150 },
|
||||
{
|
||||
field: "orderDate",
|
||||
headerName: t("purchases.order.columns.date"),
|
||||
width: 120,
|
||||
valueFormatter: (value) => dayjs(value).format("DD/MM/YYYY"),
|
||||
},
|
||||
{ field: "supplierName", headerName: t("purchases.order.columns.supplier"), flex: 1, minWidth: 200 },
|
||||
{
|
||||
field: "status",
|
||||
headerName: t("purchases.order.columns.status"),
|
||||
width: 150,
|
||||
renderCell: (params: GridRenderCellParams<PurchaseOrderDto>) =>
|
||||
getStatusChip(params.row.status),
|
||||
},
|
||||
{
|
||||
field: "totalGross",
|
||||
headerName: t("purchases.order.columns.total"),
|
||||
width: 120,
|
||||
valueFormatter: (value) =>
|
||||
new Intl.NumberFormat("it-IT", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(value),
|
||||
},
|
||||
{
|
||||
field: "actions",
|
||||
headerName: t("common.actions"),
|
||||
width: 120,
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams<PurchaseOrderDto>) => (
|
||||
<Box>
|
||||
<Tooltip title={t("common.view")}>
|
||||
<IconButton size="small" onClick={() => handleView(params.row.id)}>
|
||||
<ViewIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{(params.row.status === PurchaseOrderStatus.Draft ||
|
||||
params.row.status === PurchaseOrderStatus.Cancelled) && (
|
||||
<Tooltip title={t("common.delete")}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDelete(params.row.id)}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4">{t("purchases.order.title")}</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
{t("purchases.order.newOrder")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ height: 600, width: "100%" }}>
|
||||
<DataGrid
|
||||
rows={orders}
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
slots={{ toolbar: GridToolbar }}
|
||||
slotProps={{
|
||||
toolbar: {
|
||||
showQuickFilter: true,
|
||||
},
|
||||
}}
|
||||
disableRowSelectionOnClick
|
||||
initialState={{
|
||||
pagination: { paginationModel: { pageSize: 25 } },
|
||||
sorting: {
|
||||
sortModel: [{ field: "orderDate", sort: "desc" }],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
315
frontend/src/modules/purchases/pages/SupplierFormPage.tsx
Normal file
315
frontend/src/modules/purchases/pages/SupplierFormPage.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import { useEffect } from "react";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Paper,
|
||||
Typography,
|
||||
Grid,
|
||||
TextField,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
Alert,
|
||||
} from "@mui/material";
|
||||
import { Save as SaveIcon, ArrowBack as BackIcon } from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { supplierService } from "../services/supplierService";
|
||||
import { CreateSupplierDto, UpdateSupplierDto } from "../types";
|
||||
|
||||
export default function SupplierFormPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const isEdit = Boolean(id);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { control, handleSubmit, reset, formState: { errors } } = useForm<CreateSupplierDto & { isActive: boolean }>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
vatNumber: "",
|
||||
fiscalCode: "",
|
||||
address: "",
|
||||
city: "",
|
||||
province: "",
|
||||
zipCode: "",
|
||||
country: "Italia",
|
||||
email: "",
|
||||
pec: "",
|
||||
phone: "",
|
||||
website: "",
|
||||
paymentTerms: "",
|
||||
notes: "",
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { data: supplier, isLoading } = useQuery({
|
||||
queryKey: ["supplier", id],
|
||||
queryFn: () => supplierService.getById(Number(id)),
|
||||
enabled: isEdit,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (supplier) {
|
||||
reset(supplier);
|
||||
}
|
||||
}, [supplier, reset]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateSupplierDto) => supplierService.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["suppliers"] });
|
||||
navigate("/purchases/suppliers");
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: UpdateSupplierDto) => supplierService.update(Number(id), data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["suppliers"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["supplier", id] });
|
||||
navigate("/purchases/suppliers");
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: CreateSupplierDto & { isActive: boolean }) => {
|
||||
if (isEdit) {
|
||||
updateMutation.mutate(data);
|
||||
} else {
|
||||
createMutation.mutate(data);
|
||||
}
|
||||
};
|
||||
|
||||
if (isEdit && isLoading) return <Typography>Loading...</Typography>;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 1200, mx: "auto" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", mb: 3 }}>
|
||||
<Button
|
||||
startIcon={<BackIcon />}
|
||||
onClick={() => navigate("/purchases/suppliers")}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
<Typography variant="h4">
|
||||
{isEdit ? t("purchases.supplier.editTitle") : t("purchases.supplier.createTitle")}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{(createMutation.isError || updateMutation.isError) && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{t("common.error")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
rules={{ required: t("common.required") }}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label={t("purchases.supplier.fields.name")}
|
||||
fullWidth
|
||||
error={!!errors.name}
|
||||
helperText={errors.name?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Controller
|
||||
name="vatNumber"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label={t("purchases.supplier.fields.vatNumber")}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Controller
|
||||
name="fiscalCode"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label={t("purchases.supplier.fields.fiscalCode")}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Controller
|
||||
name="email"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label={t("purchases.supplier.fields.email")}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Controller
|
||||
name="pec"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label={t("purchases.supplier.fields.pec")}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Controller
|
||||
name="phone"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label={t("purchases.supplier.fields.phone")}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Controller
|
||||
name="address"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label={t("purchases.supplier.fields.address")}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Controller
|
||||
name="city"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label={t("purchases.supplier.fields.city")}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Controller
|
||||
name="province"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label={t("purchases.supplier.fields.province")}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Controller
|
||||
name="zipCode"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label={t("purchases.supplier.fields.zipCode")}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Controller
|
||||
name="paymentTerms"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label={t("purchases.supplier.fields.paymentTerms")}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Controller
|
||||
name="website"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label={t("purchases.supplier.fields.website")}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Controller
|
||||
name="notes"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label={t("purchases.supplier.fields.notes")}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={4}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Controller
|
||||
name="isActive"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<FormControlLabel
|
||||
control={<Switch {...field} checked={field.value} />}
|
||||
label={t("common.active")}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12 }} sx={{ display: "flex", justifyContent: "flex-end", gap: 2 }}>
|
||||
<Button onClick={() => navigate("/purchases/suppliers")}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</form>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
145
frontend/src/modules/purchases/pages/SuppliersPage.tsx
Normal file
145
frontend/src/modules/purchases/pages/SuppliersPage.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
DataGrid,
|
||||
GridColDef,
|
||||
GridRenderCellParams,
|
||||
GridToolbar,
|
||||
} from "@mui/x-data-grid";
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Edit as EditIcon,
|
||||
Delete as DeleteIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { supplierService } from "../services/supplierService";
|
||||
import { SupplierDto } from "../types";
|
||||
|
||||
export default function SuppliersPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: suppliers = [], isLoading } = useQuery({
|
||||
queryKey: ["suppliers"],
|
||||
queryFn: () => supplierService.getAll(),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => supplierService.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["suppliers"] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = () => {
|
||||
navigate("/purchases/suppliers/new");
|
||||
};
|
||||
|
||||
const handleEdit = (id: number) => {
|
||||
navigate(`/purchases/suppliers/${id}`);
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (confirm(t("common.confirmDelete"))) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{ field: "code", headerName: t("purchases.suppliers.columns.code"), width: 120 },
|
||||
{ field: "name", headerName: t("purchases.suppliers.columns.name"), flex: 1, minWidth: 200 },
|
||||
{ field: "vatNumber", headerName: t("purchases.suppliers.columns.vatNumber"), width: 150 },
|
||||
{ field: "email", headerName: t("purchases.suppliers.columns.email"), width: 200 },
|
||||
{ field: "phone", headerName: t("purchases.suppliers.columns.phone"), width: 150 },
|
||||
{ field: "city", headerName: t("purchases.suppliers.columns.city"), width: 150 },
|
||||
{
|
||||
field: "isActive",
|
||||
headerName: t("purchases.suppliers.columns.status"),
|
||||
width: 120,
|
||||
renderCell: (params: GridRenderCellParams<SupplierDto>) => (
|
||||
<Chip
|
||||
label={params.value ? t("common.active") : t("common.inactive")}
|
||||
color={params.value ? "success" : "default"}
|
||||
size="small"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
field: "actions",
|
||||
headerName: t("common.actions"),
|
||||
width: 120,
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams<SupplierDto>) => (
|
||||
<Box>
|
||||
<Tooltip title={t("common.edit")}>
|
||||
<IconButton size="small" onClick={() => handleEdit(params.row.id)}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t("common.delete")}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDelete(params.row.id)}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4">{t("purchases.suppliers.title")}</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
{t("purchases.suppliers.newSupplier")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ height: 600, width: "100%" }}>
|
||||
<DataGrid
|
||||
rows={suppliers}
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
slots={{ toolbar: GridToolbar }}
|
||||
slotProps={{
|
||||
toolbar: {
|
||||
showQuickFilter: true,
|
||||
},
|
||||
}}
|
||||
disableRowSelectionOnClick
|
||||
initialState={{
|
||||
pagination: { paginationModel: { pageSize: 25 } },
|
||||
sorting: {
|
||||
sortModel: [{ field: "name", sort: "asc" }],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
18
frontend/src/modules/purchases/routes.tsx
Normal file
18
frontend/src/modules/purchases/routes.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import SuppliersPage from "./pages/SuppliersPage";
|
||||
import SupplierFormPage from "./pages/SupplierFormPage";
|
||||
import PurchaseOrdersPage from "./pages/PurchaseOrdersPage";
|
||||
import PurchaseOrderFormPage from "./pages/PurchaseOrderFormPage";
|
||||
|
||||
export default function PurchasesRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="suppliers" element={<SuppliersPage />} />
|
||||
<Route path="suppliers/new" element={<SupplierFormPage />} />
|
||||
<Route path="suppliers/:id" element={<SupplierFormPage />} />
|
||||
<Route path="orders" element={<PurchaseOrdersPage />} />
|
||||
<Route path="orders/new" element={<PurchaseOrderFormPage />} />
|
||||
<Route path="orders/:id" element={<PurchaseOrderFormPage />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
40
frontend/src/modules/purchases/services/purchaseService.ts
Normal file
40
frontend/src/modules/purchases/services/purchaseService.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import axios from 'axios';
|
||||
import { PurchaseOrderDto, CreatePurchaseOrderDto, UpdatePurchaseOrderDto } from '../types';
|
||||
|
||||
const API_URL = '/api/purchases/orders';
|
||||
|
||||
export const purchaseService = {
|
||||
getAll: async (): Promise<PurchaseOrderDto[]> => {
|
||||
const response = await axios.get<PurchaseOrderDto[]>(API_URL);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: number): Promise<PurchaseOrderDto> => {
|
||||
const response = await axios.get<PurchaseOrderDto>(`${API_URL}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (order: CreatePurchaseOrderDto): Promise<PurchaseOrderDto> => {
|
||||
const response = await axios.post<PurchaseOrderDto>(API_URL, order);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: number, order: UpdatePurchaseOrderDto): Promise<PurchaseOrderDto> => {
|
||||
const response = await axios.put<PurchaseOrderDto>(`${API_URL}/${id}`, order);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: number): Promise<void> => {
|
||||
await axios.delete(`${API_URL}/${id}`);
|
||||
},
|
||||
|
||||
confirm: async (id: number): Promise<PurchaseOrderDto> => {
|
||||
const response = await axios.post<PurchaseOrderDto>(`${API_URL}/${id}/confirm`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
receive: async (id: number): Promise<PurchaseOrderDto> => {
|
||||
const response = await axios.post<PurchaseOrderDto>(`${API_URL}/${id}/receive`);
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
30
frontend/src/modules/purchases/services/supplierService.ts
Normal file
30
frontend/src/modules/purchases/services/supplierService.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import axios from 'axios';
|
||||
import { SupplierDto, CreateSupplierDto, UpdateSupplierDto } from '../types';
|
||||
|
||||
const API_URL = '/api/purchases/suppliers';
|
||||
|
||||
export const supplierService = {
|
||||
getAll: async (): Promise<SupplierDto[]> => {
|
||||
const response = await axios.get<SupplierDto[]>(API_URL);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: number): Promise<SupplierDto> => {
|
||||
const response = await axios.get<SupplierDto>(`${API_URL}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (supplier: CreateSupplierDto): Promise<SupplierDto> => {
|
||||
const response = await axios.post<SupplierDto>(API_URL, supplier);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: number, supplier: UpdateSupplierDto): Promise<SupplierDto> => {
|
||||
const response = await axios.put<SupplierDto>(`${API_URL}/${id}`, supplier);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: number): Promise<void> => {
|
||||
await axios.delete(`${API_URL}/${id}`);
|
||||
}
|
||||
};
|
||||
121
frontend/src/modules/purchases/types/index.ts
Normal file
121
frontend/src/modules/purchases/types/index.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
export interface SupplierDto {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
vatNumber?: string;
|
||||
fiscalCode?: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
province?: string;
|
||||
zipCode?: string;
|
||||
country?: string;
|
||||
email?: string;
|
||||
pec?: string;
|
||||
phone?: string;
|
||||
website?: string;
|
||||
paymentTerms?: string;
|
||||
notes?: string;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CreateSupplierDto {
|
||||
name: string;
|
||||
vatNumber?: string;
|
||||
fiscalCode?: string;
|
||||
address?: string;
|
||||
city?: string;
|
||||
province?: string;
|
||||
zipCode?: string;
|
||||
country?: string;
|
||||
email?: string;
|
||||
pec?: string;
|
||||
phone?: string;
|
||||
website?: string;
|
||||
paymentTerms?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateSupplierDto extends Partial<CreateSupplierDto> {
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export enum PurchaseOrderStatus {
|
||||
Draft = 0,
|
||||
Confirmed = 1,
|
||||
PartiallyReceived = 2,
|
||||
Received = 3,
|
||||
Cancelled = 4
|
||||
}
|
||||
|
||||
export interface PurchaseOrderDto {
|
||||
id: number;
|
||||
orderNumber: string;
|
||||
orderDate: string;
|
||||
expectedDeliveryDate?: string;
|
||||
supplierId: number;
|
||||
supplierName: string;
|
||||
status: PurchaseOrderStatus;
|
||||
destinationWarehouseId?: number;
|
||||
destinationWarehouseName?: string;
|
||||
notes?: string;
|
||||
totalNet: number;
|
||||
totalTax: number;
|
||||
totalGross: number;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
lines: PurchaseOrderLineDto[];
|
||||
}
|
||||
|
||||
export interface PurchaseOrderLineDto {
|
||||
id: number;
|
||||
purchaseOrderId: number;
|
||||
warehouseArticleId: number;
|
||||
articleCode: string;
|
||||
articleDescription: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
receivedQuantity: number;
|
||||
unitPrice: number;
|
||||
taxRate: number;
|
||||
discountPercent: number;
|
||||
lineTotal: number;
|
||||
}
|
||||
|
||||
export interface CreatePurchaseOrderDto {
|
||||
orderDate: string;
|
||||
expectedDeliveryDate?: string;
|
||||
supplierId: number;
|
||||
destinationWarehouseId?: number;
|
||||
notes?: string;
|
||||
lines: CreatePurchaseOrderLineDto[];
|
||||
}
|
||||
|
||||
export interface CreatePurchaseOrderLineDto {
|
||||
warehouseArticleId: number;
|
||||
description?: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
taxRate: number;
|
||||
discountPercent: number;
|
||||
}
|
||||
|
||||
export interface UpdatePurchaseOrderDto {
|
||||
orderDate: string;
|
||||
expectedDeliveryDate?: string;
|
||||
destinationWarehouseId?: number;
|
||||
notes?: string;
|
||||
lines: UpdatePurchaseOrderLineDto[];
|
||||
}
|
||||
|
||||
export interface UpdatePurchaseOrderLineDto {
|
||||
id?: number;
|
||||
warehouseArticleId: number;
|
||||
description?: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
taxRate: number;
|
||||
discountPercent: number;
|
||||
isDeleted?: boolean;
|
||||
}
|
||||
455
frontend/src/modules/sales/pages/SalesOrderFormPage.tsx
Normal file
455
frontend/src/modules/sales/pages/SalesOrderFormPage.tsx
Normal file
@@ -0,0 +1,455 @@
|
||||
import { useEffect } from "react";
|
||||
import { useForm, Controller, useFieldArray } from "react-hook-form";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Paper,
|
||||
Typography,
|
||||
Grid,
|
||||
TextField,
|
||||
Autocomplete,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
IconButton,
|
||||
Alert,
|
||||
Chip,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
Save as SaveIcon,
|
||||
ArrowBack as BackIcon,
|
||||
Add as AddIcon,
|
||||
Delete as DeleteIcon,
|
||||
Check as CheckIcon,
|
||||
LocalShipping as ShipIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { salesService } from "../services/salesService";
|
||||
import { clientiService } from "../../../services/lookupService";
|
||||
import { articleService } from "../../warehouse/services/warehouseService";
|
||||
import { CreateSalesOrderDto, UpdateSalesOrderDto, SalesOrderStatus } from "../types";
|
||||
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export default function SalesOrderFormPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const isEdit = Boolean(id);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { control, handleSubmit, reset, watch, setValue, formState: { errors } } = useForm<CreateSalesOrderDto>({
|
||||
defaultValues: {
|
||||
orderDate: new Date().toISOString(),
|
||||
expectedDeliveryDate: undefined,
|
||||
customerId: 0,
|
||||
notes: "",
|
||||
lines: [],
|
||||
},
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "lines",
|
||||
});
|
||||
|
||||
const { data: order, isLoading } = useQuery({
|
||||
queryKey: ["sales-order", id],
|
||||
queryFn: () => salesService.getById(Number(id)),
|
||||
enabled: isEdit,
|
||||
});
|
||||
|
||||
const { data: customers = [] } = useQuery({
|
||||
queryKey: ["customers"],
|
||||
queryFn: () => clientiService.getAll(),
|
||||
});
|
||||
|
||||
const { data: articles = [] } = useQuery({
|
||||
queryKey: ["articles"],
|
||||
queryFn: () => articleService.getAll(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (order) {
|
||||
reset({
|
||||
orderDate: order.orderDate,
|
||||
expectedDeliveryDate: order.expectedDeliveryDate,
|
||||
customerId: order.customerId,
|
||||
notes: order.notes,
|
||||
lines: order.lines.map(l => ({
|
||||
warehouseArticleId: l.warehouseArticleId,
|
||||
description: l.description,
|
||||
quantity: l.quantity,
|
||||
unitPrice: l.unitPrice,
|
||||
taxRate: l.taxRate,
|
||||
discountPercent: l.discountPercent,
|
||||
// Store existing ID for updates
|
||||
id: l.id
|
||||
})),
|
||||
});
|
||||
}
|
||||
}, [order, reset]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateSalesOrderDto) => salesService.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["sales-orders"] });
|
||||
navigate("/sales/orders");
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: UpdateSalesOrderDto) => salesService.update(Number(id), data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["sales-orders"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["sales-order", id] });
|
||||
navigate("/sales/orders");
|
||||
},
|
||||
});
|
||||
|
||||
const confirmMutation = useMutation({
|
||||
mutationFn: (id: number) => salesService.confirm(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["sales-orders"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["sales-order", id] });
|
||||
},
|
||||
});
|
||||
|
||||
const shipMutation = useMutation({
|
||||
mutationFn: (id: number) => salesService.ship(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["sales-orders"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["sales-order", id] });
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: CreateSalesOrderDto) => {
|
||||
if (isEdit) {
|
||||
updateMutation.mutate(data as UpdateSalesOrderDto);
|
||||
} else {
|
||||
createMutation.mutate(data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArticleChange = (index: number, articleId: number | null) => {
|
||||
if (!articleId) return;
|
||||
const article = articles.find(a => a.id === articleId);
|
||||
if (article) {
|
||||
setValue(`lines.${index}.warehouseArticleId`, article.id);
|
||||
setValue(`lines.${index}.description`, article.description);
|
||||
// Use baseSellingPrice if available, otherwise 0 or cost
|
||||
setValue(`lines.${index}.unitPrice`, article.baseSellingPrice || 0);
|
||||
setValue(`lines.${index}.taxRate`, 22); // Default tax rate
|
||||
}
|
||||
};
|
||||
|
||||
const calculateLineTotal = (index: number) => {
|
||||
const lines = watch("lines");
|
||||
const line = lines[index];
|
||||
if (!line) return 0;
|
||||
const netPrice = line.unitPrice * (1 - (line.discountPercent || 0) / 100);
|
||||
return netPrice * line.quantity;
|
||||
};
|
||||
|
||||
const calculateTotal = () => {
|
||||
const lines = watch("lines");
|
||||
return lines.reduce((acc: number, _line: any, index: number) => acc + calculateLineTotal(index), 0);
|
||||
};
|
||||
|
||||
const isReadOnly = isEdit && order?.status !== SalesOrderStatus.Draft;
|
||||
|
||||
if (isEdit && isLoading) return <Typography>Loading...</Typography>;
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, maxWidth: 1200, mx: "auto" }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 3 }}>
|
||||
<Box sx={{ display: "flex", alignItems: "center" }}>
|
||||
<Button
|
||||
startIcon={<BackIcon />}
|
||||
onClick={() => navigate("/sales/orders")}
|
||||
sx={{ mr: 2 }}
|
||||
>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
<Box>
|
||||
<Typography variant="h4">
|
||||
{isEdit ? `${t("sales.order.editTitle")} ${order?.orderNumber}` : t("sales.order.createTitle")}
|
||||
</Typography>
|
||||
{isEdit && order && (
|
||||
<Chip
|
||||
label={t(`sales.order.status.${SalesOrderStatus[order.status]}`)}
|
||||
color={order.status === SalesOrderStatus.Confirmed ? "primary" : order.status === SalesOrderStatus.Shipped ? "success" : "default"}
|
||||
size="small"
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 2 }}>
|
||||
{isEdit && order?.status === SalesOrderStatus.Draft && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<CheckIcon />}
|
||||
onClick={() => confirmMutation.mutate(Number(id))}
|
||||
disabled={confirmMutation.isPending}
|
||||
>
|
||||
{t("sales.order.actions.confirm")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isEdit && (order?.status === SalesOrderStatus.Confirmed || order?.status === SalesOrderStatus.PartiallyShipped) && (
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
startIcon={<ShipIcon />}
|
||||
onClick={() => shipMutation.mutate(Number(id))}
|
||||
disabled={shipMutation.isPending}
|
||||
>
|
||||
{t("sales.order.actions.ship")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isReadOnly && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<SaveIcon />}
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
disabled={createMutation.isPending || updateMutation.isPending}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{(createMutation.isError || updateMutation.isError || confirmMutation.isError || shipMutation.isError) && (
|
||||
<Alert severity="error" sx={{ mb: 3 }}>
|
||||
{t("common.error")}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Controller
|
||||
name="orderDate"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<DatePicker
|
||||
label={t("sales.order.fields.orderDate")}
|
||||
value={field.value ? dayjs(field.value) : null}
|
||||
onChange={(date) => field.onChange(date?.toISOString())}
|
||||
disabled={isReadOnly}
|
||||
slotProps={{ textField: { fullWidth: true } }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Controller
|
||||
name="expectedDeliveryDate"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<DatePicker
|
||||
label={t("sales.order.fields.expectedDeliveryDate")}
|
||||
value={field.value ? dayjs(field.value) : null}
|
||||
onChange={(date) => field.onChange(date?.toISOString())}
|
||||
disabled={isReadOnly}
|
||||
slotProps={{ textField: { fullWidth: true } }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Controller
|
||||
name="customerId"
|
||||
control={control}
|
||||
rules={{ required: t("common.required") }}
|
||||
render={({ field }: { field: any }) => (
|
||||
<Autocomplete
|
||||
options={customers}
|
||||
getOptionLabel={(option) => option.ragioneSociale}
|
||||
value={customers.find(c => c.id === field.value) || null}
|
||||
onChange={(_, newValue) => field.onChange(newValue?.id)}
|
||||
disabled={isReadOnly}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label={t("sales.order.fields.customer")}
|
||||
error={!!errors.customerId}
|
||||
helperText={errors.customerId?.message}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12 }}>
|
||||
<Controller
|
||||
name="notes"
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
label={t("sales.order.fields.notes")}
|
||||
fullWidth
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 2 }}>
|
||||
<Typography variant="h6">{t("sales.order.fields.lineTotal")}</Typography>
|
||||
{!isReadOnly && (
|
||||
<Button startIcon={<AddIcon />} onClick={() => append({
|
||||
warehouseArticleId: 0,
|
||||
quantity: 1,
|
||||
unitPrice: 0,
|
||||
taxRate: 22,
|
||||
discountPercent: 0,
|
||||
description: ""
|
||||
})}>
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell width="30%">{t("sales.order.fields.article")}</TableCell>
|
||||
<TableCell width="10%">{t("sales.order.fields.quantity")}</TableCell>
|
||||
<TableCell width="15%">{t("sales.order.fields.unitPrice")}</TableCell>
|
||||
<TableCell width="10%">{t("sales.order.fields.discount")}</TableCell>
|
||||
<TableCell width="10%">{t("sales.order.fields.taxRate")}</TableCell>
|
||||
<TableCell width="15%" align="right">{t("sales.order.fields.lineTotal")}</TableCell>
|
||||
<TableCell width="10%"></TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{fields.map((field: any, index: number) => (
|
||||
<TableRow key={field.id}>
|
||||
<TableCell>
|
||||
<Controller
|
||||
name={`lines.${index}.warehouseArticleId`}
|
||||
control={control}
|
||||
render={({ field: articleField }: { field: any }) => (
|
||||
<Autocomplete
|
||||
options={articles}
|
||||
getOptionLabel={(option) => `${option.code} - ${option.description}`}
|
||||
value={articles.find(a => a.id === articleField.value) || null}
|
||||
onChange={(_, newValue) => handleArticleChange(index, newValue?.id || null)}
|
||||
disabled={isReadOnly}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} size="small" variant="standard" />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Controller
|
||||
name={`lines.${index}.quantity`}
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="number"
|
||||
size="small"
|
||||
variant="standard"
|
||||
disabled={isReadOnly}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Controller
|
||||
name={`lines.${index}.unitPrice`}
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="number"
|
||||
size="small"
|
||||
variant="standard"
|
||||
disabled={isReadOnly}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Controller
|
||||
name={`lines.${index}.discountPercent`}
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="number"
|
||||
size="small"
|
||||
variant="standard"
|
||||
disabled={isReadOnly}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Controller
|
||||
name={`lines.${index}.taxRate`}
|
||||
control={control}
|
||||
render={({ field }: { field: any }) => (
|
||||
<TextField
|
||||
{...field}
|
||||
type="number"
|
||||
size="small"
|
||||
variant="standard"
|
||||
disabled={isReadOnly}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{new Intl.NumberFormat("it-IT", { style: "currency", currency: "EUR" }).format(calculateLineTotal(index))}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{!isReadOnly && (
|
||||
<IconButton size="small" color="error" onClick={() => remove(index)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} align="right">
|
||||
<Typography fontWeight="bold">{t("sales.order.totals.gross")}</Typography>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Typography fontWeight="bold">
|
||||
{new Intl.NumberFormat("it-IT", { style: "currency", currency: "EUR" }).format(calculateTotal())}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
176
frontend/src/modules/sales/pages/SalesOrdersPage.tsx
Normal file
176
frontend/src/modules/sales/pages/SalesOrdersPage.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Paper,
|
||||
Chip,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
DataGrid,
|
||||
GridColDef,
|
||||
GridRenderCellParams,
|
||||
GridToolbar,
|
||||
} from "@mui/x-data-grid";
|
||||
import {
|
||||
Add as AddIcon,
|
||||
Visibility as ViewIcon,
|
||||
Delete as DeleteIcon,
|
||||
} from "@mui/icons-material";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { salesService } from "../services/salesService";
|
||||
import { SalesOrderDto, SalesOrderStatus } from "../types";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export default function SalesOrdersPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: orders = [], isLoading } = useQuery({
|
||||
queryKey: ["sales-orders"],
|
||||
queryFn: () => salesService.getAll(),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => salesService.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["sales-orders"] });
|
||||
},
|
||||
});
|
||||
|
||||
const handleCreate = () => {
|
||||
navigate("/sales/orders/new");
|
||||
};
|
||||
|
||||
const handleView = (id: number) => {
|
||||
navigate(`/sales/orders/${id}`);
|
||||
};
|
||||
|
||||
const handleDelete = (id: number) => {
|
||||
if (confirm(t("common.confirmDelete"))) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusChip = (status: SalesOrderStatus) => {
|
||||
const label = t(`sales.order.status.${SalesOrderStatus[status]}`);
|
||||
switch (status) {
|
||||
case SalesOrderStatus.Draft:
|
||||
return <Chip label={label} size="small" />;
|
||||
case SalesOrderStatus.Confirmed:
|
||||
return <Chip label={label} color="primary" size="small" />;
|
||||
case SalesOrderStatus.PartiallyShipped:
|
||||
return <Chip label={label} color="warning" size="small" />;
|
||||
case SalesOrderStatus.Shipped:
|
||||
return <Chip label={label} color="success" size="small" />;
|
||||
case SalesOrderStatus.Invoiced:
|
||||
return <Chip label={label} color="info" size="small" />;
|
||||
case SalesOrderStatus.Cancelled:
|
||||
return <Chip label={label} color="error" size="small" />;
|
||||
default:
|
||||
return <Chip label={label} size="small" />;
|
||||
}
|
||||
};
|
||||
|
||||
const columns: GridColDef[] = [
|
||||
{ field: "orderNumber", headerName: t("sales.order.columns.number"), width: 150 },
|
||||
{
|
||||
field: "orderDate",
|
||||
headerName: t("sales.order.columns.date"),
|
||||
width: 120,
|
||||
valueFormatter: (value) => dayjs(value).format("DD/MM/YYYY"),
|
||||
},
|
||||
{ field: "customerName", headerName: t("sales.order.columns.customer"), flex: 1, minWidth: 200 },
|
||||
{
|
||||
field: "status",
|
||||
headerName: t("sales.order.columns.status"),
|
||||
width: 150,
|
||||
renderCell: (params: GridRenderCellParams<SalesOrderDto>) =>
|
||||
getStatusChip(params.row.status),
|
||||
},
|
||||
{
|
||||
field: "totalGross",
|
||||
headerName: t("sales.order.columns.total"),
|
||||
width: 120,
|
||||
valueFormatter: (value) =>
|
||||
new Intl.NumberFormat("it-IT", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format(value),
|
||||
},
|
||||
{
|
||||
field: "actions",
|
||||
headerName: t("common.actions"),
|
||||
width: 120,
|
||||
sortable: false,
|
||||
renderCell: (params: GridRenderCellParams<SalesOrderDto>) => (
|
||||
<Box>
|
||||
<Tooltip title={t("common.view")}>
|
||||
<IconButton size="small" onClick={() => handleView(params.row.id)}>
|
||||
<ViewIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{(params.row.status === SalesOrderStatus.Draft ||
|
||||
params.row.status === SalesOrderStatus.Cancelled) && (
|
||||
<Tooltip title={t("common.delete")}>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => handleDelete(params.row.id)}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4">{t("sales.order.title")}</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={handleCreate}
|
||||
>
|
||||
{t("sales.order.newOrder")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ height: 600, width: "100%" }}>
|
||||
<DataGrid
|
||||
rows={orders}
|
||||
columns={columns}
|
||||
loading={isLoading}
|
||||
slots={{ toolbar: GridToolbar }}
|
||||
slotProps={{
|
||||
toolbar: {
|
||||
showQuickFilter: true,
|
||||
},
|
||||
}}
|
||||
disableRowSelectionOnClick
|
||||
initialState={{
|
||||
pagination: { paginationModel: { pageSize: 25 } },
|
||||
sorting: {
|
||||
sortModel: [{ field: "orderDate", sort: "desc" }],
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
13
frontend/src/modules/sales/routes.tsx
Normal file
13
frontend/src/modules/sales/routes.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import SalesOrdersPage from "./pages/SalesOrdersPage";
|
||||
import SalesOrderFormPage from "./pages/SalesOrderFormPage";
|
||||
|
||||
export default function SalesRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="orders" element={<SalesOrdersPage />} />
|
||||
<Route path="orders/new" element={<SalesOrderFormPage />} />
|
||||
<Route path="orders/:id" element={<SalesOrderFormPage />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
40
frontend/src/modules/sales/services/salesService.ts
Normal file
40
frontend/src/modules/sales/services/salesService.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import axios from 'axios';
|
||||
import { SalesOrderDto, CreateSalesOrderDto, UpdateSalesOrderDto } from '../types';
|
||||
|
||||
const API_URL = '/api/sales/orders';
|
||||
|
||||
export const salesService = {
|
||||
getAll: async (): Promise<SalesOrderDto[]> => {
|
||||
const response = await axios.get<SalesOrderDto[]>(API_URL);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getById: async (id: number): Promise<SalesOrderDto> => {
|
||||
const response = await axios.get<SalesOrderDto>(`${API_URL}/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
create: async (order: CreateSalesOrderDto): Promise<SalesOrderDto> => {
|
||||
const response = await axios.post<SalesOrderDto>(API_URL, order);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: number, order: UpdateSalesOrderDto): Promise<SalesOrderDto> => {
|
||||
const response = await axios.put<SalesOrderDto>(`${API_URL}/${id}`, order);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
delete: async (id: number): Promise<void> => {
|
||||
await axios.delete(`${API_URL}/${id}`);
|
||||
},
|
||||
|
||||
confirm: async (id: number): Promise<SalesOrderDto> => {
|
||||
const response = await axios.post<SalesOrderDto>(`${API_URL}/${id}/confirm`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
ship: async (id: number): Promise<SalesOrderDto> => {
|
||||
const response = await axios.post<SalesOrderDto>(`${API_URL}/${id}/ship`);
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
77
frontend/src/modules/sales/types/index.ts
Normal file
77
frontend/src/modules/sales/types/index.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
|
||||
|
||||
export enum SalesOrderStatus {
|
||||
Draft = 0,
|
||||
Confirmed = 1,
|
||||
PartiallyShipped = 2,
|
||||
Shipped = 3,
|
||||
Invoiced = 4,
|
||||
Cancelled = 5
|
||||
}
|
||||
|
||||
export interface SalesOrderDto {
|
||||
id: number;
|
||||
orderNumber: string;
|
||||
orderDate: string;
|
||||
expectedDeliveryDate?: string;
|
||||
customerId: number;
|
||||
customerName: string;
|
||||
status: SalesOrderStatus;
|
||||
notes?: string;
|
||||
totalNet: number;
|
||||
totalTax: number;
|
||||
totalGross: number;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
lines: SalesOrderLineDto[];
|
||||
}
|
||||
|
||||
export interface SalesOrderLineDto {
|
||||
id: number;
|
||||
salesOrderId: number;
|
||||
warehouseArticleId: number;
|
||||
articleCode: string;
|
||||
articleDescription: string;
|
||||
description: string;
|
||||
quantity: number;
|
||||
shippedQuantity: number;
|
||||
unitPrice: number;
|
||||
taxRate: number;
|
||||
discountPercent: number;
|
||||
lineTotal: number;
|
||||
}
|
||||
|
||||
export interface CreateSalesOrderDto {
|
||||
orderDate: string;
|
||||
expectedDeliveryDate?: string;
|
||||
customerId: number;
|
||||
notes?: string;
|
||||
lines: CreateSalesOrderLineDto[];
|
||||
}
|
||||
|
||||
export interface CreateSalesOrderLineDto {
|
||||
warehouseArticleId: number;
|
||||
description?: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
taxRate: number;
|
||||
discountPercent: number;
|
||||
}
|
||||
|
||||
export interface UpdateSalesOrderDto {
|
||||
orderDate: string;
|
||||
expectedDeliveryDate?: string;
|
||||
notes?: string;
|
||||
lines: UpdateSalesOrderLineDto[];
|
||||
}
|
||||
|
||||
export interface UpdateSalesOrderLineDto {
|
||||
id?: number;
|
||||
warehouseArticleId: number;
|
||||
description?: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
taxRate: number;
|
||||
discountPercent: number;
|
||||
isDeleted?: boolean;
|
||||
}
|
||||
@@ -4,4 +4,18 @@ import react from '@vitejs/plugin-react'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/hubs': {
|
||||
target: 'http://localhost:5000',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using Apollinare.API.Modules.Production.Dtos;
|
||||
using Apollinare.API.Modules.Production.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Apollinare.API.Modules.Production.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/production/bom")]
|
||||
public class BillOfMaterialsController : ControllerBase
|
||||
{
|
||||
private readonly IProductionService _productionService;
|
||||
|
||||
public BillOfMaterialsController(IProductionService productionService)
|
||||
{
|
||||
_productionService = productionService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<BillOfMaterialsDto>>> GetAll()
|
||||
{
|
||||
return Ok(await _productionService.GetBillOfMaterialsAsync());
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<BillOfMaterialsDto>> GetById(int id)
|
||||
{
|
||||
var bom = await _productionService.GetBillOfMaterialsByIdAsync(id);
|
||||
if (bom == null) return NotFound();
|
||||
return Ok(bom);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<BillOfMaterialsDto>> Create(CreateBillOfMaterialsDto dto)
|
||||
{
|
||||
var bom = await _productionService.CreateBillOfMaterialsAsync(dto);
|
||||
return CreatedAtAction(nameof(GetById), new { id = bom.Id }, bom);
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<BillOfMaterialsDto>> Update(int id, UpdateBillOfMaterialsDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bom = await _productionService.UpdateBillOfMaterialsAsync(id, dto);
|
||||
return Ok(bom);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<ActionResult> Delete(int id)
|
||||
{
|
||||
await _productionService.DeleteBillOfMaterialsAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Apollinare.API.Modules.Production.Dtos;
|
||||
using Apollinare.API.Modules.Production.Services;
|
||||
using Apollinare.Domain.Entities.Production;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Apollinare.API.Modules.Production.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/production/mrp")]
|
||||
public class MrpController : ControllerBase
|
||||
{
|
||||
private readonly IMrpService _mrpService;
|
||||
|
||||
public MrpController(IMrpService mrpService)
|
||||
{
|
||||
_mrpService = mrpService;
|
||||
}
|
||||
|
||||
[HttpPost("run")]
|
||||
public async Task<IActionResult> RunMrp([FromBody] MrpConfigurationDto config)
|
||||
{
|
||||
await _mrpService.RunMrpAsync(config);
|
||||
return Ok(new { message = "MRP Run completed successfully" });
|
||||
}
|
||||
|
||||
[HttpGet("suggestions")]
|
||||
public async Task<ActionResult<List<MrpSuggestion>>> GetSuggestions([FromQuery] bool includeProcessed = false)
|
||||
{
|
||||
return await _mrpService.GetSuggestionsAsync(includeProcessed);
|
||||
}
|
||||
|
||||
[HttpPost("suggestions/{id}/process")]
|
||||
public async Task<IActionResult> ProcessSuggestion(int id)
|
||||
{
|
||||
await _mrpService.ProcessSuggestionAsync(id);
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using Apollinare.API.Modules.Production.Dtos;
|
||||
using Apollinare.API.Modules.Production.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Apollinare.API.Modules.Production.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/production/cycles")]
|
||||
public class ProductionCyclesController : ControllerBase
|
||||
{
|
||||
private readonly IProductionService _productionService;
|
||||
|
||||
public ProductionCyclesController(IProductionService productionService)
|
||||
{
|
||||
_productionService = productionService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<ProductionCycleDto>>> GetProductionCycles()
|
||||
{
|
||||
return await _productionService.GetProductionCyclesAsync();
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<ProductionCycleDto>> GetProductionCycle(int id)
|
||||
{
|
||||
var cycle = await _productionService.GetProductionCycleByIdAsync(id);
|
||||
if (cycle == null) return NotFound();
|
||||
return cycle;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<ProductionCycleDto>> CreateProductionCycle(CreateProductionCycleDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cycle = await _productionService.CreateProductionCycleAsync(dto);
|
||||
return CreatedAtAction(nameof(GetProductionCycle), new { id = cycle.Id }, cycle);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<ProductionCycleDto>> UpdateProductionCycle(int id, UpdateProductionCycleDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _productionService.UpdateProductionCycleAsync(id, dto);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> DeleteProductionCycle(int id)
|
||||
{
|
||||
await _productionService.DeleteProductionCycleAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using Apollinare.API.Modules.Production.Dtos;
|
||||
using Apollinare.API.Modules.Production.Services;
|
||||
using Apollinare.Domain.Entities.Production;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Apollinare.API.Modules.Production.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/production/orders")]
|
||||
public class ProductionOrdersController : ControllerBase
|
||||
{
|
||||
private readonly IProductionService _productionService;
|
||||
|
||||
public ProductionOrdersController(IProductionService productionService)
|
||||
{
|
||||
_productionService = productionService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<ProductionOrderDto>>> GetAll()
|
||||
{
|
||||
return Ok(await _productionService.GetProductionOrdersAsync());
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<ProductionOrderDto>> GetById(int id)
|
||||
{
|
||||
var order = await _productionService.GetProductionOrderByIdAsync(id);
|
||||
if (order == null) return NotFound();
|
||||
return Ok(order);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<ProductionOrderDto>> Create(CreateProductionOrderDto dto)
|
||||
{
|
||||
var order = await _productionService.CreateProductionOrderAsync(dto);
|
||||
return CreatedAtAction(nameof(GetById), new { id = order.Id }, order);
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<ProductionOrderDto>> Update(int id, UpdateProductionOrderDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var order = await _productionService.UpdateProductionOrderAsync(id, dto);
|
||||
return Ok(order);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{id}/status")]
|
||||
public async Task<ActionResult<ProductionOrderDto>> ChangeStatus(int id, [FromBody] ProductionOrderStatus status)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _productionService.ChangeProductionOrderStatusAsync(id, status);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{id}/phases/{phaseId}")]
|
||||
public async Task<ActionResult<ProductionOrderDto>> UpdatePhase(int id, int phaseId, UpdateProductionOrderPhaseDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _productionService.UpdateProductionOrderPhaseAsync(id, phaseId, dto);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<ActionResult> Delete(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _productionService.DeleteProductionOrderAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using Apollinare.API.Modules.Production.Dtos;
|
||||
using Apollinare.API.Modules.Production.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Apollinare.API.Modules.Production.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/production/work-centers")]
|
||||
public class WorkCentersController : ControllerBase
|
||||
{
|
||||
private readonly IProductionService _productionService;
|
||||
|
||||
public WorkCentersController(IProductionService productionService)
|
||||
{
|
||||
_productionService = productionService;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<WorkCenterDto>>> GetWorkCenters()
|
||||
{
|
||||
return await _productionService.GetWorkCentersAsync();
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<WorkCenterDto>> GetWorkCenter(int id)
|
||||
{
|
||||
var wc = await _productionService.GetWorkCenterByIdAsync(id);
|
||||
if (wc == null) return NotFound();
|
||||
return wc;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<WorkCenterDto>> CreateWorkCenter(CreateWorkCenterDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var wc = await _productionService.CreateWorkCenterAsync(dto);
|
||||
return CreatedAtAction(nameof(GetWorkCenter), new { id = wc.Id }, wc);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<WorkCenterDto>> UpdateWorkCenter(int id, UpdateWorkCenterDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _productionService.UpdateWorkCenterAsync(id, dto);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<IActionResult> DeleteWorkCenter(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _productionService.DeleteWorkCenterAsync(id);
|
||||
return NoContent();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Apollinare.API.Modules.Production.Dtos;
|
||||
|
||||
public class BillOfMaterialsDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public int ArticleId { get; set; }
|
||||
public string ArticleName { get; set; } = string.Empty;
|
||||
public decimal Quantity { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public List<BillOfMaterialsComponentDto> Components { get; set; } = new();
|
||||
}
|
||||
|
||||
public class BillOfMaterialsComponentDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ComponentArticleId { get; set; }
|
||||
public string ComponentArticleName { get; set; } = string.Empty;
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal ScrapPercentage { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Apollinare.API.Modules.Production.Dtos;
|
||||
|
||||
public class CreateBillOfMaterialsDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public int ArticleId { get; set; }
|
||||
public decimal Quantity { get; set; }
|
||||
public List<CreateBillOfMaterialsComponentDto> Components { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CreateBillOfMaterialsComponentDto
|
||||
{
|
||||
public int ComponentArticleId { get; set; }
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal ScrapPercentage { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Apollinare.API.Modules.Production.Dtos;
|
||||
|
||||
public class CreateProductionOrderDto
|
||||
{
|
||||
public int ArticleId { get; set; }
|
||||
public decimal Quantity { get; set; }
|
||||
public DateTime StartDate { get; set; }
|
||||
public DateTime DueDate { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public int? BillOfMaterialsId { get; set; } // Optional: create from BOM
|
||||
public bool CreateChildOrders { get; set; } = false; // Optional: recursively create orders for sub-assemblies
|
||||
public int? ParentProductionOrderId { get; set; } // Internal use for recursion
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Apollinare.API.Modules.Production.Dtos;
|
||||
|
||||
public class MrpConfigurationDto
|
||||
{
|
||||
public DateTime? StartDate { get; set; }
|
||||
public DateTime? EndDate { get; set; }
|
||||
public bool IncludeSafetyStock { get; set; } = true;
|
||||
public bool IncludeSalesOrders { get; set; } = true;
|
||||
public bool IncludeForecasts { get; set; } = false;
|
||||
public List<int>? WarehouseIds { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
namespace Apollinare.API.Modules.Production.Dtos;
|
||||
|
||||
public class ProductionCycleDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public int ArticleId { get; set; }
|
||||
public string ArticleName { get; set; } = string.Empty;
|
||||
public bool IsDefault { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public List<ProductionCyclePhaseDto> Phases { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ProductionCyclePhaseDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int Sequence { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public int WorkCenterId { get; set; }
|
||||
public string WorkCenterName { get; set; } = string.Empty;
|
||||
public int DurationPerUnitMinutes { get; set; }
|
||||
public int SetupTimeMinutes { get; set; }
|
||||
}
|
||||
|
||||
public class CreateProductionCycleDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public int ArticleId { get; set; }
|
||||
public bool IsDefault { get; set; }
|
||||
public List<CreateProductionCyclePhaseDto> Phases { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CreateProductionCyclePhaseDto
|
||||
{
|
||||
public int Sequence { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public int WorkCenterId { get; set; }
|
||||
public int DurationPerUnitMinutes { get; set; }
|
||||
public int SetupTimeMinutes { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateProductionCycleDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public bool IsDefault { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public List<UpdateProductionCyclePhaseDto> Phases { get; set; } = new();
|
||||
}
|
||||
|
||||
public class UpdateProductionCyclePhaseDto
|
||||
{
|
||||
public int? Id { get; set; }
|
||||
public int Sequence { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public int WorkCenterId { get; set; }
|
||||
public int DurationPerUnitMinutes { get; set; }
|
||||
public int SetupTimeMinutes { get; set; }
|
||||
public bool IsDeleted { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using Apollinare.Domain.Entities.Production;
|
||||
|
||||
namespace Apollinare.API.Modules.Production.Dtos;
|
||||
|
||||
public class ProductionOrderDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public int ArticleId { get; set; }
|
||||
public string ArticleName { get; set; } = string.Empty;
|
||||
public decimal Quantity { get; set; }
|
||||
public DateTime StartDate { get; set; }
|
||||
public DateTime? EndDate { get; set; }
|
||||
public DateTime DueDate { get; set; }
|
||||
public ProductionOrderStatus Status { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public List<ProductionOrderComponentDto> Components { get; set; } = new();
|
||||
public List<ProductionOrderPhaseDto> Phases { get; set; } = new();
|
||||
public int? ParentProductionOrderId { get; set; }
|
||||
public string? ParentProductionOrderCode { get; set; }
|
||||
public List<ProductionOrderDto> ChildOrders { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ProductionOrderPhaseDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int Sequence { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int WorkCenterId { get; set; }
|
||||
public string WorkCenterName { get; set; } = string.Empty;
|
||||
public ProductionPhaseStatus Status { get; set; }
|
||||
public DateTime? StartDate { get; set; }
|
||||
public DateTime? EndDate { get; set; }
|
||||
public decimal QuantityCompleted { get; set; }
|
||||
public decimal QuantityScrapped { get; set; }
|
||||
public int EstimatedDurationMinutes { get; set; }
|
||||
public int ActualDurationMinutes { get; set; }
|
||||
}
|
||||
|
||||
public class ProductionOrderComponentDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ArticleId { get; set; }
|
||||
public string ArticleName { get; set; } = string.Empty;
|
||||
public decimal RequiredQuantity { get; set; }
|
||||
public decimal ConsumedQuantity { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateProductionOrderPhaseDto
|
||||
{
|
||||
public ProductionPhaseStatus Status { get; set; }
|
||||
public decimal QuantityCompleted { get; set; }
|
||||
public decimal QuantityScrapped { get; set; }
|
||||
public int ActualDurationMinutes { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Apollinare.API.Modules.Production.Dtos;
|
||||
|
||||
public class UpdateBillOfMaterialsDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public decimal Quantity { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public List<UpdateBillOfMaterialsComponentDto> Components { get; set; } = new();
|
||||
}
|
||||
|
||||
public class UpdateBillOfMaterialsComponentDto
|
||||
{
|
||||
public int? Id { get; set; } // If null, it's a new component
|
||||
public int ComponentArticleId { get; set; }
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal ScrapPercentage { get; set; }
|
||||
public bool IsDeleted { get; set; } // To remove components
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Apollinare.Domain.Entities.Production;
|
||||
|
||||
namespace Apollinare.API.Modules.Production.Dtos;
|
||||
|
||||
public class UpdateProductionOrderDto
|
||||
{
|
||||
public decimal Quantity { get; set; }
|
||||
public DateTime StartDate { get; set; }
|
||||
public DateTime DueDate { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public ProductionOrderStatus Status { get; set; }
|
||||
}
|
||||
27
src/Apollinare.API/Modules/Production/Dtos/WorkCenterDto.cs
Normal file
27
src/Apollinare.API/Modules/Production/Dtos/WorkCenterDto.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
namespace Apollinare.API.Modules.Production.Dtos;
|
||||
|
||||
public class WorkCenterDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public decimal CostPerHour { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
public class CreateWorkCenterDto
|
||||
{
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public decimal CostPerHour { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateWorkCenterDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public decimal CostPerHour { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Apollinare.API.Modules.Production.Dtos;
|
||||
using Apollinare.Domain.Entities.Production;
|
||||
|
||||
namespace Apollinare.API.Modules.Production.Services;
|
||||
|
||||
public interface IMrpService
|
||||
{
|
||||
Task RunMrpAsync(MrpConfigurationDto config);
|
||||
Task<List<MrpSuggestion>> GetSuggestionsAsync(bool includeProcessed = false);
|
||||
Task ProcessSuggestionAsync(int suggestionId);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Apollinare.API.Modules.Production.Dtos;
|
||||
using Apollinare.Domain.Entities.Production;
|
||||
|
||||
namespace Apollinare.API.Modules.Production.Services;
|
||||
|
||||
public interface IProductionService
|
||||
{
|
||||
// Bill Of Materials
|
||||
Task<List<BillOfMaterialsDto>> GetBillOfMaterialsAsync();
|
||||
Task<BillOfMaterialsDto?> GetBillOfMaterialsByIdAsync(int id);
|
||||
Task<BillOfMaterialsDto> CreateBillOfMaterialsAsync(CreateBillOfMaterialsDto dto);
|
||||
Task<BillOfMaterialsDto> UpdateBillOfMaterialsAsync(int id, UpdateBillOfMaterialsDto dto);
|
||||
Task DeleteBillOfMaterialsAsync(int id);
|
||||
|
||||
// Production Orders
|
||||
Task<List<ProductionOrderDto>> GetProductionOrdersAsync();
|
||||
Task<ProductionOrderDto?> GetProductionOrderByIdAsync(int id);
|
||||
Task<ProductionOrderDto> CreateProductionOrderAsync(CreateProductionOrderDto dto);
|
||||
Task<ProductionOrderDto> UpdateProductionOrderAsync(int id, UpdateProductionOrderDto dto);
|
||||
Task<ProductionOrderDto> ChangeProductionOrderStatusAsync(int id, ProductionOrderStatus status);
|
||||
Task<ProductionOrderDto> UpdateProductionOrderPhaseAsync(int orderId, int phaseId, UpdateProductionOrderPhaseDto dto);
|
||||
Task DeleteProductionOrderAsync(int id);
|
||||
|
||||
// Work Centers
|
||||
Task<List<WorkCenterDto>> GetWorkCentersAsync();
|
||||
Task<WorkCenterDto?> GetWorkCenterByIdAsync(int id);
|
||||
Task<WorkCenterDto> CreateWorkCenterAsync(CreateWorkCenterDto dto);
|
||||
Task<WorkCenterDto> UpdateWorkCenterAsync(int id, UpdateWorkCenterDto dto);
|
||||
Task DeleteWorkCenterAsync(int id);
|
||||
|
||||
// Production Cycles
|
||||
Task<List<ProductionCycleDto>> GetProductionCyclesAsync();
|
||||
Task<ProductionCycleDto?> GetProductionCycleByIdAsync(int id);
|
||||
Task<ProductionCycleDto> CreateProductionCycleAsync(CreateProductionCycleDto dto);
|
||||
Task<ProductionCycleDto> UpdateProductionCycleAsync(int id, UpdateProductionCycleDto dto);
|
||||
Task DeleteProductionCycleAsync(int id);
|
||||
}
|
||||
260
src/Apollinare.API/Modules/Production/Services/MrpService.cs
Normal file
260
src/Apollinare.API/Modules/Production/Services/MrpService.cs
Normal file
@@ -0,0 +1,260 @@
|
||||
using Apollinare.Domain.Entities.Production;
|
||||
using Apollinare.Domain.Entities.Warehouse;
|
||||
using Apollinare.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Apollinare.API.Modules.Production.Dtos;
|
||||
|
||||
namespace Apollinare.API.Modules.Production.Services;
|
||||
|
||||
public class MrpService : IMrpService
|
||||
{
|
||||
private readonly AppollinareDbContext _context;
|
||||
private readonly ILogger<MrpService> _logger;
|
||||
|
||||
public MrpService(AppollinareDbContext context, ILogger<MrpService> logger)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task RunMrpAsync(MrpConfigurationDto config)
|
||||
{
|
||||
_logger.LogInformation("Starting Multi-Level MRP Run with config: {@Config}", config);
|
||||
|
||||
// 1. Clear existing unprocessed suggestions
|
||||
var oldSuggestions = await _context.MrpSuggestions
|
||||
.Where(s => !s.IsProcessed)
|
||||
.ToListAsync();
|
||||
_context.MrpSuggestions.RemoveRange(oldSuggestions);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// 2. Load Data
|
||||
|
||||
// 2.0 Article Details (Safety Stock, Lead Time)
|
||||
var articleDetails = await _context.WarehouseArticles
|
||||
.Select(a => new { a.Id, a.MinimumStock, a.LeadTimeDays })
|
||||
.ToDictionaryAsync(a => a.Id);
|
||||
|
||||
// 2.1 Stock Levels (Supply)
|
||||
var stockQuery = _context.StockLevels.AsQueryable();
|
||||
if (config.WarehouseIds != null && config.WarehouseIds.Any())
|
||||
{
|
||||
stockQuery = stockQuery.Where(s => config.WarehouseIds.Contains(s.WarehouseId));
|
||||
}
|
||||
|
||||
var stockLevels = await stockQuery
|
||||
.GroupBy(s => s.ArticleId)
|
||||
.Select(g => new { ArticleId = g.Key, Quantity = g.Sum(s => s.Quantity) })
|
||||
.ToDictionaryAsync(x => x.ArticleId, x => x.Quantity);
|
||||
|
||||
// 2.2 Incoming Purchase Orders (Supply)
|
||||
var incomingPurchases = await _context.PurchaseOrderLines
|
||||
.Include(l => l.PurchaseOrder)
|
||||
.Where(l => l.PurchaseOrder.Status == Domain.Entities.Purchases.PurchaseOrderStatus.Confirmed ||
|
||||
l.PurchaseOrder.Status == Domain.Entities.Purchases.PurchaseOrderStatus.PartiallyReceived)
|
||||
.GroupBy(l => l.WarehouseArticleId)
|
||||
.Select(g => new { ArticleId = g.Key, Quantity = g.Sum(l => l.Quantity - l.ReceivedQuantity) })
|
||||
.ToListAsync();
|
||||
|
||||
// 2.3 Incoming Production Orders (Supply for Parent, Demand for Components)
|
||||
var incomingProduction = await _context.ProductionOrders
|
||||
.Where(o => o.Status == ProductionOrderStatus.Planned ||
|
||||
o.Status == ProductionOrderStatus.Released ||
|
||||
o.Status == ProductionOrderStatus.InProgress)
|
||||
.ToListAsync();
|
||||
|
||||
// 2.4 Sales Orders (Independent Demand)
|
||||
var salesDemand = new List<dynamic>();
|
||||
if (config.IncludeSalesOrders)
|
||||
{
|
||||
var salesQuery = _context.SalesOrderLines
|
||||
.Include(l => l.SalesOrder)
|
||||
.Where(l => l.SalesOrder.Status == Domain.Entities.Sales.SalesOrderStatus.Confirmed ||
|
||||
l.SalesOrder.Status == Domain.Entities.Sales.SalesOrderStatus.PartiallyShipped);
|
||||
|
||||
var salesList = await salesQuery
|
||||
.GroupBy(l => l.WarehouseArticleId)
|
||||
.Select(g => new { ArticleId = g.Key, Quantity = g.Sum(l => l.Quantity) })
|
||||
.ToListAsync();
|
||||
|
||||
salesDemand.AddRange(salesList);
|
||||
}
|
||||
|
||||
// 2.5 BOMs (Structure)
|
||||
var boms = await _context.BillOfMaterials
|
||||
.Include(b => b.Components)
|
||||
.Where(b => b.IsActive)
|
||||
.ToListAsync();
|
||||
|
||||
var bomDictionary = boms.GroupBy(b => b.ArticleId).ToDictionary(g => g.Key, g => g.First());
|
||||
|
||||
// 3. Initialize In-Memory State
|
||||
var stockOnHand = new Dictionary<int, decimal>(stockLevels);
|
||||
|
||||
// Add Incoming Purchases to Stock
|
||||
foreach (var p in incomingPurchases)
|
||||
{
|
||||
if (!stockOnHand.ContainsKey(p.ArticleId)) stockOnHand[p.ArticleId] = 0;
|
||||
stockOnHand[p.ArticleId] += p.Quantity;
|
||||
}
|
||||
|
||||
// Add Incoming Production (Parent Items) to Stock
|
||||
foreach (var p in incomingProduction)
|
||||
{
|
||||
if (!stockOnHand.ContainsKey(p.ArticleId)) stockOnHand[p.ArticleId] = 0;
|
||||
stockOnHand[p.ArticleId] += p.Quantity;
|
||||
}
|
||||
|
||||
// 4. Process Demand
|
||||
var suggestions = new List<MrpSuggestion>();
|
||||
var calculationDate = DateTime.UtcNow;
|
||||
|
||||
// Helper function for recursive processing
|
||||
void ProcessRequirement(int articleId, decimal qtyNeeded, string sourceReason, DateTime neededByDate)
|
||||
{
|
||||
if (qtyNeeded <= 0) return;
|
||||
|
||||
// Safety Stock Logic
|
||||
decimal safetyStock = 0;
|
||||
int leadTimeDays = 0;
|
||||
if (articleDetails.TryGetValue(articleId, out var details))
|
||||
{
|
||||
if (config.IncludeSafetyStock && details.MinimumStock.HasValue)
|
||||
{
|
||||
safetyStock = details.MinimumStock.Value;
|
||||
}
|
||||
leadTimeDays = details.LeadTimeDays ?? 0;
|
||||
}
|
||||
|
||||
decimal currentStock = stockOnHand.ContainsKey(articleId) ? stockOnHand[articleId] : 0;
|
||||
decimal availableForDemand = currentStock - safetyStock;
|
||||
|
||||
if (availableForDemand >= qtyNeeded)
|
||||
{
|
||||
// Demand met by stock
|
||||
stockOnHand[articleId] = currentStock - qtyNeeded;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Consume remaining available stock
|
||||
decimal toConsume = Math.Max(0, availableForDemand);
|
||||
stockOnHand[articleId] = currentStock - toConsume;
|
||||
|
||||
var netRequirement = qtyNeeded - availableForDemand;
|
||||
|
||||
// Create Suggestion
|
||||
bool hasBom = bomDictionary.ContainsKey(articleId);
|
||||
var type = hasBom ? MrpSuggestionType.Production : MrpSuggestionType.Purchase;
|
||||
|
||||
var orderDate = neededByDate.AddDays(-leadTimeDays);
|
||||
if (orderDate < DateTime.UtcNow) orderDate = DateTime.UtcNow;
|
||||
|
||||
suggestions.Add(new MrpSuggestion
|
||||
{
|
||||
CalculationDate = calculationDate,
|
||||
ArticleId = articleId,
|
||||
Type = type,
|
||||
Quantity = netRequirement,
|
||||
SuggestionDate = orderDate,
|
||||
Reason = $"{sourceReason} (Net: {netRequirement:F2})",
|
||||
IsProcessed = false
|
||||
});
|
||||
|
||||
// Explode BOM if Production
|
||||
if (hasBom)
|
||||
{
|
||||
var bom = bomDictionary[articleId];
|
||||
foreach (var comp in bom.Components)
|
||||
{
|
||||
var compQtyNeeded = netRequirement * comp.Quantity;
|
||||
if (comp.ScrapPercentage > 0)
|
||||
{
|
||||
compQtyNeeded = compQtyNeeded * (1 + comp.ScrapPercentage / 100);
|
||||
}
|
||||
|
||||
ProcessRequirement(comp.ComponentArticleId, compQtyNeeded, $"Ref: {articleId}", orderDate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4.1 Process Sales Orders
|
||||
foreach (var demand in salesDemand)
|
||||
{
|
||||
ProcessRequirement(demand.ArticleId, demand.Quantity, "Sales Order", DateTime.UtcNow);
|
||||
}
|
||||
|
||||
// 4.2 Process Existing Production Order Components
|
||||
var existingOrderComponents = await _context.ProductionOrderComponents
|
||||
.Include(c => c.ProductionOrder)
|
||||
.Where(c => c.ProductionOrder.Status == ProductionOrderStatus.Planned ||
|
||||
c.ProductionOrder.Status == ProductionOrderStatus.Released ||
|
||||
c.ProductionOrder.Status == ProductionOrderStatus.InProgress)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var comp in existingOrderComponents)
|
||||
{
|
||||
var remainingNeeded = comp.RequiredQuantity - comp.ConsumedQuantity;
|
||||
if (remainingNeeded > 0)
|
||||
{
|
||||
var neededDate = comp.ProductionOrder.StartDate;
|
||||
ProcessRequirement(comp.ArticleId, remainingNeeded, $"Prod Order {comp.ProductionOrder.Code}", neededDate);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Save Suggestions
|
||||
if (suggestions.Any())
|
||||
{
|
||||
var groupedSuggestions = suggestions
|
||||
.GroupBy(s => new { s.ArticleId, s.Type })
|
||||
.Select(g => new MrpSuggestion
|
||||
{
|
||||
CalculationDate = calculationDate,
|
||||
ArticleId = g.Key.ArticleId,
|
||||
Type = g.Key.Type,
|
||||
Quantity = g.Sum(s => s.Quantity),
|
||||
SuggestionDate = g.Min(s => s.SuggestionDate),
|
||||
Reason = "Aggregated Demand",
|
||||
IsProcessed = false
|
||||
})
|
||||
.ToList();
|
||||
|
||||
_context.MrpSuggestions.AddRange(groupedSuggestions);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
_logger.LogInformation("MRP Run completed. Generated {Count} suggestions.", suggestions.Count);
|
||||
}
|
||||
|
||||
public async Task<List<MrpSuggestion>> GetSuggestionsAsync(bool includeProcessed = false)
|
||||
{
|
||||
var query = _context.MrpSuggestions
|
||||
.Include(s => s.Article)
|
||||
.AsQueryable();
|
||||
|
||||
if (!includeProcessed)
|
||||
{
|
||||
query = query.Where(s => !s.IsProcessed);
|
||||
}
|
||||
|
||||
return await query.OrderBy(s => s.SuggestionDate).ToListAsync();
|
||||
}
|
||||
|
||||
public async Task ProcessSuggestionAsync(int suggestionId)
|
||||
{
|
||||
var suggestion = await _context.MrpSuggestions.FindAsync(suggestionId);
|
||||
if (suggestion == null) return;
|
||||
|
||||
// Logic to auto-create orders based on suggestion
|
||||
if (suggestion.Type == MrpSuggestionType.Production)
|
||||
{
|
||||
// Create Production Order
|
||||
// We need to call ProductionService, but circular dependency might be an issue if we inject it.
|
||||
// Better to keep this logic in Controller or have a separate Orchestrator.
|
||||
// For now, just mark as processed. The Controller likely handles the actual creation.
|
||||
}
|
||||
|
||||
suggestion.IsProcessed = true;
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,803 @@
|
||||
using Apollinare.API.Modules.Production.Dtos;
|
||||
using Apollinare.API.Modules.Warehouse.Services;
|
||||
using Apollinare.Domain.Entities.Production;
|
||||
using Apollinare.Domain.Entities.Warehouse;
|
||||
using Apollinare.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Apollinare.API.Modules.Production.Services;
|
||||
|
||||
public class ProductionService : IProductionService
|
||||
{
|
||||
private readonly AppollinareDbContext _context;
|
||||
private readonly IWarehouseService _warehouseService;
|
||||
|
||||
public ProductionService(AppollinareDbContext context, IWarehouseService warehouseService)
|
||||
{
|
||||
_context = context;
|
||||
_warehouseService = warehouseService;
|
||||
}
|
||||
|
||||
// ===============================================
|
||||
// BILL OF MATERIALS
|
||||
// ===============================================
|
||||
|
||||
public async Task<List<BillOfMaterialsDto>> GetBillOfMaterialsAsync()
|
||||
{
|
||||
return await _context.BillOfMaterials
|
||||
.Include(b => b.Article)
|
||||
.Include(b => b.Components)
|
||||
.ThenInclude(c => c.ComponentArticle)
|
||||
.Where(b => b.IsActive)
|
||||
.Select(b => new BillOfMaterialsDto
|
||||
{
|
||||
Id = b.Id,
|
||||
Name = b.Name,
|
||||
Description = b.Description,
|
||||
ArticleId = b.ArticleId,
|
||||
ArticleName = b.Article.Description,
|
||||
Quantity = b.Quantity,
|
||||
IsActive = b.IsActive,
|
||||
Components = b.Components.Select(c => new BillOfMaterialsComponentDto
|
||||
{
|
||||
Id = c.Id,
|
||||
ComponentArticleId = c.ComponentArticleId,
|
||||
ComponentArticleName = c.ComponentArticle.Description,
|
||||
Quantity = c.Quantity,
|
||||
ScrapPercentage = c.ScrapPercentage
|
||||
}).ToList()
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<BillOfMaterialsDto?> GetBillOfMaterialsByIdAsync(int id)
|
||||
{
|
||||
var bom = await _context.BillOfMaterials
|
||||
.Include(b => b.Article)
|
||||
.Include(b => b.Components)
|
||||
.ThenInclude(c => c.ComponentArticle)
|
||||
.FirstOrDefaultAsync(b => b.Id == id);
|
||||
|
||||
if (bom == null) return null;
|
||||
|
||||
return new BillOfMaterialsDto
|
||||
{
|
||||
Id = bom.Id,
|
||||
Name = bom.Name,
|
||||
Description = bom.Description,
|
||||
ArticleId = bom.ArticleId,
|
||||
ArticleName = bom.Article.Description,
|
||||
Quantity = bom.Quantity,
|
||||
IsActive = bom.IsActive,
|
||||
Components = bom.Components.Select(c => new BillOfMaterialsComponentDto
|
||||
{
|
||||
Id = c.Id,
|
||||
ComponentArticleId = c.ComponentArticleId,
|
||||
ComponentArticleName = c.ComponentArticle.Description,
|
||||
Quantity = c.Quantity,
|
||||
ScrapPercentage = c.ScrapPercentage
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<BillOfMaterialsDto> CreateBillOfMaterialsAsync(CreateBillOfMaterialsDto dto)
|
||||
{
|
||||
var bom = new BillOfMaterials
|
||||
{
|
||||
Name = dto.Name,
|
||||
Description = dto.Description,
|
||||
ArticleId = dto.ArticleId,
|
||||
Quantity = dto.Quantity,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
foreach (var compDto in dto.Components)
|
||||
{
|
||||
bom.Components.Add(new BillOfMaterialsComponent
|
||||
{
|
||||
ComponentArticleId = compDto.ComponentArticleId,
|
||||
Quantity = compDto.Quantity,
|
||||
ScrapPercentage = compDto.ScrapPercentage
|
||||
});
|
||||
}
|
||||
|
||||
_context.BillOfMaterials.Add(bom);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return await GetBillOfMaterialsByIdAsync(bom.Id) ?? throw new InvalidOperationException("Failed to retrieve created BOM");
|
||||
}
|
||||
|
||||
public async Task<BillOfMaterialsDto> UpdateBillOfMaterialsAsync(int id, UpdateBillOfMaterialsDto dto)
|
||||
{
|
||||
var bom = await _context.BillOfMaterials
|
||||
.Include(b => b.Components)
|
||||
.FirstOrDefaultAsync(b => b.Id == id);
|
||||
|
||||
if (bom == null) throw new KeyNotFoundException($"BOM with ID {id} not found");
|
||||
|
||||
bom.Name = dto.Name;
|
||||
bom.Description = dto.Description;
|
||||
bom.Quantity = dto.Quantity;
|
||||
bom.IsActive = dto.IsActive;
|
||||
|
||||
// Update components
|
||||
foreach (var compDto in dto.Components)
|
||||
{
|
||||
if (compDto.IsDeleted)
|
||||
{
|
||||
if (compDto.Id.HasValue)
|
||||
{
|
||||
var compToDelete = bom.Components.FirstOrDefault(c => c.Id == compDto.Id.Value);
|
||||
if (compToDelete != null)
|
||||
{
|
||||
_context.BillOfMaterialsComponents.Remove(compToDelete);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (compDto.Id.HasValue)
|
||||
{
|
||||
var compToUpdate = bom.Components.FirstOrDefault(c => c.Id == compDto.Id.Value);
|
||||
if (compToUpdate != null)
|
||||
{
|
||||
compToUpdate.ComponentArticleId = compDto.ComponentArticleId;
|
||||
compToUpdate.Quantity = compDto.Quantity;
|
||||
compToUpdate.ScrapPercentage = compDto.ScrapPercentage;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// New component
|
||||
bom.Components.Add(new BillOfMaterialsComponent
|
||||
{
|
||||
ComponentArticleId = compDto.ComponentArticleId,
|
||||
Quantity = compDto.Quantity,
|
||||
ScrapPercentage = compDto.ScrapPercentage
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return await GetBillOfMaterialsByIdAsync(id) ?? throw new InvalidOperationException("Failed to retrieve updated BOM");
|
||||
}
|
||||
|
||||
public async Task DeleteBillOfMaterialsAsync(int id)
|
||||
{
|
||||
var bom = await _context.BillOfMaterials.FindAsync(id);
|
||||
if (bom != null)
|
||||
{
|
||||
_context.BillOfMaterials.Remove(bom);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// ===============================================
|
||||
// PRODUCTION ORDERS
|
||||
// ===============================================
|
||||
|
||||
public async Task<List<ProductionOrderDto>> GetProductionOrdersAsync()
|
||||
{
|
||||
return await _context.ProductionOrders
|
||||
.Include(o => o.Article)
|
||||
.Include(o => o.Components)
|
||||
.ThenInclude(c => c.Article)
|
||||
.Include(o => o.Phases)
|
||||
.ThenInclude(p => p.WorkCenter)
|
||||
.OrderByDescending(o => o.StartDate)
|
||||
.Select(o => new ProductionOrderDto
|
||||
{
|
||||
Id = o.Id,
|
||||
Code = o.Code,
|
||||
ArticleId = o.ArticleId,
|
||||
ArticleName = o.Article.Description,
|
||||
Quantity = o.Quantity,
|
||||
StartDate = o.StartDate,
|
||||
EndDate = o.EndDate,
|
||||
DueDate = o.DueDate,
|
||||
Status = o.Status,
|
||||
Notes = o.Notes,
|
||||
Components = o.Components.Select(c => new ProductionOrderComponentDto
|
||||
{
|
||||
Id = c.Id,
|
||||
ArticleId = c.ArticleId,
|
||||
ArticleName = c.Article.Description,
|
||||
RequiredQuantity = c.RequiredQuantity,
|
||||
ConsumedQuantity = c.ConsumedQuantity
|
||||
}).ToList(),
|
||||
Phases = o.Phases.OrderBy(p => p.Sequence).Select(p => new ProductionOrderPhaseDto
|
||||
{
|
||||
Id = p.Id,
|
||||
Sequence = p.Sequence,
|
||||
Name = p.Name,
|
||||
WorkCenterId = p.WorkCenterId,
|
||||
WorkCenterName = p.WorkCenter.Name,
|
||||
Status = p.Status,
|
||||
StartDate = p.StartDate,
|
||||
EndDate = p.EndDate,
|
||||
QuantityCompleted = p.QuantityCompleted,
|
||||
QuantityScrapped = p.QuantityScrapped,
|
||||
EstimatedDurationMinutes = p.EstimatedDurationMinutes,
|
||||
ActualDurationMinutes = p.ActualDurationMinutes
|
||||
}).ToList(),
|
||||
ParentProductionOrderId = o.ParentProductionOrderId,
|
||||
ParentProductionOrderCode = o.ParentProductionOrder != null ? o.ParentProductionOrder.Code : null
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<ProductionOrderDto?> GetProductionOrderByIdAsync(int id)
|
||||
{
|
||||
var order = await _context.ProductionOrders
|
||||
.Include(o => o.Article)
|
||||
.Include(o => o.Components)
|
||||
.ThenInclude(c => c.Article)
|
||||
.Include(o => o.Phases)
|
||||
.ThenInclude(p => p.WorkCenter)
|
||||
.FirstOrDefaultAsync(o => o.Id == id);
|
||||
|
||||
if (order == null) return null;
|
||||
|
||||
return new ProductionOrderDto
|
||||
{
|
||||
Id = order.Id,
|
||||
Code = order.Code,
|
||||
ArticleId = order.ArticleId,
|
||||
ArticleName = order.Article.Description,
|
||||
Quantity = order.Quantity,
|
||||
StartDate = order.StartDate,
|
||||
EndDate = order.EndDate,
|
||||
DueDate = order.DueDate,
|
||||
Status = order.Status,
|
||||
Notes = order.Notes,
|
||||
Components = order.Components.Select(c => new ProductionOrderComponentDto
|
||||
{
|
||||
Id = c.Id,
|
||||
ArticleId = c.ArticleId,
|
||||
ArticleName = c.Article.Description,
|
||||
RequiredQuantity = c.RequiredQuantity,
|
||||
ConsumedQuantity = c.ConsumedQuantity
|
||||
}).ToList(),
|
||||
Phases = order.Phases.OrderBy(p => p.Sequence).Select(p => new ProductionOrderPhaseDto
|
||||
{
|
||||
Id = p.Id,
|
||||
Sequence = p.Sequence,
|
||||
Name = p.Name,
|
||||
WorkCenterId = p.WorkCenterId,
|
||||
WorkCenterName = p.WorkCenter.Name,
|
||||
Status = p.Status,
|
||||
StartDate = p.StartDate,
|
||||
EndDate = p.EndDate,
|
||||
QuantityCompleted = p.QuantityCompleted,
|
||||
QuantityScrapped = p.QuantityScrapped,
|
||||
EstimatedDurationMinutes = p.EstimatedDurationMinutes,
|
||||
ActualDurationMinutes = p.ActualDurationMinutes
|
||||
}).ToList(),
|
||||
ParentProductionOrderId = order.ParentProductionOrderId,
|
||||
ParentProductionOrderCode = order.ParentProductionOrder?.Code,
|
||||
ChildOrders = order.ChildProductionOrders.Select(c => new ProductionOrderDto
|
||||
{
|
||||
Id = c.Id,
|
||||
Code = c.Code,
|
||||
ArticleId = c.ArticleId,
|
||||
ArticleName = c.Article.Description,
|
||||
Quantity = c.Quantity,
|
||||
StartDate = c.StartDate,
|
||||
DueDate = c.DueDate,
|
||||
Status = c.Status
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ProductionOrderDto> CreateProductionOrderAsync(CreateProductionOrderDto dto)
|
||||
{
|
||||
var order = new ProductionOrder
|
||||
{
|
||||
Code = $"PO-{DateTime.Now:yyyyMMdd}-{Guid.NewGuid().ToString().Substring(0, 4).ToUpper()}", // Simple code generation
|
||||
ArticleId = dto.ArticleId,
|
||||
Quantity = dto.Quantity,
|
||||
StartDate = dto.StartDate,
|
||||
DueDate = dto.DueDate,
|
||||
Status = ProductionOrderStatus.Draft,
|
||||
Notes = dto.Notes,
|
||||
ParentProductionOrderId = dto.ParentProductionOrderId
|
||||
};
|
||||
|
||||
// If BOM is provided, copy components
|
||||
if (dto.BillOfMaterialsId.HasValue)
|
||||
{
|
||||
var bom = await _context.BillOfMaterials
|
||||
.Include(b => b.Components)
|
||||
.FirstOrDefaultAsync(b => b.Id == dto.BillOfMaterialsId.Value);
|
||||
|
||||
if (bom != null)
|
||||
{
|
||||
// Calculate ratio based on BOM quantity vs Order quantity
|
||||
var ratio = dto.Quantity / bom.Quantity;
|
||||
|
||||
foreach (var comp in bom.Components)
|
||||
{
|
||||
order.Components.Add(new ProductionOrderComponent
|
||||
{
|
||||
ArticleId = comp.ComponentArticleId,
|
||||
RequiredQuantity = comp.Quantity * ratio,
|
||||
ConsumedQuantity = 0
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy default production cycle phases
|
||||
var defaultCycle = await _context.ProductionCycles
|
||||
.Include(c => c.Phases)
|
||||
.FirstOrDefaultAsync(c => c.ArticleId == dto.ArticleId && c.IsDefault && c.IsActive);
|
||||
|
||||
if (defaultCycle != null)
|
||||
{
|
||||
foreach (var phase in defaultCycle.Phases)
|
||||
{
|
||||
order.Phases.Add(new ProductionOrderPhase
|
||||
{
|
||||
Sequence = phase.Sequence,
|
||||
Name = phase.Name,
|
||||
WorkCenterId = phase.WorkCenterId,
|
||||
Status = ProductionPhaseStatus.Pending,
|
||||
EstimatedDurationMinutes = phase.SetupTimeMinutes + (phase.DurationPerUnitMinutes * (int)dto.Quantity),
|
||||
QuantityCompleted = 0,
|
||||
QuantityScrapped = 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_context.ProductionOrders.Add(order);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Recursively create child orders if requested
|
||||
if (dto.CreateChildOrders && order.Components.Any())
|
||||
{
|
||||
foreach (var comp in order.Components)
|
||||
{
|
||||
// Check if component has a BOM (is manufactured)
|
||||
var compBom = await _context.BillOfMaterials
|
||||
.FirstOrDefaultAsync(b => b.ArticleId == comp.ArticleId && b.IsActive);
|
||||
|
||||
if (compBom != null)
|
||||
{
|
||||
// Create child order
|
||||
var childDto = new CreateProductionOrderDto
|
||||
{
|
||||
ArticleId = comp.ArticleId,
|
||||
Quantity = comp.RequiredQuantity,
|
||||
StartDate = dto.StartDate.AddDays(-1), // Simple scheduling: start 1 day earlier
|
||||
DueDate = dto.StartDate, // Must be ready by parent start
|
||||
Notes = $"Auto-generated for Parent Order {order.Code}",
|
||||
BillOfMaterialsId = compBom.Id,
|
||||
CreateChildOrders = true, // Recursive
|
||||
ParentProductionOrderId = order.Id // Link to parent
|
||||
};
|
||||
|
||||
await CreateProductionOrderAsync(childDto);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return await GetProductionOrderByIdAsync(order.Id) ?? throw new InvalidOperationException("Failed to retrieve created order");
|
||||
}
|
||||
|
||||
public async Task<ProductionOrderDto> UpdateProductionOrderAsync(int id, UpdateProductionOrderDto dto)
|
||||
{
|
||||
var order = await _context.ProductionOrders.FindAsync(id);
|
||||
if (order == null) throw new KeyNotFoundException($"Order with ID {id} not found");
|
||||
|
||||
if (order.Status == ProductionOrderStatus.Completed || order.Status == ProductionOrderStatus.Cancelled)
|
||||
{
|
||||
throw new InvalidOperationException("Cannot update completed or cancelled orders");
|
||||
}
|
||||
|
||||
order.Quantity = dto.Quantity;
|
||||
order.StartDate = dto.StartDate;
|
||||
order.DueDate = dto.DueDate;
|
||||
order.Notes = dto.Notes;
|
||||
|
||||
// Status change logic could be complex, for now just allow simple updates if not final
|
||||
// Ideally status change should be a separate method (which it is)
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return await GetProductionOrderByIdAsync(id) ?? throw new InvalidOperationException("Failed to retrieve updated order");
|
||||
}
|
||||
|
||||
public async Task<ProductionOrderDto> ChangeProductionOrderStatusAsync(int id, ProductionOrderStatus status)
|
||||
{
|
||||
var order = await _context.ProductionOrders
|
||||
.Include(o => o.Components)
|
||||
.FirstOrDefaultAsync(o => o.Id == id);
|
||||
|
||||
if (order == null) throw new KeyNotFoundException($"Order with ID {id} not found");
|
||||
|
||||
// Simple state machine check
|
||||
if (order.Status == ProductionOrderStatus.Completed)
|
||||
throw new InvalidOperationException("Order is already completed");
|
||||
|
||||
if (status == ProductionOrderStatus.Completed)
|
||||
{
|
||||
var defaultWarehouseId = await GetDefaultWarehouseIdAsync();
|
||||
|
||||
// Get reasons
|
||||
var prodReason = await _context.MovementReasons.FirstOrDefaultAsync(r => r.Code == "PROD");
|
||||
var consReason = await _context.MovementReasons.FirstOrDefaultAsync(r => r.Code == "CONS");
|
||||
|
||||
// 1. Create Inbound Movement for Finished Good
|
||||
var inboundMovement = new StockMovement
|
||||
{
|
||||
MovementDate = DateTime.UtcNow,
|
||||
Type = MovementType.Production, // Inbound from Production
|
||||
Status = MovementStatus.Draft, // Must be Draft to be confirmed
|
||||
ReasonId = prodReason?.Id,
|
||||
ExternalReference = order.Code,
|
||||
ExternalDocumentType = ExternalDocumentType.ProductionOrder,
|
||||
Notes = $"Production Order {order.Code} Completed",
|
||||
DestinationWarehouseId = defaultWarehouseId
|
||||
};
|
||||
|
||||
inboundMovement.Lines.Add(new StockMovementLine
|
||||
{
|
||||
ArticleId = order.ArticleId,
|
||||
Quantity = order.Quantity,
|
||||
LineNumber = 1
|
||||
});
|
||||
|
||||
await _warehouseService.CreateMovementAsync(inboundMovement);
|
||||
await _warehouseService.ConfirmMovementAsync(inboundMovement.Id);
|
||||
|
||||
// 2. Create Outbound Movement for Components (Consumption)
|
||||
if (order.Components.Any())
|
||||
{
|
||||
var outboundMovement = new StockMovement
|
||||
{
|
||||
MovementDate = DateTime.UtcNow,
|
||||
Type = MovementType.Consumption, // Outbound for Production
|
||||
Status = MovementStatus.Draft, // Must be Draft to be confirmed
|
||||
ReasonId = consReason?.Id,
|
||||
ExternalReference = order.Code,
|
||||
ExternalDocumentType = ExternalDocumentType.ProductionOrder,
|
||||
Notes = $"Consumption for Production Order {order.Code}",
|
||||
SourceWarehouseId = defaultWarehouseId
|
||||
};
|
||||
|
||||
int lineNum = 1;
|
||||
foreach (var comp in order.Components)
|
||||
{
|
||||
outboundMovement.Lines.Add(new StockMovementLine
|
||||
{
|
||||
ArticleId = comp.ArticleId,
|
||||
Quantity = comp.RequiredQuantity, // Consuming required quantity
|
||||
LineNumber = lineNum++
|
||||
});
|
||||
|
||||
// Update consumed quantity on the order component
|
||||
comp.ConsumedQuantity = comp.RequiredQuantity;
|
||||
}
|
||||
|
||||
await _warehouseService.CreateMovementAsync(outboundMovement);
|
||||
await _warehouseService.ConfirmMovementAsync(outboundMovement.Id);
|
||||
}
|
||||
|
||||
order.EndDate = DateTime.Now;
|
||||
}
|
||||
|
||||
order.Status = status;
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return await GetProductionOrderByIdAsync(id) ?? throw new InvalidOperationException("Failed to retrieve updated order");
|
||||
}
|
||||
|
||||
public async Task<ProductionOrderDto> UpdateProductionOrderPhaseAsync(int orderId, int phaseId, UpdateProductionOrderPhaseDto dto)
|
||||
{
|
||||
var phase = await _context.ProductionOrderPhases
|
||||
.FirstOrDefaultAsync(p => p.Id == phaseId && p.ProductionOrderId == orderId);
|
||||
|
||||
if (phase == null) throw new KeyNotFoundException($"Phase with ID {phaseId} not found in order {orderId}");
|
||||
|
||||
phase.Status = dto.Status;
|
||||
phase.QuantityCompleted = dto.QuantityCompleted;
|
||||
phase.QuantityScrapped = dto.QuantityScrapped;
|
||||
phase.ActualDurationMinutes = dto.ActualDurationMinutes;
|
||||
|
||||
if (dto.Status == ProductionPhaseStatus.InProgress && phase.StartDate == null)
|
||||
{
|
||||
phase.StartDate = DateTime.Now;
|
||||
}
|
||||
else if (dto.Status == ProductionPhaseStatus.Completed && phase.EndDate == null)
|
||||
{
|
||||
phase.EndDate = DateTime.Now;
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return await GetProductionOrderByIdAsync(orderId) ?? throw new InvalidOperationException("Failed to retrieve updated order");
|
||||
}
|
||||
|
||||
public async Task DeleteProductionOrderAsync(int id)
|
||||
{
|
||||
var order = await _context.ProductionOrders.FindAsync(id);
|
||||
if (order != null)
|
||||
{
|
||||
if (order.Status != ProductionOrderStatus.Draft)
|
||||
throw new InvalidOperationException("Only draft orders can be deleted");
|
||||
|
||||
_context.ProductionOrders.Remove(order);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> GetDefaultWarehouseIdAsync()
|
||||
{
|
||||
var wh = await _warehouseService.GetDefaultWarehouseAsync();
|
||||
return wh?.Id ?? throw new InvalidOperationException("No default warehouse found");
|
||||
}
|
||||
|
||||
// ===============================================
|
||||
// WORK CENTERS
|
||||
// ===============================================
|
||||
|
||||
public async Task<List<WorkCenterDto>> GetWorkCentersAsync()
|
||||
{
|
||||
return await _context.WorkCenters
|
||||
.Where(w => w.IsActive)
|
||||
.Select(w => new WorkCenterDto
|
||||
{
|
||||
Id = w.Id,
|
||||
Code = w.Code,
|
||||
Name = w.Name,
|
||||
Description = w.Description,
|
||||
CostPerHour = w.CostPerHour,
|
||||
IsActive = w.IsActive
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<WorkCenterDto?> GetWorkCenterByIdAsync(int id)
|
||||
{
|
||||
var w = await _context.WorkCenters.FindAsync(id);
|
||||
if (w == null) return null;
|
||||
|
||||
return new WorkCenterDto
|
||||
{
|
||||
Id = w.Id,
|
||||
Code = w.Code,
|
||||
Name = w.Name,
|
||||
Description = w.Description,
|
||||
CostPerHour = w.CostPerHour,
|
||||
IsActive = w.IsActive
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<WorkCenterDto> CreateWorkCenterAsync(CreateWorkCenterDto dto)
|
||||
{
|
||||
if (await _context.WorkCenters.AnyAsync(w => w.Code == dto.Code))
|
||||
throw new InvalidOperationException($"Work center with code {dto.Code} already exists");
|
||||
|
||||
var workCenter = new WorkCenter
|
||||
{
|
||||
Code = dto.Code,
|
||||
Name = dto.Name,
|
||||
Description = dto.Description,
|
||||
CostPerHour = dto.CostPerHour,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
_context.WorkCenters.Add(workCenter);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return await GetWorkCenterByIdAsync(workCenter.Id) ?? throw new InvalidOperationException("Failed to retrieve created work center");
|
||||
}
|
||||
|
||||
public async Task<WorkCenterDto> UpdateWorkCenterAsync(int id, UpdateWorkCenterDto dto)
|
||||
{
|
||||
var workCenter = await _context.WorkCenters.FindAsync(id);
|
||||
if (workCenter == null) throw new KeyNotFoundException($"Work center with ID {id} not found");
|
||||
|
||||
workCenter.Name = dto.Name;
|
||||
workCenter.Description = dto.Description;
|
||||
workCenter.CostPerHour = dto.CostPerHour;
|
||||
workCenter.IsActive = dto.IsActive;
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return await GetWorkCenterByIdAsync(id) ?? throw new InvalidOperationException("Failed to retrieve updated work center");
|
||||
}
|
||||
|
||||
public async Task DeleteWorkCenterAsync(int id)
|
||||
{
|
||||
var workCenter = await _context.WorkCenters.FindAsync(id);
|
||||
if (workCenter != null)
|
||||
{
|
||||
// Check if used in any cycle or order phase
|
||||
if (await _context.ProductionCyclePhases.AnyAsync(p => p.WorkCenterId == id))
|
||||
throw new InvalidOperationException("Cannot delete work center used in production cycles");
|
||||
|
||||
if (await _context.ProductionOrderPhases.AnyAsync(p => p.WorkCenterId == id))
|
||||
throw new InvalidOperationException("Cannot delete work center used in production orders");
|
||||
|
||||
_context.WorkCenters.Remove(workCenter);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// ===============================================
|
||||
// PRODUCTION CYCLES
|
||||
// ===============================================
|
||||
|
||||
public async Task<List<ProductionCycleDto>> GetProductionCyclesAsync()
|
||||
{
|
||||
return await _context.ProductionCycles
|
||||
.Include(c => c.Article)
|
||||
.Include(c => c.Phases)
|
||||
.ThenInclude(p => p.WorkCenter)
|
||||
.Where(c => c.IsActive)
|
||||
.Select(c => new ProductionCycleDto
|
||||
{
|
||||
Id = c.Id,
|
||||
Name = c.Name,
|
||||
Description = c.Description,
|
||||
ArticleId = c.ArticleId,
|
||||
ArticleName = c.Article.Description,
|
||||
IsDefault = c.IsDefault,
|
||||
IsActive = c.IsActive,
|
||||
Phases = c.Phases.OrderBy(p => p.Sequence).Select(p => new ProductionCyclePhaseDto
|
||||
{
|
||||
Id = p.Id,
|
||||
Sequence = p.Sequence,
|
||||
Name = p.Name,
|
||||
Description = p.Description,
|
||||
WorkCenterId = p.WorkCenterId,
|
||||
WorkCenterName = p.WorkCenter.Name,
|
||||
DurationPerUnitMinutes = p.DurationPerUnitMinutes,
|
||||
SetupTimeMinutes = p.SetupTimeMinutes
|
||||
}).ToList()
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<ProductionCycleDto?> GetProductionCycleByIdAsync(int id)
|
||||
{
|
||||
var c = await _context.ProductionCycles
|
||||
.Include(c => c.Article)
|
||||
.Include(c => c.Phases)
|
||||
.ThenInclude(p => p.WorkCenter)
|
||||
.FirstOrDefaultAsync(x => x.Id == id);
|
||||
|
||||
if (c == null) return null;
|
||||
|
||||
return new ProductionCycleDto
|
||||
{
|
||||
Id = c.Id,
|
||||
Name = c.Name,
|
||||
Description = c.Description,
|
||||
ArticleId = c.ArticleId,
|
||||
ArticleName = c.Article.Description,
|
||||
IsDefault = c.IsDefault,
|
||||
IsActive = c.IsActive,
|
||||
Phases = c.Phases.OrderBy(p => p.Sequence).Select(p => new ProductionCyclePhaseDto
|
||||
{
|
||||
Id = p.Id,
|
||||
Sequence = p.Sequence,
|
||||
Name = p.Name,
|
||||
Description = p.Description,
|
||||
WorkCenterId = p.WorkCenterId,
|
||||
WorkCenterName = p.WorkCenter.Name,
|
||||
DurationPerUnitMinutes = p.DurationPerUnitMinutes,
|
||||
SetupTimeMinutes = p.SetupTimeMinutes
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<ProductionCycleDto> CreateProductionCycleAsync(CreateProductionCycleDto dto)
|
||||
{
|
||||
var cycle = new ProductionCycle
|
||||
{
|
||||
Name = dto.Name,
|
||||
Description = dto.Description,
|
||||
ArticleId = dto.ArticleId,
|
||||
IsDefault = dto.IsDefault,
|
||||
IsActive = true
|
||||
};
|
||||
|
||||
if (dto.IsDefault)
|
||||
{
|
||||
// Unset other defaults for this article
|
||||
var defaults = await _context.ProductionCycles
|
||||
.Where(c => c.ArticleId == dto.ArticleId && c.IsDefault)
|
||||
.ToListAsync();
|
||||
foreach (var d in defaults) d.IsDefault = false;
|
||||
}
|
||||
|
||||
foreach (var phaseDto in dto.Phases)
|
||||
{
|
||||
cycle.Phases.Add(new ProductionCyclePhase
|
||||
{
|
||||
Sequence = phaseDto.Sequence,
|
||||
Name = phaseDto.Name,
|
||||
Description = phaseDto.Description,
|
||||
WorkCenterId = phaseDto.WorkCenterId,
|
||||
DurationPerUnitMinutes = phaseDto.DurationPerUnitMinutes,
|
||||
SetupTimeMinutes = phaseDto.SetupTimeMinutes
|
||||
});
|
||||
}
|
||||
|
||||
_context.ProductionCycles.Add(cycle);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return await GetProductionCycleByIdAsync(cycle.Id) ?? throw new InvalidOperationException("Failed to retrieve created cycle");
|
||||
}
|
||||
|
||||
public async Task<ProductionCycleDto> UpdateProductionCycleAsync(int id, UpdateProductionCycleDto dto)
|
||||
{
|
||||
var cycle = await _context.ProductionCycles
|
||||
.Include(c => c.Phases)
|
||||
.FirstOrDefaultAsync(c => c.Id == id);
|
||||
|
||||
if (cycle == null) throw new KeyNotFoundException($"Cycle with ID {id} not found");
|
||||
|
||||
cycle.Name = dto.Name;
|
||||
cycle.Description = dto.Description;
|
||||
cycle.IsActive = dto.IsActive;
|
||||
|
||||
if (dto.IsDefault && !cycle.IsDefault)
|
||||
{
|
||||
// Unset other defaults
|
||||
var defaults = await _context.ProductionCycles
|
||||
.Where(c => c.ArticleId == cycle.ArticleId && c.IsDefault && c.Id != id)
|
||||
.ToListAsync();
|
||||
foreach (var d in defaults) d.IsDefault = false;
|
||||
}
|
||||
cycle.IsDefault = dto.IsDefault;
|
||||
|
||||
// Update phases
|
||||
foreach (var phaseDto in dto.Phases)
|
||||
{
|
||||
if (phaseDto.IsDeleted)
|
||||
{
|
||||
if (phaseDto.Id.HasValue)
|
||||
{
|
||||
var phaseToDelete = cycle.Phases.FirstOrDefault(p => p.Id == phaseDto.Id.Value);
|
||||
if (phaseToDelete != null) _context.ProductionCyclePhases.Remove(phaseToDelete);
|
||||
}
|
||||
}
|
||||
else if (phaseDto.Id.HasValue)
|
||||
{
|
||||
var phaseToUpdate = cycle.Phases.FirstOrDefault(p => p.Id == phaseDto.Id.Value);
|
||||
if (phaseToUpdate != null)
|
||||
{
|
||||
phaseToUpdate.Sequence = phaseDto.Sequence;
|
||||
phaseToUpdate.Name = phaseDto.Name;
|
||||
phaseToUpdate.Description = phaseDto.Description;
|
||||
phaseToUpdate.WorkCenterId = phaseDto.WorkCenterId;
|
||||
phaseToUpdate.DurationPerUnitMinutes = phaseDto.DurationPerUnitMinutes;
|
||||
phaseToUpdate.SetupTimeMinutes = phaseDto.SetupTimeMinutes;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
cycle.Phases.Add(new ProductionCyclePhase
|
||||
{
|
||||
Sequence = phaseDto.Sequence,
|
||||
Name = phaseDto.Name,
|
||||
Description = phaseDto.Description,
|
||||
WorkCenterId = phaseDto.WorkCenterId,
|
||||
DurationPerUnitMinutes = phaseDto.DurationPerUnitMinutes,
|
||||
SetupTimeMinutes = phaseDto.SetupTimeMinutes
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
return await GetProductionCycleByIdAsync(id) ?? throw new InvalidOperationException("Failed to retrieve updated cycle");
|
||||
}
|
||||
|
||||
public async Task DeleteProductionCycleAsync(int id)
|
||||
{
|
||||
var cycle = await _context.ProductionCycles.FindAsync(id);
|
||||
if (cycle != null)
|
||||
{
|
||||
_context.ProductionCycles.Remove(cycle);
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Apollinare.API.Modules.Purchases.Dtos;
|
||||
using Apollinare.API.Modules.Purchases.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Apollinare.API.Modules.Purchases.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/purchases/orders")]
|
||||
public class PurchaseOrdersController : ControllerBase
|
||||
{
|
||||
private readonly PurchaseService _service;
|
||||
|
||||
public PurchaseOrdersController(PurchaseService service)
|
||||
{
|
||||
_service = service;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<PurchaseOrderDto>>> GetAll()
|
||||
{
|
||||
return Ok(await _service.GetAllAsync());
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<PurchaseOrderDto>> GetById(int id)
|
||||
{
|
||||
var order = await _service.GetByIdAsync(id);
|
||||
if (order == null) return NotFound();
|
||||
return Ok(order);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<PurchaseOrderDto>> Create(CreatePurchaseOrderDto dto)
|
||||
{
|
||||
var order = await _service.CreateAsync(dto);
|
||||
return CreatedAtAction(nameof(GetById), new { id = order.Id }, order);
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<PurchaseOrderDto>> Update(int id, UpdatePurchaseOrderDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var order = await _service.UpdateAsync(id, dto);
|
||||
if (order == null) return NotFound();
|
||||
return Ok(order);
|
||||
}
|
||||
catch (System.InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<ActionResult> Delete(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _service.DeleteAsync(id);
|
||||
if (!result) return NotFound();
|
||||
return NoContent();
|
||||
}
|
||||
catch (System.InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id}/confirm")]
|
||||
public async Task<ActionResult<PurchaseOrderDto>> Confirm(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var order = await _service.ConfirmOrderAsync(id);
|
||||
return Ok(order);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
catch (System.InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id}/receive")]
|
||||
public async Task<ActionResult<PurchaseOrderDto>> Receive(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var order = await _service.ReceiveOrderAsync(id);
|
||||
return Ok(order);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
catch (System.InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Apollinare.API.Modules.Purchases.Dtos;
|
||||
using Apollinare.API.Modules.Purchases.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Apollinare.API.Modules.Purchases.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/purchases/suppliers")]
|
||||
public class SuppliersController : ControllerBase
|
||||
{
|
||||
private readonly SupplierService _service;
|
||||
|
||||
public SuppliersController(SupplierService service)
|
||||
{
|
||||
_service = service;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<SupplierDto>>> GetAll()
|
||||
{
|
||||
return Ok(await _service.GetAllAsync());
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<SupplierDto>> GetById(int id)
|
||||
{
|
||||
var supplier = await _service.GetByIdAsync(id);
|
||||
if (supplier == null) return NotFound();
|
||||
return Ok(supplier);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<SupplierDto>> Create(CreateSupplierDto dto)
|
||||
{
|
||||
var supplier = await _service.CreateAsync(dto);
|
||||
return CreatedAtAction(nameof(GetById), new { id = supplier.Id }, supplier);
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<SupplierDto>> Update(int id, UpdateSupplierDto dto)
|
||||
{
|
||||
var supplier = await _service.UpdateAsync(id, dto);
|
||||
if (supplier == null) return NotFound();
|
||||
return Ok(supplier);
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<ActionResult> Delete(int id)
|
||||
{
|
||||
var result = await _service.DeleteAsync(id);
|
||||
if (!result) return NotFound();
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Apollinare.Domain.Entities.Purchases;
|
||||
|
||||
namespace Apollinare.API.Modules.Purchases.Dtos;
|
||||
|
||||
public class PurchaseOrderDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string OrderNumber { get; set; } = string.Empty;
|
||||
public DateTime OrderDate { get; set; }
|
||||
public DateTime? ExpectedDeliveryDate { get; set; }
|
||||
public int SupplierId { get; set; }
|
||||
public string SupplierName { get; set; } = string.Empty;
|
||||
public PurchaseOrderStatus Status { get; set; }
|
||||
public int? DestinationWarehouseId { get; set; }
|
||||
public string? DestinationWarehouseName { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public decimal TotalNet { get; set; }
|
||||
public decimal TotalTax { get; set; }
|
||||
public decimal TotalGross { get; set; }
|
||||
public DateTime? CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public List<PurchaseOrderLineDto> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
public class PurchaseOrderLineDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int PurchaseOrderId { get; set; }
|
||||
public int WarehouseArticleId { get; set; }
|
||||
public string ArticleCode { get; set; } = string.Empty;
|
||||
public string ArticleDescription { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal ReceivedQuantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public decimal TaxRate { get; set; }
|
||||
public decimal DiscountPercent { get; set; }
|
||||
public decimal LineTotal { get; set; }
|
||||
}
|
||||
|
||||
public class CreatePurchaseOrderDto
|
||||
{
|
||||
public DateTime OrderDate { get; set; } = DateTime.Now;
|
||||
public DateTime? ExpectedDeliveryDate { get; set; }
|
||||
public int SupplierId { get; set; }
|
||||
public int? DestinationWarehouseId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public List<CreatePurchaseOrderLineDto> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CreatePurchaseOrderLineDto
|
||||
{
|
||||
public int WarehouseArticleId { get; set; }
|
||||
public string? Description { get; set; } // Optional override
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public decimal TaxRate { get; set; }
|
||||
public decimal DiscountPercent { get; set; }
|
||||
}
|
||||
|
||||
public class UpdatePurchaseOrderDto
|
||||
{
|
||||
public DateTime OrderDate { get; set; }
|
||||
public DateTime? ExpectedDeliveryDate { get; set; }
|
||||
public int? DestinationWarehouseId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public List<UpdatePurchaseOrderLineDto> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
public class UpdatePurchaseOrderLineDto
|
||||
{
|
||||
public int? Id { get; set; } // Null if new line
|
||||
public int WarehouseArticleId { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public decimal TaxRate { get; set; }
|
||||
public decimal DiscountPercent { get; set; }
|
||||
public bool IsDeleted { get; set; } // To mark for deletion
|
||||
}
|
||||
63
src/Apollinare.API/Modules/Purchases/Dtos/SupplierDtos.cs
Normal file
63
src/Apollinare.API/Modules/Purchases/Dtos/SupplierDtos.cs
Normal file
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
|
||||
namespace Apollinare.API.Modules.Purchases.Dtos;
|
||||
|
||||
public class SupplierDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? VatNumber { get; set; }
|
||||
public string? FiscalCode { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? Province { get; set; }
|
||||
public string? ZipCode { get; set; }
|
||||
public string? Country { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Pec { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Website { get; set; }
|
||||
public string? PaymentTerms { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public DateTime? CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
public class CreateSupplierDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? VatNumber { get; set; }
|
||||
public string? FiscalCode { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? Province { get; set; }
|
||||
public string? ZipCode { get; set; }
|
||||
public string? Country { get; set; } = "Italia";
|
||||
public string? Email { get; set; }
|
||||
public string? Pec { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Website { get; set; }
|
||||
public string? PaymentTerms { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateSupplierDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? VatNumber { get; set; }
|
||||
public string? FiscalCode { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? Province { get; set; }
|
||||
public string? ZipCode { get; set; }
|
||||
public string? Country { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Pec { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Website { get; set; }
|
||||
public string? PaymentTerms { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
340
src/Apollinare.API/Modules/Purchases/Services/PurchaseService.cs
Normal file
340
src/Apollinare.API/Modules/Purchases/Services/PurchaseService.cs
Normal file
@@ -0,0 +1,340 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Apollinare.API.Modules.Purchases.Dtos;
|
||||
using Apollinare.API.Modules.Warehouse.Services;
|
||||
using Apollinare.API.Services;
|
||||
using Apollinare.Domain.Entities.Purchases;
|
||||
using Apollinare.Domain.Entities.Warehouse;
|
||||
using Apollinare.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Apollinare.API.Modules.Purchases.Services;
|
||||
|
||||
public class PurchaseService
|
||||
{
|
||||
private readonly AppollinareDbContext _db;
|
||||
private readonly AutoCodeService _autoCodeService;
|
||||
private readonly IWarehouseService _warehouseService;
|
||||
|
||||
public PurchaseService(AppollinareDbContext db, AutoCodeService autoCodeService, IWarehouseService warehouseService)
|
||||
{
|
||||
_db = db;
|
||||
_autoCodeService = autoCodeService;
|
||||
_warehouseService = warehouseService;
|
||||
}
|
||||
|
||||
public async Task<List<PurchaseOrderDto>> GetAllAsync()
|
||||
{
|
||||
return await _db.PurchaseOrders
|
||||
.AsNoTracking()
|
||||
.Include(o => o.Supplier)
|
||||
.Include(o => o.DestinationWarehouse)
|
||||
.OrderByDescending(o => o.OrderDate)
|
||||
.Select(o => new PurchaseOrderDto
|
||||
{
|
||||
Id = o.Id,
|
||||
OrderNumber = o.OrderNumber,
|
||||
OrderDate = o.OrderDate,
|
||||
ExpectedDeliveryDate = o.ExpectedDeliveryDate,
|
||||
SupplierId = o.SupplierId,
|
||||
SupplierName = o.Supplier!.Name,
|
||||
Status = o.Status,
|
||||
DestinationWarehouseId = o.DestinationWarehouseId,
|
||||
DestinationWarehouseName = o.DestinationWarehouse != null ? o.DestinationWarehouse.Name : null,
|
||||
Notes = o.Notes,
|
||||
TotalNet = o.TotalNet,
|
||||
TotalTax = o.TotalTax,
|
||||
TotalGross = o.TotalGross,
|
||||
CreatedAt = o.CreatedAt,
|
||||
UpdatedAt = o.UpdatedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<PurchaseOrderDto?> GetByIdAsync(int id)
|
||||
{
|
||||
var order = await _db.PurchaseOrders
|
||||
.AsNoTracking()
|
||||
.Include(o => o.Supplier)
|
||||
.Include(o => o.DestinationWarehouse)
|
||||
.Include(o => o.Lines)
|
||||
.ThenInclude(l => l.WarehouseArticle)
|
||||
.FirstOrDefaultAsync(o => o.Id == id);
|
||||
|
||||
if (order == null) return null;
|
||||
|
||||
return new PurchaseOrderDto
|
||||
{
|
||||
Id = order.Id,
|
||||
OrderNumber = order.OrderNumber,
|
||||
OrderDate = order.OrderDate,
|
||||
ExpectedDeliveryDate = order.ExpectedDeliveryDate,
|
||||
SupplierId = order.SupplierId,
|
||||
SupplierName = order.Supplier!.Name,
|
||||
Status = order.Status,
|
||||
DestinationWarehouseId = order.DestinationWarehouseId,
|
||||
DestinationWarehouseName = order.DestinationWarehouse?.Name,
|
||||
Notes = order.Notes,
|
||||
TotalNet = order.TotalNet,
|
||||
TotalTax = order.TotalTax,
|
||||
TotalGross = order.TotalGross,
|
||||
CreatedAt = order.CreatedAt,
|
||||
UpdatedAt = order.UpdatedAt,
|
||||
Lines = order.Lines.Select(l => new PurchaseOrderLineDto
|
||||
{
|
||||
Id = l.Id,
|
||||
PurchaseOrderId = l.PurchaseOrderId,
|
||||
WarehouseArticleId = l.WarehouseArticleId,
|
||||
ArticleCode = l.WarehouseArticle!.Code,
|
||||
ArticleDescription = l.WarehouseArticle.Description,
|
||||
Description = l.Description,
|
||||
Quantity = l.Quantity,
|
||||
ReceivedQuantity = l.ReceivedQuantity,
|
||||
UnitPrice = l.UnitPrice,
|
||||
TaxRate = l.TaxRate,
|
||||
DiscountPercent = l.DiscountPercent,
|
||||
LineTotal = l.LineTotal
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<PurchaseOrderDto> CreateAsync(CreatePurchaseOrderDto dto)
|
||||
{
|
||||
var code = await _autoCodeService.GenerateNextCodeAsync("purchase_order");
|
||||
if (string.IsNullOrEmpty(code))
|
||||
{
|
||||
code = $"ODA{DateTime.Now:yyyy}-{Guid.NewGuid().ToString().Substring(0, 5).ToUpper()}";
|
||||
}
|
||||
|
||||
var order = new PurchaseOrder
|
||||
{
|
||||
OrderNumber = code,
|
||||
OrderDate = dto.OrderDate,
|
||||
ExpectedDeliveryDate = dto.ExpectedDeliveryDate,
|
||||
SupplierId = dto.SupplierId,
|
||||
DestinationWarehouseId = dto.DestinationWarehouseId,
|
||||
Notes = dto.Notes,
|
||||
Status = PurchaseOrderStatus.Draft
|
||||
};
|
||||
|
||||
foreach (var lineDto in dto.Lines)
|
||||
{
|
||||
var line = new PurchaseOrderLine
|
||||
{
|
||||
WarehouseArticleId = lineDto.WarehouseArticleId,
|
||||
Description = lineDto.Description ?? string.Empty,
|
||||
Quantity = lineDto.Quantity,
|
||||
UnitPrice = lineDto.UnitPrice,
|
||||
TaxRate = lineDto.TaxRate,
|
||||
DiscountPercent = lineDto.DiscountPercent
|
||||
};
|
||||
|
||||
// If description is empty, fetch from article
|
||||
if (string.IsNullOrEmpty(line.Description))
|
||||
{
|
||||
var article = await _db.WarehouseArticles.FindAsync(line.WarehouseArticleId);
|
||||
if (article != null) line.Description = article.Description;
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
var netPrice = line.UnitPrice * (1 - line.DiscountPercent / 100);
|
||||
line.LineTotal = Math.Round(netPrice * line.Quantity, 2);
|
||||
|
||||
order.Lines.Add(line);
|
||||
}
|
||||
|
||||
CalculateOrderTotals(order);
|
||||
|
||||
_db.PurchaseOrders.Add(order);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return await GetByIdAsync(order.Id) ?? throw new InvalidOperationException("Failed to retrieve created order");
|
||||
}
|
||||
|
||||
public async Task<PurchaseOrderDto?> UpdateAsync(int id, UpdatePurchaseOrderDto dto)
|
||||
{
|
||||
var order = await _db.PurchaseOrders
|
||||
.Include(o => o.Lines)
|
||||
.FirstOrDefaultAsync(o => o.Id == id);
|
||||
|
||||
if (order == null) return null;
|
||||
if (order.Status != PurchaseOrderStatus.Draft)
|
||||
throw new InvalidOperationException("Solo gli ordini in bozza possono essere modificati");
|
||||
|
||||
order.OrderDate = dto.OrderDate;
|
||||
order.ExpectedDeliveryDate = dto.ExpectedDeliveryDate;
|
||||
order.DestinationWarehouseId = dto.DestinationWarehouseId;
|
||||
order.Notes = dto.Notes;
|
||||
order.UpdatedAt = DateTime.Now;
|
||||
|
||||
// Update lines
|
||||
foreach (var lineDto in dto.Lines)
|
||||
{
|
||||
if (lineDto.IsDeleted)
|
||||
{
|
||||
if (lineDto.Id.HasValue)
|
||||
{
|
||||
var lineToDelete = order.Lines.FirstOrDefault(l => l.Id == lineDto.Id.Value);
|
||||
if (lineToDelete != null) order.Lines.Remove(lineToDelete);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
PurchaseOrderLine line;
|
||||
if (lineDto.Id.HasValue)
|
||||
{
|
||||
line = order.Lines.FirstOrDefault(l => l.Id == lineDto.Id.Value)
|
||||
?? throw new KeyNotFoundException($"Line {lineDto.Id} not found");
|
||||
}
|
||||
else
|
||||
{
|
||||
line = new PurchaseOrderLine();
|
||||
order.Lines.Add(line);
|
||||
}
|
||||
|
||||
line.WarehouseArticleId = lineDto.WarehouseArticleId;
|
||||
line.Description = lineDto.Description ?? string.Empty;
|
||||
line.Quantity = lineDto.Quantity;
|
||||
line.UnitPrice = lineDto.UnitPrice;
|
||||
line.TaxRate = lineDto.TaxRate;
|
||||
line.DiscountPercent = lineDto.DiscountPercent;
|
||||
|
||||
if (string.IsNullOrEmpty(line.Description))
|
||||
{
|
||||
var article = await _db.WarehouseArticles.FindAsync(line.WarehouseArticleId);
|
||||
if (article != null) line.Description = article.Description;
|
||||
}
|
||||
|
||||
var netPrice = line.UnitPrice * (1 - line.DiscountPercent / 100);
|
||||
line.LineTotal = Math.Round(netPrice * line.Quantity, 2);
|
||||
}
|
||||
|
||||
CalculateOrderTotals(order);
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return await GetByIdAsync(id);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(int id)
|
||||
{
|
||||
var order = await _db.PurchaseOrders.FindAsync(id);
|
||||
if (order == null) return false;
|
||||
if (order.Status != PurchaseOrderStatus.Draft && order.Status != PurchaseOrderStatus.Cancelled)
|
||||
throw new InvalidOperationException("Solo gli ordini in bozza o annullati possono essere eliminati");
|
||||
|
||||
_db.PurchaseOrders.Remove(order);
|
||||
await _db.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<PurchaseOrderDto> ConfirmOrderAsync(int id)
|
||||
{
|
||||
var order = await _db.PurchaseOrders.FindAsync(id);
|
||||
if (order == null) throw new KeyNotFoundException("Order not found");
|
||||
if (order.Status != PurchaseOrderStatus.Draft) throw new InvalidOperationException("Solo gli ordini in bozza possono essere confermati");
|
||||
|
||||
order.Status = PurchaseOrderStatus.Confirmed;
|
||||
order.UpdatedAt = DateTime.Now;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return await GetByIdAsync(id) ?? throw new InvalidOperationException("Failed to retrieve order");
|
||||
}
|
||||
|
||||
public async Task<PurchaseOrderDto> ReceiveOrderAsync(int id)
|
||||
{
|
||||
var order = await _db.PurchaseOrders
|
||||
.Include(o => o.Lines)
|
||||
.FirstOrDefaultAsync(o => o.Id == id);
|
||||
|
||||
if (order == null) throw new KeyNotFoundException("Order not found");
|
||||
if (order.Status != PurchaseOrderStatus.Confirmed && order.Status != PurchaseOrderStatus.PartiallyReceived)
|
||||
throw new InvalidOperationException("L'ordine deve essere confermato per essere ricevuto");
|
||||
|
||||
// Create Stock Movement (Inbound)
|
||||
var warehouseId = order.DestinationWarehouseId;
|
||||
if (!warehouseId.HasValue)
|
||||
{
|
||||
var defaultWarehouse = await _warehouseService.GetDefaultWarehouseAsync();
|
||||
warehouseId = defaultWarehouse?.Id;
|
||||
}
|
||||
|
||||
if (!warehouseId.HasValue) throw new InvalidOperationException("Nessun magazzino di destinazione specificato o di default");
|
||||
|
||||
// Genera numero documento movimento
|
||||
var docNumber = await _warehouseService.GenerateDocumentNumberAsync(MovementType.Inbound);
|
||||
|
||||
// Trova causale di default per acquisto (se esiste, altrimenti null o crea)
|
||||
var reason = (await _warehouseService.GetMovementReasonsAsync(MovementType.Inbound))
|
||||
.FirstOrDefault(r => r.Code == "ACQ" || r.Description.Contains("Acquisto"));
|
||||
|
||||
var movement = new StockMovement
|
||||
{
|
||||
DocumentNumber = docNumber,
|
||||
MovementDate = DateTime.Now,
|
||||
Type = MovementType.Inbound,
|
||||
Status = MovementStatus.Draft,
|
||||
DestinationWarehouseId = warehouseId,
|
||||
ReasonId = reason?.Id,
|
||||
ExternalReference = order.OrderNumber,
|
||||
Notes = $"Ricevimento merce da ordine {order.OrderNumber}"
|
||||
};
|
||||
|
||||
movement = await _warehouseService.CreateMovementAsync(movement);
|
||||
|
||||
// Add lines to movement
|
||||
foreach (var line in order.Lines)
|
||||
{
|
||||
var remainingQty = line.Quantity - line.ReceivedQuantity;
|
||||
if (remainingQty <= 0) continue;
|
||||
|
||||
// Update received quantity on order line
|
||||
line.ReceivedQuantity += remainingQty;
|
||||
|
||||
// Add movement line directly via DbContext since IWarehouseService doesn't expose AddLine (it exposes UpdateMovement)
|
||||
// Or better, construct the movement with lines initially if possible.
|
||||
// Since CreateMovementAsync saves, we need to add lines and save again.
|
||||
|
||||
var movementLine = new StockMovementLine
|
||||
{
|
||||
MovementId = movement.Id,
|
||||
ArticleId = line.WarehouseArticleId,
|
||||
Quantity = remainingQty,
|
||||
UnitCost = line.UnitPrice * (1 - line.DiscountPercent / 100),
|
||||
LineValue = Math.Round(remainingQty * (line.UnitPrice * (1 - line.DiscountPercent / 100)), 2)
|
||||
};
|
||||
|
||||
_db.StockMovementLines.Add(movementLine);
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Confirm movement to update stock
|
||||
await _warehouseService.ConfirmMovementAsync(movement.Id);
|
||||
|
||||
// Update order status
|
||||
var allReceived = order.Lines.All(l => l.ReceivedQuantity >= l.Quantity);
|
||||
order.Status = allReceived ? PurchaseOrderStatus.Received : PurchaseOrderStatus.PartiallyReceived;
|
||||
order.UpdatedAt = DateTime.Now;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return await GetByIdAsync(id) ?? throw new InvalidOperationException("Failed to retrieve order");
|
||||
}
|
||||
|
||||
private void CalculateOrderTotals(PurchaseOrder order)
|
||||
{
|
||||
order.TotalNet = 0;
|
||||
order.TotalTax = 0;
|
||||
order.TotalGross = 0;
|
||||
|
||||
foreach (var line in order.Lines)
|
||||
{
|
||||
order.TotalNet += line.LineTotal;
|
||||
var taxAmount = line.LineTotal * (line.TaxRate / 100);
|
||||
order.TotalTax += taxAmount;
|
||||
}
|
||||
|
||||
order.TotalGross = order.TotalNet + order.TotalTax;
|
||||
}
|
||||
}
|
||||
166
src/Apollinare.API/Modules/Purchases/Services/SupplierService.cs
Normal file
166
src/Apollinare.API/Modules/Purchases/Services/SupplierService.cs
Normal file
@@ -0,0 +1,166 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Apollinare.API.Modules.Purchases.Dtos;
|
||||
using Apollinare.API.Services;
|
||||
using Apollinare.Domain.Entities.Purchases;
|
||||
using Apollinare.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Apollinare.API.Modules.Purchases.Services;
|
||||
|
||||
public class SupplierService
|
||||
{
|
||||
private readonly AppollinareDbContext _db;
|
||||
private readonly AutoCodeService _autoCodeService;
|
||||
|
||||
public SupplierService(AppollinareDbContext db, AutoCodeService autoCodeService)
|
||||
{
|
||||
_db = db;
|
||||
_autoCodeService = autoCodeService;
|
||||
}
|
||||
|
||||
public async Task<List<SupplierDto>> GetAllAsync()
|
||||
{
|
||||
return await _db.Suppliers
|
||||
.AsNoTracking()
|
||||
.OrderBy(s => s.Name)
|
||||
.Select(s => new SupplierDto
|
||||
{
|
||||
Id = s.Id,
|
||||
Code = s.Code,
|
||||
Name = s.Name,
|
||||
VatNumber = s.VatNumber,
|
||||
FiscalCode = s.FiscalCode,
|
||||
Address = s.Address,
|
||||
City = s.City,
|
||||
Province = s.Province,
|
||||
ZipCode = s.ZipCode,
|
||||
Country = s.Country,
|
||||
Email = s.Email,
|
||||
Pec = s.Pec,
|
||||
Phone = s.Phone,
|
||||
Website = s.Website,
|
||||
PaymentTerms = s.PaymentTerms,
|
||||
Notes = s.Notes,
|
||||
IsActive = s.IsActive,
|
||||
CreatedAt = s.CreatedAt,
|
||||
UpdatedAt = s.UpdatedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<SupplierDto?> GetByIdAsync(int id)
|
||||
{
|
||||
var supplier = await _db.Suppliers
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Id == id);
|
||||
|
||||
if (supplier == null) return null;
|
||||
|
||||
return new SupplierDto
|
||||
{
|
||||
Id = supplier.Id,
|
||||
Code = supplier.Code,
|
||||
Name = supplier.Name,
|
||||
VatNumber = supplier.VatNumber,
|
||||
FiscalCode = supplier.FiscalCode,
|
||||
Address = supplier.Address,
|
||||
City = supplier.City,
|
||||
Province = supplier.Province,
|
||||
ZipCode = supplier.ZipCode,
|
||||
Country = supplier.Country,
|
||||
Email = supplier.Email,
|
||||
Pec = supplier.Pec,
|
||||
Phone = supplier.Phone,
|
||||
Website = supplier.Website,
|
||||
PaymentTerms = supplier.PaymentTerms,
|
||||
Notes = supplier.Notes,
|
||||
IsActive = supplier.IsActive,
|
||||
CreatedAt = supplier.CreatedAt,
|
||||
UpdatedAt = supplier.UpdatedAt
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<SupplierDto> CreateAsync(CreateSupplierDto dto)
|
||||
{
|
||||
// Genera codice automatico
|
||||
var code = await _autoCodeService.GenerateNextCodeAsync("supplier");
|
||||
if (string.IsNullOrEmpty(code))
|
||||
{
|
||||
// Fallback se disabilitato
|
||||
code = $"FOR-{DateTime.Now:yyyyMMdd}-{Guid.NewGuid().ToString().Substring(0, 4).ToUpper()}";
|
||||
}
|
||||
|
||||
var supplier = new Supplier
|
||||
{
|
||||
Code = code,
|
||||
Name = dto.Name,
|
||||
VatNumber = dto.VatNumber,
|
||||
FiscalCode = dto.FiscalCode,
|
||||
Address = dto.Address,
|
||||
City = dto.City,
|
||||
Province = dto.Province,
|
||||
ZipCode = dto.ZipCode,
|
||||
Country = dto.Country,
|
||||
Email = dto.Email,
|
||||
Pec = dto.Pec,
|
||||
Phone = dto.Phone,
|
||||
Website = dto.Website,
|
||||
PaymentTerms = dto.PaymentTerms,
|
||||
Notes = dto.Notes,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.Now
|
||||
};
|
||||
|
||||
_db.Suppliers.Add(supplier);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return await GetByIdAsync(supplier.Id) ?? throw new InvalidOperationException("Failed to retrieve created supplier");
|
||||
}
|
||||
|
||||
public async Task<SupplierDto?> UpdateAsync(int id, UpdateSupplierDto dto)
|
||||
{
|
||||
var supplier = await _db.Suppliers.FindAsync(id);
|
||||
if (supplier == null) return null;
|
||||
|
||||
supplier.Name = dto.Name;
|
||||
supplier.VatNumber = dto.VatNumber;
|
||||
supplier.FiscalCode = dto.FiscalCode;
|
||||
supplier.Address = dto.Address;
|
||||
supplier.City = dto.City;
|
||||
supplier.Province = dto.Province;
|
||||
supplier.ZipCode = dto.ZipCode;
|
||||
supplier.Country = dto.Country;
|
||||
supplier.Email = dto.Email;
|
||||
supplier.Pec = dto.Pec;
|
||||
supplier.Phone = dto.Phone;
|
||||
supplier.Website = dto.Website;
|
||||
supplier.PaymentTerms = dto.PaymentTerms;
|
||||
supplier.Notes = dto.Notes;
|
||||
supplier.IsActive = dto.IsActive;
|
||||
supplier.UpdatedAt = DateTime.Now;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return await GetByIdAsync(id);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(int id)
|
||||
{
|
||||
var supplier = await _db.Suppliers.FindAsync(id);
|
||||
if (supplier == null) return false;
|
||||
|
||||
// Check if used in purchase orders
|
||||
var hasOrders = await _db.PurchaseOrders.AnyAsync(o => o.SupplierId == id);
|
||||
if (hasOrders)
|
||||
{
|
||||
throw new InvalidOperationException("Impossibile eliminare il fornitore perché ha ordini associati.");
|
||||
}
|
||||
|
||||
_db.Suppliers.Remove(supplier);
|
||||
await _db.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Apollinare.API.Modules.Sales.Dtos;
|
||||
using Apollinare.API.Modules.Sales.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Apollinare.API.Modules.Sales.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/sales/orders")]
|
||||
public class SalesOrdersController : ControllerBase
|
||||
{
|
||||
private readonly SalesService _service;
|
||||
|
||||
public SalesOrdersController(SalesService service)
|
||||
{
|
||||
_service = service;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<SalesOrderDto>>> GetAll()
|
||||
{
|
||||
return Ok(await _service.GetAllAsync());
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<ActionResult<SalesOrderDto>> GetById(int id)
|
||||
{
|
||||
var order = await _service.GetByIdAsync(id);
|
||||
if (order == null) return NotFound();
|
||||
return Ok(order);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<SalesOrderDto>> Create(CreateSalesOrderDto dto)
|
||||
{
|
||||
var order = await _service.CreateAsync(dto);
|
||||
return CreatedAtAction(nameof(GetById), new { id = order.Id }, order);
|
||||
}
|
||||
|
||||
[HttpPut("{id}")]
|
||||
public async Task<ActionResult<SalesOrderDto>> Update(int id, UpdateSalesOrderDto dto)
|
||||
{
|
||||
try
|
||||
{
|
||||
var order = await _service.UpdateAsync(id, dto);
|
||||
if (order == null) return NotFound();
|
||||
return Ok(order);
|
||||
}
|
||||
catch (System.InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
public async Task<ActionResult> Delete(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _service.DeleteAsync(id);
|
||||
if (!result) return NotFound();
|
||||
return NoContent();
|
||||
}
|
||||
catch (System.InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id}/confirm")]
|
||||
public async Task<ActionResult<SalesOrderDto>> Confirm(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var order = await _service.ConfirmOrderAsync(id);
|
||||
return Ok(order);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
catch (System.InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id}/ship")]
|
||||
public async Task<ActionResult<SalesOrderDto>> Ship(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var order = await _service.ShipOrderAsync(id);
|
||||
return Ok(order);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
catch (System.InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
78
src/Apollinare.API/Modules/Sales/Dtos/SalesOrderDtos.cs
Normal file
78
src/Apollinare.API/Modules/Sales/Dtos/SalesOrderDtos.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Apollinare.Domain.Entities.Sales;
|
||||
|
||||
namespace Apollinare.API.Modules.Sales.Dtos;
|
||||
|
||||
public class SalesOrderDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string OrderNumber { get; set; } = string.Empty;
|
||||
public DateTime OrderDate { get; set; }
|
||||
public DateTime? ExpectedDeliveryDate { get; set; }
|
||||
public int CustomerId { get; set; }
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public SalesOrderStatus Status { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public decimal TotalNet { get; set; }
|
||||
public decimal TotalTax { get; set; }
|
||||
public decimal TotalGross { get; set; }
|
||||
public DateTime? CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public List<SalesOrderLineDto> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
public class SalesOrderLineDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int SalesOrderId { get; set; }
|
||||
public int WarehouseArticleId { get; set; }
|
||||
public string ArticleCode { get; set; } = string.Empty;
|
||||
public string ArticleDescription { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal ShippedQuantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public decimal TaxRate { get; set; }
|
||||
public decimal DiscountPercent { get; set; }
|
||||
public decimal LineTotal { get; set; }
|
||||
}
|
||||
|
||||
public class CreateSalesOrderDto
|
||||
{
|
||||
public DateTime OrderDate { get; set; } = DateTime.Now;
|
||||
public DateTime? ExpectedDeliveryDate { get; set; }
|
||||
public int CustomerId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public List<CreateSalesOrderLineDto> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CreateSalesOrderLineDto
|
||||
{
|
||||
public int WarehouseArticleId { get; set; }
|
||||
public string? Description { get; set; } // Optional override
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public decimal TaxRate { get; set; }
|
||||
public decimal DiscountPercent { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateSalesOrderDto
|
||||
{
|
||||
public DateTime OrderDate { get; set; }
|
||||
public DateTime? ExpectedDeliveryDate { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public List<UpdateSalesOrderLineDto> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
public class UpdateSalesOrderLineDto
|
||||
{
|
||||
public int? Id { get; set; } // Null if new line
|
||||
public int WarehouseArticleId { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public decimal TaxRate { get; set; }
|
||||
public decimal DiscountPercent { get; set; }
|
||||
public bool IsDeleted { get; set; } // To mark for deletion
|
||||
}
|
||||
324
src/Apollinare.API/Modules/Sales/Services/SalesService.cs
Normal file
324
src/Apollinare.API/Modules/Sales/Services/SalesService.cs
Normal file
@@ -0,0 +1,324 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Apollinare.API.Modules.Sales.Dtos;
|
||||
using Apollinare.API.Modules.Warehouse.Services;
|
||||
using Apollinare.API.Services;
|
||||
using Apollinare.Domain.Entities.Sales;
|
||||
using Apollinare.Domain.Entities.Warehouse;
|
||||
using Apollinare.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Apollinare.API.Modules.Sales.Services;
|
||||
|
||||
public class SalesService
|
||||
{
|
||||
private readonly AppollinareDbContext _db;
|
||||
private readonly AutoCodeService _autoCodeService;
|
||||
private readonly IWarehouseService _warehouseService;
|
||||
|
||||
public SalesService(AppollinareDbContext db, AutoCodeService autoCodeService, IWarehouseService warehouseService)
|
||||
{
|
||||
_db = db;
|
||||
_autoCodeService = autoCodeService;
|
||||
_warehouseService = warehouseService;
|
||||
}
|
||||
|
||||
public async Task<List<SalesOrderDto>> GetAllAsync()
|
||||
{
|
||||
return await _db.SalesOrders
|
||||
.AsNoTracking()
|
||||
.Include(o => o.Customer)
|
||||
.OrderByDescending(o => o.OrderDate)
|
||||
.Select(o => new SalesOrderDto
|
||||
{
|
||||
Id = o.Id,
|
||||
OrderNumber = o.OrderNumber,
|
||||
OrderDate = o.OrderDate,
|
||||
ExpectedDeliveryDate = o.ExpectedDeliveryDate,
|
||||
CustomerId = o.CustomerId,
|
||||
CustomerName = o.Customer!.RagioneSociale,
|
||||
Status = o.Status,
|
||||
Notes = o.Notes,
|
||||
TotalNet = o.TotalNet,
|
||||
TotalTax = o.TotalTax,
|
||||
TotalGross = o.TotalGross,
|
||||
CreatedAt = o.CreatedAt,
|
||||
UpdatedAt = o.UpdatedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<SalesOrderDto?> GetByIdAsync(int id)
|
||||
{
|
||||
var order = await _db.SalesOrders
|
||||
.AsNoTracking()
|
||||
.Include(o => o.Customer)
|
||||
.Include(o => o.Lines)
|
||||
.ThenInclude(l => l.WarehouseArticle)
|
||||
.FirstOrDefaultAsync(o => o.Id == id);
|
||||
|
||||
if (order == null) return null;
|
||||
|
||||
return new SalesOrderDto
|
||||
{
|
||||
Id = order.Id,
|
||||
OrderNumber = order.OrderNumber,
|
||||
OrderDate = order.OrderDate,
|
||||
ExpectedDeliveryDate = order.ExpectedDeliveryDate,
|
||||
CustomerId = order.CustomerId,
|
||||
CustomerName = order.Customer!.RagioneSociale,
|
||||
Status = order.Status,
|
||||
Notes = order.Notes,
|
||||
TotalNet = order.TotalNet,
|
||||
TotalTax = order.TotalTax,
|
||||
TotalGross = order.TotalGross,
|
||||
CreatedAt = order.CreatedAt,
|
||||
UpdatedAt = order.UpdatedAt,
|
||||
Lines = order.Lines.Select(l => new SalesOrderLineDto
|
||||
{
|
||||
Id = l.Id,
|
||||
SalesOrderId = l.SalesOrderId,
|
||||
WarehouseArticleId = l.WarehouseArticleId,
|
||||
ArticleCode = l.WarehouseArticle!.Code,
|
||||
ArticleDescription = l.WarehouseArticle.Description,
|
||||
Description = l.Description,
|
||||
Quantity = l.Quantity,
|
||||
ShippedQuantity = l.ShippedQuantity,
|
||||
UnitPrice = l.UnitPrice,
|
||||
TaxRate = l.TaxRate,
|
||||
DiscountPercent = l.DiscountPercent,
|
||||
LineTotal = l.LineTotal
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<SalesOrderDto> CreateAsync(CreateSalesOrderDto dto)
|
||||
{
|
||||
var code = await _autoCodeService.GenerateNextCodeAsync("sales_order");
|
||||
if (string.IsNullOrEmpty(code))
|
||||
{
|
||||
code = $"ODV{DateTime.Now:yyyy}-{Guid.NewGuid().ToString().Substring(0, 5).ToUpper()}";
|
||||
}
|
||||
|
||||
var order = new SalesOrder
|
||||
{
|
||||
OrderNumber = code,
|
||||
OrderDate = dto.OrderDate,
|
||||
ExpectedDeliveryDate = dto.ExpectedDeliveryDate,
|
||||
CustomerId = dto.CustomerId,
|
||||
Notes = dto.Notes,
|
||||
Status = SalesOrderStatus.Draft
|
||||
};
|
||||
|
||||
foreach (var lineDto in dto.Lines)
|
||||
{
|
||||
var line = new SalesOrderLine
|
||||
{
|
||||
WarehouseArticleId = lineDto.WarehouseArticleId,
|
||||
Description = lineDto.Description ?? string.Empty,
|
||||
Quantity = lineDto.Quantity,
|
||||
UnitPrice = lineDto.UnitPrice,
|
||||
TaxRate = lineDto.TaxRate,
|
||||
DiscountPercent = lineDto.DiscountPercent
|
||||
};
|
||||
|
||||
// If description is empty, fetch from article
|
||||
if (string.IsNullOrEmpty(line.Description))
|
||||
{
|
||||
var article = await _db.WarehouseArticles.FindAsync(line.WarehouseArticleId);
|
||||
if (article != null) line.Description = article.Description;
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
var netPrice = line.UnitPrice * (1 - line.DiscountPercent / 100);
|
||||
line.LineTotal = Math.Round(netPrice * line.Quantity, 2);
|
||||
|
||||
order.Lines.Add(line);
|
||||
}
|
||||
|
||||
CalculateOrderTotals(order);
|
||||
|
||||
_db.SalesOrders.Add(order);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
return await GetByIdAsync(order.Id) ?? throw new InvalidOperationException("Failed to retrieve created order");
|
||||
}
|
||||
|
||||
public async Task<SalesOrderDto?> UpdateAsync(int id, UpdateSalesOrderDto dto)
|
||||
{
|
||||
var order = await _db.SalesOrders
|
||||
.Include(o => o.Lines)
|
||||
.FirstOrDefaultAsync(o => o.Id == id);
|
||||
|
||||
if (order == null) return null;
|
||||
if (order.Status != SalesOrderStatus.Draft)
|
||||
throw new InvalidOperationException("Solo gli ordini in bozza possono essere modificati");
|
||||
|
||||
order.OrderDate = dto.OrderDate;
|
||||
order.ExpectedDeliveryDate = dto.ExpectedDeliveryDate;
|
||||
order.Notes = dto.Notes;
|
||||
order.UpdatedAt = DateTime.Now;
|
||||
|
||||
// Update lines
|
||||
foreach (var lineDto in dto.Lines)
|
||||
{
|
||||
if (lineDto.IsDeleted)
|
||||
{
|
||||
if (lineDto.Id.HasValue)
|
||||
{
|
||||
var lineToDelete = order.Lines.FirstOrDefault(l => l.Id == lineDto.Id.Value);
|
||||
if (lineToDelete != null) order.Lines.Remove(lineToDelete);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
SalesOrderLine line;
|
||||
if (lineDto.Id.HasValue)
|
||||
{
|
||||
line = order.Lines.FirstOrDefault(l => l.Id == lineDto.Id.Value)
|
||||
?? throw new KeyNotFoundException($"Line {lineDto.Id} not found");
|
||||
}
|
||||
else
|
||||
{
|
||||
line = new SalesOrderLine();
|
||||
order.Lines.Add(line);
|
||||
}
|
||||
|
||||
line.WarehouseArticleId = lineDto.WarehouseArticleId;
|
||||
line.Description = lineDto.Description ?? string.Empty;
|
||||
line.Quantity = lineDto.Quantity;
|
||||
line.UnitPrice = lineDto.UnitPrice;
|
||||
line.TaxRate = lineDto.TaxRate;
|
||||
line.DiscountPercent = lineDto.DiscountPercent;
|
||||
|
||||
if (string.IsNullOrEmpty(line.Description))
|
||||
{
|
||||
var article = await _db.WarehouseArticles.FindAsync(line.WarehouseArticleId);
|
||||
if (article != null) line.Description = article.Description;
|
||||
}
|
||||
|
||||
var netPrice = line.UnitPrice * (1 - line.DiscountPercent / 100);
|
||||
line.LineTotal = Math.Round(netPrice * line.Quantity, 2);
|
||||
}
|
||||
|
||||
CalculateOrderTotals(order);
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return await GetByIdAsync(id);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(int id)
|
||||
{
|
||||
var order = await _db.SalesOrders.FindAsync(id);
|
||||
if (order == null) return false;
|
||||
if (order.Status != SalesOrderStatus.Draft && order.Status != SalesOrderStatus.Cancelled)
|
||||
throw new InvalidOperationException("Solo gli ordini in bozza o annullati possono essere eliminati");
|
||||
|
||||
_db.SalesOrders.Remove(order);
|
||||
await _db.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<SalesOrderDto> ConfirmOrderAsync(int id)
|
||||
{
|
||||
var order = await _db.SalesOrders.FindAsync(id);
|
||||
if (order == null) throw new KeyNotFoundException("Order not found");
|
||||
if (order.Status != SalesOrderStatus.Draft) throw new InvalidOperationException("Solo gli ordini in bozza possono essere confermati");
|
||||
|
||||
order.Status = SalesOrderStatus.Confirmed;
|
||||
order.UpdatedAt = DateTime.Now;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return await GetByIdAsync(id) ?? throw new InvalidOperationException("Failed to retrieve order");
|
||||
}
|
||||
|
||||
public async Task<SalesOrderDto> ShipOrderAsync(int id)
|
||||
{
|
||||
var order = await _db.SalesOrders
|
||||
.Include(o => o.Lines)
|
||||
.FirstOrDefaultAsync(o => o.Id == id);
|
||||
|
||||
if (order == null) throw new KeyNotFoundException("Order not found");
|
||||
if (order.Status != SalesOrderStatus.Confirmed && order.Status != SalesOrderStatus.PartiallyShipped)
|
||||
throw new InvalidOperationException("L'ordine deve essere confermato per essere spedito");
|
||||
|
||||
// Create Stock Movement (Outbound)
|
||||
var defaultWarehouse = await _warehouseService.GetDefaultWarehouseAsync();
|
||||
var warehouseId = defaultWarehouse?.Id;
|
||||
|
||||
if (!warehouseId.HasValue) throw new InvalidOperationException("Nessun magazzino di default trovato per la spedizione");
|
||||
|
||||
// Genera numero documento movimento
|
||||
var docNumber = await _warehouseService.GenerateDocumentNumberAsync(MovementType.Outbound);
|
||||
|
||||
// Trova causale di default per vendita
|
||||
var reason = (await _warehouseService.GetMovementReasonsAsync(MovementType.Outbound))
|
||||
.FirstOrDefault(r => r.Code == "VEN" || r.Description.Contains("Vendita"));
|
||||
|
||||
var movement = new StockMovement
|
||||
{
|
||||
DocumentNumber = docNumber,
|
||||
MovementDate = DateTime.Now,
|
||||
Type = MovementType.Outbound,
|
||||
Status = MovementStatus.Draft,
|
||||
SourceWarehouseId = warehouseId,
|
||||
ReasonId = reason?.Id,
|
||||
ExternalReference = order.OrderNumber,
|
||||
Notes = $"Spedizione merce ordine {order.OrderNumber}"
|
||||
};
|
||||
|
||||
movement = await _warehouseService.CreateMovementAsync(movement);
|
||||
|
||||
// Add lines to movement
|
||||
foreach (var line in order.Lines)
|
||||
{
|
||||
var remainingQty = line.Quantity - line.ShippedQuantity;
|
||||
if (remainingQty <= 0) continue;
|
||||
|
||||
// Update shipped quantity on order line
|
||||
line.ShippedQuantity += remainingQty;
|
||||
|
||||
var movementLine = new StockMovementLine
|
||||
{
|
||||
MovementId = movement.Id,
|
||||
ArticleId = line.WarehouseArticleId,
|
||||
Quantity = remainingQty,
|
||||
UnitCost = 0, // Outbound movement cost is calculated by FIFO/LIFO/Avg logic usually, but here we just set 0 or let the system handle it during confirmation
|
||||
LineValue = 0
|
||||
};
|
||||
|
||||
_db.StockMovementLines.Add(movementLine);
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
// Confirm movement to update stock
|
||||
await _warehouseService.ConfirmMovementAsync(movement.Id);
|
||||
|
||||
// Update order status
|
||||
var allShipped = order.Lines.All(l => l.ShippedQuantity >= l.Quantity);
|
||||
order.Status = allShipped ? SalesOrderStatus.Shipped : SalesOrderStatus.PartiallyShipped;
|
||||
order.UpdatedAt = DateTime.Now;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return await GetByIdAsync(id) ?? throw new InvalidOperationException("Failed to retrieve order");
|
||||
}
|
||||
|
||||
private void CalculateOrderTotals(SalesOrder order)
|
||||
{
|
||||
order.TotalNet = 0;
|
||||
order.TotalTax = 0;
|
||||
order.TotalGross = 0;
|
||||
|
||||
foreach (var line in order.Lines)
|
||||
{
|
||||
order.TotalNet += line.LineTotal;
|
||||
var taxAmount = line.LineTotal * (line.TaxRate / 100);
|
||||
order.TotalTax += taxAmount;
|
||||
}
|
||||
|
||||
order.TotalGross = order.TotalNet + order.TotalTax;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@ using Apollinare.API.Services;
|
||||
// Trigger rebuild
|
||||
using Apollinare.API.Services.Reports;
|
||||
using Apollinare.API.Modules.Warehouse.Services;
|
||||
using Apollinare.API.Modules.Purchases.Services;
|
||||
using Apollinare.API.Modules.Sales.Services;
|
||||
using Apollinare.API.Modules.Production.Services;
|
||||
using Apollinare.Infrastructure.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text.Json.Serialization;
|
||||
@@ -27,6 +30,17 @@ builder.Services.AddSingleton<DataNotificationService>();
|
||||
// Warehouse Module Services
|
||||
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
|
||||
|
||||
// Purchases Module Services
|
||||
builder.Services.AddScoped<SupplierService>();
|
||||
builder.Services.AddScoped<PurchaseService>();
|
||||
|
||||
// Sales Module Services
|
||||
builder.Services.AddScoped<SalesService>();
|
||||
|
||||
// Production Module Services
|
||||
builder.Services.AddScoped<IProductionService, ProductionService>();
|
||||
builder.Services.AddScoped<IMrpService, MrpService>();
|
||||
|
||||
// Memory cache for module state
|
||||
builder.Services.AddMemoryCache();
|
||||
|
||||
|
||||
@@ -136,6 +136,12 @@ public class AutoCodeService
|
||||
"articolo" => !await _db.Articoli
|
||||
.AnyAsync(a => a.Codice == code && (excludeId == null || a.Id != excludeId)),
|
||||
|
||||
"supplier" => !await _db.Suppliers
|
||||
.AnyAsync(s => s.Code == code && (excludeId == null || s.Id != excludeId)),
|
||||
|
||||
"purchase_order" => !await _db.PurchaseOrders
|
||||
.AnyAsync(o => o.OrderNumber == code && (excludeId == null || o.Id != excludeId)),
|
||||
|
||||
_ => true // Entità non gestita, assume codice unico
|
||||
};
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -27,4 +27,5 @@ public class Cliente : BaseEntity
|
||||
public bool Attivo { get; set; } = true;
|
||||
|
||||
public ICollection<Evento> Eventi { get; set; } = new List<Evento>();
|
||||
public ICollection<Apollinare.Domain.Entities.Sales.SalesOrder> SalesOrders { get; set; } = new List<Apollinare.Domain.Entities.Sales.SalesOrder>();
|
||||
}
|
||||
|
||||
20
src/Apollinare.Domain/Entities/Production/BillOfMaterials.cs
Normal file
20
src/Apollinare.Domain/Entities/Production/BillOfMaterials.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using Apollinare.Domain.Entities.Warehouse;
|
||||
|
||||
namespace Apollinare.Domain.Entities.Production;
|
||||
|
||||
public class BillOfMaterials : BaseEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
// The article that is produced
|
||||
public int ArticleId { get; set; }
|
||||
public WarehouseArticle Article { get; set; } = null!;
|
||||
|
||||
// Quantity produced by this BOM (usually 1)
|
||||
public decimal Quantity { get; set; } = 1;
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
public ICollection<BillOfMaterialsComponent> Components { get; set; } = new List<BillOfMaterialsComponent>();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using Apollinare.Domain.Entities.Warehouse;
|
||||
|
||||
namespace Apollinare.Domain.Entities.Production;
|
||||
|
||||
public class BillOfMaterialsComponent : BaseEntity
|
||||
{
|
||||
public int BillOfMaterialsId { get; set; }
|
||||
public BillOfMaterials BillOfMaterials { get; set; } = null!;
|
||||
|
||||
// The raw material
|
||||
public int ComponentArticleId { get; set; }
|
||||
public WarehouseArticle ComponentArticle { get; set; } = null!;
|
||||
|
||||
public decimal Quantity { get; set; }
|
||||
|
||||
// Scrap percentage
|
||||
public decimal ScrapPercentage { get; set; }
|
||||
}
|
||||
26
src/Apollinare.Domain/Entities/Production/MrpSuggestion.cs
Normal file
26
src/Apollinare.Domain/Entities/Production/MrpSuggestion.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using Apollinare.Domain.Entities.Warehouse;
|
||||
|
||||
namespace Apollinare.Domain.Entities.Production;
|
||||
|
||||
public class MrpSuggestion : BaseEntity
|
||||
{
|
||||
public DateTime CalculationDate { get; set; }
|
||||
|
||||
public int ArticleId { get; set; }
|
||||
public WarehouseArticle Article { get; set; } = null!;
|
||||
|
||||
public MrpSuggestionType Type { get; set; }
|
||||
|
||||
public decimal Quantity { get; set; }
|
||||
public DateTime SuggestionDate { get; set; }
|
||||
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
public bool IsProcessed { get; set; }
|
||||
}
|
||||
|
||||
public enum MrpSuggestionType
|
||||
{
|
||||
Production = 0,
|
||||
Purchase = 1
|
||||
}
|
||||
40
src/Apollinare.Domain/Entities/Production/ProductionCycle.cs
Normal file
40
src/Apollinare.Domain/Entities/Production/ProductionCycle.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
using Apollinare.Domain.Entities.Warehouse;
|
||||
|
||||
namespace Apollinare.Domain.Entities.Production;
|
||||
|
||||
public class ProductionCycle : BaseEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
|
||||
public int ArticleId { get; set; }
|
||||
public WarehouseArticle Article { get; set; } = null!;
|
||||
|
||||
public bool IsDefault { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
public ICollection<ProductionCyclePhase> Phases { get; set; } = new List<ProductionCyclePhase>();
|
||||
}
|
||||
|
||||
public class ProductionCyclePhase : BaseEntity
|
||||
{
|
||||
public int ProductionCycleId { get; set; }
|
||||
public ProductionCycle ProductionCycle { get; set; } = null!;
|
||||
|
||||
public int Sequence { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
|
||||
public int WorkCenterId { get; set; }
|
||||
public WorkCenter WorkCenter { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Duration in minutes per unit produced
|
||||
/// </summary>
|
||||
public int DurationPerUnitMinutes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Fixed setup time in minutes
|
||||
/// </summary>
|
||||
public int SetupTimeMinutes { get; set; }
|
||||
}
|
||||
39
src/Apollinare.Domain/Entities/Production/ProductionOrder.cs
Normal file
39
src/Apollinare.Domain/Entities/Production/ProductionOrder.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using Apollinare.Domain.Entities.Warehouse;
|
||||
|
||||
namespace Apollinare.Domain.Entities.Production;
|
||||
|
||||
public class ProductionOrder : BaseEntity
|
||||
{
|
||||
public string Code { get; set; } = string.Empty; // Auto-generated
|
||||
|
||||
public int ArticleId { get; set; }
|
||||
public WarehouseArticle Article { get; set; } = null!;
|
||||
|
||||
public decimal Quantity { get; set; }
|
||||
|
||||
public DateTime StartDate { get; set; }
|
||||
public DateTime? EndDate { get; set; }
|
||||
public DateTime DueDate { get; set; }
|
||||
|
||||
public ProductionOrderStatus Status { get; set; } = ProductionOrderStatus.Draft;
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public ICollection<ProductionOrderComponent> Components { get; set; } = new List<ProductionOrderComponent>();
|
||||
public ICollection<ProductionOrderPhase> Phases { get; set; } = new List<ProductionOrderPhase>();
|
||||
|
||||
// Hierarchy
|
||||
public int? ParentProductionOrderId { get; set; }
|
||||
public ProductionOrder? ParentProductionOrder { get; set; }
|
||||
public ICollection<ProductionOrder> ChildProductionOrders { get; set; } = new List<ProductionOrder>();
|
||||
}
|
||||
|
||||
public enum ProductionOrderStatus
|
||||
{
|
||||
Draft = 0,
|
||||
Planned = 1,
|
||||
Released = 2,
|
||||
InProgress = 3,
|
||||
Completed = 4,
|
||||
Cancelled = 5
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using Apollinare.Domain.Entities.Warehouse;
|
||||
|
||||
namespace Apollinare.Domain.Entities.Production;
|
||||
|
||||
public class ProductionOrderComponent : BaseEntity
|
||||
{
|
||||
public int ProductionOrderId { get; set; }
|
||||
public ProductionOrder ProductionOrder { get; set; } = null!;
|
||||
|
||||
public int ArticleId { get; set; }
|
||||
public WarehouseArticle Article { get; set; } = null!;
|
||||
|
||||
public decimal RequiredQuantity { get; set; }
|
||||
public decimal ConsumedQuantity { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using Apollinare.Domain.Entities.Warehouse;
|
||||
|
||||
namespace Apollinare.Domain.Entities.Production;
|
||||
|
||||
public class ProductionOrderPhase : BaseEntity
|
||||
{
|
||||
public int ProductionOrderId { get; set; }
|
||||
public ProductionOrder ProductionOrder { get; set; } = null!;
|
||||
|
||||
public int Sequence { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public int WorkCenterId { get; set; }
|
||||
public WorkCenter WorkCenter { get; set; } = null!;
|
||||
|
||||
public ProductionPhaseStatus Status { get; set; } = ProductionPhaseStatus.Pending;
|
||||
|
||||
public DateTime? StartDate { get; set; }
|
||||
public DateTime? EndDate { get; set; }
|
||||
|
||||
public decimal QuantityCompleted { get; set; }
|
||||
public decimal QuantityScrapped { get; set; }
|
||||
|
||||
public int EstimatedDurationMinutes { get; set; }
|
||||
public int ActualDurationMinutes { get; set; }
|
||||
}
|
||||
|
||||
public enum ProductionPhaseStatus
|
||||
{
|
||||
Pending = 0,
|
||||
InProgress = 1,
|
||||
Completed = 2,
|
||||
Paused = 3
|
||||
}
|
||||
12
src/Apollinare.Domain/Entities/Production/WorkCenter.cs
Normal file
12
src/Apollinare.Domain/Entities/Production/WorkCenter.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using Apollinare.Domain.Entities.Warehouse;
|
||||
|
||||
namespace Apollinare.Domain.Entities.Production;
|
||||
|
||||
public class WorkCenter : BaseEntity
|
||||
{
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public decimal CostPerHour { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
95
src/Apollinare.Domain/Entities/Purchases/PurchaseOrder.cs
Normal file
95
src/Apollinare.Domain/Entities/Purchases/PurchaseOrder.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Apollinare.Domain.Entities;
|
||||
using Apollinare.Domain.Entities.Warehouse;
|
||||
|
||||
namespace Apollinare.Domain.Entities.Purchases;
|
||||
|
||||
/// <summary>
|
||||
/// Ordine di acquisto a fornitore
|
||||
/// </summary>
|
||||
public class PurchaseOrder : BaseEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Numero ordine (generato automaticamente)
|
||||
/// </summary>
|
||||
public string OrderNumber { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Data ordine
|
||||
/// </summary>
|
||||
public DateTime OrderDate { get; set; } = DateTime.Now;
|
||||
|
||||
/// <summary>
|
||||
/// Data consegna prevista
|
||||
/// </summary>
|
||||
public DateTime? ExpectedDeliveryDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ID Fornitore
|
||||
/// </summary>
|
||||
public int SupplierId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Stato dell'ordine
|
||||
/// </summary>
|
||||
public PurchaseOrderStatus Status { get; set; } = PurchaseOrderStatus.Draft;
|
||||
|
||||
/// <summary>
|
||||
/// ID Magazzino di destinazione (opzionale, se null usa il default)
|
||||
/// </summary>
|
||||
public int? DestinationWarehouseId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Note interne
|
||||
/// </summary>
|
||||
public string? Notes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Totale imponibile (calcolato)
|
||||
/// </summary>
|
||||
public decimal TotalNet { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Totale tasse (calcolato)
|
||||
/// </summary>
|
||||
public decimal TotalTax { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Totale lordo (calcolato)
|
||||
/// </summary>
|
||||
public decimal TotalGross { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
public Supplier? Supplier { get; set; }
|
||||
public WarehouseLocation? DestinationWarehouse { get; set; }
|
||||
public ICollection<PurchaseOrderLine> Lines { get; set; } = new List<PurchaseOrderLine>();
|
||||
}
|
||||
|
||||
public enum PurchaseOrderStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Bozza
|
||||
/// </summary>
|
||||
Draft = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Confermato/Inviato al fornitore
|
||||
/// </summary>
|
||||
Confirmed = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Ricevuto parzialmente
|
||||
/// </summary>
|
||||
PartiallyReceived = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Ricevuto completamente
|
||||
/// </summary>
|
||||
Received = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Annullato
|
||||
/// </summary>
|
||||
Cancelled = 4
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Apollinare.Domain.Entities;
|
||||
using Apollinare.Domain.Entities.Warehouse;
|
||||
|
||||
namespace Apollinare.Domain.Entities.Purchases;
|
||||
|
||||
/// <summary>
|
||||
/// Riga ordine di acquisto
|
||||
/// </summary>
|
||||
public class PurchaseOrderLine : BaseEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// ID Ordine di acquisto
|
||||
/// </summary>
|
||||
public int PurchaseOrderId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ID Articolo di magazzino
|
||||
/// </summary>
|
||||
public int WarehouseArticleId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Descrizione (default da articolo, ma modificabile)
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Quantità ordinata
|
||||
/// </summary>
|
||||
public decimal Quantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Quantità ricevuta
|
||||
/// </summary>
|
||||
public decimal ReceivedQuantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Prezzo unitario
|
||||
/// </summary>
|
||||
public decimal UnitPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Aliquota IVA (percentuale)
|
||||
/// </summary>
|
||||
public decimal TaxRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Sconto (percentuale)
|
||||
/// </summary>
|
||||
public decimal DiscountPercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Totale riga (netto)
|
||||
/// </summary>
|
||||
public decimal LineTotal { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
public PurchaseOrder? PurchaseOrder { get; set; }
|
||||
public WarehouseArticle? WarehouseArticle { get; set; }
|
||||
}
|
||||
93
src/Apollinare.Domain/Entities/Purchases/Supplier.cs
Normal file
93
src/Apollinare.Domain/Entities/Purchases/Supplier.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
using System.Collections.Generic;
|
||||
using Apollinare.Domain.Entities;
|
||||
|
||||
namespace Apollinare.Domain.Entities.Purchases;
|
||||
|
||||
/// <summary>
|
||||
/// Fornitore di beni o servizi
|
||||
/// </summary>
|
||||
public class Supplier : BaseEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Codice fornitore (generato automaticamente o manuale)
|
||||
/// </summary>
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Ragione sociale o nome
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Partita IVA
|
||||
/// </summary>
|
||||
public string? VatNumber { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Codice Fiscale
|
||||
/// </summary>
|
||||
public string? FiscalCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Indirizzo
|
||||
/// </summary>
|
||||
public string? Address { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Città
|
||||
/// </summary>
|
||||
public string? City { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Provincia
|
||||
/// </summary>
|
||||
public string? Province { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// CAP
|
||||
/// </summary>
|
||||
public string? ZipCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Nazione
|
||||
/// </summary>
|
||||
public string? Country { get; set; } = "Italia";
|
||||
|
||||
/// <summary>
|
||||
/// Email principale
|
||||
/// </summary>
|
||||
public string? Email { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// PEC
|
||||
/// </summary>
|
||||
public string? Pec { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Telefono
|
||||
/// </summary>
|
||||
public string? Phone { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Sito web
|
||||
/// </summary>
|
||||
public string? Website { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Termini di pagamento (descrizione testuale o riferimento a tabella pagamenti)
|
||||
/// </summary>
|
||||
public string? PaymentTerms { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Note interne
|
||||
/// </summary>
|
||||
public string? Notes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Se attivo, il fornitore può essere utilizzato
|
||||
/// </summary>
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
// Navigation properties
|
||||
public ICollection<PurchaseOrder> PurchaseOrders { get; set; } = new List<PurchaseOrder>();
|
||||
}
|
||||
94
src/Apollinare.Domain/Entities/Sales/SalesOrder.cs
Normal file
94
src/Apollinare.Domain/Entities/Sales/SalesOrder.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Apollinare.Domain.Entities;
|
||||
using Apollinare.Domain.Entities.Warehouse;
|
||||
|
||||
namespace Apollinare.Domain.Entities.Sales;
|
||||
|
||||
/// <summary>
|
||||
/// Ordine di vendita a cliente
|
||||
/// </summary>
|
||||
public class SalesOrder : BaseEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// Numero ordine (generato automaticamente)
|
||||
/// </summary>
|
||||
public string OrderNumber { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Data ordine
|
||||
/// </summary>
|
||||
public DateTime OrderDate { get; set; } = DateTime.Now;
|
||||
|
||||
/// <summary>
|
||||
/// Data consegna prevista
|
||||
/// </summary>
|
||||
public DateTime? ExpectedDeliveryDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ID Cliente
|
||||
/// </summary>
|
||||
public int CustomerId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Stato dell'ordine
|
||||
/// </summary>
|
||||
public SalesOrderStatus Status { get; set; } = SalesOrderStatus.Draft;
|
||||
|
||||
/// <summary>
|
||||
/// Note interne
|
||||
/// </summary>
|
||||
public string? Notes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Totale imponibile (calcolato)
|
||||
/// </summary>
|
||||
public decimal TotalNet { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Totale tasse (calcolato)
|
||||
/// </summary>
|
||||
public decimal TotalTax { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Totale lordo (calcolato)
|
||||
/// </summary>
|
||||
public decimal TotalGross { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
public Cliente? Customer { get; set; }
|
||||
public ICollection<SalesOrderLine> Lines { get; set; } = new List<SalesOrderLine>();
|
||||
}
|
||||
|
||||
public enum SalesOrderStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Bozza
|
||||
/// </summary>
|
||||
Draft = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Confermato
|
||||
/// </summary>
|
||||
Confirmed = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Spedito parzialmente
|
||||
/// </summary>
|
||||
PartiallyShipped = 2,
|
||||
|
||||
/// <summary>
|
||||
/// Spedito completamente
|
||||
/// </summary>
|
||||
Shipped = 3,
|
||||
|
||||
/// <summary>
|
||||
/// Fatturato
|
||||
/// </summary>
|
||||
Invoiced = 4,
|
||||
|
||||
/// <summary>
|
||||
/// Annullato
|
||||
/// </summary>
|
||||
Cancelled = 5
|
||||
}
|
||||
59
src/Apollinare.Domain/Entities/Sales/SalesOrderLine.cs
Normal file
59
src/Apollinare.Domain/Entities/Sales/SalesOrderLine.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using Apollinare.Domain.Entities;
|
||||
using Apollinare.Domain.Entities.Warehouse;
|
||||
|
||||
namespace Apollinare.Domain.Entities.Sales;
|
||||
|
||||
/// <summary>
|
||||
/// Riga ordine di vendita
|
||||
/// </summary>
|
||||
public class SalesOrderLine : BaseEntity
|
||||
{
|
||||
/// <summary>
|
||||
/// ID Ordine di vendita
|
||||
/// </summary>
|
||||
public int SalesOrderId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// ID Articolo di magazzino
|
||||
/// </summary>
|
||||
public int WarehouseArticleId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Descrizione (default da articolo, ma modificabile)
|
||||
/// </summary>
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Quantità ordinata
|
||||
/// </summary>
|
||||
public decimal Quantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Quantità spedita
|
||||
/// </summary>
|
||||
public decimal ShippedQuantity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Prezzo unitario
|
||||
/// </summary>
|
||||
public decimal UnitPrice { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Aliquota IVA (percentuale)
|
||||
/// </summary>
|
||||
public decimal TaxRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Sconto (percentuale)
|
||||
/// </summary>
|
||||
public decimal DiscountPercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Totale riga (netto)
|
||||
/// </summary>
|
||||
public decimal LineTotal { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
public SalesOrder? SalesOrder { get; set; }
|
||||
public WarehouseArticle? WarehouseArticle { get; set; }
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
using Apollinare.Domain.Entities;
|
||||
using Apollinare.Domain.Entities.Warehouse;
|
||||
using Apollinare.Domain.Entities.Purchases;
|
||||
using Apollinare.Domain.Entities.Sales;
|
||||
using Apollinare.Domain.Entities.Production;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Apollinare.Infrastructure.Data;
|
||||
@@ -63,6 +66,26 @@ public class AppollinareDbContext : DbContext
|
||||
public DbSet<InventoryCount> InventoryCounts => Set<InventoryCount>();
|
||||
public DbSet<InventoryCountLine> InventoryCountLines => Set<InventoryCountLine>();
|
||||
|
||||
// Purchases module entities
|
||||
public DbSet<Supplier> Suppliers => Set<Supplier>();
|
||||
public DbSet<PurchaseOrder> PurchaseOrders => Set<PurchaseOrder>();
|
||||
public DbSet<PurchaseOrderLine> PurchaseOrderLines => Set<PurchaseOrderLine>();
|
||||
|
||||
// Sales module entities
|
||||
public DbSet<SalesOrder> SalesOrders => Set<SalesOrder>();
|
||||
public DbSet<SalesOrderLine> SalesOrderLines => Set<SalesOrderLine>();
|
||||
|
||||
// Production module entities
|
||||
public DbSet<BillOfMaterials> BillOfMaterials => Set<BillOfMaterials>();
|
||||
public DbSet<BillOfMaterialsComponent> BillOfMaterialsComponents => Set<BillOfMaterialsComponent>();
|
||||
public DbSet<ProductionOrder> ProductionOrders => Set<ProductionOrder>();
|
||||
public DbSet<ProductionOrderComponent> ProductionOrderComponents => Set<ProductionOrderComponent>();
|
||||
public DbSet<WorkCenter> WorkCenters => Set<WorkCenter>();
|
||||
public DbSet<ProductionCycle> ProductionCycles => Set<ProductionCycle>();
|
||||
public DbSet<ProductionCyclePhase> ProductionCyclePhases => Set<ProductionCyclePhase>();
|
||||
public DbSet<ProductionOrderPhase> ProductionOrderPhases => Set<ProductionOrderPhase>();
|
||||
public DbSet<MrpSuggestion> MrpSuggestions => Set<MrpSuggestion>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
@@ -627,5 +650,272 @@ public class AppollinareDbContext : DbContext
|
||||
.HasForeignKey(e => e.BatchId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// ===============================================
|
||||
// PURCHASES MODULE ENTITIES
|
||||
// ===============================================
|
||||
|
||||
// Supplier
|
||||
modelBuilder.Entity<Supplier>(entity =>
|
||||
{
|
||||
entity.ToTable("Suppliers");
|
||||
entity.HasIndex(e => e.Code).IsUnique();
|
||||
entity.HasIndex(e => e.Name);
|
||||
entity.HasIndex(e => e.VatNumber);
|
||||
entity.HasIndex(e => e.IsActive);
|
||||
});
|
||||
|
||||
// PurchaseOrder
|
||||
modelBuilder.Entity<PurchaseOrder>(entity =>
|
||||
{
|
||||
entity.ToTable("PurchaseOrders");
|
||||
entity.HasIndex(e => e.OrderNumber).IsUnique();
|
||||
entity.HasIndex(e => e.OrderDate);
|
||||
entity.HasIndex(e => e.SupplierId);
|
||||
entity.HasIndex(e => e.Status);
|
||||
|
||||
entity.Property(e => e.TotalNet).HasPrecision(18, 4);
|
||||
entity.Property(e => e.TotalTax).HasPrecision(18, 4);
|
||||
entity.Property(e => e.TotalGross).HasPrecision(18, 4);
|
||||
|
||||
entity.HasOne(e => e.Supplier)
|
||||
.WithMany(s => s.PurchaseOrders)
|
||||
.HasForeignKey(e => e.SupplierId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
entity.HasOne(e => e.DestinationWarehouse)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.DestinationWarehouseId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// PurchaseOrderLine
|
||||
modelBuilder.Entity<PurchaseOrderLine>(entity =>
|
||||
{
|
||||
entity.ToTable("PurchaseOrderLines");
|
||||
entity.HasIndex(e => e.PurchaseOrderId);
|
||||
entity.HasIndex(e => e.WarehouseArticleId);
|
||||
|
||||
entity.Property(e => e.Quantity).HasPrecision(18, 4);
|
||||
entity.Property(e => e.ReceivedQuantity).HasPrecision(18, 4);
|
||||
entity.Property(e => e.UnitPrice).HasPrecision(18, 4);
|
||||
entity.Property(e => e.TaxRate).HasPrecision(18, 2);
|
||||
entity.Property(e => e.DiscountPercent).HasPrecision(18, 2);
|
||||
entity.Property(e => e.LineTotal).HasPrecision(18, 4);
|
||||
|
||||
entity.HasOne(e => e.PurchaseOrder)
|
||||
.WithMany(o => o.Lines)
|
||||
.HasForeignKey(e => e.PurchaseOrderId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(e => e.WarehouseArticle)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.WarehouseArticleId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
// ===============================================
|
||||
// SALES MODULE ENTITIES
|
||||
// ===============================================
|
||||
|
||||
// SalesOrder
|
||||
modelBuilder.Entity<SalesOrder>(entity =>
|
||||
{
|
||||
entity.ToTable("SalesOrders");
|
||||
entity.HasIndex(e => e.OrderNumber).IsUnique();
|
||||
entity.HasIndex(e => e.OrderDate);
|
||||
entity.HasIndex(e => e.CustomerId);
|
||||
entity.HasIndex(e => e.Status);
|
||||
|
||||
entity.Property(e => e.TotalNet).HasPrecision(18, 4);
|
||||
entity.Property(e => e.TotalTax).HasPrecision(18, 4);
|
||||
entity.Property(e => e.TotalGross).HasPrecision(18, 4);
|
||||
|
||||
entity.HasOne(e => e.Customer)
|
||||
.WithMany(c => c.SalesOrders)
|
||||
.HasForeignKey(e => e.CustomerId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
// SalesOrderLine
|
||||
modelBuilder.Entity<SalesOrderLine>(entity =>
|
||||
{
|
||||
entity.ToTable("SalesOrderLines");
|
||||
entity.HasIndex(e => e.SalesOrderId);
|
||||
entity.HasIndex(e => e.WarehouseArticleId);
|
||||
|
||||
entity.Property(e => e.Quantity).HasPrecision(18, 4);
|
||||
entity.Property(e => e.ShippedQuantity).HasPrecision(18, 4);
|
||||
entity.Property(e => e.UnitPrice).HasPrecision(18, 4);
|
||||
entity.Property(e => e.TaxRate).HasPrecision(18, 2);
|
||||
entity.Property(e => e.DiscountPercent).HasPrecision(18, 2);
|
||||
entity.Property(e => e.LineTotal).HasPrecision(18, 4);
|
||||
|
||||
entity.HasOne(e => e.SalesOrder)
|
||||
.WithMany(o => o.Lines)
|
||||
.HasForeignKey(e => e.SalesOrderId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(e => e.WarehouseArticle)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.WarehouseArticleId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
// ===============================================
|
||||
// PRODUCTION MODULE ENTITIES
|
||||
// ===============================================
|
||||
|
||||
// BillOfMaterials
|
||||
modelBuilder.Entity<BillOfMaterials>(entity =>
|
||||
{
|
||||
entity.ToTable("BillOfMaterials");
|
||||
entity.HasIndex(e => e.ArticleId);
|
||||
entity.HasIndex(e => e.IsActive);
|
||||
|
||||
entity.Property(e => e.Quantity).HasPrecision(18, 4);
|
||||
|
||||
entity.HasOne(e => e.Article)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.ArticleId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
// BillOfMaterialsComponent
|
||||
modelBuilder.Entity<BillOfMaterialsComponent>(entity =>
|
||||
{
|
||||
entity.ToTable("BillOfMaterialsComponents");
|
||||
entity.HasIndex(e => e.BillOfMaterialsId);
|
||||
entity.HasIndex(e => e.ComponentArticleId);
|
||||
|
||||
entity.Property(e => e.Quantity).HasPrecision(18, 4);
|
||||
entity.Property(e => e.ScrapPercentage).HasPrecision(18, 2);
|
||||
|
||||
entity.HasOne(e => e.BillOfMaterials)
|
||||
.WithMany(b => b.Components)
|
||||
.HasForeignKey(e => e.BillOfMaterialsId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(e => e.ComponentArticle)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.ComponentArticleId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
// ProductionOrder
|
||||
modelBuilder.Entity<ProductionOrder>(entity =>
|
||||
{
|
||||
entity.ToTable("ProductionOrders");
|
||||
entity.HasIndex(e => e.Code).IsUnique();
|
||||
entity.HasIndex(e => e.ArticleId);
|
||||
entity.HasIndex(e => e.Status);
|
||||
entity.HasIndex(e => e.StartDate);
|
||||
|
||||
entity.Property(e => e.Quantity).HasPrecision(18, 4);
|
||||
|
||||
entity.HasOne(e => e.Article)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.ArticleId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
// ProductionOrderComponent
|
||||
modelBuilder.Entity<ProductionOrderComponent>(entity =>
|
||||
{
|
||||
entity.ToTable("ProductionOrderComponents");
|
||||
entity.HasIndex(e => e.ProductionOrderId);
|
||||
entity.HasIndex(e => e.ArticleId);
|
||||
|
||||
entity.Property(e => e.RequiredQuantity).HasPrecision(18, 4);
|
||||
entity.Property(e => e.ConsumedQuantity).HasPrecision(18, 4);
|
||||
|
||||
entity.HasOne(e => e.ProductionOrder)
|
||||
.WithMany(o => o.Components)
|
||||
.HasForeignKey(e => e.ProductionOrderId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(e => e.Article)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.ArticleId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
// WorkCenter
|
||||
modelBuilder.Entity<WorkCenter>(entity =>
|
||||
{
|
||||
entity.ToTable("WorkCenters");
|
||||
entity.HasIndex(e => e.Code).IsUnique();
|
||||
entity.HasIndex(e => e.IsActive);
|
||||
entity.Property(e => e.CostPerHour).HasPrecision(18, 4);
|
||||
});
|
||||
|
||||
// ProductionCycle
|
||||
modelBuilder.Entity<ProductionCycle>(entity =>
|
||||
{
|
||||
entity.ToTable("ProductionCycles");
|
||||
entity.HasIndex(e => e.ArticleId);
|
||||
entity.HasIndex(e => e.IsActive);
|
||||
|
||||
entity.HasOne(e => e.Article)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.ArticleId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
// ProductionCyclePhase
|
||||
modelBuilder.Entity<ProductionCyclePhase>(entity =>
|
||||
{
|
||||
entity.ToTable("ProductionCyclePhases");
|
||||
entity.HasIndex(e => e.ProductionCycleId);
|
||||
entity.HasIndex(e => e.WorkCenterId);
|
||||
|
||||
entity.HasOne(e => e.ProductionCycle)
|
||||
.WithMany(c => c.Phases)
|
||||
.HasForeignKey(e => e.ProductionCycleId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(e => e.WorkCenter)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.WorkCenterId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
// ProductionOrderPhase
|
||||
modelBuilder.Entity<ProductionOrderPhase>(entity =>
|
||||
{
|
||||
entity.ToTable("ProductionOrderPhases");
|
||||
entity.HasIndex(e => e.ProductionOrderId);
|
||||
entity.HasIndex(e => e.WorkCenterId);
|
||||
entity.HasIndex(e => e.Status);
|
||||
|
||||
entity.Property(e => e.QuantityCompleted).HasPrecision(18, 4);
|
||||
entity.Property(e => e.QuantityScrapped).HasPrecision(18, 4);
|
||||
|
||||
entity.HasOne(e => e.ProductionOrder)
|
||||
.WithMany(o => o.Phases)
|
||||
.HasForeignKey(e => e.ProductionOrderId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(e => e.WorkCenter)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.WorkCenterId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
// MrpSuggestion
|
||||
modelBuilder.Entity<MrpSuggestion>(entity =>
|
||||
{
|
||||
entity.ToTable("MrpSuggestions");
|
||||
entity.HasIndex(e => e.ArticleId);
|
||||
entity.HasIndex(e => e.CalculationDate);
|
||||
entity.HasIndex(e => e.IsProcessed);
|
||||
|
||||
entity.Property(e => e.Quantity).HasPrecision(18, 4);
|
||||
|
||||
entity.HasOne(e => e.Article)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.ArticleId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
3570
src/Apollinare.Infrastructure/Migrations/20251130134233_AddPurchasesModule.Designer.cs
generated
Normal file
3570
src/Apollinare.Infrastructure/Migrations/20251130134233_AddPurchasesModule.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,195 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Apollinare.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPurchasesModule : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Suppliers",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Code = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
VatNumber = table.Column<string>(type: "TEXT", nullable: true),
|
||||
FiscalCode = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Address = table.Column<string>(type: "TEXT", nullable: true),
|
||||
City = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Province = table.Column<string>(type: "TEXT", nullable: true),
|
||||
ZipCode = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Country = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Email = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Pec = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Phone = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Website = table.Column<string>(type: "TEXT", nullable: true),
|
||||
PaymentTerms = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Notes = table.Column<string>(type: "TEXT", nullable: true),
|
||||
IsActive = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Suppliers", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PurchaseOrders",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
OrderNumber = table.Column<string>(type: "TEXT", nullable: false),
|
||||
OrderDate = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
ExpectedDeliveryDate = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
SupplierId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Status = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
DestinationWarehouseId = table.Column<int>(type: "INTEGER", nullable: true),
|
||||
Notes = table.Column<string>(type: "TEXT", nullable: true),
|
||||
TotalNet = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: false),
|
||||
TotalTax = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: false),
|
||||
TotalGross = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PurchaseOrders", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_PurchaseOrders_Suppliers_SupplierId",
|
||||
column: x => x.SupplierId,
|
||||
principalTable: "Suppliers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_PurchaseOrders_WarehouseLocations_DestinationWarehouseId",
|
||||
column: x => x.DestinationWarehouseId,
|
||||
principalTable: "WarehouseLocations",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "PurchaseOrderLines",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
PurchaseOrderId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
WarehouseArticleId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Description = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Quantity = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: false),
|
||||
ReceivedQuantity = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: false),
|
||||
UnitPrice = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: false),
|
||||
TaxRate = table.Column<decimal>(type: "TEXT", precision: 18, scale: 2, nullable: false),
|
||||
DiscountPercent = table.Column<decimal>(type: "TEXT", precision: 18, scale: 2, nullable: false),
|
||||
LineTotal = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_PurchaseOrderLines", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_PurchaseOrderLines_PurchaseOrders_PurchaseOrderId",
|
||||
column: x => x.PurchaseOrderId,
|
||||
principalTable: "PurchaseOrders",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_PurchaseOrderLines_WarehouseArticles_WarehouseArticleId",
|
||||
column: x => x.WarehouseArticleId,
|
||||
principalTable: "WarehouseArticles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PurchaseOrderLines_PurchaseOrderId",
|
||||
table: "PurchaseOrderLines",
|
||||
column: "PurchaseOrderId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PurchaseOrderLines_WarehouseArticleId",
|
||||
table: "PurchaseOrderLines",
|
||||
column: "WarehouseArticleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PurchaseOrders_DestinationWarehouseId",
|
||||
table: "PurchaseOrders",
|
||||
column: "DestinationWarehouseId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PurchaseOrders_OrderDate",
|
||||
table: "PurchaseOrders",
|
||||
column: "OrderDate");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PurchaseOrders_OrderNumber",
|
||||
table: "PurchaseOrders",
|
||||
column: "OrderNumber",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PurchaseOrders_Status",
|
||||
table: "PurchaseOrders",
|
||||
column: "Status");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_PurchaseOrders_SupplierId",
|
||||
table: "PurchaseOrders",
|
||||
column: "SupplierId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Suppliers_Code",
|
||||
table: "Suppliers",
|
||||
column: "Code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Suppliers_IsActive",
|
||||
table: "Suppliers",
|
||||
column: "IsActive");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Suppliers_Name",
|
||||
table: "Suppliers",
|
||||
column: "Name");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Suppliers_VatNumber",
|
||||
table: "Suppliers",
|
||||
column: "VatNumber");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "PurchaseOrderLines");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "PurchaseOrders");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Suppliers");
|
||||
}
|
||||
}
|
||||
}
|
||||
3737
src/Apollinare.Infrastructure/Migrations/20251130143646_AddSalesModule.Designer.cs
generated
Normal file
3737
src/Apollinare.Infrastructure/Migrations/20251130143646_AddSalesModule.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,126 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Apollinare.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSalesModule : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SalesOrders",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
OrderNumber = table.Column<string>(type: "TEXT", nullable: false),
|
||||
OrderDate = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
ExpectedDeliveryDate = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
CustomerId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Status = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Notes = table.Column<string>(type: "TEXT", nullable: true),
|
||||
TotalNet = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: false),
|
||||
TotalTax = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: false),
|
||||
TotalGross = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SalesOrders", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_SalesOrders_Clienti_CustomerId",
|
||||
column: x => x.CustomerId,
|
||||
principalTable: "Clienti",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "SalesOrderLines",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
SalesOrderId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
WarehouseArticleId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Description = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Quantity = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: false),
|
||||
ShippedQuantity = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: false),
|
||||
UnitPrice = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: false),
|
||||
TaxRate = table.Column<decimal>(type: "TEXT", precision: 18, scale: 2, nullable: false),
|
||||
DiscountPercent = table.Column<decimal>(type: "TEXT", precision: 18, scale: 2, nullable: false),
|
||||
LineTotal = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_SalesOrderLines", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_SalesOrderLines_SalesOrders_SalesOrderId",
|
||||
column: x => x.SalesOrderId,
|
||||
principalTable: "SalesOrders",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_SalesOrderLines_WarehouseArticles_WarehouseArticleId",
|
||||
column: x => x.WarehouseArticleId,
|
||||
principalTable: "WarehouseArticles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SalesOrderLines_SalesOrderId",
|
||||
table: "SalesOrderLines",
|
||||
column: "SalesOrderId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SalesOrderLines_WarehouseArticleId",
|
||||
table: "SalesOrderLines",
|
||||
column: "WarehouseArticleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SalesOrders_CustomerId",
|
||||
table: "SalesOrders",
|
||||
column: "CustomerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SalesOrders_OrderDate",
|
||||
table: "SalesOrders",
|
||||
column: "OrderDate");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SalesOrders_OrderNumber",
|
||||
table: "SalesOrders",
|
||||
column: "OrderNumber",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_SalesOrders_Status",
|
||||
table: "SalesOrders",
|
||||
column: "Status");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "SalesOrderLines");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "SalesOrders");
|
||||
}
|
||||
}
|
||||
}
|
||||
4004
src/Apollinare.Infrastructure/Migrations/20251130152222_AddProductionModule.Designer.cs
generated
Normal file
4004
src/Apollinare.Infrastructure/Migrations/20251130152222_AddProductionModule.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,207 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Apollinare.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddProductionModule : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BillOfMaterials",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Description = table.Column<string>(type: "TEXT", nullable: false),
|
||||
ArticleId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Quantity = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: false),
|
||||
IsActive = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_BillOfMaterials", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_BillOfMaterials_WarehouseArticles_ArticleId",
|
||||
column: x => x.ArticleId,
|
||||
principalTable: "WarehouseArticles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ProductionOrders",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Code = table.Column<string>(type: "TEXT", nullable: false),
|
||||
ArticleId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Quantity = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: false),
|
||||
StartDate = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
EndDate = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
DueDate = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
Status = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Notes = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ProductionOrders", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ProductionOrders_WarehouseArticles_ArticleId",
|
||||
column: x => x.ArticleId,
|
||||
principalTable: "WarehouseArticles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BillOfMaterialsComponents",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
BillOfMaterialsId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ComponentArticleId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Quantity = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: false),
|
||||
ScrapPercentage = table.Column<decimal>(type: "TEXT", precision: 18, scale: 2, nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_BillOfMaterialsComponents", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_BillOfMaterialsComponents_BillOfMaterials_BillOfMaterialsId",
|
||||
column: x => x.BillOfMaterialsId,
|
||||
principalTable: "BillOfMaterials",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_BillOfMaterialsComponents_WarehouseArticles_ComponentArticleId",
|
||||
column: x => x.ComponentArticleId,
|
||||
principalTable: "WarehouseArticles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ProductionOrderComponents",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
ProductionOrderId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ArticleId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
RequiredQuantity = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: false),
|
||||
ConsumedQuantity = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ProductionOrderComponents", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ProductionOrderComponents_ProductionOrders_ProductionOrderId",
|
||||
column: x => x.ProductionOrderId,
|
||||
principalTable: "ProductionOrders",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_ProductionOrderComponents_WarehouseArticles_ArticleId",
|
||||
column: x => x.ArticleId,
|
||||
principalTable: "WarehouseArticles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BillOfMaterials_ArticleId",
|
||||
table: "BillOfMaterials",
|
||||
column: "ArticleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BillOfMaterials_IsActive",
|
||||
table: "BillOfMaterials",
|
||||
column: "IsActive");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BillOfMaterialsComponents_BillOfMaterialsId",
|
||||
table: "BillOfMaterialsComponents",
|
||||
column: "BillOfMaterialsId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BillOfMaterialsComponents_ComponentArticleId",
|
||||
table: "BillOfMaterialsComponents",
|
||||
column: "ComponentArticleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProductionOrderComponents_ArticleId",
|
||||
table: "ProductionOrderComponents",
|
||||
column: "ArticleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProductionOrderComponents_ProductionOrderId",
|
||||
table: "ProductionOrderComponents",
|
||||
column: "ProductionOrderId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProductionOrders_ArticleId",
|
||||
table: "ProductionOrders",
|
||||
column: "ArticleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProductionOrders_Code",
|
||||
table: "ProductionOrders",
|
||||
column: "Code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProductionOrders_StartDate",
|
||||
table: "ProductionOrders",
|
||||
column: "StartDate");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProductionOrders_Status",
|
||||
table: "ProductionOrders",
|
||||
column: "Status");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "BillOfMaterialsComponents");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ProductionOrderComponents");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "BillOfMaterials");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ProductionOrders");
|
||||
}
|
||||
}
|
||||
}
|
||||
4341
src/Apollinare.Infrastructure/Migrations/20251130161658_AddAdvancedProduction.Designer.cs
generated
Normal file
4341
src/Apollinare.Infrastructure/Migrations/20251130161658_AddAdvancedProduction.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,251 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Apollinare.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAdvancedProduction : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MrpSuggestions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
CalculationDate = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
ArticleId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Type = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Quantity = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: false),
|
||||
SuggestionDate = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
Reason = table.Column<string>(type: "TEXT", nullable: false),
|
||||
IsProcessed = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MrpSuggestions", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_MrpSuggestions_WarehouseArticles_ArticleId",
|
||||
column: x => x.ArticleId,
|
||||
principalTable: "WarehouseArticles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ProductionCycles",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Description = table.Column<string>(type: "TEXT", nullable: true),
|
||||
ArticleId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
IsDefault = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ProductionCycles", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ProductionCycles_WarehouseArticles_ArticleId",
|
||||
column: x => x.ArticleId,
|
||||
principalTable: "WarehouseArticles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "WorkCenters",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Code = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Description = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CostPerHour = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: false),
|
||||
IsActive = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_WorkCenters", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ProductionCyclePhases",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
ProductionCycleId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Sequence = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Description = table.Column<string>(type: "TEXT", nullable: true),
|
||||
WorkCenterId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
DurationPerUnitMinutes = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
SetupTimeMinutes = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ProductionCyclePhases", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ProductionCyclePhases_ProductionCycles_ProductionCycleId",
|
||||
column: x => x.ProductionCycleId,
|
||||
principalTable: "ProductionCycles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_ProductionCyclePhases_WorkCenters_WorkCenterId",
|
||||
column: x => x.WorkCenterId,
|
||||
principalTable: "WorkCenters",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ProductionOrderPhases",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
ProductionOrderId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Sequence = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
WorkCenterId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Status = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
StartDate = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
EndDate = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
QuantityCompleted = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: false),
|
||||
QuantityScrapped = table.Column<decimal>(type: "TEXT", precision: 18, scale: 4, nullable: false),
|
||||
EstimatedDurationMinutes = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ActualDurationMinutes = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
UpdatedAt = table.Column<DateTime>(type: "TEXT", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CustomFieldsJson = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ProductionOrderPhases", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ProductionOrderPhases_ProductionOrders_ProductionOrderId",
|
||||
column: x => x.ProductionOrderId,
|
||||
principalTable: "ProductionOrders",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_ProductionOrderPhases_WorkCenters_WorkCenterId",
|
||||
column: x => x.WorkCenterId,
|
||||
principalTable: "WorkCenters",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MrpSuggestions_ArticleId",
|
||||
table: "MrpSuggestions",
|
||||
column: "ArticleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MrpSuggestions_CalculationDate",
|
||||
table: "MrpSuggestions",
|
||||
column: "CalculationDate");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MrpSuggestions_IsProcessed",
|
||||
table: "MrpSuggestions",
|
||||
column: "IsProcessed");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProductionCyclePhases_ProductionCycleId",
|
||||
table: "ProductionCyclePhases",
|
||||
column: "ProductionCycleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProductionCyclePhases_WorkCenterId",
|
||||
table: "ProductionCyclePhases",
|
||||
column: "WorkCenterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProductionCycles_ArticleId",
|
||||
table: "ProductionCycles",
|
||||
column: "ArticleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProductionCycles_IsActive",
|
||||
table: "ProductionCycles",
|
||||
column: "IsActive");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProductionOrderPhases_ProductionOrderId",
|
||||
table: "ProductionOrderPhases",
|
||||
column: "ProductionOrderId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProductionOrderPhases_Status",
|
||||
table: "ProductionOrderPhases",
|
||||
column: "Status");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ProductionOrderPhases_WorkCenterId",
|
||||
table: "ProductionOrderPhases",
|
||||
column: "WorkCenterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_WorkCenters_Code",
|
||||
table: "WorkCenters",
|
||||
column: "Code",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_WorkCenters_IsActive",
|
||||
table: "WorkCenters",
|
||||
column: "IsActive");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "MrpSuggestions");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ProductionCyclePhases");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ProductionOrderPhases");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "ProductionCycles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "WorkCenters");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user