This commit is contained in:
2025-11-29 16:06:13 +01:00
parent c7dbcde5dd
commit cedcc503fa
34 changed files with 9097 additions and 191 deletions

182
CLAUDE.md
View File

@@ -46,12 +46,103 @@ XX. **Nome Problema (FIX/IMPLEMENTATO DATA):** - **Problema:** Descrizione breve
## Quick Start - Session Recovery ## Quick Start - Session Recovery
**Ultima sessione:** 29 Novembre 2025 (sera) **Ultima sessione:** 30 Novembre 2025
**Stato progetto:** Migrazione Oracle APEX → .NET + React TypeScript in corso **Stato progetto:** Migrazione Oracle APEX → .NET + React TypeScript in corso
**Lavoro completato nell'ultima sessione:** **Lavoro completato nell'ultima sessione:**
- **NUOVA FEATURE: Sistema Codici Automatici Configurabili** - COMPLETATO
- **Obiettivo:** Sistema admin per configurare la generazione automatica di codici (articoli, magazzini, movimenti, ecc.)
- **Backend implementato:**
- `AutoCode.cs` - Entity con pattern configurabile, prefisso, sequenza, reset periodico
- `AutoCodeService.cs` - Logica business (generazione, preview, reset, validazione pattern)
- `AutoCodesController.cs` - API REST complete
- Migration EF Core `AddAutoCodeSystem`
- Seed automatico configurazioni default per tutte le entità
- **Frontend implementato:**
- `autoCode.ts` - Types TypeScript
- `autoCodeService.ts` - API calls
- `AutoCodesAdminPage.tsx` - Pagina admin con tabella configurazioni, dialog modifica, guida pattern
- **Pattern supportati:**
- `{PREFIX}` - Prefisso configurabile
- `{SEQ:n}` - Sequenza numerica con n cifre
- `{YYYY}`, `{YY}` - Anno
- `{MM}`, `{DD}` - Mese, Giorno
- **Funzionalità:**
- Configurazione per entità (warehouse_article, stock_movement, cliente, evento, ecc.)
- Reset sequenza annuale o mensile automatico
- Preview prossimo codice senza incremento
- Reset manuale sequenza
- Abilitazione/disabilitazione per entità
- Raggruppamento per modulo nell'UI
- **API Endpoints:**
- `GET /api/autocodes` - Lista configurazioni
- `GET /api/autocodes/{entityCode}` - Dettaglio
- `GET /api/autocodes/{entityCode}/preview` - Anteprima prossimo codice
- `POST /api/autocodes/{entityCode}/generate` - Genera nuovo codice
- `PUT /api/autocodes/{id}` - Aggiorna configurazione
- `POST /api/autocodes/{entityCode}/reset-sequence` - Reset sequenza
- `GET /api/autocodes/placeholders` - Lista placeholder disponibili
- **File principali:**
- `src/Apollinare.Domain/Entities/AutoCode.cs`
- `src/Apollinare.API/Services/AutoCodeService.cs`
- `src/Apollinare.API/Controllers/AutoCodesController.cs`
- `frontend/src/pages/AutoCodesAdminPage.tsx`
**Lavoro completato nelle sessioni precedenti (29 Novembre 2025 notte):**
- **NUOVA FEATURE: Modulo Magazzino (warehouse)** - COMPLETATO
- **Backend implementato:**
- Entities complete in `/src/Apollinare.Domain/Entities/Warehouse/`:
- `WarehouseLocation.cs` - Magazzini fisici/logici con Type enum (Physical, Virtual, Transit)
- `WarehouseArticleCategory.cs` - Categorie gerarchiche con Color, Icon, Level, FullPath
- `WarehouseArticle.cs` - Articoli con batch/serial management flags, valorizzazione
- `ArticleBatch.cs` - Tracciabilità lotti con scadenza
- `ArticleSerial.cs` - Tracciabilità numeri seriali
- `StockLevel.cs` - Giacenze per articolo/magazzino/batch
- `StockMovement.cs` - Movimenti (Inbound/Outbound/Transfer/Adjustment)
- `StockMovementLine.cs` - Righe movimento
- `MovementReason.cs` - Causali movimento
- `ArticleBarcode.cs` - Multi-barcode support
- `StockValuation.cs` + `StockValuationLayer.cs` - Valorizzazione periodo e layer FIFO/LIFO
- `InventoryCount.cs` + `InventoryCountLine.cs` - Inventari fisici
- Service completo `WarehouseService.cs` con:
- CRUD articoli, categorie, magazzini
- Gestione movimenti (carico/scarico/trasferimento/rettifica)
- Conferma movimenti con aggiornamento giacenze
- Calcolo valorizzazione (WeightedAverage, FIFO, LIFO, StandardCost)
- Gestione partite e seriali
- Controllers REST in `/src/Apollinare.API/Modules/Warehouse/Controllers/`:
- `WarehouseLocationsController.cs`
- `WarehouseArticlesController.cs`
- `WarehouseArticleCategoriesController.cs`
- `StockMovementsController.cs`
- `StockLevelsController.cs`
- Seed dati default (magazzino principale + transito, categorie base, causali)
- **CONFIGURAZIONE: EF Core Code First Migrations** - COMPLETATO
- **Problema:** Le tabelle venivano create manualmente invece che con migrations EF Core
- **Soluzione implementata:**
- Sostituito `db.Database.EnsureCreated()` con `db.Database.MigrateAsync()` in `Program.cs`
- Creata migration `InitialCreate` con tutte le tabelle (sistema + moduli + warehouse)
- Le migrations vengono applicate **automaticamente all'avvio** dell'applicazione
- Logging delle migrations pendenti prima dell'applicazione
- **Comandi per future migrations:**
```bash
# Creare nuova migration
dotnet ef migrations add NomeMigration \
--project src/Apollinare.Infrastructure \
--startup-project src/Apollinare.API
# L'applicazione è AUTOMATICA all'avvio - non serve "dotnet ef database update"
```
- **File modificati:** `Program.cs`, `src/Apollinare.Infrastructure/Migrations/`
**Lavoro completato nelle sessioni precedenti (29 Novembre 2025 sera):**
- **NUOVA FEATURE: Sistema Moduli Applicativi** - COMPLETATO (continuazione) - **NUOVA FEATURE: Sistema Moduli Applicativi** - COMPLETATO (continuazione)
- **Obiettivo:** Sistema di modularizzazione per gestire licenze, abbonamenti e funzionalità dinamiche - **Obiettivo:** Sistema di modularizzazione per gestire licenze, abbonamenti e funzionalità dinamiche
- **Backend implementato:** - **Backend implementato:**
@@ -318,11 +409,14 @@ XX. **Nome Problema (FIX/IMPLEMENTATO DATA):** - **Problema:** Descrizione breve
**MODULI BUSINESS (PRIORITÀ ALTA):** **MODULI BUSINESS (PRIORITÀ ALTA):**
1. [ ] **Implementare modulo Magazzino (warehouse)** - Base per tutti gli altri 1. [x] **Implementare modulo Magazzino (warehouse)** - COMPLETATO (backend)
2. [ ] **Implementare modulo Acquisti (purchases)** - Dipende da Magazzino - Backend: Entities, Service, Controllers, API completi
3. [ ] **Implementare modulo Vendite (sales)** - Dipende da Magazzino - Manca: Frontend (pagine React per gestione articoli, movimenti, giacenze)
4. [ ] **Implementare modulo Produzione (production)** - Dipende da Magazzino 2. [ ] **Frontend modulo Magazzino** - Pagine React per warehouse
5. [ ] **Implementare modulo Qualità (quality)** - Indipendente 3. [ ] **Implementare modulo Acquisti (purchases)** - Dipende da Magazzino
4. [ ] **Implementare modulo Vendite (sales)** - Dipende da Magazzino
5. [ ] **Implementare modulo Produzione (production)** - Dipende da Magazzino
6. [ ] **Implementare modulo Qualità (quality)** - Indipendente
**Report System (completamento):** **Report System (completamento):**
@@ -1855,3 +1949,79 @@ public interface IWarehouseService
- `ModulePurchasePage.tsx`: Rimosso `moduleService` import - `ModulePurchasePage.tsx`: Rimosso `moduleService` import
- `ModulesAdminPage.tsx`: Rimosso `PowerIcon`, `CheckIcon`, `CancelIcon` - `ModulesAdminPage.tsx`: Rimosso `PowerIcon`, `CheckIcon`, `CancelIcon`
- **File:** Vari componenti frontend - **File:** Vari componenti frontend
33. **EF Core Code First vs Database First (FIX 29/11/2025):**
- **Problema:** Le tabelle venivano create manualmente con SQL invece di usare EF Core migrations
- **Causa:** `db.Database.EnsureCreated()` non supporta migrations e crea le tabelle direttamente
- **Soluzione:**
- Sostituito `EnsureCreated()` con `MigrateAsync()` in `Program.cs`
- Rimosso database e migrations esistenti
- Creata nuova migration `InitialCreate` con `dotnet ef migrations add`
- Le migrations vengono ora applicate automaticamente all'avvio
- **File:** `Program.cs`, `src/Apollinare.Infrastructure/Migrations/20251129134709_InitialCreate.cs`
34. **Modulo Warehouse - Struttura Completa (IMPLEMENTATO 29/11/2025):**
- **Entities in `/src/Apollinare.Domain/Entities/Warehouse/`:**
- `WarehouseLocation.cs` - Magazzini (Physical, Virtual, Transit)
- `WarehouseArticle.cs` - Articoli con batch/serial flags
- `WarehouseArticleCategory.cs` - Categorie gerarchiche
- `ArticleBatch.cs` - Lotti con scadenza
- `ArticleSerial.cs` - Numeri seriali
- `StockLevel.cs` - Giacenze
- `StockMovement.cs` + `StockMovementLine.cs` - Movimenti
- `MovementReason.cs` - Causali
- `ArticleBarcode.cs` - Multi-barcode
- `StockValuation.cs` + `StockValuationLayer.cs` - Valorizzazione
- `InventoryCount.cs` + `InventoryCountLine.cs` - Inventari
- **Service:** `WarehouseService.cs` con CRUD completo, movimenti, giacenze, valorizzazione
- **Controllers:** `WarehouseLocationsController`, `WarehouseArticlesController`, `WarehouseArticleCategoriesController`, `StockMovementsController`, `StockLevelsController`
- **API Endpoints principali:**
- `GET/POST /api/warehouse/locations` - Magazzini
- `GET/POST /api/warehouse/articles` - Articoli
- `GET/POST /api/warehouse/categories` - Categorie
- `POST /api/warehouse/movements/inbound` - Carichi
- `POST /api/warehouse/movements/outbound` - Scarichi
- `POST /api/warehouse/movements/{id}/confirm` - Conferma movimento
- `GET /api/warehouse/articles/{id}/stock` - Giacenza articolo
35. **Sistema Codici Automatici Configurabili (IMPLEMENTATO 30/11/2025):**
- **Obiettivo:** Sistema per generare automaticamente codici univoci per tutte le entità (articoli, magazzini, movimenti, clienti, eventi, ecc.)
- **Entity:** `AutoCode.cs` in `/src/Apollinare.Domain/Entities/`
- `EntityCode` - Identificativo entità (es. "warehouse_article")
- `EntityName` - Nome visualizzato
- `Prefix` - Prefisso per {PREFIX}
- `Pattern` - Pattern con placeholder (es. "{PREFIX}{YYYY}-{SEQ:5}")
- `LastSequence` - Ultimo numero usato
- `ResetSequenceYearly` / `ResetSequenceMonthly` - Reset automatico
- `IsEnabled` - Abilita generazione
- `IsReadOnly` - Codice non modificabile
- `ModuleCode` - Raggruppa per modulo
- **Service:** `AutoCodeService.cs` in `/src/Apollinare.API/Services/`
- `GenerateNextCodeAsync(entityCode)` - Genera e incrementa
- `PreviewNextCodeAsync(entityCode)` - Anteprima senza incremento
- `IsCodeUniqueAsync(entityCode, code)` - Verifica univocità
- `ResetSequenceAsync(entityCode)` - Reset manuale
- `SeedDefaultConfigurationsAsync()` - Seed configurazioni default
- **Controller:** `AutoCodesController.cs`
- **Frontend:**
- `AutoCodesAdminPage.tsx` - Pagina admin con accordions per modulo
- `autoCodeService.ts` - API calls
- `autoCode.ts` - Types
- **Pattern supportati:**
- `{PREFIX}` - Prefisso configurabile
- `{SEQ:n}` - Sequenza con n cifre (es. {SEQ:5} → 00001)
- `{YYYY}`, `{YY}` - Anno 4 o 2 cifre
- `{MM}`, `{DD}` - Mese e giorno
- Testo statico (es. "-", "/")
- **Entità preconfigurate:**
- Core: cliente, evento, articolo
- Warehouse: warehouse_location, warehouse_article, warehouse_category, stock_movement, inventory_count, article_batch
- Purchases (future): purchase_order, supplier
- Sales (future): sales_order, invoice
- **Esempio utilizzo nel codice:**
```csharp
// Nel service che crea un articolo
var code = await _autoCodeService.GenerateNextCodeAsync("warehouse_article");
if (code != null)
article.Code = code;
```

View File

