This commit is contained in:
2025-12-01 10:00:40 +01:00
parent 20b13e962c
commit 8cd4c48e95
91 changed files with 27185 additions and 7 deletions

View File

@@ -54,12 +54,115 @@ XX. **Nome Problema (FIX/IMPLEMENTATO DATA):** - **Problema:** Descrizione breve
## Quick Start - Session Recovery
**Ultima sessione:** 29 Novembre 2025 (pomeriggio)
**Ultima sessione:** 30 Novembre 2025 (Notte)
**Stato progetto:** Migrazione Oracle APEX → .NET + React TypeScript in corso
**Stato progetto:** Modulo Produzione COMPLETATO (incluso MRP ricorsivo e Dashboard). Prossimo step: Modulo Qualità o completamento Report System.
**Lavoro completato nell'ultima sessione:**
- **NUOVA FEATURE: Modulo Produzione (production) - Avanzato** - COMPLETATO
- **Backend implementato:**
- Entities: `WorkCenter`, `ProductionCycle`, `ProductionCyclePhase`, `ProductionOrderPhase`, `MrpSuggestion`
- DTOs: `WorkCenterDto`, `ProductionCycleDto`, `ProductionOrderPhaseDto`, `MrpSuggestionDto` (con Create/Update variants)
- Services: `ProductionService` (esteso), `MrpService`
- Controllers: `WorkCentersController`, `ProductionCyclesController`, `MrpController`
- Migration: `AddAdvancedProduction`
- **Frontend implementato:**
- Pages: `WorkCentersPage`, `ProductionCyclesPage`, `ProductionCycleFormPage`, `MrpPage`
- Components: `ProductionOrderPhases` (gestione avanzamento), `ProductionLayout` (navigazione tab)
- Services: `productionService.ts` (esteso)
- Routing: `/production/work-centers`, `/production/cycles`, `/production/mrp`
- Traduzioni: Aggiunte chiavi per centri lavoro, cicli, fasi e MRP in `translation.json` (IT e EN)
- **Funzionalità:**
- **Centri di Lavoro**: Gestione risorse produttive e costi orari
- **Cicli Produttivi**: Definizione sequenze fasi standard per articolo
- **Ordini di Produzione**:
- Copia automatica fasi dal ciclo default alla creazione ordine
- Gestione avanzamento fasi (Start/Complete) con tempi e quantità
- **MRP (Material Requirements Planning)**:
- Calcolo fabbisogni basato su ordini clienti e scorte minime
- Generazione suggerimenti produzione/acquisto
- Processamento suggerimenti (creazione automatica ordini)
- **FIX: Traduzioni Modulo Produzione** - RISOLTO
- **Problema:** Alcune chiavi di traduzione mancanti o errate nel componente `ProductionOrderPhases`.
- **Soluzione:**
- Aggiornato `ProductionOrderPhases.tsx` per usare le chiavi corrette (`production.order.phases.*`).
- Aggiunte le chiavi mancanti (`durationHelp`, stati fasi) in `it/translation.json` e `en/translation.json`.
- **File modificati:** `ProductionOrderPhases.tsx`, `it/translation.json`, `en/translation.json`.
- **NUOVA FEATURE: Distinta Base Multilivello e MRP Ricorsivo** - COMPLETATO
- **Backend:**
- Aggiornato `MrpService` per calcolare i fabbisogni in modo ricorsivo (infiniti livelli).
- Aggiornato `ProductionService` per supportare la creazione automatica e ricorsiva degli ordini figli per i semilavorati.
- **Frontend:**
- Aggiunta opzione "Crea ordini figli" nel form di creazione Ordine di Produzione.
- Aggiornate traduzioni.
- **Nuova Dashboard Produzione:** Creata pagina con KPI e ordini recenti.
- **Visualizzazione Gerarchia:** Aggiunta colonna "Ordine Padre" nella lista e tab "Ordini Figli" nel dettaglio ordine.
- **NUOVA FEATURE: MRP Configurabile e Distinta Base Multilivello** - COMPLETATO
- **Backend:**
- Aggiornato `MrpService` per supportare configurazione (Safety Stock, Sales Orders, Forecasts).
- Implementata logica MRP ricorsiva con gestione Lead Time e Safety Stock.
- Aggiunto `MrpConfigurationDto`.
- Aggiornato `MrpController` per accettare configurazione.
- **Frontend:**
- Creato `MrpConfigurationDialog` per impostare parametri MRP.
- Aggiornato `MrpPage` per usare il dialog.
- Aggiornato `productionService` per passare la configurazione.
- Aggiunte traduzioni per la configurazione MRP.
- **Test:**
- Verificata creazione articoli e BOM multilivello.
- Verificata esecuzione MRP (backend logica corretta).
- **NUOVA FEATURE: Modulo Vendite (sales)** - COMPLETATO
- **Backend implementato:**
- Entities: `SalesOrder`, `SalesOrderLine`
- DTOs: `SalesOrderDto`, `SalesOrderLineDto` (con Create/Update variants)
- Services: `SalesService` (CRUD, Confirm, Ship logic)
- Controllers: `SalesOrdersController`
- Migration: `AddSalesModule`
- **Frontend implementato:**
- Pages: `SalesOrdersPage`, `SalesOrderFormPage`
- Services: `salesService.ts`
- Routing: `/sales/orders`
- Menu: Aggiunta voce "Vendite" nella sidebar
- Traduzioni: Aggiunte chiavi per ordini in `translation.json` (IT)
- **Funzionalità:**
- Gestione ordini di vendita
- Creazione ordini (Bozza -> Confermato -> Spedito)
- Spedizione merce (Ship)
- Calcolo totali ordine
- **FIX: Traduzioni Modulo Vendite e Menu** - RISOLTO
- **Problema:** Chiavi di traduzione errate in `SalesOrderFormPage` e voci di menu mancanti in Inglese.
- **Soluzione:**
- Allineate chiavi `sales.orders.*` -> `sales.order.*` nel frontend.
- Aggiunta sezione `sales` completa in `en/translation.json`.
- Aggiunte voci menu "Purchases" e "Sales" in `en/translation.json`.
- **File modificati:** `SalesOrderFormPage.tsx`, `en/translation.json`, `it/translation.json`.
- **NUOVA FEATURE: Modulo Acquisti (purchases)** - COMPLETATO
- **Backend implementato:**
- Entities: `Supplier`, `PurchaseOrder`, `PurchaseOrderLine`
- DTOs: `SupplierDto`, `PurchaseOrderDto`, `PurchaseOrderLineDto` (con Create/Update variants)
- Services: `SupplierService`, `PurchaseService` (CRUD, Confirm, Receive logic)
- Controllers: `SuppliersController`, `PurchaseOrdersController`
- Migration: `AddPurchasesModule`
- AutoCode: Integrazione per generazione codici `Supplier` e `PurchaseOrder`
- **Frontend implementato:**
- Pages: `SuppliersPage`, `SupplierFormPage`, `PurchaseOrdersPage`, `PurchaseOrderFormPage`
- Services: `supplierService.ts`, `purchaseService.ts`
- Routing: `/purchases/suppliers`, `/purchases/orders`
- Menu: Aggiunta voce "Acquisti" nella sidebar
- Traduzioni: Aggiunte chiavi per fornitori e ordini in `translation.json` (IT e EN)
- **Funzionalità:**
- Gestione anagrafica fornitori
- Creazione ordini di acquisto (Bozza -> Confermato -> Ricevuto)
- Ricezione merce con generazione automatica movimenti di magazzino (Inbound)
- Calcolo totali ordine (imponibile, IVA, totale)
- **NUOVA FEATURE: Supporto Tema Scuro e Multilingua** - COMPLETATO
- **Obiettivo:** Permettere all'utente di cambiare tema (Chiaro/Scuro) e lingua (Italiano/Inglese) con persistenza
- **Frontend implementato:**
@@ -157,6 +260,17 @@ XX. **Nome Problema (FIX/IMPLEMENTATO DATA):** - **Problema:** Descrizione breve
- In modifica: campo Codice mostra il valore reale, sempre disabled
- Campo "Codice Alternativo" sempre modificabile (opzionale)
- **FIX: API 404 / Pagine Bianche in Dev Mode** - RISOLTO
- **Problema:** Le chiamate API dal frontend fallivano con 404 (o ritornavano HTML) e le pagine rimanevano bianche.
- **Causa:** Mancava la configurazione del proxy in `vite.config.ts` per inoltrare le richieste `/api` al backend (.NET su porta 5000).
- **Soluzione:** Aggiunto proxy per `/api` e `/hubs` verso `http://localhost:5000` in `vite.config.ts`.
- **File modificati:** `frontend/vite.config.ts`
- **FIX: Traduzioni Mancanti e Chiavi Errate** - RISOLTO
- **Problema:** Errori di interfaccia dovuti a chiavi di traduzione mancanti (`common.required`, `common.add`, `common.active`) e percorsi errati in `PurchaseOrderFormPage` e `SuppliersPage`.
- **Soluzione:** Aggiunte chiavi mancanti in `en/translation.json` e corretti i percorsi delle chiavi nei componenti React.
- **File modificati:** `frontend/public/locales/en/translation.json`, `frontend/src/modules/purchases/pages/PurchaseOrderFormPage.tsx`, `frontend/src/modules/purchases/pages/SuppliersPage.tsx`
**Lavoro completato nelle sessioni precedenti (30 Novembre 2025):**
- **NUOVA FEATURE: Sistema Codici Automatici Configurabili** - COMPLETATO
@@ -520,9 +634,12 @@ XX. **Nome Problema (FIX/IMPLEMENTATO DATA):** - **Problema:** Descrizione breve
- Backend: Entities, Service, Controllers, API completi
- Manca: Frontend (pagine React per gestione articoli, movimenti, giacenze)
2. [x] **Frontend modulo Magazzino** - Pagine React per warehouse (Articoli, Movimenti, Giacenze, Inventario)
3. [ ] **Implementare modulo Acquisti (purchases)** - Dipende da Magazzino
4. [ ] **Implementare modulo Vendite (sales)** - Dipende da Magazzino
5. [ ] **Implementare modulo Produzione (production)** - Dipende da Magazzino
3. [x] **Implementare modulo Acquisti (purchases)** - COMPLETATO
4. [x] **Implementare modulo Vendite (sales)** - COMPLETATO
5. [x] **Implementare modulo Produzione (production)** - COMPLETATO
- Backend: Entities, Service, Controllers, API completi
- Frontend: Pagine React per BOM e Ordini, integrazione Magazzino
- Test: Verificato flusso completo (BOM -> Ordine -> Stati -> Completamento)
6. [ ] **Implementare modulo Qualità (quality)** - Indipendente
**Report System (completamento):**
@@ -558,6 +675,30 @@ make check # Verifica prerequisiti installati (dotnet, node, npm)
- Frontend: in dev mode (`make frontend-run`) il hot-reload è automatico per la maggior parte delle modifiche, ma per modifiche strutturali (nuovi file, cambi a tipi, etc.) potrebbe essere necessario riavviare
---
# Development Documentation
## Production Module Implementation
- **Backend**:
- Entities: `BillOfMaterials`, `BillOfMaterialsComponent`, `ProductionOrder`, `ProductionOrderComponent`
- Services: `ProductionService` (implements `IProductionService`)
- Controllers: `BillOfMaterialsController`, `ProductionOrdersController`
- Integration: Registered in `Program.cs`, added to `AppollinareDbContext`
- **Frontend**:
- Types: `frontend/src/modules/production/types/index.ts`
- Services: `frontend/src/modules/production/services/productionService.ts`
- Pages:
- `ProductionOrdersPage` (List)
- `ProductionOrderFormPage` (Create/Edit)
- `BillOfMaterialsPage` (List)
- `BillOfMaterialsFormPage` (Create/Edit)
- Routes: `frontend/src/modules/production/routes.tsx`
- Menu: Added to `Layout.tsx` with `Factory` icon
- Translations: Added `production` section to `it` and `en` locales
## Recent Changes
- Fixed build errors in Purchases and Sales modules (types and unused imports).
- Implemented Production module with full CRUD and status management.
- Integrated Production module with Warehouse module for article selection.
## Project Overview

