-
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
|
## 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:**
|
**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
|
- **NUOVA FEATURE: Supporto Tema Scuro e Multilingua** - COMPLETATO
|
||||||
- **Obiettivo:** Permettere all'utente di cambiare tema (Chiaro/Scuro) e lingua (Italiano/Inglese) con persistenza
|
- **Obiettivo:** Permettere all'utente di cambiare tema (Chiaro/Scuro) e lingua (Italiano/Inglese) con persistenza
|
||||||
- **Frontend implementato:**
|
- **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
|
- In modifica: campo Codice mostra il valore reale, sempre disabled
|
||||||
- Campo "Codice Alternativo" sempre modificabile (opzionale)
|
- 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):**
|
**Lavoro completato nelle sessioni precedenti (30 Novembre 2025):**
|
||||||
|
|
||||||
- **NUOVA FEATURE: Sistema Codici Automatici Configurabili** - COMPLETATO
|
- **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
|
- Backend: Entities, Service, Controllers, API completi
|
||||||
- Manca: Frontend (pagine React per gestione articoli, movimenti, giacenze)
|
- Manca: Frontend (pagine React per gestione articoli, movimenti, giacenze)
|
||||||
2. [x] **Frontend modulo Magazzino** - Pagine React per warehouse (Articoli, Movimenti, Giacenze, Inventario)
|
2. [x] **Frontend modulo Magazzino** - Pagine React per warehouse (Articoli, Movimenti, Giacenze, Inventario)
|
||||||
3. [ ] **Implementare modulo Acquisti (purchases)** - Dipende da Magazzino
|
3. [x] **Implementare modulo Acquisti (purchases)** - COMPLETATO
|
||||||
4. [ ] **Implementare modulo Vendite (sales)** - Dipende da Magazzino
|
4. [x] **Implementare modulo Vendite (sales)** - COMPLETATO
|
||||||
5. [ ] **Implementare modulo Produzione (production)** - Dipende da Magazzino
|
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
|
6. [ ] **Implementare modulo Qualità (quality)** - Indipendente
|
||||||
|
|
||||||
**Report System (completamento):**
|
**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
|
- 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
|
## Project Overview
|
||||||
|
|
||||||
|
|||||||
17
frontend/package-lock.json
generated
17
frontend/package-lock.json
generated
@@ -31,6 +31,7 @@
|
|||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "^3.0.2",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-hook-form": "^7.67.0",
|
||||||
"react-i18next": "^16.3.5",
|
"react-i18next": "^16.3.5",
|
||||||
"react-router-dom": "^7.9.6",
|
"react-router-dom": "^7.9.6",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
@@ -4973,6 +4974,22 @@
|
|||||||
"react": "^19.2.0"
|
"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": {
|
"node_modules/react-i18next": {
|
||||||
"version": "16.3.5",
|
"version": "16.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.3.5.tgz",
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "^3.0.2",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"react-hook-form": "^7.67.0",
|
||||||
"react-i18next": "^16.3.5",
|
"react-i18next": "^16.3.5",
|
||||||
"react-router-dom": "^7.9.6",
|
"react-router-dom": "^7.9.6",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
|
|||||||
@@ -28,7 +28,12 @@
|
|||||||
"optional": "Optional",
|
"optional": "Optional",
|
||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
"preview": "Preview",
|
"preview": "Preview",
|
||||||
"none": "None"
|
"none": "None",
|
||||||
|
"view": "View",
|
||||||
|
"required": "Required",
|
||||||
|
"add": "Add",
|
||||||
|
"active": "Active",
|
||||||
|
"inactive": "Inactive"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
@@ -39,6 +44,9 @@
|
|||||||
"articles": "Articles",
|
"articles": "Articles",
|
||||||
"resources": "Resources",
|
"resources": "Resources",
|
||||||
"warehouse": "Warehouse",
|
"warehouse": "Warehouse",
|
||||||
|
"purchases": "Purchases",
|
||||||
|
"sales": "Sales",
|
||||||
|
"production": "Production",
|
||||||
"reports": "Reports",
|
"reports": "Reports",
|
||||||
"modules": "Modules",
|
"modules": "Modules",
|
||||||
"autoCodes": "Auto Codes",
|
"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",
|
"optional": "Opzionale",
|
||||||
"notes": "Note",
|
"notes": "Note",
|
||||||
"preview": "Anteprima",
|
"preview": "Anteprima",
|
||||||
"none": "Nessuno"
|
"none": "Nessuno",
|
||||||
|
"view": "Dettaglio"
|
||||||
},
|
},
|
||||||
"menu": {
|
"menu": {
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
@@ -39,6 +40,9 @@
|
|||||||
"articles": "Articoli",
|
"articles": "Articoli",
|
||||||
"resources": "Risorse",
|
"resources": "Risorse",
|
||||||
"warehouse": "Magazzino",
|
"warehouse": "Magazzino",
|
||||||
|
"purchases": "Acquisti",
|
||||||
|
"sales": "Vendite",
|
||||||
|
"production": "Produzione",
|
||||||
"reports": "Report",
|
"reports": "Report",
|
||||||
"modules": "Moduli",
|
"modules": "Moduli",
|
||||||
"autoCodes": "Codici Auto",
|
"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": {
|
"warehouse": {
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
"newInbound": "Nuovo Carico",
|
"newInbound": "Nuovo Carico",
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ import ModulePurchasePage from "./pages/ModulePurchasePage";
|
|||||||
import AutoCodesAdminPage from "./pages/AutoCodesAdminPage";
|
import AutoCodesAdminPage from "./pages/AutoCodesAdminPage";
|
||||||
import CustomFieldsAdminPage from "./pages/CustomFieldsAdminPage";
|
import CustomFieldsAdminPage from "./pages/CustomFieldsAdminPage";
|
||||||
import WarehouseRoutes from "./modules/warehouse/routes";
|
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 { ModuleGuard } from "./components/ModuleGuard";
|
||||||
import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates";
|
import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates";
|
||||||
import { CollaborationProvider } from "./contexts/CollaborationContext";
|
import { CollaborationProvider } from "./contexts/CollaborationContext";
|
||||||
@@ -99,6 +102,33 @@ function App() {
|
|||||||
</ModuleGuard>
|
</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>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</RealTimeProvider>
|
</RealTimeProvider>
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ import {
|
|||||||
Extension as ModulesIcon,
|
Extension as ModulesIcon,
|
||||||
Warehouse as WarehouseIcon,
|
Warehouse as WarehouseIcon,
|
||||||
Code as AutoCodeIcon,
|
Code as AutoCodeIcon,
|
||||||
|
ShoppingCart as ShoppingCartIcon,
|
||||||
|
Sell as SellIcon,
|
||||||
|
Factory as ProductionIcon,
|
||||||
} from "@mui/icons-material";
|
} from "@mui/icons-material";
|
||||||
import CollaborationIndicator from "./collaboration/CollaborationIndicator";
|
import CollaborationIndicator from "./collaboration/CollaborationIndicator";
|
||||||
import { useModules } from "../contexts/ModuleContext";
|
import { useModules } from "../contexts/ModuleContext";
|
||||||
@@ -68,6 +71,24 @@ export default function Layout() {
|
|||||||
path: "/warehouse",
|
path: "/warehouse",
|
||||||
moduleCode: "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.reports'), icon: <PrintIcon />, path: "/report-templates" },
|
||||||
{ text: t('menu.modules'), icon: <ModulesIcon />, path: "/modules" },
|
{ text: t('menu.modules'), icon: <ModulesIcon />, path: "/modules" },
|
||||||
{ text: t('menu.autoCodes'), icon: <AutoCodeIcon />, path: "/admin/auto-codes" },
|
{ 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/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
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
|
// Trigger rebuild
|
||||||
using Apollinare.API.Services.Reports;
|
using Apollinare.API.Services.Reports;
|
||||||
using Apollinare.API.Modules.Warehouse.Services;
|
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 Apollinare.Infrastructure.Data;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
@@ -27,6 +30,17 @@ builder.Services.AddSingleton<DataNotificationService>();
|
|||||||
// Warehouse Module Services
|
// Warehouse Module Services
|
||||||
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
|
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
|
// Memory cache for module state
|
||||||
builder.Services.AddMemoryCache();
|
builder.Services.AddMemoryCache();
|
||||||
|
|
||||||
|
|||||||
@@ -136,6 +136,12 @@ public class AutoCodeService
|
|||||||
"articolo" => !await _db.Articoli
|
"articolo" => !await _db.Articoli
|
||||||
.AnyAsync(a => a.Codice == code && (excludeId == null || a.Id != excludeId)),
|
.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
|
_ => 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 bool Attivo { get; set; } = true;
|
||||||
|
|
||||||
public ICollection<Evento> Eventi { get; set; } = new List<Evento>();
|
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;
|
||||||
using Apollinare.Domain.Entities.Warehouse;
|
using Apollinare.Domain.Entities.Warehouse;
|
||||||
|
using Apollinare.Domain.Entities.Purchases;
|
||||||
|
using Apollinare.Domain.Entities.Sales;
|
||||||
|
using Apollinare.Domain.Entities.Production;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Apollinare.Infrastructure.Data;
|
namespace Apollinare.Infrastructure.Data;
|
||||||
@@ -63,6 +66,26 @@ public class AppollinareDbContext : DbContext
|
|||||||
public DbSet<InventoryCount> InventoryCounts => Set<InventoryCount>();
|
public DbSet<InventoryCount> InventoryCounts => Set<InventoryCount>();
|
||||||
public DbSet<InventoryCountLine> InventoryCountLines => Set<InventoryCountLine>();
|
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)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
@@ -627,5 +650,272 @@ public class AppollinareDbContext : DbContext
|
|||||||
.HasForeignKey(e => e.BatchId)
|
.HasForeignKey(e => e.BatchId)
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.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