@@ -19,6 +19,7 @@ import ReportTemplatesPage from "./pages/ReportTemplatesPage";
import ReportEditorPage from "./pages/ReportEditorPage"; import ReportEditorPage from "./pages/ReportEditorPage";
import ModulesAdminPage from "./pages/ModulesAdminPage"; import ModulesAdminPage from "./pages/ModulesAdminPage";
import ModulePurchasePage from "./pages/ModulePurchasePage"; import ModulePurchasePage from "./pages/ModulePurchasePage";
import AutoCodesAdminPage from "./pages/AutoCodesAdminPage";
import WarehouseRoutes from "./modules/warehouse/routes"; import WarehouseRoutes from "./modules/warehouse/routes";
import { ModuleGuard } from "./components/ModuleGuard"; import { ModuleGuard } from "./components/ModuleGuard";
import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates"; import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates";
@@ -90,12 +91,16 @@ function App() {
path="report-editor/:id" path="report-editor/:id"
element={<ReportEditorPage />} element={<ReportEditorPage />}
/> />
{/* Moduli */} {/* Admin */}
<Route path="modules" element={<ModulesAdminPage />} /> <Route path="modules" element={<ModulesAdminPage />} />
<Route <Route
path="modules/purchase/:code" path="modules/purchase/:code"
element={<ModulePurchasePage />} element={<ModulePurchasePage />}
/> />
<Route
path="admin/auto-codes"
element={<AutoCodesAdminPage />}
/>
{/* Warehouse Module */} {/* Warehouse Module */}
<Route <Route
path="warehouse/*" path="warehouse/*"

View File

@@ -29,6 +29,7 @@ import {
Close as CloseIcon, Close as CloseIcon,
Extension as ModulesIcon, Extension as ModulesIcon,
Warehouse as WarehouseIcon, Warehouse as WarehouseIcon,
Code as AutoCodeIcon,
} from "@mui/icons-material"; } from "@mui/icons-material";
import CollaborationIndicator from "./collaboration/CollaborationIndicator"; import CollaborationIndicator from "./collaboration/CollaborationIndicator";
import { useModules } from "../contexts/ModuleContext"; import { useModules } from "../contexts/ModuleContext";
@@ -52,6 +53,7 @@ const menuItems = [
}, },
{ text: "Report", icon: <PrintIcon />, path: "/report-templates" }, { text: "Report", icon: <PrintIcon />, path: "/report-templates" },
{ text: "Moduli", icon: <ModulesIcon />, path: "/modules" }, { text: "Moduli", icon: <ModulesIcon />, path: "/modules" },
{ text: "Codici Auto", icon: <AutoCodeIcon />, path: "/admin/auto-codes" },
]; ];
export default function Layout() { export default function Layout() {

View File

@@ -86,6 +86,7 @@ export default function ArticleFormPage() {
const [tabValue, setTabValue] = useState(0); const [tabValue, setTabValue] = useState(0);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
code: "", code: "",
alternativeCode: "",
description: "", description: "",
shortDescription: "", shortDescription: "",
categoryId: undefined as number | undefined, categoryId: undefined as number | undefined,
@@ -138,6 +139,7 @@ export default function ArticleFormPage() {
if (article) { if (article) {
setFormData({ setFormData({
code: article.code, code: article.code,
alternativeCode: article.alternativeCode || "",
description: article.description, description: article.description,
shortDescription: article.shortDescription || "", shortDescription: article.shortDescription || "",
categoryId: article.categoryId, categoryId: article.categoryId,
@@ -190,7 +192,8 @@ export default function ArticleFormPage() {
const validate = (): boolean => { const validate = (): boolean => {
const newErrors: Record<string, string> = {}; const newErrors: Record<string, string> = {};
if (!formData.code.trim()) { // Il codice è generato automaticamente, non richiede validazione in creazione
if (!isNew && !formData.code.trim()) {
newErrors.code = "Il codice è obbligatorio"; newErrors.code = "Il codice è obbligatorio";
} }
if (!formData.description.trim()) { if (!formData.description.trim()) {
@@ -211,9 +214,10 @@ export default function ArticleFormPage() {
let savedId: number; let savedId: number;
if (isNew) { if (isNew) {
const createData: CreateArticleDto = { const createData: CreateArticleDto = {
code: formData.code, // code è generato automaticamente dal backend
description: formData.description, description: formData.description,
shortDescription: formData.shortDescription || undefined, shortDescription: formData.shortDescription || undefined,
alternativeCode: formData.alternativeCode || undefined,
categoryId: formData.categoryId, categoryId: formData.categoryId,
unitOfMeasure: formData.unitOfMeasure, unitOfMeasure: formData.unitOfMeasure,
barcode: formData.barcode || undefined, barcode: formData.barcode || undefined,
@@ -234,9 +238,10 @@ export default function ArticleFormPage() {
savedId = result.id; savedId = result.id;
} else { } else {
const updateData: UpdateArticleDto = { const updateData: UpdateArticleDto = {
code: formData.code, // code non modificabile
description: formData.description, description: formData.description,
shortDescription: formData.shortDescription || undefined, shortDescription: formData.shortDescription || undefined,
alternativeCode: formData.alternativeCode || undefined,
categoryId: formData.categoryId, categoryId: formData.categoryId,
unitOfMeasure: formData.unitOfMeasure, unitOfMeasure: formData.unitOfMeasure,
barcode: formData.barcode || undefined, barcode: formData.barcode || undefined,
@@ -327,19 +332,46 @@ export default function ArticleFormPage() {
Informazioni Base Informazioni Base
</Typography> </Typography>
<Grid container spacing={2}> <Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 4 }}> <Grid size={{ xs: 12, sm: 3 }}>
<TextField <TextField
fullWidth fullWidth
label="Codice" label="Codice"
value={formData.code} value={
onChange={(e) => handleChange("code", e.target.value)} isNew ? "(Generato al salvataggio)" : formData.code
error={!!errors.code} }
helperText={errors.code} disabled
required helperText={
disabled={!isNew} isNew
? "Verrà assegnato automaticamente"
: "Generato automaticamente"
}
InputProps={{
readOnly: true,
}}
sx={
isNew
? {
"& .MuiInputBase-input.Mui-disabled": {
fontStyle: "italic",
color: "text.secondary",
},
}
: undefined
}
/> />
</Grid> </Grid>
<Grid size={{ xs: 12, sm: 8 }}> <Grid size={{ xs: 12, sm: 3 }}>
<TextField
fullWidth
label="Codice Alternativo"
value={formData.alternativeCode}
onChange={(e) =>
handleChange("alternativeCode", e.target.value)
}
helperText="Opzionale"
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="Descrizione" label="Descrizione"

View File

@@ -47,6 +47,7 @@ import {
const initialFormData = { const initialFormData = {
code: "", code: "",
alternativeCode: "",
name: "", name: "",
description: "", description: "",
type: WarehouseType.Physical, type: WarehouseType.Physical,
@@ -77,6 +78,7 @@ export default function WarehouseLocationsPage() {
setEditingWarehouse(warehouse); setEditingWarehouse(warehouse);
setFormData({ setFormData({
code: warehouse.code, code: warehouse.code,
alternativeCode: warehouse.alternativeCode || "",
name: warehouse.name, name: warehouse.name,
description: warehouse.description || "", description: warehouse.description || "",
type: warehouse.type, type: warehouse.type,
@@ -109,9 +111,7 @@ export default function WarehouseLocationsPage() {
const validate = (): boolean => { const validate = (): boolean => {
const newErrors: Record<string, string> = {}; const newErrors: Record<string, string> = {};
if (!formData.code.trim()) { // Il codice è generato automaticamente, non richiede validazione in creazione
newErrors.code = "Il codice è obbligatorio";
}
if (!formData.name.trim()) { if (!formData.name.trim()) {
newErrors.name = "Il nome è obbligatorio"; newErrors.name = "Il nome è obbligatorio";
} }
@@ -124,12 +124,16 @@ export default function WarehouseLocationsPage() {
try { try {
if (editingWarehouse) { if (editingWarehouse) {
// In modifica non inviamo il code (non modificabile)
const { code: _code, ...updateData } = formData;
await updateMutation.mutateAsync({ await updateMutation.mutateAsync({
id: editingWarehouse.id, id: editingWarehouse.id,
data: formData, data: updateData,
}); });
} else { } else {
await createMutation.mutateAsync(formData); // In creazione non inviamo il code (generato automaticamente dal backend)
const { code: _code, ...createData } = formData;
await createMutation.mutateAsync(createData);
} }
handleCloseDialog(); handleCloseDialog();
} catch (error) { } catch (error) {
@@ -364,19 +368,46 @@ export default function WarehouseLocationsPage() {
</DialogTitle> </DialogTitle>
<DialogContent> <DialogContent>
<Grid container spacing={2} sx={{ mt: 1 }}> <Grid container spacing={2} sx={{ mt: 1 }}>
<Grid size={{ xs: 12, sm: 4 }}> <Grid size={{ xs: 12, sm: 3 }}>
<TextField <TextField
fullWidth fullWidth
label="Codice" label="Codice"
value={formData.code} value={
onChange={(e) => handleChange("code", e.target.value)} editingWarehouse ? formData.code : "(Generato al salvataggio)"
error={!!errors.code} }
helperText={errors.code} disabled
required helperText={
disabled={!!editingWarehouse} editingWarehouse
? "Generato automaticamente"
: "Verrà assegnato automaticamente"
}
InputProps={{
readOnly: true,
}}
sx={
!editingWarehouse
? {
"& .MuiInputBase-input.Mui-disabled": {
fontStyle: "italic",
color: "text.secondary",
},
}
: undefined
}
/> />
</Grid> </Grid>
<Grid size={{ xs: 12, sm: 8 }}> <Grid size={{ xs: 12, sm: 3 }}>
<TextField
fullWidth
label="Codice Alternativo"
value={formData.alternativeCode}
onChange={(e) =>
handleChange("alternativeCode", e.target.value)
}
helperText="Opzionale"
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField <TextField
fullWidth fullWidth
label="Nome" label="Nome"

View File

@@ -117,6 +117,7 @@ export enum InventoryStatus {
export interface WarehouseLocationDto { export interface WarehouseLocationDto {
id: number; id: number;
code: string; code: string;
alternativeCode?: string;
name: string; name: string;
description?: string; description?: string;
address?: string; address?: string;
@@ -134,7 +135,8 @@ export interface WarehouseLocationDto {
} }
export interface CreateWarehouseDto { export interface CreateWarehouseDto {
code: string; // code è generato automaticamente dal backend
alternativeCode?: string;
name: string; name: string;
description?: string; description?: string;
address?: string; address?: string;
@@ -148,7 +150,20 @@ export interface CreateWarehouseDto {
notes?: string; notes?: string;
} }
export interface UpdateWarehouseDto extends CreateWarehouseDto { export interface UpdateWarehouseDto {
// code non è modificabile
alternativeCode?: string;
name: string;
description?: string;
address?: string;
city?: string;
province?: string;
postalCode?: string;
country?: string;
type: WarehouseType;
isDefault: boolean;
sortOrder: number;
notes?: string;
isActive: boolean; isActive: boolean;
} }
@@ -159,6 +174,7 @@ export interface UpdateWarehouseDto extends CreateWarehouseDto {
export interface CategoryDto { export interface CategoryDto {
id: number; id: number;
code: string; code: string;
alternativeCode?: string;
name: string; name: string;
description?: string; description?: string;
parentCategoryId?: number; parentCategoryId?: number;
@@ -189,7 +205,8 @@ export interface CategoryTreeDto {
} }
export interface CreateCategoryDto { export interface CreateCategoryDto {
code: string; // code è generato automaticamente dal backend
alternativeCode?: string;
name: string; name: string;
description?: string; description?: string;
parentCategoryId?: number; parentCategoryId?: number;
@@ -201,7 +218,8 @@ export interface CreateCategoryDto {
} }
export interface UpdateCategoryDto { export interface UpdateCategoryDto {
code: string; // code non è modificabile
alternativeCode?: string;
name: string; name: string;
description?: string; description?: string;
icon?: string; icon?: string;
@@ -219,6 +237,7 @@ export interface UpdateCategoryDto {
export interface ArticleDto { export interface ArticleDto {
id: number; id: number;
code: string; code: string;
alternativeCode?: string;
description: string; description: string;
shortDescription?: string; shortDescription?: string;
barcode?: string; barcode?: string;
@@ -253,9 +272,10 @@ export interface ArticleDto {
} }
export interface CreateArticleDto { export interface CreateArticleDto {
code: string; // code è generato automaticamente dal backend
description: string; description: string;
shortDescription?: string; shortDescription?: string;
alternativeCode?: string;
barcode?: string; barcode?: string;
manufacturerCode?: string; manufacturerCode?: string;
categoryId?: number; categoryId?: number;
@@ -280,7 +300,33 @@ export interface CreateArticleDto {
notes?: string; notes?: string;
} }
export interface UpdateArticleDto extends CreateArticleDto { export interface UpdateArticleDto {
// code non è modificabile
description: string;
shortDescription?: string;
alternativeCode?: string;
barcode?: string;
manufacturerCode?: string;
categoryId?: number;
unitOfMeasure: string;
secondaryUnitOfMeasure?: string;
unitConversionFactor?: number;
stockManagement: StockManagementType;
isBatchManaged: boolean;
isSerialManaged: boolean;
hasExpiry: boolean;
expiryWarningDays?: number;
minimumStock?: number;
maximumStock?: number;
reorderPoint?: number;
reorderQuantity?: number;
leadTimeDays?: number;
valuationMethod?: ValuationMethod;
standardCost?: number;
baseSellingPrice?: number;
weight?: number;
volume?: number;
notes?: string;
isActive: boolean; isActive: boolean;
} }
@@ -816,7 +862,7 @@ export function formatCurrency(value: number | undefined | null): string {
export function formatQuantity( export function formatQuantity(
value: number | undefined | null, value: number | undefined | null,
decimals: number = 2 decimals: number = 2,
): string { ): string {
if (value === undefined || value === null) return "-"; if (value === undefined || value === null) return "-";
return new Intl.NumberFormat("it-IT", { return new Intl.NumberFormat("it-IT", {

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
Box, Box,
Typography, Typography,
@@ -16,11 +16,15 @@ import {
InputLabel, InputLabel,
Select, Select,
MenuItem, MenuItem,
} from '@mui/material'; } from "@mui/material";
import { DataGrid, GridColDef } from '@mui/x-data-grid'; import { DataGrid, GridColDef } from "@mui/x-data-grid";
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material'; import {
import { articoliService, lookupService } from '../services/lookupService'; Add as AddIcon,
import { Articolo } from '../types'; Edit as EditIcon,
Delete as DeleteIcon,
} from "@mui/icons-material";
import { articoliService, lookupService } from "../services/lookupService";
import { Articolo } from "../types";
export default function ArticoliPage() { export default function ArticoliPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -29,39 +33,40 @@ export default function ArticoliPage() {
const [formData, setFormData] = useState<Partial<Articolo>>({ attivo: true }); const [formData, setFormData] = useState<Partial<Articolo>>({ attivo: true });
const { data: articoli = [], isLoading } = useQuery({ const { data: articoli = [], isLoading } = useQuery({
queryKey: ['articoli'], queryKey: ["articoli"],
queryFn: () => articoliService.getAll(), queryFn: () => articoliService.getAll(),
}); });
const { data: tipiMateriale = [] } = useQuery({ const { data: tipiMateriale = [] } = useQuery({
queryKey: ['lookup', 'tipi-materiale'], queryKey: ["lookup", "tipi-materiale"],
queryFn: () => lookupService.getTipiMateriale(), queryFn: () => lookupService.getTipiMateriale(),
}); });
const { data: categorie = [] } = useQuery({ const { data: categorie = [] } = useQuery({
queryKey: ['lookup', 'categorie'], queryKey: ["lookup", "categorie"],
queryFn: () => lookupService.getCategorie(), queryFn: () => lookupService.getCategorie(),
}); });
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (data: Partial<Articolo>) => articoliService.create(data), mutationFn: (data: Partial<Articolo>) => articoliService.create(data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['articoli'] }); queryClient.invalidateQueries({ queryKey: ["articoli"] });
handleCloseDialog(); handleCloseDialog();
}, },
}); });
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<Articolo> }) => articoliService.update(id, data), mutationFn: ({ id, data }: { id: number; data: Partial<Articolo> }) =>
articoliService.update(id, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['articoli'] }); queryClient.invalidateQueries({ queryKey: ["articoli"] });
handleCloseDialog(); handleCloseDialog();
}, },
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: number) => articoliService.delete(id), mutationFn: (id: number) => articoliService.delete(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['articoli'] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["articoli"] }),
}); });
const handleCloseDialog = () => { const handleCloseDialog = () => {
@@ -78,35 +83,45 @@ export default function ArticoliPage() {
const handleSubmit = () => { const handleSubmit = () => {
if (editingId) { if (editingId) {
updateMutation.mutate({ id: editingId, data: formData }); // In modifica, non inviamo il codice (non modificabile)
const { codice: _codice, ...updateData } = formData;
updateMutation.mutate({ id: editingId, data: updateData });
} else { } else {
createMutation.mutate(formData); // In creazione, non inviamo il codice (generato automaticamente)
const { codice: _codice, ...createData } = formData;
createMutation.mutate(createData);
} }
}; };
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ field: 'codice', headerName: 'Codice', width: 100 }, { field: "codice", headerName: "Codice", width: 100 },
{ field: 'descrizione', headerName: 'Descrizione', flex: 1, minWidth: 200 }, { field: "codiceAlternativo", headerName: "Cod. Alt.", width: 100 },
{ field: "descrizione", headerName: "Descrizione", flex: 1, minWidth: 200 },
{ {
field: 'tipoMateriale', field: "tipoMateriale",
headerName: 'Tipo', headerName: "Tipo",
width: 130, width: 130,
valueGetter: (value: any) => value?.descrizione || '', valueGetter: (value: any) => value?.descrizione || "",
}, },
{ {
field: 'categoria', field: "categoria",
headerName: 'Categoria', headerName: "Categoria",
width: 120, width: 120,
valueGetter: (value: any) => value?.descrizione || '', valueGetter: (value: any) => value?.descrizione || "",
}, },
{ field: 'qtaDisponibile', headerName: 'Disponibile', width: 100, type: 'number' },
{ field: 'qtaStdA', headerName: 'Qta A', width: 80, type: 'number' },
{ field: 'qtaStdB', headerName: 'Qta B', width: 80, type: 'number' },
{ field: 'qtaStdS', headerName: 'Qta S', width: 80, type: 'number' },
{ field: 'unitaMisura', headerName: 'UM', width: 60 },
{ {
field: 'actions', field: "qtaDisponibile",
headerName: 'Azioni', headerName: "Disponibile",
width: 100,
type: "number",
},
{ field: "qtaStdA", headerName: "Qta A", width: 80, type: "number" },
{ field: "qtaStdB", headerName: "Qta B", width: 80, type: "number" },
{ field: "qtaStdS", headerName: "Qta S", width: 80, type: "number" },
{ field: "unitaMisura", headerName: "UM", width: 60 },
{
field: "actions",
headerName: "Azioni",
width: 120, width: 120,
sortable: false, sortable: false,
renderCell: (params) => ( renderCell: (params) => (
@@ -118,7 +133,7 @@ export default function ArticoliPage() {
size="small" size="small"
color="error" color="error"
onClick={() => { onClick={() => {
if (confirm('Eliminare questo articolo?')) { if (confirm("Eliminare questo articolo?")) {
deleteMutation.mutate(params.row.id); deleteMutation.mutate(params.row.id);
} }
}} }}
@@ -132,14 +147,25 @@ export default function ArticoliPage() {
return ( return (
<Box> <Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> <Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 2,
}}
>
<Typography variant="h4">Articoli</Typography> <Typography variant="h4">Articoli</Typography>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpenDialog(true)}> <Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => setOpenDialog(true)}
>
Nuovo Articolo Nuovo Articolo
</Button> </Button>
</Box> </Box>
<Paper sx={{ height: 600, width: '100%' }}> <Paper sx={{ height: 600, width: "100%" }}>
<DataGrid <DataGrid
rows={articoli} rows={articoli}
columns={columns} columns={columns}
@@ -152,38 +178,89 @@ export default function ArticoliPage() {
/> />
</Paper> </Paper>
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="md" fullWidth> <Dialog
<DialogTitle>{editingId ? 'Modifica Articolo' : 'Nuovo Articolo'}</DialogTitle> open={openDialog}
onClose={handleCloseDialog}
maxWidth="md"
fullWidth
>
<DialogTitle>
{editingId ? "Modifica Articolo" : "Nuovo Articolo"}
</DialogTitle>
<DialogContent> <DialogContent>
<Grid container spacing={2} sx={{ mt: 1 }}> <Grid container spacing={2} sx={{ mt: 1 }}>
<Grid size={{ xs: 12, md: 4 }}> <Grid size={{ xs: 12, md: 3 }}>
<TextField <TextField
label="Codice" label="Codice"
fullWidth fullWidth
required value={
value={formData.codice || ''} editingId
onChange={(e) => setFormData({ ...formData, codice: e.target.value })} ? formData.codice || ""
: "(Generato al salvataggio)"
}
disabled
helperText={
editingId
? "Generato automaticamente"
: "Verrà assegnato automaticamente"
}
InputProps={{
readOnly: true,
}}
sx={
!editingId
? {
"& .MuiInputBase-input.Mui-disabled": {
fontStyle: "italic",
color: "text.secondary",
},
}
: undefined
}
/> />
</Grid> </Grid>
<Grid size={{ xs: 12, md: 8 }}> <Grid size={{ xs: 12, md: 3 }}>
<TextField
label="Codice Alternativo"
fullWidth
value={formData.codiceAlternativo || ""}
onChange={(e) =>
setFormData({
...formData,
codiceAlternativo: e.target.value,
})
}
helperText="Opzionale"
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField <TextField
label="Descrizione" label="Descrizione"
fullWidth fullWidth
required required
value={formData.descrizione || ''} value={formData.descrizione || ""}
onChange={(e) => setFormData({ ...formData, descrizione: e.target.value })} onChange={(e) =>
setFormData({ ...formData, descrizione: e.target.value })
}
/> />
</Grid> </Grid>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<FormControl fullWidth> <FormControl fullWidth>
<InputLabel>Tipo Materiale</InputLabel> <InputLabel>Tipo Materiale</InputLabel>
<Select <Select
value={formData.tipoMaterialeId || ''} value={formData.tipoMaterialeId || ""}
label="Tipo Materiale" label="Tipo Materiale"
onChange={(e) => setFormData({ ...formData, tipoMaterialeId: e.target.value as number })} onChange={(e) =>
setFormData({
...formData,
tipoMaterialeId: e.target.value as number,
})
}
> >
{tipiMateriale.map((t) => ( {tipiMateriale.map((t) => (
<MenuItem key={t.id} value={t.id}>{t.descrizione}</MenuItem> <MenuItem key={t.id} value={t.id}>
{t.descrizione}
</MenuItem>
))} ))}
</Select> </Select>
</FormControl> </FormControl>
@@ -192,12 +269,19 @@ export default function ArticoliPage() {
<FormControl fullWidth> <FormControl fullWidth>
<InputLabel>Categoria</InputLabel> <InputLabel>Categoria</InputLabel>
<Select <Select
value={formData.categoriaId || ''} value={formData.categoriaId || ""}
label="Categoria" label="Categoria"
onChange={(e) => setFormData({ ...formData, categoriaId: e.target.value as number })} onChange={(e) =>
setFormData({
...formData,
categoriaId: e.target.value as number,
})
}
> >
{categorie.map((c) => ( {categorie.map((c) => (
<MenuItem key={c.id} value={c.id}>{c.descrizione}</MenuItem> <MenuItem key={c.id} value={c.id}>
{c.descrizione}
</MenuItem>
))} ))}
</Select> </Select>
</FormControl> </FormControl>
@@ -207,16 +291,23 @@ export default function ArticoliPage() {
label="Quantità Disponibile" label="Quantità Disponibile"
fullWidth fullWidth
type="number" type="number"
value={formData.qtaDisponibile || ''} value={formData.qtaDisponibile || ""}
onChange={(e) => setFormData({ ...formData, qtaDisponibile: parseFloat(e.target.value) || undefined })} onChange={(e) =>
setFormData({
...formData,
qtaDisponibile: parseFloat(e.target.value) || undefined,
})
}
/> />
</Grid> </Grid>
<Grid size={{ xs: 12, md: 4 }}> <Grid size={{ xs: 12, md: 4 }}>
<TextField <TextField
label="Unità Misura" label="Unità Misura"
fullWidth fullWidth
value={formData.unitaMisura || ''} value={formData.unitaMisura || ""}
onChange={(e) => setFormData({ ...formData, unitaMisura: e.target.value })} onChange={(e) =>
setFormData({ ...formData, unitaMisura: e.target.value })
}
/> />
</Grid> </Grid>
<Grid size={{ xs: 12, md: 4 }}></Grid> <Grid size={{ xs: 12, md: 4 }}></Grid>
@@ -225,8 +316,13 @@ export default function ArticoliPage() {
label="Qta Std Adulti (A)" label="Qta Std Adulti (A)"
fullWidth fullWidth
type="number" type="number"
value={formData.qtaStdA || ''} value={formData.qtaStdA || ""}
onChange={(e) => setFormData({ ...formData, qtaStdA: parseFloat(e.target.value) || undefined })} onChange={(e) =>
setFormData({
...formData,
qtaStdA: parseFloat(e.target.value) || undefined,
})
}
/> />
</Grid> </Grid>
<Grid size={{ xs: 12, md: 4 }}> <Grid size={{ xs: 12, md: 4 }}>
@@ -234,8 +330,13 @@ export default function ArticoliPage() {
label="Qta Std Buffet (B)" label="Qta Std Buffet (B)"
fullWidth fullWidth
type="number" type="number"
value={formData.qtaStdB || ''} value={formData.qtaStdB || ""}
onChange={(e) => setFormData({ ...formData, qtaStdB: parseFloat(e.target.value) || undefined })} onChange={(e) =>
setFormData({
...formData,
qtaStdB: parseFloat(e.target.value) || undefined,
})
}
/> />
</Grid> </Grid>
<Grid size={{ xs: 12, md: 4 }}> <Grid size={{ xs: 12, md: 4 }}>
@@ -243,8 +344,13 @@ export default function ArticoliPage() {
label="Qta Std Seduti (S)" label="Qta Std Seduti (S)"
fullWidth fullWidth
type="number" type="number"
value={formData.qtaStdS || ''} value={formData.qtaStdS || ""}
onChange={(e) => setFormData({ ...formData, qtaStdS: parseFloat(e.target.value) || undefined })} onChange={(e) =>
setFormData({
...formData,
qtaStdS: parseFloat(e.target.value) || undefined,
})
}
/> />
</Grid> </Grid>
<Grid size={{ xs: 12 }}> <Grid size={{ xs: 12 }}>
@@ -253,8 +359,10 @@ export default function ArticoliPage() {
fullWidth fullWidth
multiline multiline
rows={3} rows={3}
value={formData.note || ''} value={formData.note || ""}
onChange={(e) => setFormData({ ...formData, note: e.target.value })} onChange={(e) =>
setFormData({ ...formData, note: e.target.value })
}
/> />
</Grid> </Grid>
</Grid> </Grid>
@@ -262,7 +370,7 @@ export default function ArticoliPage() {
<DialogActions> <DialogActions>
<Button onClick={handleCloseDialog}>Annulla</Button> <Button onClick={handleCloseDialog}>Annulla</Button>
<Button variant="contained" onClick={handleSubmit}> <Button variant="contained" onClick={handleSubmit}>
{editingId ? 'Salva' : 'Crea'} {editingId ? "Salva" : "Crea"}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>

View File

@@ -0,0 +1,793 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Box,
Card,
CardContent,
Typography,
Button,
Chip,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Alert,
CircularProgress,
Tooltip,
LinearProgress,
Switch,
FormControlLabel,
TextField,
Accordion,
AccordionSummary,
AccordionDetails,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
FormControl,
InputLabel,
Select,
MenuItem,
InputAdornment,
} from "@mui/material";
import Grid from "@mui/material/Grid";
import {
Refresh as RefreshIcon,
Edit as EditIcon,
RestartAlt as ResetIcon,
ExpandMore as ExpandMoreIcon,
Code as CodeIcon,
Preview as PreviewIcon,
Help as HelpIcon,
ContentCopy as CopyIcon,
} from "@mui/icons-material";
import * as Icons from "@mui/icons-material";
import { autoCodeService } from "../services/autoCodeService";
import type {
AutoCodeDto,
AutoCodeUpdateDto,
PlaceholderInfo,
} from "../types/autoCode";
import { groupByModule, moduleNames, moduleIcons } from "../types/autoCode";
export default function AutoCodesAdminPage() {
const queryClient = useQueryClient();
const [editingConfig, setEditingConfig] = useState<AutoCodeDto | null>(null);
const [confirmReset, setConfirmReset] = useState<string | null>(null);
const [previewCode, setPreviewCode] = useState<string | null>(null);
const [showHelp, setShowHelp] = useState(false);
const [expandedModule, setExpandedModule] = useState<string | false>("core");
// Query per tutte le configurazioni
const {
data: configs = [],
isLoading,
refetch,
} = useQuery({
queryKey: ["autocodes"],
queryFn: () => autoCodeService.getAll(),
});
// Query per i placeholder disponibili
const { data: placeholders = [] } = useQuery({
queryKey: ["autocodes", "placeholders"],
queryFn: () => autoCodeService.getPlaceholders(),
});
// Mutation per aggiornare configurazione
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: AutoCodeUpdateDto }) =>
autoCodeService.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["autocodes"] });
setEditingConfig(null);
},
});
// Mutation per reset sequenza
const resetMutation = useMutation({
mutationFn: (entityCode: string) =>
autoCodeService.resetSequence(entityCode),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["autocodes"] });
setConfirmReset(null);
},
});
// Mutation per preview codice
const previewMutation = useMutation({
mutationFn: (entityCode: string) => autoCodeService.previewCode(entityCode),
onSuccess: (data) => {
setPreviewCode(data.code);
},
});
// Raggruppa configurazioni per modulo
const groupedConfigs = groupByModule(configs);
// Helper per ottenere icona modulo
const getModuleIcon = (moduleCode: string) => {
const iconName = moduleIcons[moduleCode] || "Extension";
const IconComponent = (Icons as Record<string, React.ComponentType>)[
iconName
];
return IconComponent ? <IconComponent /> : <Icons.Extension />;
};
if (isLoading) {
return (
<Box sx={{ p: 3 }}>
<LinearProgress />
</Box>
);
}
return (
<Box sx={{ p: 3 }}>
{/* Header */}
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 3,
flexWrap: "wrap",
gap: 2,
}}
>
<Box>
<Typography variant="h4" gutterBottom>
Codici Automatici
</Typography>
<Typography color="text.secondary">
Configura i pattern per la generazione automatica dei codici
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1 }}>
<Button
variant="outlined"
startIcon={<HelpIcon />}
onClick={() => setShowHelp(true)}
>
Guida Pattern
</Button>
<Button
variant="outlined"
startIcon={<RefreshIcon />}
onClick={() => refetch()}
>
Aggiorna
</Button>
</Box>
</Box>
{/* Accordion per moduli */}
{Object.entries(groupedConfigs).map(([moduleCode, moduleConfigs]) => (
<Accordion
key={moduleCode}
expanded={expandedModule === moduleCode}
onChange={(_, isExpanded) =>
setExpandedModule(isExpanded ? moduleCode : false)
}
sx={{ mb: 1 }}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{getModuleIcon(moduleCode)}
<Typography variant="h6">
{moduleNames[moduleCode] || moduleCode}
</Typography>
<Chip
label={`${moduleConfigs.length} configurazioni`}
size="small"
variant="outlined"
/>
</Box>
</AccordionSummary>
<AccordionDetails>
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Entita</TableCell>
<TableCell>Prefisso</TableCell>
<TableCell>Pattern</TableCell>
<TableCell>Esempio</TableCell>
<TableCell>Sequenza</TableCell>
<TableCell>Reset</TableCell>
<TableCell align="center">Stato</TableCell>
<TableCell align="right">Azioni</TableCell>
</TableRow>
</TableHead>
<TableBody>
{moduleConfigs.map((config) => (
<TableRow key={config.id} hover>
<TableCell>
<Box>
<Typography variant="body2" fontWeight="medium">
{config.entityName}
</Typography>
<Typography variant="caption" color="text.secondary">
{config.entityCode}
</Typography>
</Box>
</TableCell>
<TableCell>
<Chip
label={config.prefix || "-"}
size="small"
variant="outlined"
/>
</TableCell>
<TableCell>
<Typography
variant="body2"
sx={{ fontFamily: "monospace", fontSize: "0.85rem" }}
>
{config.pattern}
</Typography>
</TableCell>
<TableCell>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 0.5,
}}
>
<Typography
variant="body2"
sx={{
fontFamily: "monospace",
color: "primary.main",
fontWeight: "medium",
}}
>
{config.exampleCode}
</Typography>
<Tooltip title="Anteprima prossimo codice">
<IconButton
size="small"
onClick={() =>
previewMutation.mutate(config.entityCode)
}
disabled={!config.isEnabled}
>
<PreviewIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</TableCell>
<TableCell>
<Typography variant="body2">
{config.lastSequence}
</Typography>
</TableCell>
<TableCell>
{config.resetSequenceMonthly ? (
<Chip label="Mensile" size="small" color="info" />
) : config.resetSequenceYearly ? (
<Chip label="Annuale" size="small" color="warning" />
) : (
<Chip label="Mai" size="small" variant="outlined" />
)}
</TableCell>
<TableCell align="center">
<Chip
label={config.isEnabled ? "Attivo" : "Disattivo"}
size="small"
color={config.isEnabled ? "success" : "default"}
/>
</TableCell>
<TableCell align="right">
<Tooltip title="Modifica">
<IconButton
size="small"
onClick={() => setEditingConfig(config)}
>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Reset sequenza">
<IconButton
size="small"
onClick={() => setConfirmReset(config.entityCode)}
disabled={!config.isEnabled}
>
<ResetIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</AccordionDetails>
</Accordion>
))}
{/* Dialog modifica configurazione */}
<EditConfigDialog
config={editingConfig}
placeholders={placeholders}
onClose={() => setEditingConfig(null)}
onSave={(data) => {
if (editingConfig) {
updateMutation.mutate({ id: editingConfig.id, data });
}
}}
isSaving={updateMutation.isPending}
error={updateMutation.error as Error | null}
/>
{/* Dialog conferma reset */}
<Dialog
open={!!confirmReset}
onClose={() => setConfirmReset(null)}
maxWidth="xs"
fullWidth
>
<DialogTitle>Conferma Reset Sequenza</DialogTitle>
<DialogContent>
<Typography>
Sei sicuro di voler resettare la sequenza per{" "}
<strong>
{configs.find((c) => c.entityCode === confirmReset)?.entityName}
</strong>
?
</Typography>
<Alert severity="warning" sx={{ mt: 2 }}>
La sequenza verra riportata a 0. Il prossimo codice generato partira
da 1.
</Alert>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmReset(null)}>Annulla</Button>
<Button
color="warning"
variant="contained"
onClick={() => confirmReset && resetMutation.mutate(confirmReset)}
disabled={resetMutation.isPending}
startIcon={
resetMutation.isPending ? (
<CircularProgress size={16} color="inherit" />
) : (
<ResetIcon />
)
}
>
Reset
</Button>
</DialogActions>
</Dialog>
{/* Dialog anteprima codice */}
<Dialog
open={!!previewCode}
onClose={() => setPreviewCode(null)}
maxWidth="xs"
fullWidth
>
<DialogTitle>Anteprima Prossimo Codice</DialogTitle>
<DialogContent>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
p: 3,
bgcolor: "grey.100",
borderRadius: 1,
gap: 1,
}}
>
<CodeIcon color="primary" />
<Typography
variant="h5"
sx={{ fontFamily: "monospace", fontWeight: "bold" }}
>
{previewCode}
</Typography>
<Tooltip title="Copia">
<IconButton
size="small"
onClick={() => {
navigator.clipboard.writeText(previewCode || "");
}}
>
<CopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", mt: 2, textAlign: "center" }}
>
Questo e il codice che verra generato alla prossima creazione.
<br />
La sequenza non e stata incrementata.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setPreviewCode(null)}>Chiudi</Button>
</DialogActions>
</Dialog>
{/* Dialog guida pattern */}
<Dialog
open={showHelp}
onClose={() => setShowHelp(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>Guida ai Pattern</DialogTitle>
<DialogContent dividers>
<Typography variant="body2" paragraph>
I pattern definiscono come vengono generati i codici automatici.
Puoi combinare testo statico e placeholder dinamici.
</Typography>
<Typography variant="subtitle2" gutterBottom sx={{ mt: 2 }}>
Placeholder Disponibili
</Typography>
<TableContainer component={Paper} variant="outlined" sx={{ mb: 3 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Placeholder</TableCell>
<TableCell>Descrizione</TableCell>
<TableCell>Esempio</TableCell>
</TableRow>
</TableHead>
<TableBody>
{placeholders.map((p) => (
<TableRow key={p.placeholder}>
<TableCell>
<Typography sx={{ fontFamily: "monospace" }}>
{p.placeholder}
</Typography>
</TableCell>
<TableCell>{p.description}</TableCell>
<TableCell>
<Typography sx={{ fontFamily: "monospace" }}>
{p.example}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Typography variant="subtitle2" gutterBottom>
Esempi di Pattern
</Typography>
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 6 }}>
<Card variant="outlined">
<CardContent>
<Typography
variant="body2"
sx={{ fontFamily: "monospace", mb: 1 }}
>
{"{PREFIX}-{SEQ:5}"}
</Typography>
<Typography variant="caption" color="text.secondary">
Risultato: ART-00001, ART-00002, ...
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Card variant="outlined">
<CardContent>
<Typography
variant="body2"
sx={{ fontFamily: "monospace", mb: 1 }}
>
{"{PREFIX}{YYYY}-{SEQ:5}"}
</Typography>
<Typography variant="caption" color="text.secondary">
Risultato: EVT2025-00001 (reset annuale)
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Card variant="outlined">
<CardContent>
<Typography
variant="body2"
sx={{ fontFamily: "monospace", mb: 1 }}
>
{"{PREFIX}{YY}{MM}-{SEQ:4}"}
</Typography>
<Typography variant="caption" color="text.secondary">
Risultato: MOV2511-0001 (reset mensile)
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Card variant="outlined">
<CardContent>
<Typography
variant="body2"
sx={{ fontFamily: "monospace", mb: 1 }}
>
{"FT{YYYY}/{SEQ:5}"}
</Typography>
<Typography variant="caption" color="text.secondary">
Risultato: FT2025/00001 (formato fattura)
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={() => setShowHelp(false)}>Chiudi</Button>
</DialogActions>
</Dialog>
</Box>
);
}
// Dialog per modifica configurazione
interface EditConfigDialogProps {
config: AutoCodeDto | null;
placeholders: PlaceholderInfo[];
onClose: () => void;
onSave: (data: AutoCodeUpdateDto) => void;
isSaving: boolean;
error: Error | null;
}
function EditConfigDialog({
config,
placeholders,
onClose,
onSave,
isSaving,
error,
}: EditConfigDialogProps) {
const [formData, setFormData] = useState<AutoCodeUpdateDto>({});
// Reset form quando cambia config
const handleOpen = () => {
if (config) {
setFormData({
prefix: config.prefix,
pattern: config.pattern,
resetSequenceYearly: config.resetSequenceYearly,
resetSequenceMonthly: config.resetSequenceMonthly,
isEnabled: config.isEnabled,
isReadOnly: config.isReadOnly,
description: config.description,
});
}
};
const handleSave = () => {
onSave(formData);
};
// Calcola esempio in tempo reale
const getExampleFromPattern = (pattern: string, prefix: string | null) => {
const now = new Date();
return pattern
.replace("{PREFIX}", prefix || "")
.replace("{YEAR}", now.getFullYear().toString())
.replace("{YYYY}", now.getFullYear().toString())
.replace("{YY}", now.getFullYear().toString().slice(-2))
.replace("{MONTH}", (now.getMonth() + 1).toString().padStart(2, "0"))
.replace("{MM}", (now.getMonth() + 1).toString().padStart(2, "0"))
.replace("{DAY}", now.getDate().toString().padStart(2, "0"))
.replace("{DD}", now.getDate().toString().padStart(2, "0"))
.replace(/\{SEQ:(\d+)\}/g, (_, digits) => "X".repeat(parseInt(digits)));
};
const currentExample = getExampleFromPattern(
formData.pattern || config?.pattern || "",
formData.prefix !== undefined ? formData.prefix : config?.prefix || null,
);
return (
<Dialog
open={!!config}
onClose={onClose}
maxWidth="sm"
fullWidth
TransitionProps={{ onEntered: handleOpen }}
>
{config && (
<>
<DialogTitle>
Modifica Configurazione: {config.entityName}
</DialogTitle>
<DialogContent dividers>
<Grid container spacing={2}>
<Grid size={{ xs: 12 }}>
<TextField
label="Prefisso"
value={formData.prefix ?? config.prefix ?? ""}
onChange={(e) =>
setFormData({ ...formData, prefix: e.target.value || null })
}
fullWidth
size="small"
helperText="Testo sostituito nel placeholder {PREFIX}"
/>
</Grid>
<Grid size={{ xs: 12 }}>
<TextField
label="Pattern"
value={formData.pattern ?? config.pattern}
onChange={(e) =>
setFormData({ ...formData, pattern: e.target.value })
}
fullWidth
size="small"
helperText="Pattern per generazione codice"
InputProps={{
sx: { fontFamily: "monospace" },
endAdornment: (
<InputAdornment position="end">
<Tooltip
title={
<Box>
{placeholders.map((p) => (
<Typography
key={p.placeholder}
variant="caption"
display="block"
>
{p.placeholder}: {p.description}
</Typography>
))}
</Box>
}
>
<HelpIcon fontSize="small" color="action" />
</Tooltip>
</InputAdornment>
),
}}
/>
</Grid>
<Grid size={{ xs: 12 }}>
<Box
sx={{
p: 2,
bgcolor: "grey.100",
borderRadius: 1,
textAlign: "center",
}}
>
<Typography variant="caption" color="text.secondary">
Anteprima:
</Typography>
<Typography
variant="h6"
sx={{ fontFamily: "monospace", color: "primary.main" }}
>
{currentExample}
</Typography>
</Box>
</Grid>
<Grid size={{ xs: 12 }}>
<FormControl fullWidth size="small">
<InputLabel>Reset Sequenza</InputLabel>
<Select
value={
(formData.resetSequenceMonthly ??
config.resetSequenceMonthly)
? "monthly"
: (formData.resetSequenceYearly ??
config.resetSequenceYearly)
? "yearly"
: "never"
}
label="Reset Sequenza"
onChange={(e) => {
const value = e.target.value;
setFormData({
...formData,
resetSequenceYearly: value === "yearly",
resetSequenceMonthly: value === "monthly",
});
}}
>
<MenuItem value="never">Mai</MenuItem>
<MenuItem value="yearly">Ogni anno</MenuItem>
<MenuItem value="monthly">Ogni mese</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12 }}>
<TextField
label="Descrizione"
value={formData.description ?? config.description ?? ""}
onChange={(e) =>
setFormData({
...formData,
description: e.target.value || null,
})
}
fullWidth
size="small"
multiline
rows={2}
/>
</Grid>
<Grid size={{ xs: 6 }}>
<FormControlLabel
control={
<Switch
checked={formData.isEnabled ?? config.isEnabled}
onChange={(e) =>
setFormData({
...formData,
isEnabled: e.target.checked,
})
}
/>
}
label="Generazione attiva"
/>
</Grid>
<Grid size={{ xs: 6 }}>
<FormControlLabel
control={
<Switch
checked={formData.isReadOnly ?? config.isReadOnly}
onChange={(e) =>
setFormData({
...formData,
isReadOnly: e.target.checked,
})
}
/>
}
label="Codice non modificabile"
/>
</Grid>
</Grid>
{error && (
<Alert severity="error" sx={{ mt: 2 }}>
{error.message || "Errore durante il salvataggio"}
</Alert>
)}
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Annulla</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={isSaving}
startIcon={
isSaving ? <CircularProgress size={16} color="inherit" /> : null
}
>
Salva
</Button>
</DialogActions>
</>
)}
</Dialog>
);
}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'; import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { import {
Box, Box,
Typography, Typography,
@@ -12,11 +12,15 @@ import {
DialogActions, DialogActions,
TextField, TextField,
Grid, Grid,
} from '@mui/material'; } from "@mui/material";
import { DataGrid, GridColDef } from '@mui/x-data-grid'; import { DataGrid, GridColDef } from "@mui/x-data-grid";
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material'; import {
import { clientiService } from '../services/lookupService'; Add as AddIcon,
import { Cliente } from '../types'; Edit as EditIcon,
Delete as DeleteIcon,
} from "@mui/icons-material";
import { clientiService } from "../services/lookupService";
import { Cliente } from "../types";
export default function ClientiPage() { export default function ClientiPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -25,29 +29,30 @@ export default function ClientiPage() {
const [formData, setFormData] = useState<Partial<Cliente>>({ attivo: true }); const [formData, setFormData] = useState<Partial<Cliente>>({ attivo: true });
const { data: clienti = [], isLoading } = useQuery({ const { data: clienti = [], isLoading } = useQuery({
queryKey: ['clienti'], queryKey: ["clienti"],
queryFn: () => clientiService.getAll(), queryFn: () => clientiService.getAll(),
}); });
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: (data: Partial<Cliente>) => clientiService.create(data), mutationFn: (data: Partial<Cliente>) => clientiService.create(data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['clienti'] }); queryClient.invalidateQueries({ queryKey: ["clienti"] });
handleCloseDialog(); handleCloseDialog();
}, },
}); });
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: Partial<Cliente> }) => clientiService.update(id, data), mutationFn: ({ id, data }: { id: number; data: Partial<Cliente> }) =>
clientiService.update(id, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['clienti'] }); queryClient.invalidateQueries({ queryKey: ["clienti"] });
handleCloseDialog(); handleCloseDialog();
}, },
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: number) => clientiService.delete(id), mutationFn: (id: number) => clientiService.delete(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['clienti'] }), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clienti"] }),
}); });
const handleCloseDialog = () => { const handleCloseDialog = () => {
@@ -64,22 +69,33 @@ export default function ClientiPage() {
const handleSubmit = () => { const handleSubmit = () => {
if (editingId) { if (editingId) {
updateMutation.mutate({ id: editingId, data: formData }); // In modifica, non inviamo il codice (non modificabile)
const { codice: _codice, ...updateData } = formData;
updateMutation.mutate({ id: editingId, data: updateData });
} else { } else {
createMutation.mutate(formData); // In creazione, non inviamo il codice (generato automaticamente)
const { codice: _codice, ...createData } = formData;
createMutation.mutate(createData);
} }
}; };
const columns: GridColDef[] = [ const columns: GridColDef[] = [
{ field: 'ragioneSociale', headerName: 'Ragione Sociale', flex: 1, minWidth: 200 }, { field: "codice", headerName: "Codice", width: 100 },
{ field: 'citta', headerName: 'Città', width: 150 }, { field: "codiceAlternativo", headerName: "Cod. Alt.", width: 100 },
{ field: 'provincia', headerName: 'Prov.', width: 80 },
{ field: 'telefono', headerName: 'Telefono', width: 130 },
{ field: 'email', headerName: 'Email', width: 200 },
{ field: 'partitaIva', headerName: 'P.IVA', width: 130 },
{ {
field: 'actions', field: "ragioneSociale",
headerName: 'Azioni', headerName: "Ragione Sociale",
flex: 1,
minWidth: 200,
},
{ field: "citta", headerName: "Città", width: 150 },
{ field: "provincia", headerName: "Prov.", width: 80 },
{ field: "telefono", headerName: "Telefono", width: 130 },
{ field: "email", headerName: "Email", width: 200 },
{ field: "partitaIva", headerName: "P.IVA", width: 130 },
{
field: "actions",
headerName: "Azioni",
width: 120, width: 120,
sortable: false, sortable: false,
renderCell: (params) => ( renderCell: (params) => (
@@ -91,7 +107,7 @@ export default function ClientiPage() {
size="small" size="small"
color="error" color="error"
onClick={() => { onClick={() => {
if (confirm('Eliminare questo cliente?')) { if (confirm("Eliminare questo cliente?")) {
deleteMutation.mutate(params.row.id); deleteMutation.mutate(params.row.id);
} }
}} }}
@@ -105,14 +121,25 @@ export default function ClientiPage() {
return ( return (
<Box> <Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}> <Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 2,
}}
>
<Typography variant="h4">Clienti</Typography> <Typography variant="h4">Clienti</Typography>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpenDialog(true)}> <Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => setOpenDialog(true)}
>
Nuovo Cliente Nuovo Cliente
</Button> </Button>
</Box> </Box>
<Paper sx={{ height: 600, width: '100%' }}> <Paper sx={{ height: 600, width: "100%" }}>
<DataGrid <DataGrid
rows={clienti} rows={clienti}
columns={columns} columns={columns}
@@ -125,57 +152,120 @@ export default function ClientiPage() {
/> />
</Paper> </Paper>
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="md" fullWidth> <Dialog
<DialogTitle>{editingId ? 'Modifica Cliente' : 'Nuovo Cliente'}</DialogTitle> open={openDialog}
onClose={handleCloseDialog}
maxWidth="md"
fullWidth
>
<DialogTitle>
{editingId ? "Modifica Cliente" : "Nuovo Cliente"}
</DialogTitle>
<DialogContent> <DialogContent>
<Grid container spacing={2} sx={{ mt: 1 }}> <Grid container spacing={2} sx={{ mt: 1 }}>
<Grid size={{ xs: 12 }}> <Grid size={{ xs: 12, md: 3 }}>
<TextField
label="Codice"
fullWidth
value={
editingId
? formData.codice || ""
: "(Generato al salvataggio)"
}
disabled
helperText={
editingId
? "Generato automaticamente"
: "Verrà assegnato automaticamente"
}
InputProps={{
readOnly: true,
}}
sx={
!editingId
? {
"& .MuiInputBase-input.Mui-disabled": {
fontStyle: "italic",
color: "text.secondary",
},
}
: undefined
}
/>
</Grid>
<Grid size={{ xs: 12, md: 3 }}>
<TextField
label="Codice Alternativo"
fullWidth
value={formData.codiceAlternativo || ""}
onChange={(e) =>
setFormData({
...formData,
codiceAlternativo: e.target.value,
})
}
helperText="Opzionale"
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField <TextField
label="Ragione Sociale" label="Ragione Sociale"
fullWidth fullWidth
required required
value={formData.ragioneSociale || ''} value={formData.ragioneSociale || ""}
onChange={(e) => setFormData({ ...formData, ragioneSociale: e.target.value })} onChange={(e) =>
setFormData({ ...formData, ragioneSociale: e.target.value })
}
/> />
</Grid> </Grid>
<Grid size={{ xs: 12, md: 8 }}> <Grid size={{ xs: 12, md: 8 }}>
<TextField <TextField
label="Indirizzo" label="Indirizzo"
fullWidth fullWidth
value={formData.indirizzo || ''} value={formData.indirizzo || ""}
onChange={(e) => setFormData({ ...formData, indirizzo: e.target.value })} onChange={(e) =>
setFormData({ ...formData, indirizzo: e.target.value })
}
/> />
</Grid> </Grid>
<Grid size={{ xs: 12, md: 4 }}> <Grid size={{ xs: 12, md: 4 }}>
<TextField <TextField
label="CAP" label="CAP"
fullWidth fullWidth
value={formData.cap || ''} value={formData.cap || ""}
onChange={(e) => setFormData({ ...formData, cap: e.target.value })} onChange={(e) =>
setFormData({ ...formData, cap: e.target.value })
}
/> />
</Grid> </Grid>
<Grid size={{ xs: 12, md: 8 }}> <Grid size={{ xs: 12, md: 8 }}>
<TextField <TextField
label="Città" label="Città"
fullWidth fullWidth
value={formData.citta || ''} value={formData.citta || ""}
onChange={(e) => setFormData({ ...formData, citta: e.target.value })} onChange={(e) =>
setFormData({ ...formData, citta: e.target.value })
}
/> />
</Grid> </Grid>
<Grid size={{ xs: 12, md: 4 }}> <Grid size={{ xs: 12, md: 4 }}>
<TextField <TextField
label="Provincia" label="Provincia"
fullWidth fullWidth
value={formData.provincia || ''} value={formData.provincia || ""}
onChange={(e) => setFormData({ ...formData, provincia: e.target.value })} onChange={(e) =>
setFormData({ ...formData, provincia: e.target.value })
}
/> />
</Grid> </Grid>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<TextField <TextField
label="Telefono" label="Telefono"
fullWidth fullWidth
value={formData.telefono || ''} value={formData.telefono || ""}
onChange={(e) => setFormData({ ...formData, telefono: e.target.value })} onChange={(e) =>
setFormData({ ...formData, telefono: e.target.value })
}
/> />
</Grid> </Grid>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
@@ -183,40 +273,53 @@ export default function ClientiPage() {
label="Email" label="Email"
fullWidth fullWidth
type="email" type="email"
value={formData.email || ''} value={formData.email || ""}
onChange={(e) => setFormData({ ...formData, email: e.target.value })} onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
/> />
</Grid> </Grid>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<TextField <TextField
label="PEC" label="PEC"
fullWidth fullWidth
value={formData.pec || ''} value={formData.pec || ""}
onChange={(e) => setFormData({ ...formData, pec: e.target.value })} onChange={(e) =>
setFormData({ ...formData, pec: e.target.value })
}
/> />
</Grid> </Grid>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<TextField <TextField
label="Codice Fiscale" label="Codice Fiscale"
fullWidth fullWidth
value={formData.codiceFiscale || ''} value={formData.codiceFiscale || ""}
onChange={(e) => setFormData({ ...formData, codiceFiscale: e.target.value })} onChange={(e) =>
setFormData({ ...formData, codiceFiscale: e.target.value })
}
/> />
</Grid> </Grid>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<TextField <TextField
label="Partita IVA" label="Partita IVA"
fullWidth fullWidth
value={formData.partitaIva || ''} value={formData.partitaIva || ""}
onChange={(e) => setFormData({ ...formData, partitaIva: e.target.value })} onChange={(e) =>
setFormData({ ...formData, partitaIva: e.target.value })
}
/> />
</Grid> </Grid>
<Grid size={{ xs: 12, md: 6 }}> <Grid size={{ xs: 12, md: 6 }}>
<TextField <TextField
label="Codice Destinatario" label="Codice Destinatario"
fullWidth fullWidth
value={formData.codiceDestinatario || ''} value={formData.codiceDestinatario || ""}
onChange={(e) => setFormData({ ...formData, codiceDestinatario: e.target.value })} onChange={(e) =>
setFormData({
...formData,
codiceDestinatario: e.target.value,
})
}
/> />
</Grid> </Grid>
<Grid size={{ xs: 12 }}> <Grid size={{ xs: 12 }}>
@@ -225,8 +328,10 @@ export default function ClientiPage() {
fullWidth fullWidth
multiline multiline
rows={3} rows={3}
value={formData.note || ''} value={formData.note || ""}
onChange={(e) => setFormData({ ...formData, note: e.target.value })} onChange={(e) =>
setFormData({ ...formData, note: e.target.value })
}
/> />
</Grid> </Grid>
</Grid> </Grid>
@@ -234,7 +339,7 @@ export default function ClientiPage() {
<DialogActions> <DialogActions>
<Button onClick={handleCloseDialog}>Annulla</Button> <Button onClick={handleCloseDialog}>Annulla</Button>
<Button variant="contained" onClick={handleSubmit}> <Button variant="contained" onClick={handleSubmit}>
{editingId ? 'Salva' : 'Crea'} {editingId ? "Salva" : "Crea"}
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>

View File

@@ -0,0 +1,97 @@
import api from "./api";
import type {
AutoCodeDto,
AutoCodeUpdateDto,
GenerateCodeResponse,
ResetSequenceRequest,
CheckUniqueResponse,
PlaceholderInfo,
} from "../types/autoCode";
/**
* Service per la gestione dei codici automatici
*/
export const autoCodeService = {
/**
* Ottiene tutte le configurazioni AutoCode
*/
getAll: async (): Promise<AutoCodeDto[]> => {
const response = await api.get("/autocodes");
return response.data;
},
/**
* Ottiene le configurazioni per un modulo specifico
*/
getByModule: async (moduleCode: string): Promise<AutoCodeDto[]> => {
const response = await api.get(`/autocodes/module/${moduleCode}`);
return response.data;
},
/**
* Ottiene una configurazione specifica per codice entita
*/
getByEntityCode: async (entityCode: string): Promise<AutoCodeDto> => {
const response = await api.get(`/autocodes/${entityCode}`);
return response.data;
},
/**
* Genera un nuovo codice per un'entita
*/
generateCode: async (entityCode: string): Promise<GenerateCodeResponse> => {
const response = await api.post(`/autocodes/${entityCode}/generate`);
return response.data;
},
/**
* Ottiene un'anteprima del prossimo codice senza incrementare la sequenza
*/
previewCode: async (entityCode: string): Promise<GenerateCodeResponse> => {
const response = await api.get(`/autocodes/${entityCode}/preview`);
return response.data;
},
/**
* Aggiorna una configurazione AutoCode
*/
update: async (id: number, data: AutoCodeUpdateDto): Promise<AutoCodeDto> => {
const response = await api.put(`/autocodes/${id}`, data);
return response.data;
},
/**
* Resetta la sequenza per un'entita
*/
resetSequence: async (
entityCode: string,
request?: ResetSequenceRequest
): Promise<{ message: string }> => {
const response = await api.post(`/autocodes/${entityCode}/reset-sequence`, request || {});
return response.data;
},
/**
* Verifica se un codice e unico per un'entita
*/
checkUnique: async (
entityCode: string,
code: string,
excludeId?: number
): Promise<CheckUniqueResponse> => {
const response = await api.get(`/autocodes/${entityCode}/check-unique`, {
params: { code, excludeId },
});
return response.data;
},
/**
* Ottiene i placeholder disponibili per i pattern
*/
getPlaceholders: async (): Promise<PlaceholderInfo[]> => {
const response = await api.get("/autocodes/placeholders");
return response.data;
},
};
export default autoCodeService;

View File

@@ -0,0 +1,94 @@
/**
* Types per il sistema di codici automatici
*/
export interface AutoCodeDto {
id: number;
entityCode: string;
entityName: string;
prefix: string | null;
pattern: string;
lastSequence: number;
resetSequenceYearly: boolean;
resetSequenceMonthly: boolean;
lastResetYear: number | null;
lastResetMonth: number | null;
isEnabled: boolean;
isReadOnly: boolean;
moduleCode: string | null;
description: string | null;
sortOrder: number;
exampleCode: string;
createdAt: string | null;
updatedAt: string | null;
}
export interface AutoCodeUpdateDto {
prefix?: string | null;
pattern?: string | null;
resetSequenceYearly?: boolean;
resetSequenceMonthly?: boolean;
isEnabled?: boolean;
isReadOnly?: boolean;
description?: string | null;
}
export interface GenerateCodeResponse {
code: string;
entityCode: string;
isPreview?: boolean;
}
export interface ResetSequenceRequest {
newValue?: number;
}
export interface CheckUniqueResponse {
isUnique: boolean;
code: string;
entityCode: string;
}
export interface PlaceholderInfo {
placeholder: string;
description: string;
example: string;
}
/**
* Raggruppa le configurazioni per modulo
*/
export function groupByModule(configs: AutoCodeDto[]): Record<string, AutoCodeDto[]> {
return configs.reduce((acc, config) => {
const module = config.moduleCode || "core";
if (!acc[module]) {
acc[module] = [];
}
acc[module].push(config);
return acc;
}, {} as Record<string, AutoCodeDto[]>);
}
/**
* Nomi visualizzati per i moduli
*/
export const moduleNames: Record<string, string> = {
core: "Sistema Base",
warehouse: "Magazzino",
purchases: "Acquisti",
sales: "Vendite",
production: "Produzione",
quality: "Qualità",
};
/**
* Icone per i moduli (nomi MUI icons)
*/
export const moduleIcons: Record<string, string> = {
core: "Settings",
warehouse: "Warehouse",
purchases: "ShoppingCart",
sales: "PointOfSale",
production: "Factory",
quality: "VerifiedUser",
};

View File

@@ -13,6 +13,8 @@ export interface BaseEntity {
} }
export interface Cliente extends BaseEntity { export interface Cliente extends BaseEntity {
codice: string;
codiceAlternativo?: string;
ragioneSociale: string; ragioneSociale: string;
indirizzo?: string; indirizzo?: string;
cap?: string; cap?: string;
@@ -89,6 +91,7 @@ export interface Risorsa extends BaseEntity {
export interface Articolo extends BaseEntity { export interface Articolo extends BaseEntity {
codice: string; codice: string;
codiceAlternativo?: string;
descrizione: string; descrizione: string;
tipoMaterialeId?: number; tipoMaterialeId?: number;
tipoMateriale?: TipoMateriale; tipoMateriale?: TipoMateriale;

View File

@@ -1,3 +1,4 @@
using Apollinare.API.Services;
using Apollinare.Domain.Entities; using Apollinare.Domain.Entities;
using Apollinare.Infrastructure.Data; using Apollinare.Infrastructure.Data;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -10,10 +11,12 @@ namespace Apollinare.API.Controllers;
public class ArticoliController : ControllerBase public class ArticoliController : ControllerBase
{ {
private readonly AppollinareDbContext _context; private readonly AppollinareDbContext _context;
private readonly AutoCodeService _autoCodeService;
public ArticoliController(AppollinareDbContext context) public ArticoliController(AppollinareDbContext context, AutoCodeService autoCodeService)
{ {
_context = context; _context = context;
_autoCodeService = autoCodeService;
} }
[HttpGet] [HttpGet]
@@ -60,6 +63,13 @@ public class ArticoliController : ControllerBase
[HttpPost] [HttpPost]
public async Task<ActionResult<Articolo>> CreateArticolo(Articolo articolo) public async Task<ActionResult<Articolo>> CreateArticolo(Articolo articolo)
{ {
// Genera codice automatico
var codice = await _autoCodeService.GenerateNextCodeAsync("articolo");
if (!string.IsNullOrEmpty(codice))
{
articolo.Codice = codice;
}
articolo.CreatedAt = DateTime.UtcNow; articolo.CreatedAt = DateTime.UtcNow;
_context.Articoli.Add(articolo); _context.Articoli.Add(articolo);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();

View File

@@ -0,0 +1,240 @@
using Apollinare.API.Services;
using Apollinare.Domain.Entities;
using Microsoft.AspNetCore.Mvc;
namespace Apollinare.API.Controllers;
[ApiController]
[Route("api/[controller]")]
public class AutoCodesController : ControllerBase
{
private readonly AutoCodeService _autoCodeService;
private readonly ILogger<AutoCodesController> _logger;
public AutoCodesController(AutoCodeService autoCodeService, ILogger<AutoCodesController> logger)
{
_autoCodeService = autoCodeService;
_logger = logger;
}
/// <summary>
/// Ottiene tutte le configurazioni AutoCode.
/// </summary>
[HttpGet]
public async Task<ActionResult<List<AutoCodeDto>>> GetAll()
{
var configs = await _autoCodeService.GetAllConfigurationsAsync();
return Ok(configs.Select(ToDto).ToList());
}
/// <summary>
/// Ottiene le configurazioni AutoCode per un modulo specifico.
/// </summary>
[HttpGet("module/{moduleCode}")]
public async Task<ActionResult<List<AutoCodeDto>>> GetByModule(string moduleCode)
{
var configs = await _autoCodeService.GetConfigurationsByModuleAsync(moduleCode);
return Ok(configs.Select(ToDto).ToList());
}
/// <summary>
/// Ottiene una configurazione AutoCode specifica.
/// </summary>
[HttpGet("{entityCode}")]
public async Task<ActionResult<AutoCodeDto>> Get(string entityCode)
{
var config = await _autoCodeService.GetConfigurationAsync(entityCode);
if (config == null)
return NotFound($"Configurazione per '{entityCode}' non trovata");
return Ok(ToDto(config));
}
/// <summary>
/// Genera un nuovo codice per un'entità specifica.
/// </summary>
[HttpPost("{entityCode}/generate")]
public async Task<ActionResult<GenerateCodeResponse>> GenerateCode(string entityCode)
{
try
{
var code = await _autoCodeService.GenerateNextCodeAsync(entityCode);
if (code == null)
{
return BadRequest(new { error = "Generazione automatica disabilitata o configurazione non trovata" });
}
return Ok(new GenerateCodeResponse { Code = code, EntityCode = entityCode });
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore durante la generazione del codice per {EntityCode}", entityCode);
return StatusCode(500, new { error = "Errore durante la generazione del codice" });
}
}
/// <summary>
/// Ottiene un'anteprima del prossimo codice senza incrementare la sequenza.
/// </summary>
[HttpGet("{entityCode}/preview")]
public async Task<ActionResult<GenerateCodeResponse>> PreviewCode(string entityCode)
{
var code = await _autoCodeService.PreviewNextCodeAsync(entityCode);
if (code == null)
{
return BadRequest(new { error = "Generazione automatica disabilitata o configurazione non trovata" });
}
return Ok(new GenerateCodeResponse { Code = code, EntityCode = entityCode, IsPreview = true });
}
/// <summary>
/// Aggiorna una configurazione AutoCode.
/// </summary>
[HttpPut("{id:int}")]
public async Task<ActionResult<AutoCodeDto>> Update(int id, [FromBody] AutoCodeUpdateDto dto)
{
try
{
var config = await _autoCodeService.UpdateConfigurationAsync(id, dto);
return Ok(ToDto(config));
}
catch (KeyNotFoundException ex)
{
return NotFound(new { error = ex.Message });
}
catch (ArgumentException ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Resetta la sequenza per un'entità specifica.
/// </summary>
[HttpPost("{entityCode}/reset-sequence")]
public async Task<ActionResult> ResetSequence(string entityCode, [FromBody] ResetSequenceRequest? request)
{
try
{
await _autoCodeService.ResetSequenceAsync(entityCode, request?.NewValue ?? 0);
return Ok(new { message = $"Sequenza resettata per '{entityCode}'" });
}
catch (KeyNotFoundException ex)
{
return NotFound(new { error = ex.Message });
}
}
/// <summary>
/// Verifica se un codice è unico per un'entità.
/// </summary>
[HttpGet("{entityCode}/check-unique")]
public async Task<ActionResult<CheckUniqueResponse>> CheckUnique(
string entityCode,
[FromQuery] string code,
[FromQuery] int? excludeId = null)
{
var isUnique = await _autoCodeService.IsCodeUniqueAsync(entityCode, code, excludeId);
return Ok(new CheckUniqueResponse { IsUnique = isUnique, Code = code, EntityCode = entityCode });
}
/// <summary>
/// Ottiene i placeholder disponibili per i pattern.
/// </summary>
[HttpGet("placeholders")]
public ActionResult<List<PlaceholderInfo>> GetPlaceholders()
{
var placeholders = new List<PlaceholderInfo>
{
new() { Placeholder = "{PREFIX}", Description = "Prefisso configurato", Example = "ART" },
new() { Placeholder = "{SEQ:n}", Description = "Sequenza numerica con n cifre", Example = "{SEQ:5} → 00001" },
new() { Placeholder = "{YYYY}", Description = "Anno a 4 cifre", Example = "2025" },
new() { Placeholder = "{YY}", Description = "Anno a 2 cifre", Example = "25" },
new() { Placeholder = "{MM}", Description = "Mese a 2 cifre", Example = "11" },
new() { Placeholder = "{DD}", Description = "Giorno a 2 cifre", Example = "29" },
new() { Placeholder = "{YEAR}", Description = "Alias per {YYYY}", Example = "2025" },
new() { Placeholder = "{MONTH}", Description = "Alias per {MM}", Example = "11" },
new() { Placeholder = "{DAY}", Description = "Alias per {DD}", Example = "29" },
};
return Ok(placeholders);
}
private static AutoCodeDto ToDto(AutoCode entity)
{
return new AutoCodeDto
{
Id = entity.Id,
EntityCode = entity.EntityCode,
EntityName = entity.EntityName,
Prefix = entity.Prefix,
Pattern = entity.Pattern,
LastSequence = entity.LastSequence,
ResetSequenceYearly = entity.ResetSequenceYearly,
ResetSequenceMonthly = entity.ResetSequenceMonthly,
LastResetYear = entity.LastResetYear,
LastResetMonth = entity.LastResetMonth,
IsEnabled = entity.IsEnabled,
IsReadOnly = entity.IsReadOnly,
ModuleCode = entity.ModuleCode,
Description = entity.Description,
SortOrder = entity.SortOrder,
ExampleCode = entity.GetExampleCode(),
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt
};
}
}
#region DTOs
public class AutoCodeDto
{
public int Id { get; set; }
public string EntityCode { get; set; } = string.Empty;
public string EntityName { get; set; } = string.Empty;
public string? Prefix { get; set; }
public string Pattern { get; set; } = string.Empty;
public long LastSequence { get; set; }
public bool ResetSequenceYearly { get; set; }
public bool ResetSequenceMonthly { get; set; }
public int? LastResetYear { get; set; }
public int? LastResetMonth { get; set; }
public bool IsEnabled { get; set; }
public bool IsReadOnly { get; set; }
public string? ModuleCode { get; set; }
public string? Description { get; set; }
public int SortOrder { get; set; }
public string ExampleCode { get; set; } = string.Empty;
public DateTime? CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}
public class GenerateCodeResponse
{
public string Code { get; set; } = string.Empty;
public string EntityCode { get; set; } = string.Empty;
public bool IsPreview { get; set; }
}
public class ResetSequenceRequest
{
public long NewValue { get; set; } = 0;
}
public class CheckUniqueResponse
{
public bool IsUnique { get; set; }
public string Code { get; set; } = string.Empty;
public string EntityCode { get; set; } = string.Empty;
}
public class PlaceholderInfo
{
public string Placeholder { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string Example { get; set; } = string.Empty;
}
#endregion

View File

@@ -1,3 +1,4 @@
using Apollinare.API.Services;
using Apollinare.Domain.Entities; using Apollinare.Domain.Entities;
using Apollinare.Infrastructure.Data; using Apollinare.Infrastructure.Data;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -10,10 +11,12 @@ namespace Apollinare.API.Controllers;
public class ClientiController : ControllerBase public class ClientiController : ControllerBase
{ {
private readonly AppollinareDbContext _context; private readonly AppollinareDbContext _context;
private readonly AutoCodeService _autoCodeService;
public ClientiController(AppollinareDbContext context) public ClientiController(AppollinareDbContext context, AutoCodeService autoCodeService)
{ {
_context = context; _context = context;
_autoCodeService = autoCodeService;
} }
[HttpGet] [HttpGet]
@@ -47,6 +50,13 @@ public class ClientiController : ControllerBase
[HttpPost] [HttpPost]
public async Task<ActionResult<Cliente>> CreateCliente(Cliente cliente) public async Task<ActionResult<Cliente>> CreateCliente(Cliente cliente)
{ {
// Genera codice automatico
var codice = await _autoCodeService.GenerateNextCodeAsync("cliente");
if (!string.IsNullOrEmpty(codice))
{
cliente.Codice = codice;
}
cliente.CreatedAt = DateTime.UtcNow; cliente.CreatedAt = DateTime.UtcNow;
_context.Clienti.Add(cliente); _context.Clienti.Add(cliente);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();

View File

@@ -1,4 +1,5 @@
using Apollinare.API.Hubs; using Apollinare.API.Hubs;
using Apollinare.API.Services;
using Apollinare.Domain.Entities; using Apollinare.Domain.Entities;
using Apollinare.Domain.Enums; using Apollinare.Domain.Enums;
using Apollinare.Infrastructure.Data; using Apollinare.Infrastructure.Data;
@@ -13,11 +14,13 @@ public class EventiController : ControllerBase
{ {
private readonly AppollinareDbContext _context; private readonly AppollinareDbContext _context;
private readonly DataNotificationService _notifier; private readonly DataNotificationService _notifier;
private readonly AutoCodeService _autoCodeService;
public EventiController(AppollinareDbContext context, DataNotificationService notifier) public EventiController(AppollinareDbContext context, DataNotificationService notifier, AutoCodeService autoCodeService)
{ {
_context = context; _context = context;
_notifier = notifier; _notifier = notifier;
_autoCodeService = autoCodeService;
} }
[HttpGet] [HttpGet]
@@ -343,6 +346,12 @@ public class EventiController : ControllerBase
private async Task<string> GeneraCodiceEvento() private async Task<string> GeneraCodiceEvento()
{ {
// Usa AutoCodeService per generare il codice
var generatedCode = await _autoCodeService.GenerateNextCodeAsync("evento");
if (generatedCode != null)
return generatedCode;
// Fallback: metodo legacy
var anno = DateTime.Now.Year; var anno = DateTime.Now.Year;
var ultimoEvento = await _context.Eventi var ultimoEvento = await _context.Eventi
.Where(e => e.Codice != null && e.Codice.StartsWith($"EV{anno}")) .Where(e => e.Codice != null && e.Codice.StartsWith($"EV{anno}"))

View File

@@ -239,6 +239,7 @@ public class WarehouseArticlesController : ControllerBase
public record ArticleDto( public record ArticleDto(
int Id, int Id,
string Code, string Code,
string? AlternativeCode,
string Description, string Description,
string? ShortDescription, string? ShortDescription,
string? Barcode, string? Barcode,
@@ -273,36 +274,36 @@ public class WarehouseArticlesController : ControllerBase
); );
public record CreateArticleDto( public record CreateArticleDto(
string Code,
string Description, string Description,
string? ShortDescription,
string? Barcode,
string? ManufacturerCode,
int? CategoryId,
string UnitOfMeasure, string UnitOfMeasure,
string? SecondaryUnitOfMeasure, string? AlternativeCode = null,
decimal? UnitConversionFactor, string? ShortDescription = null,
StockManagementType StockManagement, string? Barcode = null,
bool IsBatchManaged, string? ManufacturerCode = null,
bool IsSerialManaged, int? CategoryId = null,
bool HasExpiry, string? SecondaryUnitOfMeasure = null,
int? ExpiryWarningDays, decimal? UnitConversionFactor = null,
decimal? MinimumStock, StockManagementType StockManagement = StockManagementType.Standard,
decimal? MaximumStock, bool IsBatchManaged = false,
decimal? ReorderPoint, bool IsSerialManaged = false,
decimal? ReorderQuantity, bool HasExpiry = false,
int? LeadTimeDays, int? ExpiryWarningDays = null,
ValuationMethod? ValuationMethod, decimal? MinimumStock = null,
decimal? StandardCost, decimal? MaximumStock = null,
decimal? BaseSellingPrice, decimal? ReorderPoint = null,
decimal? Weight, decimal? ReorderQuantity = null,
decimal? Volume, int? LeadTimeDays = null,
string? Notes ValuationMethod? ValuationMethod = null,
decimal? StandardCost = null,
decimal? BaseSellingPrice = null,
decimal? Weight = null,
decimal? Volume = null,
string? Notes = null
); );
public record UpdateArticleDto( public record UpdateArticleDto(
string Code,
string Description, string Description,
string? AlternativeCode,
string? ShortDescription, string? ShortDescription,
string? Barcode, string? Barcode,
string? ManufacturerCode, string? ManufacturerCode,
@@ -363,6 +364,7 @@ public class WarehouseArticlesController : ControllerBase
private static ArticleDto MapToDto(WarehouseArticle article) => new( private static ArticleDto MapToDto(WarehouseArticle article) => new(
article.Id, article.Id,
article.Code, article.Code,
article.AlternativeCode,
article.Description, article.Description,
article.ShortDescription, article.ShortDescription,
article.Barcode, article.Barcode,
@@ -398,7 +400,8 @@ public class WarehouseArticlesController : ControllerBase
private static WarehouseArticle MapFromDto(CreateArticleDto dto) => new() private static WarehouseArticle MapFromDto(CreateArticleDto dto) => new()
{ {
Code = dto.Code, // Code viene generato automaticamente da WarehouseService.CreateArticleAsync
AlternativeCode = dto.AlternativeCode,
Description = dto.Description, Description = dto.Description,
ShortDescription = dto.ShortDescription, ShortDescription = dto.ShortDescription,
Barcode = dto.Barcode, Barcode = dto.Barcode,
@@ -428,7 +431,8 @@ public class WarehouseArticlesController : ControllerBase
private static void UpdateFromDto(WarehouseArticle article, UpdateArticleDto dto) private static void UpdateFromDto(WarehouseArticle article, UpdateArticleDto dto)
{ {
article.Code = dto.Code; // Code non viene aggiornato - è generato automaticamente e immutabile
article.AlternativeCode = dto.AlternativeCode;
article.Description = dto.Description; article.Description = dto.Description;
article.ShortDescription = dto.ShortDescription; article.ShortDescription = dto.ShortDescription;
article.Barcode = dto.Barcode; article.Barcode = dto.Barcode;

View File

@@ -1,3 +1,4 @@
using Apollinare.API.Services;
using Apollinare.Domain.Entities.Warehouse; using Apollinare.Domain.Entities.Warehouse;
using Apollinare.Infrastructure.Data; using Apollinare.Infrastructure.Data;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -13,6 +14,7 @@ public class WarehouseService : IWarehouseService
private readonly AppollinareDbContext _context; private readonly AppollinareDbContext _context;
private readonly IMemoryCache _cache; private readonly IMemoryCache _cache;
private readonly ILogger<WarehouseService> _logger; private readonly ILogger<WarehouseService> _logger;
private readonly AutoCodeService _autoCodeService;
private const string WAREHOUSES_CACHE_KEY = "warehouse_locations"; private const string WAREHOUSES_CACHE_KEY = "warehouse_locations";
private const string CATEGORIES_CACHE_KEY = "warehouse_categories"; private const string CATEGORIES_CACHE_KEY = "warehouse_categories";
@@ -22,11 +24,13 @@ public class WarehouseService : IWarehouseService
public WarehouseService( public WarehouseService(
AppollinareDbContext context, AppollinareDbContext context,
IMemoryCache cache, IMemoryCache cache,
ILogger<WarehouseService> logger) ILogger<WarehouseService> logger,
AutoCodeService autoCodeService)
{ {
_context = context; _context = context;
_cache = cache; _cache = cache;
_logger = logger; _logger = logger;
_autoCodeService = autoCodeService;
} }
#region Articoli #region Articoli
@@ -118,6 +122,16 @@ public class WarehouseService : IWarehouseService
public async Task<WarehouseArticle> CreateArticleAsync(WarehouseArticle article) public async Task<WarehouseArticle> CreateArticleAsync(WarehouseArticle article)
{ {
// Genera codice automaticamente se non specificato
if (string.IsNullOrWhiteSpace(article.Code))
{
var generatedCode = await _autoCodeService.GenerateNextCodeAsync("warehouse_article");
if (generatedCode != null)
article.Code = generatedCode;
else
throw new InvalidOperationException("Impossibile generare codice automatico per l'articolo");
}
// Verifica unicità codice // Verifica unicità codice
if (await _context.WarehouseArticles.AnyAsync(a => a.Code == article.Code)) if (await _context.WarehouseArticles.AnyAsync(a => a.Code == article.Code))
throw new InvalidOperationException($"Esiste già un articolo con codice '{article.Code}'"); throw new InvalidOperationException($"Esiste già un articolo con codice '{article.Code}'");
@@ -230,6 +244,16 @@ public class WarehouseService : IWarehouseService
public async Task<WarehouseArticleCategory> CreateCategoryAsync(WarehouseArticleCategory category) public async Task<WarehouseArticleCategory> CreateCategoryAsync(WarehouseArticleCategory category)
{ {
// Genera codice automaticamente se non specificato
if (string.IsNullOrWhiteSpace(category.Code))
{
var generatedCode = await _autoCodeService.GenerateNextCodeAsync("warehouse_category");
if (generatedCode != null)
category.Code = generatedCode;
else
throw new InvalidOperationException("Impossibile generare codice automatico per la categoria");
}
if (await _context.WarehouseArticleCategories.AnyAsync(c => c.Code == category.Code)) if (await _context.WarehouseArticleCategories.AnyAsync(c => c.Code == category.Code))
throw new InvalidOperationException($"Esiste già una categoria con codice '{category.Code}'"); throw new InvalidOperationException($"Esiste già una categoria con codice '{category.Code}'");
@@ -336,6 +360,16 @@ public class WarehouseService : IWarehouseService
public async Task<WarehouseLocation> CreateWarehouseAsync(WarehouseLocation warehouse) public async Task<WarehouseLocation> CreateWarehouseAsync(WarehouseLocation warehouse)
{ {
// Genera codice automaticamente se non specificato
if (string.IsNullOrWhiteSpace(warehouse.Code))
{
var generatedCode = await _autoCodeService.GenerateNextCodeAsync("warehouse_location");
if (generatedCode != null)
warehouse.Code = generatedCode;
else
throw new InvalidOperationException("Impossibile generare codice automatico per il magazzino");
}
if (await _context.WarehouseLocations.AnyAsync(w => w.Code == warehouse.Code)) if (await _context.WarehouseLocations.AnyAsync(w => w.Code == warehouse.Code))
throw new InvalidOperationException($"Esiste già un magazzino con codice '{warehouse.Code}'"); throw new InvalidOperationException($"Esiste già un magazzino con codice '{warehouse.Code}'");
@@ -464,6 +498,16 @@ public class WarehouseService : IWarehouseService
if (!article.IsBatchManaged) if (!article.IsBatchManaged)
throw new InvalidOperationException("L'articolo non è gestito a lotti"); throw new InvalidOperationException("L'articolo non è gestito a lotti");
// Genera numero lotto automaticamente se non specificato
if (string.IsNullOrWhiteSpace(batch.BatchNumber))
{
var generatedCode = await _autoCodeService.GenerateNextCodeAsync("article_batch");
if (generatedCode != null)
batch.BatchNumber = generatedCode;
else
throw new InvalidOperationException("Impossibile generare numero lotto automatico");
}
// Verifica unicità batch number per articolo // Verifica unicità batch number per articolo
if (await _context.ArticleBatches.AnyAsync(b => b.ArticleId == batch.ArticleId && b.BatchNumber == batch.BatchNumber)) if (await _context.ArticleBatches.AnyAsync(b => b.ArticleId == batch.ArticleId && b.BatchNumber == batch.BatchNumber))
throw new InvalidOperationException($"Esiste già un lotto '{batch.BatchNumber}' per questo articolo"); throw new InvalidOperationException($"Esiste già un lotto '{batch.BatchNumber}' per questo articolo");
@@ -809,9 +853,15 @@ public class WarehouseService : IWarehouseService
public async Task<StockMovement> CreateMovementAsync(StockMovement movement) public async Task<StockMovement> CreateMovementAsync(StockMovement movement)
{ {
// Genera numero documento se non specificato // Genera numero documento automaticamente se non specificato
if (string.IsNullOrEmpty(movement.DocumentNumber)) if (string.IsNullOrEmpty(movement.DocumentNumber))
movement.DocumentNumber = await GenerateDocumentNumberAsync(movement.Type); {
var generatedCode = await _autoCodeService.GenerateNextCodeAsync("stock_movement");
if (generatedCode != null)
movement.DocumentNumber = generatedCode;
else
movement.DocumentNumber = await GenerateDocumentNumberAsync(movement.Type); // Fallback
}
// Verifica unicità documento // Verifica unicità documento
if (await _context.StockMovements.AnyAsync(m => m.DocumentNumber == movement.DocumentNumber)) if (await _context.StockMovements.AnyAsync(m => m.DocumentNumber == movement.DocumentNumber))
@@ -1428,8 +1478,15 @@ public class WarehouseService : IWarehouseService
public async Task<InventoryCount> CreateInventoryCountAsync(InventoryCount inventory) public async Task<InventoryCount> CreateInventoryCountAsync(InventoryCount inventory)
{ {
// Genera codice automaticamente se non specificato
if (string.IsNullOrEmpty(inventory.Code)) if (string.IsNullOrEmpty(inventory.Code))
inventory.Code = $"INV/{DateTime.UtcNow:yyyyMMdd}/{await GenerateInventorySequenceAsync()}"; {
var generatedCode = await _autoCodeService.GenerateNextCodeAsync("inventory_count");
if (generatedCode != null)
inventory.Code = generatedCode;
else
inventory.Code = $"INV/{DateTime.UtcNow:yyyyMMdd}/{await GenerateInventorySequenceAsync()}"; // Fallback
}
inventory.CreatedAt = DateTime.UtcNow; inventory.CreatedAt = DateTime.UtcNow;
_context.InventoryCounts.Add(inventory); _context.InventoryCounts.Add(inventory);

View File

@@ -19,6 +19,7 @@ builder.Services.AddScoped<EventoCostiService>();
builder.Services.AddScoped<DemoDataService>(); builder.Services.AddScoped<DemoDataService>();
builder.Services.AddScoped<ReportGeneratorService>(); builder.Services.AddScoped<ReportGeneratorService>();
builder.Services.AddScoped<ModuleService>(); builder.Services.AddScoped<ModuleService>();
builder.Services.AddScoped<AutoCodeService>();
builder.Services.AddSingleton<DataNotificationService>(); builder.Services.AddSingleton<DataNotificationService>();
// Warehouse Module Services // Warehouse Module Services
@@ -100,6 +101,10 @@ using (var scope = app.Services.CreateScope())
// Seed warehouse default data // Seed warehouse default data
var warehouseService = scope.ServiceProvider.GetRequiredService<IWarehouseService>(); var warehouseService = scope.ServiceProvider.GetRequiredService<IWarehouseService>();
await warehouseService.SeedDefaultDataAsync(); await warehouseService.SeedDefaultDataAsync();
// Seed AutoCode configurations
var autoCodeService = scope.ServiceProvider.GetRequiredService<AutoCodeService>();
await autoCodeService.SeedDefaultConfigurationsAsync();
} }
if (app.Environment.IsDevelopment()) if (app.Environment.IsDevelopment())

View File

@@ -0,0 +1,489 @@
using System.Text.RegularExpressions;
using Apollinare.Domain.Entities;
using Apollinare.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace Apollinare.API.Services;
/// <summary>
/// Servizio per la generazione automatica di codici univoci.
/// Thread-safe grazie all'uso di transazioni database.
/// </summary>
public class AutoCodeService
{
private readonly AppollinareDbContext _db;
private readonly ILogger<AutoCodeService> _logger;
private static readonly Regex SequencePattern = new(@"\{SEQ:(\d+)\}", RegexOptions.Compiled);
public AutoCodeService(AppollinareDbContext db, ILogger<AutoCodeService> logger)
{
_db = db;
_logger = logger;
}
/// <summary>
/// Genera il prossimo codice per un'entità specifica.
/// Incrementa automaticamente la sequenza e gestisce i reset periodici.
/// </summary>
/// <param name="entityCode">Codice dell'entità (es. "warehouse_article")</param>
/// <returns>Il nuovo codice generato, o null se la generazione automatica è disabilitata</returns>
public async Task<string?> GenerateNextCodeAsync(string entityCode)
{
// Usa una transazione per garantire atomicità
await using var transaction = await _db.Database.BeginTransactionAsync();
try
{
var config = await _db.AutoCodes
.FirstOrDefaultAsync(c => c.EntityCode == entityCode);
if (config == null)
{
_logger.LogWarning("Configurazione AutoCode non trovata per entità: {EntityCode}", entityCode);
return null;
}
if (!config.IsEnabled)
{
_logger.LogDebug("Generazione automatica disabilitata per entità: {EntityCode}", entityCode);
return null;
}
var now = DateTime.Now;
// Gestione reset sequenza
if (ShouldResetSequence(config, now))
{
config.LastSequence = 0;
config.LastResetYear = now.Year;
config.LastResetMonth = now.Month;
_logger.LogInformation("Sequenza resettata per entità {EntityCode} (Anno: {Year}, Mese: {Month})",
entityCode, now.Year, now.Month);
}
// Incrementa sequenza
config.LastSequence++;
config.UpdatedAt = now;
// Genera codice dal pattern
var code = GenerateCodeFromPattern(config.Pattern, config.Prefix, config.LastSequence, now);
await _db.SaveChangesAsync();
await transaction.CommitAsync();
_logger.LogDebug("Codice generato per {EntityCode}: {Code}", entityCode, code);
return code;
}
catch (Exception ex)
{
await transaction.RollbackAsync();
_logger.LogError(ex, "Errore durante la generazione del codice per {EntityCode}", entityCode);
throw;
}
}
/// <summary>
/// Genera un codice di anteprima senza incrementare la sequenza.
/// Utile per mostrare all'utente cosa verrà generato.
/// </summary>
public async Task<string?> PreviewNextCodeAsync(string entityCode)
{
var config = await _db.AutoCodes
.AsNoTracking()
.FirstOrDefaultAsync(c => c.EntityCode == entityCode);
if (config == null || !config.IsEnabled)
return null;
var now = DateTime.Now;
var nextSequence = config.LastSequence + 1;
// Simula reset se necessario
if (ShouldResetSequence(config, now))
nextSequence = 1;
return GenerateCodeFromPattern(config.Pattern, config.Prefix, nextSequence, now);
}
/// <summary>
/// Verifica se un codice è già utilizzato per un'entità.
/// </summary>
public async Task<bool> IsCodeUniqueAsync(string entityCode, string code, int? excludeId = null)
{
return entityCode switch
{
"warehouse_article" => !await _db.WarehouseArticles
.AnyAsync(a => a.Code == code && (excludeId == null || a.Id != excludeId)),
"warehouse_location" => !await _db.WarehouseLocations
.AnyAsync(w => w.Code == code && (excludeId == null || w.Id != excludeId)),
"warehouse_category" => !await _db.WarehouseArticleCategories
.AnyAsync(c => c.Code == code && (excludeId == null || c.Id != excludeId)),
"stock_movement" => !await _db.StockMovements
.AnyAsync(m => m.DocumentNumber == code && (excludeId == null || m.Id != excludeId)),
"inventory_count" => !await _db.InventoryCounts
.AnyAsync(i => i.Code == code && (excludeId == null || i.Id != excludeId)),
"cliente" => !await _db.Clienti
.AnyAsync(c => c.Codice == code && (excludeId == null || c.Id != excludeId)),
"evento" => !await _db.Eventi
.AnyAsync(e => e.Codice == code && (excludeId == null || e.Id != excludeId)),
"articolo" => !await _db.Articoli
.AnyAsync(a => a.Codice == code && (excludeId == null || a.Id != excludeId)),
_ => true // Entità non gestita, assume codice unico
};
}
/// <summary>
/// Ottiene tutte le configurazioni AutoCode.
/// </summary>
public async Task<List<AutoCode>> GetAllConfigurationsAsync()
{
return await _db.AutoCodes
.OrderBy(c => c.ModuleCode)
.ThenBy(c => c.SortOrder)
.ThenBy(c => c.EntityName)
.ToListAsync();
}
/// <summary>
/// Ottiene le configurazioni AutoCode per un modulo specifico.
/// </summary>
public async Task<List<AutoCode>> GetConfigurationsByModuleAsync(string moduleCode)
{
return await _db.AutoCodes
.Where(c => c.ModuleCode == moduleCode)
.OrderBy(c => c.SortOrder)
.ThenBy(c => c.EntityName)
.ToListAsync();
}
/// <summary>
/// Ottiene una configurazione AutoCode per codice entità.
/// </summary>
public async Task<AutoCode?> GetConfigurationAsync(string entityCode)
{
return await _db.AutoCodes
.FirstOrDefaultAsync(c => c.EntityCode == entityCode);
}
/// <summary>
/// Aggiorna una configurazione AutoCode.
/// </summary>
public async Task<AutoCode> UpdateConfigurationAsync(int id, AutoCodeUpdateDto dto)
{
var config = await _db.AutoCodes.FindAsync(id)
?? throw new KeyNotFoundException($"Configurazione AutoCode con ID {id} non trovata");
if (dto.Prefix != null)
config.Prefix = dto.Prefix;
if (dto.Pattern != null)
{
// Valida il pattern
ValidatePattern(dto.Pattern);
config.Pattern = dto.Pattern;
}
if (dto.ResetSequenceYearly.HasValue)
config.ResetSequenceYearly = dto.ResetSequenceYearly.Value;
if (dto.ResetSequenceMonthly.HasValue)
config.ResetSequenceMonthly = dto.ResetSequenceMonthly.Value;
if (dto.IsEnabled.HasValue)
config.IsEnabled = dto.IsEnabled.Value;
if (dto.IsReadOnly.HasValue)
config.IsReadOnly = dto.IsReadOnly.Value;
if (dto.Description != null)
config.Description = dto.Description;
config.UpdatedAt = DateTime.Now;
await _db.SaveChangesAsync();
return config;
}
/// <summary>
/// Resetta manualmente la sequenza per un'entità.
/// </summary>
public async Task ResetSequenceAsync(string entityCode, long newValue = 0)
{
var config = await _db.AutoCodes
.FirstOrDefaultAsync(c => c.EntityCode == entityCode)
?? throw new KeyNotFoundException($"Configurazione AutoCode per '{entityCode}' non trovata");
config.LastSequence = newValue;
config.LastResetYear = DateTime.Now.Year;
config.LastResetMonth = DateTime.Now.Month;
config.UpdatedAt = DateTime.Now;
await _db.SaveChangesAsync();
_logger.LogInformation("Sequenza resettata manualmente per {EntityCode} a {Value}", entityCode, newValue);
}
/// <summary>
/// Inizializza le configurazioni di default per tutte le entità.
/// Chiamato al seed dell'applicazione.
/// </summary>
public async Task SeedDefaultConfigurationsAsync()
{
var defaults = new List<AutoCode>
{
// Core
new()
{
EntityCode = "cliente",
EntityName = "Cliente",
Prefix = "CLI",
Pattern = "{PREFIX}-{SEQ:5}",
ModuleCode = "core",
Description = "Codice cliente (es. CLI-00001)",
SortOrder = 10
},
new()
{
EntityCode = "evento",
EntityName = "Evento",
Prefix = "EVT",
Pattern = "{PREFIX}{YYYY}-{SEQ:5}",
ResetSequenceYearly = true,
ModuleCode = "core",
Description = "Codice evento con anno (es. EVT2025-00001)",
SortOrder = 20
},
new()
{
EntityCode = "articolo",
EntityName = "Articolo (Legacy)",
Prefix = "ART",
Pattern = "{PREFIX}-{SEQ:5}",
ModuleCode = "core",
Description = "Codice articolo legacy (es. ART-00001)",
SortOrder = 30
},
// Warehouse module
new()
{
EntityCode = "warehouse_location",
EntityName = "Magazzino",
Prefix = "MAG",
Pattern = "{PREFIX}-{SEQ:3}",
ModuleCode = "warehouse",
Description = "Codice magazzino (es. MAG-001)",
SortOrder = 10
},
new()
{
EntityCode = "warehouse_article",
EntityName = "Articolo Magazzino",
Prefix = "WA",
Pattern = "{PREFIX}{SEQ:6}",
ModuleCode = "warehouse",
Description = "Codice articolo magazzino (es. WA000001)",
SortOrder = 20
},
new()
{
EntityCode = "warehouse_category",
EntityName = "Categoria Articolo",
Prefix = "CAT",
Pattern = "{PREFIX}-{SEQ:4}",
ModuleCode = "warehouse",
Description = "Codice categoria (es. CAT-0001)",
SortOrder = 30
},
new()
{
EntityCode = "stock_movement",
EntityName = "Movimento Magazzino",
Prefix = "MOV",
Pattern = "{PREFIX}{YYYY}{MM}-{SEQ:5}",
ResetSequenceMonthly = true,
ModuleCode = "warehouse",
Description = "Numero documento movimento (es. MOV202511-00001)",
SortOrder = 40
},
new()
{
EntityCode = "inventory_count",
EntityName = "Inventario",
Prefix = "INV",
Pattern = "{PREFIX}{YYYY}-{SEQ:4}",
ResetSequenceYearly = true,
ModuleCode = "warehouse",
Description = "Codice inventario (es. INV2025-0001)",
SortOrder = 50
},
new()
{
EntityCode = "article_batch",
EntityName = "Lotto Articolo",
Prefix = "LOT",
Pattern = "{PREFIX}{YYYY}{MM}{DD}-{SEQ:4}",
ResetSequenceMonthly = true,
ModuleCode = "warehouse",
Description = "Numero lotto (es. LOT20251129-0001)",
SortOrder = 60
},
// Future: Purchases module
new()
{
EntityCode = "purchase_order",
EntityName = "Ordine Acquisto",
Prefix = "ODA",
Pattern = "{PREFIX}{YYYY}-{SEQ:5}",
ResetSequenceYearly = true,
ModuleCode = "purchases",
Description = "Numero ordine acquisto (es. ODA2025-00001)",
SortOrder = 10,
IsEnabled = false // Disabilitato finché il modulo non è implementato
},
new()
{
EntityCode = "supplier",
EntityName = "Fornitore",
Prefix = "FOR",
Pattern = "{PREFIX}-{SEQ:5}",
ModuleCode = "purchases",
Description = "Codice fornitore (es. FOR-00001)",
SortOrder = 20,
IsEnabled = false
},
// Future: Sales module
new()
{
EntityCode = "sales_order",
EntityName = "Ordine Vendita",
Prefix = "ODV",
Pattern = "{PREFIX}{YYYY}-{SEQ:5}",
ResetSequenceYearly = true,
ModuleCode = "sales",
Description = "Numero ordine vendita (es. ODV2025-00001)",
SortOrder = 10,
IsEnabled = false
},
new()
{
EntityCode = "invoice",
EntityName = "Fattura",
Prefix = "FT",
Pattern = "{PREFIX}{YYYY}/{SEQ:5}",
ResetSequenceYearly = true,
ModuleCode = "sales",
Description = "Numero fattura (es. FT2025/00001)",
SortOrder = 20,
IsEnabled = false
},
};
foreach (var config in defaults)
{
var exists = await _db.AutoCodes.AnyAsync(c => c.EntityCode == config.EntityCode);
if (!exists)
{
config.CreatedAt = DateTime.Now;
_db.AutoCodes.Add(config);
_logger.LogInformation("Configurazione AutoCode creata per {EntityCode}", config.EntityCode);
}
}
await _db.SaveChangesAsync();
}
#region Private Methods
private bool ShouldResetSequence(AutoCode config, DateTime now)
{
if (config.ResetSequenceMonthly)
{
return config.LastResetYear != now.Year || config.LastResetMonth != now.Month;
}
if (config.ResetSequenceYearly)
{
return config.LastResetYear != now.Year;
}
return false;
}
private static string GenerateCodeFromPattern(string pattern, string? prefix, long sequence, DateTime date)
{
var code = pattern
.Replace("{PREFIX}", prefix ?? "")
.Replace("{YEAR}", date.Year.ToString())
.Replace("{YYYY}", date.Year.ToString())
.Replace("{YY}", date.Year.ToString().Substring(2))
.Replace("{MONTH}", date.Month.ToString("D2"))
.Replace("{MM}", date.Month.ToString("D2"))
.Replace("{DAY}", date.Day.ToString("D2"))
.Replace("{DD}", date.Day.ToString("D2"));
// Gestisci {SEQ:n}
code = SequencePattern.Replace(code, match =>
{
var digits = int.Parse(match.Groups[1].Value);
return sequence.ToString($"D{digits}");
});
return code;
}
private static void ValidatePattern(string pattern)
{
// Verifica che ci sia almeno un placeholder sequenza
if (!SequencePattern.IsMatch(pattern))
{
throw new ArgumentException(
"Il pattern deve contenere almeno un placeholder {SEQ:n} per la sequenza numerica");
}
// Verifica che i placeholder siano validi
var validPlaceholders = new[]
{
"{PREFIX}", "{YEAR}", "{YYYY}", "{YY}",
"{MONTH}", "{MM}", "{DAY}", "{DD}"
};
var placeholderRegex = new Regex(@"\{[^}]+\}");
var matches = placeholderRegex.Matches(pattern);
foreach (Match match in matches)
{
var placeholder = match.Value;
if (!SequencePattern.IsMatch(placeholder) && !validPlaceholders.Contains(placeholder))
{
throw new ArgumentException($"Placeholder non valido: {placeholder}");
}
}
}
#endregion
}
/// <summary>
/// DTO per l'aggiornamento di una configurazione AutoCode.
/// </summary>
public class AutoCodeUpdateDto
{
public string? Prefix { get; set; }
public string? Pattern { get; set; }
public bool? ResetSequenceYearly { get; set; }
public bool? ResetSequenceMonthly { get; set; }
public bool? IsEnabled { get; set; }
public bool? IsReadOnly { get; set; }
public string? Description { get; set; }
}

Binary file not shown.

Binary file not shown.

View File

@@ -2,7 +2,16 @@ namespace Apollinare.Domain.Entities;
public class Articolo : BaseEntity public class Articolo : BaseEntity
{ {
/// <summary>
/// Codice articolo - generato automaticamente
/// </summary>
public string Codice { get; set; } = string.Empty; public string Codice { get; set; } = string.Empty;
/// <summary>
/// Codice alternativo (opzionale, inserito dall'utente)
/// </summary>
public string? CodiceAlternativo { get; set; }
public string Descrizione { get; set; } = string.Empty; public string Descrizione { get; set; } = string.Empty;
public int? TipoMaterialeId { get; set; } public int? TipoMaterialeId { get; set; }
public int? CategoriaId { get; set; } public int? CategoriaId { get; set; }

View File

@@ -0,0 +1,117 @@
namespace Apollinare.Domain.Entities;
/// <summary>
/// Configurazione per la generazione automatica di codici.
/// Ogni entità può avere la propria configurazione con pattern personalizzabile.
///
/// Pattern supportati:
/// - {SEQ:n} - Sequenza numerica con n cifre (es. {SEQ:5} → 00001)
/// - {YEAR} o {YYYY} - Anno corrente a 4 cifre
/// - {YY} - Anno corrente a 2 cifre
/// - {MONTH} o {MM} - Mese corrente a 2 cifre
/// - {DAY} o {DD} - Giorno corrente a 2 cifre
/// - {PREFIX} - Usa il prefisso definito
/// - Testo statico (es. "ART-", "-", "/")
///
/// Esempi di pattern:
/// - "ART-{YYYY}-{SEQ:5}" → ART-2025-00001
/// - "{PREFIX}{YY}{MM}{SEQ:4}" → MAG2511-0001
/// - "CLI/{YYYY}/{SEQ:6}" → CLI/2025/000001
/// </summary>
public class AutoCode : BaseEntity
{
/// <summary>
/// Codice univoco dell'entità (es. "warehouse_article", "warehouse_location", "cliente")
/// </summary>
public string EntityCode { get; set; } = string.Empty;
/// <summary>
/// Nome visualizzato dell'entità (es. "Articolo Magazzino", "Magazzino", "Cliente")
/// </summary>
public string EntityName { get; set; } = string.Empty;
/// <summary>
/// Prefisso opzionale da usare nel pattern con {PREFIX}
/// </summary>
public string? Prefix { get; set; }
/// <summary>
/// Pattern per la generazione del codice
/// </summary>
public string Pattern { get; set; } = "{PREFIX}{SEQ:5}";
/// <summary>
/// Ultimo numero di sequenza utilizzato (per {SEQ:n})
/// </summary>
public long LastSequence { get; set; } = 0;
/// <summary>
/// Se true, la sequenza viene resettata ogni anno
/// </summary>
public bool ResetSequenceYearly { get; set; } = false;
/// <summary>
/// Se true, la sequenza viene resettata ogni mese
/// </summary>
public bool ResetSequenceMonthly { get; set; } = false;
/// <summary>
/// Anno dell'ultimo reset della sequenza
/// </summary>
public int? LastResetYear { get; set; }
/// <summary>
/// Mese dell'ultimo reset della sequenza (se ResetSequenceMonthly)
/// </summary>
public int? LastResetMonth { get; set; }
/// <summary>
/// Se true, la generazione automatica è abilitata
/// </summary>
public bool IsEnabled { get; set; } = true;
/// <summary>
/// Se true, il codice non può essere modificato manualmente
/// </summary>
public bool IsReadOnly { get; set; } = false;
/// <summary>
/// Modulo di appartenenza (es. "core", "warehouse", "purchases")
/// </summary>
public string? ModuleCode { get; set; }
/// <summary>
/// Descrizione della configurazione
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Ordine di visualizzazione nel pannello admin
/// </summary>
public int SortOrder { get; set; } = 0;
/// <summary>
/// Esempio di codice generato (calcolato, non persistito)
/// </summary>
public string GetExampleCode()
{
var now = DateTime.Now;
return Pattern
.Replace("{PREFIX}", Prefix ?? "")
.Replace("{YEAR}", now.Year.ToString())
.Replace("{YYYY}", now.Year.ToString())
.Replace("{YY}", now.Year.ToString().Substring(2))
.Replace("{MONTH}", now.Month.ToString("D2"))
.Replace("{MM}", now.Month.ToString("D2"))
.Replace("{DAY}", now.Day.ToString("D2"))
.Replace("{DD}", now.Day.ToString("D2"))
.Replace("{SEQ:1}", "X")
.Replace("{SEQ:2}", "XX")
.Replace("{SEQ:3}", "XXX")
.Replace("{SEQ:4}", "XXXX")
.Replace("{SEQ:5}", "XXXXX")
.Replace("{SEQ:6}", "XXXXXX")
.Replace("{SEQ:7}", "XXXXXXX")
.Replace("{SEQ:8}", "XXXXXXXX");
}
}

View File

@@ -2,6 +2,16 @@ namespace Apollinare.Domain.Entities;
public class Cliente : BaseEntity public class Cliente : BaseEntity
{ {
/// <summary>
/// Codice cliente - generato automaticamente
/// </summary>
public string Codice { get; set; } = string.Empty;
/// <summary>
/// Codice alternativo (opzionale, inserito dall'utente)
/// </summary>
public string? CodiceAlternativo { get; set; }
public string RagioneSociale { get; set; } = string.Empty; public string RagioneSociale { get; set; } = string.Empty;
public string? Indirizzo { get; set; } public string? Indirizzo { get; set; }
public string? Cap { get; set; } public string? Cap { get; set; }

View File

@@ -6,10 +6,15 @@ namespace Apollinare.Domain.Entities.Warehouse;
public class WarehouseArticle : BaseEntity public class WarehouseArticle : BaseEntity
{ {
/// <summary> /// <summary>
/// Codice univoco articolo (SKU) /// Codice univoco articolo (SKU) - generato automaticamente
/// </summary> /// </summary>
public string Code { get; set; } = string.Empty; public string Code { get; set; } = string.Empty;
/// <summary>
/// Codice alternativo (opzionale, inserito dall'utente)
/// </summary>
public string? AlternativeCode { get; set; }
/// <summary> /// <summary>
/// Descrizione articolo /// Descrizione articolo
/// </summary> /// </summary>

View File

@@ -6,10 +6,15 @@ namespace Apollinare.Domain.Entities.Warehouse;
public class WarehouseArticleCategory : BaseEntity public class WarehouseArticleCategory : BaseEntity
{ {
/// <summary> /// <summary>
/// Codice categoria /// Codice categoria - generato automaticamente
/// </summary> /// </summary>
public string Code { get; set; } = string.Empty; public string Code { get; set; } = string.Empty;
/// <summary>
/// Codice alternativo (opzionale, inserito dall'utente)
/// </summary>
public string? AlternativeCode { get; set; }
/// <summary> /// <summary>
/// Nome categoria /// Nome categoria
/// </summary> /// </summary>

View File

@@ -6,10 +6,15 @@ namespace Apollinare.Domain.Entities.Warehouse;
public class WarehouseLocation : BaseEntity public class WarehouseLocation : BaseEntity
{ {
/// <summary> /// <summary>
/// Codice univoco del magazzino (es. "MAG01", "CENTRALE") /// Codice univoco del magazzino - generato automaticamente
/// </summary> /// </summary>
public string Code { get; set; } = string.Empty; public string Code { get; set; } = string.Empty;
/// <summary>
/// Codice alternativo (opzionale, inserito dall'utente)
/// </summary>
public string? AlternativeCode { get; set; }
/// <summary> /// <summary>
/// Nome descrittivo del magazzino /// Nome descrittivo del magazzino
/// </summary> /// </summary>

View File

@@ -41,6 +41,9 @@ public class AppollinareDbContext : DbContext
public DbSet<AppModule> AppModules => Set<AppModule>(); public DbSet<AppModule> AppModules => Set<AppModule>();
public DbSet<ModuleSubscription> ModuleSubscriptions => Set<ModuleSubscription>(); public DbSet<ModuleSubscription> ModuleSubscriptions => Set<ModuleSubscription>();
// Auto Code system
public DbSet<AutoCode> AutoCodes => Set<AutoCode>();
// Warehouse module entities // Warehouse module entities
public DbSet<WarehouseLocation> WarehouseLocations => Set<WarehouseLocation>(); public DbSet<WarehouseLocation> WarehouseLocations => Set<WarehouseLocation>();
public DbSet<WarehouseArticle> WarehouseArticles => Set<WarehouseArticle>(); public DbSet<WarehouseArticle> WarehouseArticles => Set<WarehouseArticle>();
@@ -274,6 +277,13 @@ public class AppollinareDbContext : DbContext
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
// AutoCode
modelBuilder.Entity<AutoCode>(entity =>
{
entity.HasIndex(e => e.EntityCode).IsUnique();
entity.HasIndex(e => e.ModuleCode);
});
// =============================================== // ===============================================
// WAREHOUSE MODULE ENTITIES // WAREHOUSE MODULE ENTITIES
// =============================================== // ===============================================

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,63 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Apollinare.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddAutoCodeSystem : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AutoCodes",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
EntityCode = table.Column<string>(type: "TEXT", nullable: false),
EntityName = table.Column<string>(type: "TEXT", nullable: false),
Prefix = table.Column<string>(type: "TEXT", nullable: true),
Pattern = table.Column<string>(type: "TEXT", nullable: false),
LastSequence = table.Column<long>(type: "INTEGER", nullable: false),
ResetSequenceYearly = table.Column<bool>(type: "INTEGER", nullable: false),
ResetSequenceMonthly = table.Column<bool>(type: "INTEGER", nullable: false),
LastResetYear = table.Column<int>(type: "INTEGER", nullable: true),
LastResetMonth = table.Column<int>(type: "INTEGER", nullable: true),
IsEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
IsReadOnly = table.Column<bool>(type: "INTEGER", nullable: false),
ModuleCode = table.Column<string>(type: "TEXT", nullable: true),
Description = table.Column<string>(type: "TEXT", nullable: true),
SortOrder = 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)
},
constraints: table =>
{
table.PrimaryKey("PK_AutoCodes", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_AutoCodes_EntityCode",
table: "AutoCodes",
column: "EntityCode",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_AutoCodes_ModuleCode",
table: "AutoCodes",
column: "ModuleCode");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AutoCodes");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,79 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Apollinare.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddAlternativeCodeFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "AlternativeCode",
table: "WarehouseLocations",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "AlternativeCode",
table: "WarehouseArticles",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "AlternativeCode",
table: "WarehouseArticleCategories",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Codice",
table: "Clienti",
type: "TEXT",
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "CodiceAlternativo",
table: "Clienti",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "CodiceAlternativo",
table: "Articoli",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AlternativeCode",
table: "WarehouseLocations");
migrationBuilder.DropColumn(
name: "AlternativeCode",
table: "WarehouseArticles");
migrationBuilder.DropColumn(
name: "AlternativeCode",
table: "WarehouseArticleCategories");
migrationBuilder.DropColumn(
name: "Codice",
table: "Clienti");
migrationBuilder.DropColumn(
name: "CodiceAlternativo",
table: "Clienti");
migrationBuilder.DropColumn(
name: "CodiceAlternativo",
table: "Articoli");
}
}
}

View File

@@ -98,6 +98,9 @@ namespace Apollinare.Infrastructure.Migrations
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("CodiceAlternativo")
.HasColumnType("TEXT");
b.Property<DateTime?>("CreatedAt") b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -153,6 +156,79 @@ namespace Apollinare.Infrastructure.Migrations
b.ToTable("Articoli"); b.ToTable("Articoli");
}); });
modelBuilder.Entity("Apollinare.Domain.Entities.AutoCode", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<string>("EntityCode")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EntityName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("IsEnabled")
.HasColumnType("INTEGER");
b.Property<bool>("IsReadOnly")
.HasColumnType("INTEGER");
b.Property<int?>("LastResetMonth")
.HasColumnType("INTEGER");
b.Property<int?>("LastResetYear")
.HasColumnType("INTEGER");
b.Property<long>("LastSequence")
.HasColumnType("INTEGER");
b.Property<string>("ModuleCode")
.HasColumnType("TEXT");
b.Property<string>("Pattern")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Prefix")
.HasColumnType("TEXT");
b.Property<bool>("ResetSequenceMonthly")
.HasColumnType("INTEGER");
b.Property<bool>("ResetSequenceYearly")
.HasColumnType("INTEGER");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("UpdatedBy")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("EntityCode")
.IsUnique();
b.HasIndex("ModuleCode");
b.ToTable("AutoCodes");
});
modelBuilder.Entity("Apollinare.Domain.Entities.Cliente", b => modelBuilder.Entity("Apollinare.Domain.Entities.Cliente", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -168,6 +244,13 @@ namespace Apollinare.Infrastructure.Migrations
b.Property<string>("Citta") b.Property<string>("Citta")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Codice")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CodiceAlternativo")
.HasColumnType("TEXT");
b.Property<string>("CodiceDestinatario") b.Property<string>("CodiceDestinatario")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -2170,6 +2253,9 @@ namespace Apollinare.Infrastructure.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("AlternativeCode")
.HasColumnType("TEXT");
b.Property<string>("Barcode") b.Property<string>("Barcode")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -2315,6 +2401,9 @@ namespace Apollinare.Infrastructure.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("AlternativeCode")
.HasColumnType("TEXT");
b.Property<string>("Code") b.Property<string>("Code")
.IsRequired() .IsRequired()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -2386,6 +2475,9 @@ namespace Apollinare.Infrastructure.Migrations
b.Property<string>("Address") b.Property<string>("Address")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("AlternativeCode")
.HasColumnType("TEXT");
b.Property<string>("City") b.Property<string>("City")
.HasColumnType("TEXT"); .HasColumnType("TEXT");