View File

@@ -31,6 +31,7 @@
"i18next-http-backend": "^3.0.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.67.0",
"react-i18next": "^16.3.5",
"react-router-dom": "^7.9.6",
"uuid": "^13.0.0",
@@ -4973,6 +4974,22 @@
"react": "^19.2.0"
}
},
"node_modules/react-hook-form": {
"version": "7.67.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.67.0.tgz",
"integrity": "sha512-E55EOwKJHHIT/I6J9DmQbCWToAYSw9nN5R57MZw9rMtjh+YQreMDxRLfdjfxQbiJ3/qbg3Z02wGzBX4M+5fMtQ==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/react-hook-form"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19"
}
},
"node_modules/react-i18next": {
"version": "16.3.5",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.3.5.tgz",

View File

@@ -33,6 +33,7 @@
"i18next-http-backend": "^3.0.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.67.0",
"react-i18next": "^16.3.5",
"react-router-dom": "^7.9.6",
"uuid": "^13.0.0",

View File

@@ -28,7 +28,12 @@
"optional": "Optional",
"notes": "Notes",
"preview": "Preview",
"none": "None"
"none": "None",
"view": "View",
"required": "Required",
"add": "Add",
"active": "Active",
"inactive": "Inactive"
},
"menu": {
"dashboard": "Dashboard",
@@ -39,6 +44,9 @@
"articles": "Articles",
"resources": "Resources",
"warehouse": "Warehouse",
"purchases": "Purchases",
"sales": "Sales",
"production": "Production",
"reports": "Reports",
"modules": "Modules",
"autoCodes": "Auto Codes",
@@ -1045,5 +1053,289 @@
}
}
}
},
"purchases": {
"menu": {
"suppliers": "Suppliers",
"orders": "Purchase Orders"
},
"suppliers": {
"title": "Suppliers",
"newSupplier": "New Supplier",
"editSupplier": "Edit Supplier",
"columns": {
"code": "Code",
"name": "Name",
"vatNumber": "VAT Number",
"email": "Email",
"phone": "Phone",
"city": "City",
"status": "Status"
},
"fields": {
"code": "Code",
"name": "Business Name",
"vatNumber": "VAT Number",
"fiscalCode": "Fiscal Code",
"email": "Email",
"pec": "PEC",
"phone": "Phone",
"website": "Website",
"address": "Address",
"city": "City",
"province": "Province",
"zipCode": "ZIP Code",
"country": "Country",
"paymentTerms": "Payment Terms",
"notes": "Notes",
"isActive": "Active"
},
"placeholders": {
"search": "Search supplier...",
"generatedAutomatically": "Generated automatically"
},
"deleteConfirm": "Are you sure you want to delete this supplier?"
},
"orders": {
"title": "Purchase Orders",
"newOrder": "New Order",
"editOrder": "Edit Order",
"columns": {
"orderNumber": "Order Number",
"orderDate": "Date",
"supplier": "Supplier",
"status": "Status",
"total": "Total",
"deliveryDate": "Delivery Date"
},
"fields": {
"orderNumber": "Order Number",
"orderDate": "Order Date",
"expectedDeliveryDate": "Expected Delivery",
"supplier": "Supplier",
"destinationWarehouse": "Destination Warehouse",
"notes": "Notes",
"article": "Article",
"quantity": "Quantity",
"unitPrice": "Unit Price",
"discount": "Discount %",
"taxRate": "Tax Rate %",
"lineTotal": "Total"
},
"status": {
"Draft": "Draft",
"Confirmed": "Confirmed",
"Received": "Received",
"Cancelled": "Cancelled"
},
"actions": {
"addLine": "Add Line",
"confirm": "Confirm Order",
"receive": "Receive Goods",
"view": "View",
"delete": "Delete"
},
"totals": {
"net": "Net Total",
"tax": "Tax",
"gross": "Gross Total"
},
"deleteConfirm": "Are you sure you want to delete this order?",
"confirmDialog": {
"title": "Confirm Order",
"content": "Are you sure you want to confirm this order? It will no longer be editable.",
"confirm": "Confirm",
"cancel": "Cancel"
},
"receiveDialog": {
"title": "Receive Goods",
"content": "Are you sure you want to mark this order as received? This will generate stock movements.",
"confirm": "Receive",
"cancel": "Cancel"
}
}
},
"sales": {
"order": {
"title": "Sales Orders",
"newOrder": "New Order",
"createTitle": "New Order",
"editTitle": "Edit Order",
"status": {
"Draft": "Draft",
"Confirmed": "Confirmed",
"PartiallyShipped": "Partially Shipped",
"Shipped": "Shipped",
"Invoiced": "Invoiced",
"Cancelled": "Cancelled"
},
"columns": {
"number": "Number",
"date": "Date",
"customer": "Customer",
"status": "Status",
"total": "Total"
},
"fields": {
"orderDate": "Order Date",
"expectedDeliveryDate": "Expected Delivery Date",
"customer": "Customer",
"notes": "Notes",
"lineTotal": "Line Total",
"article": "Article",
"quantity": "Quantity",
"unitPrice": "Unit Price",
"discount": "Discount %",
"taxRate": "Tax Rate %"
},
"totals": {
"gross": "Gross Total"
},
"actions": {
"confirm": "Confirm Order",
"ship": "Ship Goods"
}
}
},
"production": {
"bom": {
"title": "Bills of Materials",
"newBom": "New BOM",
"createTitle": "New Bill of Materials",
"editTitle": "Edit Bill of Materials",
"fields": {
"name": "Name",
"description": "Description",
"article": "Produced Article",
"quantity": "Produced Quantity",
"components": "Components",
"componentArticle": "Component Article",
"componentQuantity": "Quantity",
"scrapPercentage": "Scrap %",
"noComponents": "No components added"
},
"columns": {
"name": "Name",
"article": "Article",
"quantity": "Quantity"
}
},
"dashboard": {
"title": "Production Dashboard",
"activeOrders": "Active Orders",
"lateOrders": "Late Orders",
"mrpSuggestions": "MRP Suggestions",
"completedToday": "Completed Today",
"recentOrders": "Recent Orders"
},
"order": {
"title": "Production Orders",
"newOrder": "New Order",
"createTitle": "New Order",
"editTitle": "Edit Order",
"subOrders": "Sub-Orders",
"noSubOrders": "No sub-orders present",
"status": {
"Draft": "Draft",
"Planned": "Planned",
"Released": "Released",
"InProgress": "In Progress",
"Completed": "Completed",
"Cancelled": "Cancelled"
},
"columns": {
"code": "Code",
"startDate": "Start Date",
"article": "Article",
"parentOrder": "Parent Order",
"quantity": "Quantity",
"status": "Status"
},
"fields": {
"article": "Article to Produce",
"quantity": "Quantity",
"startDate": "Start Date",
"dueDate": "Due Date",
"notes": "Notes",
"bom": "Bill of Materials",
"bomHelp": "Select a BOM to pre-fill components",
"createChildOrders": "Create child orders for sub-assemblies"
},
"actions": {
"plan": "Plan",
"release": "Release",
"start": "Start",
"complete": "Complete"
},
"phases": {
"title": "Production Phases",
"sequence": "Seq",
"name": "Phase",
"workCenter": "Work Center",
"status": "Status",
"progress": "Progress",
"actions": "Actions",
"start": "Start Phase",
"complete": "Complete Phase",
"quantity": "Qty Completed",
"scrapped": "Qty Scrapped",
"duration": "Duration (min)",
"durationHelp": "Estimated: {{estimated}} min",
"statusValue": {
"Pending": "Pending",
"InProgress": "In Progress",
"Completed": "Completed",
"Paused": "Paused"
}
}
},
"workCenter": {
"title": "Work Centers",
"new": "New Work Center",
"edit": "Edit Work Center",
"fields": {
"code": "Code",
"name": "Name",
"description": "Description",
"costPerHour": "Hourly Cost"
}
},
"cycle": {
"title": "Production Cycles",
"new": "New Cycle",
"createTitle": "New Production Cycle",
"editTitle": "Edit Production Cycle",
"phases": "Cycle Phases",
"addPhase": "Add Phase",
"noPhases": "No phases defined",
"fields": {
"name": "Cycle Name",
"description": "Description",
"article": "Article",
"isDefault": "Default",
"phaseName": "Phase Name",
"workCenter": "Work Center",
"duration": "Unit Duration (min)",
"setupTime": "Setup Time (min)"
}
},
"mrp": {
"title": "MRP Planning",
"run": "Run MRP",
"columns": {
"date": "Calculation Date",
"article": "Article",
"type": "Type",
"quantity": "Quantity",
"reason": "Reason"
},
"type": {
"production": "Production",
"purchase": "Purchase"
},
"actions": {
"process": "Process / Create Order"
}
}
}
}

View File

@@ -28,7 +28,8 @@
"optional": "Opzionale",
"notes": "Note",
"preview": "Anteprima",
"none": "Nessuno"
"none": "Nessuno",
"view": "Dettaglio"
},
"menu": {
"dashboard": "Dashboard",
@@ -39,6 +40,9 @@
"articles": "Articoli",
"resources": "Risorse",
"warehouse": "Magazzino",
"purchases": "Acquisti",
"sales": "Vendite",
"production": "Produzione",
"reports": "Report",
"modules": "Moduli",
"autoCodes": "Codici Auto",
@@ -571,6 +575,267 @@
}
}
},
"purchases": {
"supplier": {
"title": "Fornitori",
"newSupplier": "Nuovo Fornitore",
"createTitle": "Nuovo Fornitore",
"editTitle": "Modifica Fornitore",
"columns": {
"code": "Codice",
"name": "Ragione Sociale",
"vatNumber": "P.IVA",
"email": "Email",
"phone": "Telefono",
"city": "Città",
"status": "Stato"
},
"fields": {
"name": "Ragione Sociale",
"vatNumber": "P.IVA",
"fiscalCode": "Codice Fiscale",
"address": "Indirizzo",
"city": "Città",
"province": "Provincia",
"zipCode": "CAP",
"country": "Nazione",
"email": "Email",
"pec": "PEC",
"phone": "Telefono",
"website": "Sito Web",
"paymentTerms": "Termini di Pagamento",
"notes": "Note"
}
},
"order": {
"title": "Ordini Acquisto",
"newOrder": "Nuovo Ordine",
"createTitle": "Nuovo Ordine",
"editTitle": "Modifica Ordine",
"status": {
"Draft": "Bozza",
"Confirmed": "Confermato",
"PartiallyReceived": "Parz. Ricevuto",
"Received": "Ricevuto",
"Cancelled": "Annullato"
},
"columns": {
"number": "Numero",
"date": "Data",
"supplier": "Fornitore",
"status": "Stato",
"total": "Totale"
},
"fields": {
"date": "Data Ordine",
"expectedDate": "Data Prevista Consegna",
"supplier": "Fornitore",
"warehouse": "Magazzino Destinazione",
"notes": "Note"
},
"lines": {
"article": "Articolo",
"quantity": "Quantità",
"price": "Prezzo Unit.",
"discount": "Sconto %",
"tax": "IVA %",
"total": "Totale"
},
"total": "Totale Ordine",
"actions": {
"confirm": "Conferma Ordine",
"receive": "Ricevi Merce"
}
}
},
"sales": {
"order": {
"title": "Ordini Vendita",
"newOrder": "Nuovo Ordine",
"createTitle": "Nuovo Ordine",
"editTitle": "Modifica Ordine",
"status": {
"Draft": "Bozza",
"Confirmed": "Confermato",
"PartiallyShipped": "Parz. Spedito",
"Shipped": "Spedito",
"Invoiced": "Fatturato",
"Cancelled": "Annullato"
},
"columns": {
"number": "Numero",
"date": "Data",
"customer": "Cliente",
"status": "Stato",
"total": "Totale"
},
"fields": {
"orderDate": "Data Ordine",
"expectedDeliveryDate": "Data Prevista Consegna",
"customer": "Cliente",
"notes": "Note",
"lineTotal": "Totale Riga",
"article": "Articolo",
"quantity": "Quantità",
"unitPrice": "Prezzo Unit.",
"discount": "Sconto %",
"taxRate": "IVA %"
},
"totals": {
"gross": "Totale Lordo"
},
"actions": {
"confirm": "Conferma Ordine",
"ship": "Spedisci Merce"
}
}
},
"production": {
"bom": {
"title": "Distinte Base",
"newBom": "Nuova Distinta Base",
"createTitle": "Nuova Distinta Base",
"editTitle": "Modifica Distinta Base",
"fields": {
"name": "Nome",
"description": "Descrizione",
"article": "Articolo Prodotto",
"quantity": "Quantità Prodotta",
"components": "Componenti",
"componentArticle": "Articolo Componente",
"componentQuantity": "Quantità",
"scrapPercentage": "Scarto %",
"noComponents": "Nessun componente aggiunto"
},
"columns": {
"name": "Nome",
"article": "Articolo",
"quantity": "Quantità"
}
},
"dashboard": {
"title": "Dashboard Produzione",
"activeOrders": "Ordini Attivi",
"lateOrders": "Ordini in Ritardo",
"mrpSuggestions": "Suggerimenti MRP",
"completedToday": "Completati Oggi",
"recentOrders": "Ordini Recenti"
},
"order": {
"title": "Ordini di Produzione",
"newOrder": "Nuovo Ordine",
"createTitle": "Nuovo Ordine",
"editTitle": "Modifica Ordine",
"subOrders": "Ordini Figli",
"noSubOrders": "Nessun ordine figlio presente",
"status": {
"Draft": "Bozza",
"Planned": "Pianificato",
"Released": "Rilasciato",
"InProgress": "In Corso",
"Completed": "Completato",
"Cancelled": "Annullato"
},
"columns": {
"code": "Codice",
"startDate": "Data Inizio",
"article": "Articolo",
"parentOrder": "Ordine Padre",
"quantity": "Quantità",
"status": "Stato"
},
"fields": {
"article": "Articolo da Produrre",
"quantity": "Quantità",
"startDate": "Data Inizio",
"dueDate": "Data Scadenza",
"notes": "Note",
"bom": "Distinta Base",
"bomHelp": "Seleziona una DiBa per precompilare i componenti",
"createChildOrders": "Crea ordini figli per semilavorati automaticamente"
},
"actions": {
"plan": "Pianifica",
"release": "Rilascia",
"start": "Avvia",
"complete": "Completa"
},
"phases": {
"title": "Fasi di Produzione",
"sequence": "Seq",
"name": "Fase",
"workCenter": "Centro di Lavoro",
"status": "Stato",
"progress": "Avanzamento",
"actions": "Azioni",
"start": "Avvia Fase",
"complete": "Completa Fase",
"quantity": "Qta Completata",
"scrapped": "Qta Scartata",
"duration": "Durata (min)",
"durationHelp": "Stimata: {{estimated}} min",
"statusValue": {
"Pending": "In Attesa",
"InProgress": "In Corso",
"Completed": "Completata",
"Paused": "In Pausa"
}
}
},
"workCenter": {
"title": "Centri di Lavoro",
"new": "Nuovo Centro",
"edit": "Modifica Centro",
"fields": {
"code": "Codice",
"name": "Nome",
"description": "Descrizione",
"costPerHour": "Costo Orario"
}
},
"cycle": {
"title": "Cicli Produttivi",
"new": "Nuovo Ciclo",
"createTitle": "Nuovo Ciclo Produttivo",
"editTitle": "Modifica Ciclo Produttivo",
"phases": "Fasi del Ciclo",
"addPhase": "Aggiungi Fase",
"noPhases": "Nessuna fase definita",
"fields": {
"name": "Nome Ciclo",
"description": "Descrizione",
"article": "Articolo",
"isDefault": "Predefinito",
"phaseName": "Nome Fase",
"workCenter": "Centro di Lavoro",
"duration": "Durata Unit. (min)",
"setupTime": "Tempo Setup (min)"
}
},
"mrp": {
"title": "Pianificazione MRP",
"run": "Esegui MRP",
"configurationTitle": "Configurazione MRP",
"configurationDescription": "Seleziona le opzioni per l'esecuzione del calcolo MRP.",
"includeSafetyStock": "Includi Scorta di Sicurezza",
"includeSalesOrders": "Includi Ordini di Vendita",
"includeForecasts": "Includi Previsioni",
"columns": {
"date": "Data Calcolo",
"article": "Articolo",
"type": "Tipo",
"quantity": "Quantità",
"reason": "Motivo"
},
"type": {
"production": "Produzione",
"purchase": "Acquisto"
},
"actions": {
"process": "Processa / Crea Ordine"
}
}
},
"warehouse": {
"dashboard": {
"newInbound": "Nuovo Carico",

View File

@@ -21,6 +21,9 @@ import ModulePurchasePage from "./pages/ModulePurchasePage";
import AutoCodesAdminPage from "./pages/AutoCodesAdminPage";
import CustomFieldsAdminPage from "./pages/CustomFieldsAdminPage";
import WarehouseRoutes from "./modules/warehouse/routes";
import PurchasesRoutes from "./modules/purchases/routes";
import SalesRoutes from "./modules/sales/routes";
import ProductionRoutes from "./modules/production/routes";
import { ModuleGuard } from "./components/ModuleGuard";
import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates";
import { CollaborationProvider } from "./contexts/CollaborationContext";
@@ -99,6 +102,33 @@ function App() {
</ModuleGuard>
}
/>
{/* Purchases Module */}
<Route
path="purchases/*"
element={
<ModuleGuard moduleCode="purchases">
<PurchasesRoutes />
</ModuleGuard>
}
/>
{/* Sales Module */}
<Route
path="sales/*"
element={
<ModuleGuard moduleCode="sales">
<SalesRoutes />
</ModuleGuard>
}
/>
{/* Production Module */}
<Route
path="production/*"
element={
<ModuleGuard moduleCode="production">
<ProductionRoutes />
</ModuleGuard>
}
/>
</Route>
</Routes>
</RealTimeProvider>

View File

@@ -30,6 +30,9 @@ import {
Extension as ModulesIcon,
Warehouse as WarehouseIcon,
Code as AutoCodeIcon,
ShoppingCart as ShoppingCartIcon,
Sell as SellIcon,
Factory as ProductionIcon,
} from "@mui/icons-material";
import CollaborationIndicator from "./collaboration/CollaborationIndicator";
import { useModules } from "../contexts/ModuleContext";
@@ -68,6 +71,24 @@ export default function Layout() {
path: "/warehouse",
moduleCode: "warehouse",
},
{
text: t('menu.purchases'),
icon: <ShoppingCartIcon />,
path: "/purchases/orders",
moduleCode: "purchases",
},
{
text: t('menu.sales'),
icon: <SellIcon />,
path: "/sales/orders",
moduleCode: "sales",
},
{
text: t('menu.production'),
icon: <ProductionIcon />,
path: "/production/orders",
moduleCode: "production",
},
{ text: t('menu.reports'), icon: <PrintIcon />, path: "/report-templates" },
{ text: t('menu.modules'), icon: <ModulesIcon />, path: "/modules" },
{ text: t('menu.autoCodes'), icon: <AutoCodeIcon />, path: "/admin/auto-codes" },

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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`);
}
};

View 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[];
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}
};

View 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}`);
}
};

View 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;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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;
}
};

View 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;
}

View File

@@ -4,4 +4,18 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': {
target: 'http://localhost:5000',
changeOrigin: true,
secure: false,
},
'/hubs': {
target: 'http://localhost:5000',
changeOrigin: true,
ws: true,
}
}
}
})

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}
}

View File

@@ -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 });
}
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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
}

View File

@@ -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; }
}

View 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; }
}

View File

@@ -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);
}

View File

@@ -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);
}

View 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();
}
}

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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();
}
}

View File

@@ -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
}

View 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; }
}

View 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;
}
}

View 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;
}
}

View File

@@ -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);
}
}
}

View 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
}

View 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;
}
}

View File

@@ -3,6 +3,9 @@ using Apollinare.API.Services;
// Trigger rebuild
using Apollinare.API.Services.Reports;
using Apollinare.API.Modules.Warehouse.Services;
using Apollinare.API.Modules.Purchases.Services;
using Apollinare.API.Modules.Sales.Services;
using Apollinare.API.Modules.Production.Services;
using Apollinare.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using System.Text.Json.Serialization;
@@ -27,6 +30,17 @@ builder.Services.AddSingleton<DataNotificationService>();
// Warehouse Module Services
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
// Purchases Module Services
builder.Services.AddScoped<SupplierService>();
builder.Services.AddScoped<PurchaseService>();
// Sales Module Services
builder.Services.AddScoped<SalesService>();
// Production Module Services
builder.Services.AddScoped<IProductionService, ProductionService>();
builder.Services.AddScoped<IMrpService, MrpService>();
// Memory cache for module state
builder.Services.AddMemoryCache();

View File

@@ -136,6 +136,12 @@ public class AutoCodeService
"articolo" => !await _db.Articoli
.AnyAsync(a => a.Codice == code && (excludeId == null || a.Id != excludeId)),
"supplier" => !await _db.Suppliers
.AnyAsync(s => s.Code == code && (excludeId == null || s.Id != excludeId)),
"purchase_order" => !await _db.PurchaseOrders
.AnyAsync(o => o.OrderNumber == code && (excludeId == null || o.Id != excludeId)),
_ => true // Entità non gestita, assume codice unico
};
}

Binary file not shown.

View File

@@ -27,4 +27,5 @@ public class Cliente : BaseEntity
public bool Attivo { get; set; } = true;
public ICollection<Evento> Eventi { get; set; } = new List<Evento>();
public ICollection<Apollinare.Domain.Entities.Sales.SalesOrder> SalesOrders { get; set; } = new List<Apollinare.Domain.Entities.Sales.SalesOrder>();
}

View 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>();
}

View File

@@ -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; }
}

View 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
}

View 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; }
}

View 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
}

View File

@@ -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; }
}

View File

@@ -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
}

View 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;
}

View 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
}

View File

@@ -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; }
}

View 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>();
}

View 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
}

View 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; }
}

View File

@@ -1,5 +1,8 @@
using Apollinare.Domain.Entities;
using Apollinare.Domain.Entities.Warehouse;
using Apollinare.Domain.Entities.Purchases;
using Apollinare.Domain.Entities.Sales;
using Apollinare.Domain.Entities.Production;
using Microsoft.EntityFrameworkCore;
namespace Apollinare.Infrastructure.Data;
@@ -63,6 +66,26 @@ public class AppollinareDbContext : DbContext
public DbSet<InventoryCount> InventoryCounts => Set<InventoryCount>();
public DbSet<InventoryCountLine> InventoryCountLines => Set<InventoryCountLine>();
// Purchases module entities
public DbSet<Supplier> Suppliers => Set<Supplier>();
public DbSet<PurchaseOrder> PurchaseOrders => Set<PurchaseOrder>();
public DbSet<PurchaseOrderLine> PurchaseOrderLines => Set<PurchaseOrderLine>();
// Sales module entities
public DbSet<SalesOrder> SalesOrders => Set<SalesOrder>();
public DbSet<SalesOrderLine> SalesOrderLines => Set<SalesOrderLine>();
// Production module entities
public DbSet<BillOfMaterials> BillOfMaterials => Set<BillOfMaterials>();
public DbSet<BillOfMaterialsComponent> BillOfMaterialsComponents => Set<BillOfMaterialsComponent>();
public DbSet<ProductionOrder> ProductionOrders => Set<ProductionOrder>();
public DbSet<ProductionOrderComponent> ProductionOrderComponents => Set<ProductionOrderComponent>();
public DbSet<WorkCenter> WorkCenters => Set<WorkCenter>();
public DbSet<ProductionCycle> ProductionCycles => Set<ProductionCycle>();
public DbSet<ProductionCyclePhase> ProductionCyclePhases => Set<ProductionCyclePhase>();
public DbSet<ProductionOrderPhase> ProductionOrderPhases => Set<ProductionOrderPhase>();
public DbSet<MrpSuggestion> MrpSuggestions => Set<MrpSuggestion>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@@ -627,5 +650,272 @@ public class AppollinareDbContext : DbContext
.HasForeignKey(e => e.BatchId)
.OnDelete(DeleteBehavior.SetNull);
});
// ===============================================
// PURCHASES MODULE ENTITIES
// ===============================================
// Supplier
modelBuilder.Entity<Supplier>(entity =>
{
entity.ToTable("Suppliers");
entity.HasIndex(e => e.Code).IsUnique();
entity.HasIndex(e => e.Name);
entity.HasIndex(e => e.VatNumber);
entity.HasIndex(e => e.IsActive);
});
// PurchaseOrder
modelBuilder.Entity<PurchaseOrder>(entity =>
{
entity.ToTable("PurchaseOrders");
entity.HasIndex(e => e.OrderNumber).IsUnique();
entity.HasIndex(e => e.OrderDate);
entity.HasIndex(e => e.SupplierId);
entity.HasIndex(e => e.Status);
entity.Property(e => e.TotalNet).HasPrecision(18, 4);
entity.Property(e => e.TotalTax).HasPrecision(18, 4);
entity.Property(e => e.TotalGross).HasPrecision(18, 4);
entity.HasOne(e => e.Supplier)
.WithMany(s => s.PurchaseOrders)
.HasForeignKey(e => e.SupplierId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.DestinationWarehouse)
.WithMany()
.HasForeignKey(e => e.DestinationWarehouseId)
.OnDelete(DeleteBehavior.SetNull);
});
// PurchaseOrderLine
modelBuilder.Entity<PurchaseOrderLine>(entity =>
{
entity.ToTable("PurchaseOrderLines");
entity.HasIndex(e => e.PurchaseOrderId);
entity.HasIndex(e => e.WarehouseArticleId);
entity.Property(e => e.Quantity).HasPrecision(18, 4);
entity.Property(e => e.ReceivedQuantity).HasPrecision(18, 4);
entity.Property(e => e.UnitPrice).HasPrecision(18, 4);
entity.Property(e => e.TaxRate).HasPrecision(18, 2);
entity.Property(e => e.DiscountPercent).HasPrecision(18, 2);
entity.Property(e => e.LineTotal).HasPrecision(18, 4);
entity.HasOne(e => e.PurchaseOrder)
.WithMany(o => o.Lines)
.HasForeignKey(e => e.PurchaseOrderId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.WarehouseArticle)
.WithMany()
.HasForeignKey(e => e.WarehouseArticleId)
.OnDelete(DeleteBehavior.Restrict);
});
// ===============================================
// SALES MODULE ENTITIES
// ===============================================
// SalesOrder
modelBuilder.Entity<SalesOrder>(entity =>
{
entity.ToTable("SalesOrders");
entity.HasIndex(e => e.OrderNumber).IsUnique();
entity.HasIndex(e => e.OrderDate);
entity.HasIndex(e => e.CustomerId);
entity.HasIndex(e => e.Status);
entity.Property(e => e.TotalNet).HasPrecision(18, 4);
entity.Property(e => e.TotalTax).HasPrecision(18, 4);
entity.Property(e => e.TotalGross).HasPrecision(18, 4);
entity.HasOne(e => e.Customer)
.WithMany(c => c.SalesOrders)
.HasForeignKey(e => e.CustomerId)
.OnDelete(DeleteBehavior.Restrict);
});
// SalesOrderLine
modelBuilder.Entity<SalesOrderLine>(entity =>
{
entity.ToTable("SalesOrderLines");
entity.HasIndex(e => e.SalesOrderId);
entity.HasIndex(e => e.WarehouseArticleId);
entity.Property(e => e.Quantity).HasPrecision(18, 4);
entity.Property(e => e.ShippedQuantity).HasPrecision(18, 4);
entity.Property(e => e.UnitPrice).HasPrecision(18, 4);
entity.Property(e => e.TaxRate).HasPrecision(18, 2);
entity.Property(e => e.DiscountPercent).HasPrecision(18, 2);
entity.Property(e => e.LineTotal).HasPrecision(18, 4);
entity.HasOne(e => e.SalesOrder)
.WithMany(o => o.Lines)
.HasForeignKey(e => e.SalesOrderId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.WarehouseArticle)
.WithMany()
.HasForeignKey(e => e.WarehouseArticleId)
.OnDelete(DeleteBehavior.Restrict);
});
// ===============================================
// PRODUCTION MODULE ENTITIES
// ===============================================
// BillOfMaterials
modelBuilder.Entity<BillOfMaterials>(entity =>
{
entity.ToTable("BillOfMaterials");
entity.HasIndex(e => e.ArticleId);
entity.HasIndex(e => e.IsActive);
entity.Property(e => e.Quantity).HasPrecision(18, 4);
entity.HasOne(e => e.Article)
.WithMany()
.HasForeignKey(e => e.ArticleId)
.OnDelete(DeleteBehavior.Restrict);
});
// BillOfMaterialsComponent
modelBuilder.Entity<BillOfMaterialsComponent>(entity =>
{
entity.ToTable("BillOfMaterialsComponents");
entity.HasIndex(e => e.BillOfMaterialsId);
entity.HasIndex(e => e.ComponentArticleId);
entity.Property(e => e.Quantity).HasPrecision(18, 4);
entity.Property(e => e.ScrapPercentage).HasPrecision(18, 2);
entity.HasOne(e => e.BillOfMaterials)
.WithMany(b => b.Components)
.HasForeignKey(e => e.BillOfMaterialsId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.ComponentArticle)
.WithMany()
.HasForeignKey(e => e.ComponentArticleId)
.OnDelete(DeleteBehavior.Restrict);
});
// ProductionOrder
modelBuilder.Entity<ProductionOrder>(entity =>
{
entity.ToTable("ProductionOrders");
entity.HasIndex(e => e.Code).IsUnique();
entity.HasIndex(e => e.ArticleId);
entity.HasIndex(e => e.Status);
entity.HasIndex(e => e.StartDate);
entity.Property(e => e.Quantity).HasPrecision(18, 4);
entity.HasOne(e => e.Article)
.WithMany()
.HasForeignKey(e => e.ArticleId)
.OnDelete(DeleteBehavior.Restrict);
});
// ProductionOrderComponent
modelBuilder.Entity<ProductionOrderComponent>(entity =>
{
entity.ToTable("ProductionOrderComponents");
entity.HasIndex(e => e.ProductionOrderId);
entity.HasIndex(e => e.ArticleId);
entity.Property(e => e.RequiredQuantity).HasPrecision(18, 4);
entity.Property(e => e.ConsumedQuantity).HasPrecision(18, 4);
entity.HasOne(e => e.ProductionOrder)
.WithMany(o => o.Components)
.HasForeignKey(e => e.ProductionOrderId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Article)
.WithMany()
.HasForeignKey(e => e.ArticleId)
.OnDelete(DeleteBehavior.Restrict);
});
// WorkCenter
modelBuilder.Entity<WorkCenter>(entity =>
{
entity.ToTable("WorkCenters");
entity.HasIndex(e => e.Code).IsUnique();
entity.HasIndex(e => e.IsActive);
entity.Property(e => e.CostPerHour).HasPrecision(18, 4);
});
// ProductionCycle
modelBuilder.Entity<ProductionCycle>(entity =>
{
entity.ToTable("ProductionCycles");
entity.HasIndex(e => e.ArticleId);
entity.HasIndex(e => e.IsActive);
entity.HasOne(e => e.Article)
.WithMany()
.HasForeignKey(e => e.ArticleId)
.OnDelete(DeleteBehavior.Restrict);
});
// ProductionCyclePhase
modelBuilder.Entity<ProductionCyclePhase>(entity =>
{
entity.ToTable("ProductionCyclePhases");
entity.HasIndex(e => e.ProductionCycleId);
entity.HasIndex(e => e.WorkCenterId);
entity.HasOne(e => e.ProductionCycle)
.WithMany(c => c.Phases)
.HasForeignKey(e => e.ProductionCycleId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.WorkCenter)
.WithMany()
.HasForeignKey(e => e.WorkCenterId)
.OnDelete(DeleteBehavior.Restrict);
});
// ProductionOrderPhase
modelBuilder.Entity<ProductionOrderPhase>(entity =>
{
entity.ToTable("ProductionOrderPhases");
entity.HasIndex(e => e.ProductionOrderId);
entity.HasIndex(e => e.WorkCenterId);
entity.HasIndex(e => e.Status);
entity.Property(e => e.QuantityCompleted).HasPrecision(18, 4);
entity.Property(e => e.QuantityScrapped).HasPrecision(18, 4);
entity.HasOne(e => e.ProductionOrder)
.WithMany(o => o.Phases)
.HasForeignKey(e => e.ProductionOrderId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.WorkCenter)
.WithMany()
.HasForeignKey(e => e.WorkCenterId)
.OnDelete(DeleteBehavior.Restrict);
});
// MrpSuggestion
modelBuilder.Entity<MrpSuggestion>(entity =>
{
entity.ToTable("MrpSuggestions");
entity.HasIndex(e => e.ArticleId);
entity.HasIndex(e => e.CalculationDate);
entity.HasIndex(e => e.IsProcessed);
entity.Property(e => e.Quantity).HasPrecision(18, 4);
entity.HasOne(e => e.Article)
.WithMany()
.HasForeignKey(e => e.ArticleId)
.OnDelete(DeleteBehavior.Cascade);
});
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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");
}
}
}