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
**Ultima sessione:** 29 Novembre 2025 (sera)
**Ultima sessione:** 30 Novembre 2025
**Stato progetto:** Migrazione Oracle APEX → .NET + React TypeScript in corso
**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)
- **Obiettivo:** Sistema di modularizzazione per gestire licenze, abbonamenti e funzionalità dinamiche
- **Backend implementato:**
@@ -318,11 +409,14 @@ XX. **Nome Problema (FIX/IMPLEMENTATO DATA):** - **Problema:** Descrizione breve
**MODULI BUSINESS (PRIORITÀ ALTA):**
1. [ ] **Implementare modulo Magazzino (warehouse)** - Base per tutti gli altri
2. [ ] **Implementare modulo Acquisti (purchases)** - Dipende da Magazzino
3. [ ] **Implementare modulo Vendite (sales)** - Dipende da Magazzino
4. [ ] **Implementare modulo Produzione (production)** - Dipende da Magazzino
5. [ ] **Implementare modulo Qualità (quality)** - Indipendente
1. [x] **Implementare modulo Magazzino (warehouse)** - COMPLETATO (backend)
- Backend: Entities, Service, Controllers, API completi
- Manca: Frontend (pagine React per gestione articoli, movimenti, giacenze)
2. [ ] **Frontend modulo Magazzino** - Pagine React per warehouse
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):**
@@ -1855,3 +1949,79 @@ public interface IWarehouseService
- `ModulePurchasePage.tsx`: Rimosso `moduleService` import
- `ModulesAdminPage.tsx`: Rimosso `PowerIcon`, `CheckIcon`, `CancelIcon`
- **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 ModulesAdminPage from "./pages/ModulesAdminPage";
import ModulePurchasePage from "./pages/ModulePurchasePage";
import AutoCodesAdminPage from "./pages/AutoCodesAdminPage";
import WarehouseRoutes from "./modules/warehouse/routes";
import { ModuleGuard } from "./components/ModuleGuard";
import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates";
@@ -90,12 +91,16 @@ function App() {
path="report-editor/:id"
element={<ReportEditorPage />}
/>
{/* Moduli */}
{/* Admin */}
<Route path="modules" element={<ModulesAdminPage />} />
<Route
path="modules/purchase/:code"
element={<ModulePurchasePage />}
/>
<Route
path="admin/auto-codes"
element={<AutoCodesAdminPage />}
/>
{/* Warehouse Module */}
<Route
path="warehouse/*"

View File

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

View File

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

View File

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

View File

@@ -117,6 +117,7 @@ export enum InventoryStatus {
export interface WarehouseLocationDto {
id: number;
code: string;
alternativeCode?: string;
name: string;
description?: string;
address?: string;
@@ -134,7 +135,8 @@ export interface WarehouseLocationDto {
}
export interface CreateWarehouseDto {
code: string;
// code è generato automaticamente dal backend
alternativeCode?: string;
name: string;
description?: string;
address?: string;
@@ -148,7 +150,20 @@ export interface CreateWarehouseDto {
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;
}
@@ -159,6 +174,7 @@ export interface UpdateWarehouseDto extends CreateWarehouseDto {
export interface CategoryDto {
id: number;
code: string;
alternativeCode?: string;
name: string;
description?: string;
parentCategoryId?: number;
@@ -189,7 +205,8 @@ export interface CategoryTreeDto {
}
export interface CreateCategoryDto {
code: string;
// code è generato automaticamente dal backend
alternativeCode?: string;
name: string;
description?: string;
parentCategoryId?: number;
@@ -201,7 +218,8 @@ export interface CreateCategoryDto {
}
export interface UpdateCategoryDto {
code: string;
// code non è modificabile
alternativeCode?: string;
name: string;
description?: string;
icon?: string;
@@ -219,6 +237,7 @@ export interface UpdateCategoryDto {
export interface ArticleDto {
id: number;
code: string;
alternativeCode?: string;
description: string;
shortDescription?: string;
barcode?: string;
@@ -253,9 +272,10 @@ export interface ArticleDto {
}
export interface CreateArticleDto {
code: string;
// code è generato automaticamente dal backend
description: string;
shortDescription?: string;
alternativeCode?: string;
barcode?: string;
manufacturerCode?: string;
categoryId?: number;
@@ -280,7 +300,33 @@ export interface CreateArticleDto {
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;
}
@@ -816,7 +862,7 @@ export function formatCurrency(value: number | undefined | null): string {
export function formatQuantity(
value: number | undefined | null,
decimals: number = 2
decimals: number = 2,
): string {
if (value === undefined || value === null) return "-";
return new Intl.NumberFormat("it-IT", {

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Box,
Typography,
@@ -16,11 +16,15 @@ import {
InputLabel,
Select,
MenuItem,
} from '@mui/material';
import { DataGrid, GridColDef } from '@mui/x-data-grid';
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material';
import { articoliService, lookupService } from '../services/lookupService';
import { Articolo } from '../types';
} from "@mui/material";
import { DataGrid, GridColDef } from "@mui/x-data-grid";
import {
Add as AddIcon,
Edit as EditIcon,
Delete as DeleteIcon,
} from "@mui/icons-material";
import { articoliService, lookupService } from "../services/lookupService";
import { Articolo } from "../types";
export default function ArticoliPage() {
const queryClient = useQueryClient();
@@ -29,39 +33,40 @@ export default function ArticoliPage() {
const [formData, setFormData] = useState<Partial<Articolo>>({ attivo: true });
const { data: articoli = [], isLoading } = useQuery({
queryKey: ['articoli'],
queryKey: ["articoli"],
queryFn: () => articoliService.getAll(),
});
const { data: tipiMateriale = [] } = useQuery({
queryKey: ['lookup', 'tipi-materiale'],
queryKey: ["lookup", "tipi-materiale"],
queryFn: () => lookupService.getTipiMateriale(),
});
const { data: categorie = [] } = useQuery({
queryKey: ['lookup', 'categorie'],
queryKey: ["lookup", "categorie"],
queryFn: () => lookupService.getCategorie(),
});
const createMutation = useMutation({
mutationFn: (data: Partial<Articolo>) => articoliService.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['articoli'] });
queryClient.invalidateQueries({ queryKey: ["articoli"] });
handleCloseDialog();
},
});
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: () => {
queryClient.invalidateQueries({ queryKey: ['articoli'] });
queryClient.invalidateQueries({ queryKey: ["articoli"] });
handleCloseDialog();
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => articoliService.delete(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['articoli'] }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["articoli"] }),
});
const handleCloseDialog = () => {
@@ -78,35 +83,45 @@ export default function ArticoliPage() {
const handleSubmit = () => {
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 {
createMutation.mutate(formData);
// In creazione, non inviamo il codice (generato automaticamente)
const { codice: _codice, ...createData } = formData;
createMutation.mutate(createData);
}
};
const columns: GridColDef[] = [
{ field: 'codice', headerName: 'Codice', width: 100 },
{ field: 'descrizione', headerName: 'Descrizione', flex: 1, minWidth: 200 },
{ field: "codice", headerName: "Codice", width: 100 },
{ field: "codiceAlternativo", headerName: "Cod. Alt.", width: 100 },
{ field: "descrizione", headerName: "Descrizione", flex: 1, minWidth: 200 },
{
field: 'tipoMateriale',
headerName: 'Tipo',
field: "tipoMateriale",
headerName: "Tipo",
width: 130,
valueGetter: (value: any) => value?.descrizione || '',
valueGetter: (value: any) => value?.descrizione || "",
},
{
field: 'categoria',
headerName: 'Categoria',
field: "categoria",
headerName: "Categoria",
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',
headerName: 'Azioni',
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",
headerName: "Azioni",
width: 120,
sortable: false,
renderCell: (params) => (
@@ -118,7 +133,7 @@ export default function ArticoliPage() {
size="small"
color="error"
onClick={() => {
if (confirm('Eliminare questo articolo?')) {
if (confirm("Eliminare questo articolo?")) {
deleteMutation.mutate(params.row.id);
}
}}
@@ -132,14 +147,25 @@ export default function ArticoliPage() {
return (
<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>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpenDialog(true)}>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => setOpenDialog(true)}
>
Nuovo Articolo
</Button>
</Box>
<Paper sx={{ height: 600, width: '100%' }}>
<Paper sx={{ height: 600, width: "100%" }}>
<DataGrid
rows={articoli}
columns={columns}
@@ -152,38 +178,89 @@ export default function ArticoliPage() {
/>
</Paper>
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="md" fullWidth>
<DialogTitle>{editingId ? 'Modifica Articolo' : 'Nuovo Articolo'}</DialogTitle>
<Dialog
open={openDialog}
onClose={handleCloseDialog}
maxWidth="md"
fullWidth
>
<DialogTitle>
{editingId ? "Modifica Articolo" : "Nuovo Articolo"}
</DialogTitle>
<DialogContent>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid size={{ xs: 12, md: 4 }}>
<Grid size={{ xs: 12, md: 3 }}>
<TextField
label="Codice"
fullWidth
required
value={formData.codice || ''}
onChange={(e) => setFormData({ ...formData, codice: e.target.value })}
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: 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
label="Descrizione"
fullWidth
required
value={formData.descrizione || ''}
onChange={(e) => setFormData({ ...formData, descrizione: e.target.value })}
value={formData.descrizione || ""}
onChange={(e) =>
setFormData({ ...formData, descrizione: e.target.value })
}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<FormControl fullWidth>
<InputLabel>Tipo Materiale</InputLabel>
<Select
value={formData.tipoMaterialeId || ''}
value={formData.tipoMaterialeId || ""}
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) => (
<MenuItem key={t.id} value={t.id}>{t.descrizione}</MenuItem>
<MenuItem key={t.id} value={t.id}>
{t.descrizione}
</MenuItem>
))}
</Select>
</FormControl>
@@ -192,12 +269,19 @@ export default function ArticoliPage() {
<FormControl fullWidth>
<InputLabel>Categoria</InputLabel>
<Select
value={formData.categoriaId || ''}
value={formData.categoriaId || ""}
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) => (
<MenuItem key={c.id} value={c.id}>{c.descrizione}</MenuItem>
<MenuItem key={c.id} value={c.id}>
{c.descrizione}
</MenuItem>
))}
</Select>
</FormControl>
@@ -207,16 +291,23 @@ export default function ArticoliPage() {
label="Quantità Disponibile"
fullWidth
type="number"
value={formData.qtaDisponibile || ''}
onChange={(e) => setFormData({ ...formData, qtaDisponibile: parseFloat(e.target.value) || undefined })}
value={formData.qtaDisponibile || ""}
onChange={(e) =>
setFormData({
...formData,
qtaDisponibile: parseFloat(e.target.value) || undefined,
})
}
/>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<TextField
label="Unità Misura"
fullWidth
value={formData.unitaMisura || ''}
onChange={(e) => setFormData({ ...formData, unitaMisura: e.target.value })}
value={formData.unitaMisura || ""}
onChange={(e) =>
setFormData({ ...formData, unitaMisura: e.target.value })
}
/>
</Grid>
<Grid size={{ xs: 12, md: 4 }}></Grid>
@@ -225,8 +316,13 @@ export default function ArticoliPage() {
label="Qta Std Adulti (A)"
fullWidth
type="number"
value={formData.qtaStdA || ''}
onChange={(e) => setFormData({ ...formData, qtaStdA: parseFloat(e.target.value) || undefined })}
value={formData.qtaStdA || ""}
onChange={(e) =>
setFormData({
...formData,
qtaStdA: parseFloat(e.target.value) || undefined,
})
}
/>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
@@ -234,8 +330,13 @@ export default function ArticoliPage() {
label="Qta Std Buffet (B)"
fullWidth
type="number"
value={formData.qtaStdB || ''}
onChange={(e) => setFormData({ ...formData, qtaStdB: parseFloat(e.target.value) || undefined })}
value={formData.qtaStdB || ""}
onChange={(e) =>
setFormData({
...formData,
qtaStdB: parseFloat(e.target.value) || undefined,
})
}
/>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
@@ -243,8 +344,13 @@ export default function ArticoliPage() {
label="Qta Std Seduti (S)"
fullWidth
type="number"
value={formData.qtaStdS || ''}
onChange={(e) => setFormData({ ...formData, qtaStdS: parseFloat(e.target.value) || undefined })}
value={formData.qtaStdS || ""}
onChange={(e) =>
setFormData({
...formData,
qtaStdS: parseFloat(e.target.value) || undefined,
})
}
/>
</Grid>
<Grid size={{ xs: 12 }}>
@@ -253,8 +359,10 @@ export default function ArticoliPage() {
fullWidth
multiline
rows={3}
value={formData.note || ''}
onChange={(e) => setFormData({ ...formData, note: e.target.value })}
value={formData.note || ""}
onChange={(e) =>
setFormData({ ...formData, note: e.target.value })
}
/>
</Grid>
</Grid>
@@ -262,7 +370,7 @@ export default function ArticoliPage() {
<DialogActions>
<Button onClick={handleCloseDialog}>Annulla</Button>
<Button variant="contained" onClick={handleSubmit}>
{editingId ? 'Salva' : 'Crea'}
{editingId ? "Salva" : "Crea"}
</Button>
</DialogActions>
</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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Box,
Typography,
@@ -12,11 +12,15 @@ import {
DialogActions,
TextField,
Grid,
} from '@mui/material';
import { DataGrid, GridColDef } from '@mui/x-data-grid';
import { Add as AddIcon, Edit as EditIcon, Delete as DeleteIcon } from '@mui/icons-material';
import { clientiService } from '../services/lookupService';
import { Cliente } from '../types';
} from "@mui/material";
import { DataGrid, GridColDef } from "@mui/x-data-grid";
import {
Add as AddIcon,
Edit as EditIcon,
Delete as DeleteIcon,
} from "@mui/icons-material";
import { clientiService } from "../services/lookupService";
import { Cliente } from "../types";
export default function ClientiPage() {
const queryClient = useQueryClient();
@@ -25,29 +29,30 @@ export default function ClientiPage() {
const [formData, setFormData] = useState<Partial<Cliente>>({ attivo: true });
const { data: clienti = [], isLoading } = useQuery({
queryKey: ['clienti'],
queryKey: ["clienti"],
queryFn: () => clientiService.getAll(),
});
const createMutation = useMutation({
mutationFn: (data: Partial<Cliente>) => clientiService.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['clienti'] });
queryClient.invalidateQueries({ queryKey: ["clienti"] });
handleCloseDialog();
},
});
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: () => {
queryClient.invalidateQueries({ queryKey: ['clienti'] });
queryClient.invalidateQueries({ queryKey: ["clienti"] });
handleCloseDialog();
},
});
const deleteMutation = useMutation({
mutationFn: (id: number) => clientiService.delete(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['clienti'] }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["clienti"] }),
});
const handleCloseDialog = () => {
@@ -64,22 +69,33 @@ export default function ClientiPage() {
const handleSubmit = () => {
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 {
createMutation.mutate(formData);
// In creazione, non inviamo il codice (generato automaticamente)
const { codice: _codice, ...createData } = formData;
createMutation.mutate(createData);
}
};
const columns: GridColDef[] = [
{ field: 'ragioneSociale', 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: "codice", headerName: "Codice", width: 100 },
{ field: "codiceAlternativo", headerName: "Cod. Alt.", width: 100 },
{
field: 'actions',
headerName: 'Azioni',
field: "ragioneSociale",
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,
sortable: false,
renderCell: (params) => (
@@ -91,7 +107,7 @@ export default function ClientiPage() {
size="small"
color="error"
onClick={() => {
if (confirm('Eliminare questo cliente?')) {
if (confirm("Eliminare questo cliente?")) {
deleteMutation.mutate(params.row.id);
}
}}
@@ -105,14 +121,25 @@ export default function ClientiPage() {
return (
<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>
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setOpenDialog(true)}>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => setOpenDialog(true)}
>
Nuovo Cliente
</Button>
</Box>
<Paper sx={{ height: 600, width: '100%' }}>
<Paper sx={{ height: 600, width: "100%" }}>
<DataGrid
rows={clienti}
columns={columns}
@@ -125,57 +152,120 @@ export default function ClientiPage() {
/>
</Paper>
<Dialog open={openDialog} onClose={handleCloseDialog} maxWidth="md" fullWidth>
<DialogTitle>{editingId ? 'Modifica Cliente' : 'Nuovo Cliente'}</DialogTitle>
<Dialog
open={openDialog}
onClose={handleCloseDialog}
maxWidth="md"
fullWidth
>
<DialogTitle>
{editingId ? "Modifica Cliente" : "Nuovo Cliente"}
</DialogTitle>
<DialogContent>
<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
label="Ragione Sociale"
fullWidth
required
value={formData.ragioneSociale || ''}
onChange={(e) => setFormData({ ...formData, ragioneSociale: e.target.value })}
value={formData.ragioneSociale || ""}
onChange={(e) =>
setFormData({ ...formData, ragioneSociale: e.target.value })
}
/>
</Grid>
<Grid size={{ xs: 12, md: 8 }}>
<TextField
label="Indirizzo"
fullWidth
value={formData.indirizzo || ''}
onChange={(e) => setFormData({ ...formData, indirizzo: e.target.value })}
value={formData.indirizzo || ""}
onChange={(e) =>
setFormData({ ...formData, indirizzo: e.target.value })
}
/>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<TextField
label="CAP"
fullWidth
value={formData.cap || ''}
onChange={(e) => setFormData({ ...formData, cap: e.target.value })}
value={formData.cap || ""}
onChange={(e) =>
setFormData({ ...formData, cap: e.target.value })
}
/>
</Grid>
<Grid size={{ xs: 12, md: 8 }}>
<TextField
label="Città"
fullWidth
value={formData.citta || ''}
onChange={(e) => setFormData({ ...formData, citta: e.target.value })}
value={formData.citta || ""}
onChange={(e) =>
setFormData({ ...formData, citta: e.target.value })
}
/>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<TextField
label="Provincia"
fullWidth
value={formData.provincia || ''}
onChange={(e) => setFormData({ ...formData, provincia: e.target.value })}
value={formData.provincia || ""}
onChange={(e) =>
setFormData({ ...formData, provincia: e.target.value })
}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
label="Telefono"
fullWidth
value={formData.telefono || ''}
onChange={(e) => setFormData({ ...formData, telefono: e.target.value })}
value={formData.telefono || ""}
onChange={(e) =>
setFormData({ ...formData, telefono: e.target.value })
}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
@@ -183,40 +273,53 @@ export default function ClientiPage() {
label="Email"
fullWidth
type="email"
value={formData.email || ''}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
value={formData.email || ""}
onChange={(e) =>
setFormData({ ...formData, email: e.target.value })
}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
label="PEC"
fullWidth
value={formData.pec || ''}
onChange={(e) => setFormData({ ...formData, pec: e.target.value })}
value={formData.pec || ""}
onChange={(e) =>
setFormData({ ...formData, pec: e.target.value })
}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
label="Codice Fiscale"
fullWidth
value={formData.codiceFiscale || ''}
onChange={(e) => setFormData({ ...formData, codiceFiscale: e.target.value })}
value={formData.codiceFiscale || ""}
onChange={(e) =>
setFormData({ ...formData, codiceFiscale: e.target.value })
}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
label="Partita IVA"
fullWidth
value={formData.partitaIva || ''}
onChange={(e) => setFormData({ ...formData, partitaIva: e.target.value })}
value={formData.partitaIva || ""}
onChange={(e) =>
setFormData({ ...formData, partitaIva: e.target.value })
}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
label="Codice Destinatario"
fullWidth
value={formData.codiceDestinatario || ''}
onChange={(e) => setFormData({ ...formData, codiceDestinatario: e.target.value })}
value={formData.codiceDestinatario || ""}
onChange={(e) =>
setFormData({
...formData,
codiceDestinatario: e.target.value,
})
}
/>
</Grid>
<Grid size={{ xs: 12 }}>
@@ -225,8 +328,10 @@ export default function ClientiPage() {
fullWidth
multiline
rows={3}
value={formData.note || ''}
onChange={(e) => setFormData({ ...formData, note: e.target.value })}
value={formData.note || ""}
onChange={(e) =>
setFormData({ ...formData, note: e.target.value })
}
/>
</Grid>
</Grid>
@@ -234,7 +339,7 @@ export default function ClientiPage() {
<DialogActions>
<Button onClick={handleCloseDialog}>Annulla</Button>
<Button variant="contained" onClick={handleSubmit}>
{editingId ? 'Salva' : 'Crea'}
{editingId ? "Salva" : "Crea"}
</Button>
</DialogActions>
</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 {
codice: string;
codiceAlternativo?: string;
ragioneSociale: string;
indirizzo?: string;
cap?: string;
@@ -89,6 +91,7 @@ export interface Risorsa extends BaseEntity {
export interface Articolo extends BaseEntity {
codice: string;
codiceAlternativo?: string;
descrizione: string;
tipoMaterialeId?: number;
tipoMateriale?: TipoMateriale;

View File

@@ -1,3 +1,4 @@
using Apollinare.API.Services;
using Apollinare.Domain.Entities;
using Apollinare.Infrastructure.Data;
using Microsoft.AspNetCore.Mvc;
@@ -10,10 +11,12 @@ namespace Apollinare.API.Controllers;
public class ArticoliController : ControllerBase
{
private readonly AppollinareDbContext _context;
private readonly AutoCodeService _autoCodeService;
public ArticoliController(AppollinareDbContext context)
public ArticoliController(AppollinareDbContext context, AutoCodeService autoCodeService)
{
_context = context;
_autoCodeService = autoCodeService;
}
[HttpGet]
@@ -60,6 +63,13 @@ public class ArticoliController : ControllerBase
[HttpPost]
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;
_context.Articoli.Add(articolo);
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.Infrastructure.Data;
using Microsoft.AspNetCore.Mvc;
@@ -10,10 +11,12 @@ namespace Apollinare.API.Controllers;
public class ClientiController : ControllerBase
{
private readonly AppollinareDbContext _context;
private readonly AutoCodeService _autoCodeService;
public ClientiController(AppollinareDbContext context)
public ClientiController(AppollinareDbContext context, AutoCodeService autoCodeService)
{
_context = context;
_autoCodeService = autoCodeService;
}
[HttpGet]
@@ -47,6 +50,13 @@ public class ClientiController : ControllerBase
[HttpPost]
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;
_context.Clienti.Add(cliente);
await _context.SaveChangesAsync();

View File

@@ -1,4 +1,5 @@
using Apollinare.API.Hubs;
using Apollinare.API.Services;
using Apollinare.Domain.Entities;
using Apollinare.Domain.Enums;
using Apollinare.Infrastructure.Data;
@@ -13,11 +14,13 @@ public class EventiController : ControllerBase
{
private readonly AppollinareDbContext _context;
private readonly DataNotificationService _notifier;
private readonly AutoCodeService _autoCodeService;
public EventiController(AppollinareDbContext context, DataNotificationService notifier)
public EventiController(AppollinareDbContext context, DataNotificationService notifier, AutoCodeService autoCodeService)
{
_context = context;
_notifier = notifier;
_autoCodeService = autoCodeService;
}
[HttpGet]
@@ -343,6 +346,12 @@ public class EventiController : ControllerBase
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 ultimoEvento = await _context.Eventi
.Where(e => e.Codice != null && e.Codice.StartsWith($"EV{anno}"))

View File

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

View File

@@ -1,3 +1,4 @@
using Apollinare.API.Services;
using Apollinare.Domain.Entities.Warehouse;
using Apollinare.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
@@ -13,6 +14,7 @@ public class WarehouseService : IWarehouseService
private readonly AppollinareDbContext _context;
private readonly IMemoryCache _cache;
private readonly ILogger<WarehouseService> _logger;
private readonly AutoCodeService _autoCodeService;
private const string WAREHOUSES_CACHE_KEY = "warehouse_locations";
private const string CATEGORIES_CACHE_KEY = "warehouse_categories";
@@ -22,11 +24,13 @@ public class WarehouseService : IWarehouseService
public WarehouseService(
AppollinareDbContext context,
IMemoryCache cache,
ILogger<WarehouseService> logger)
ILogger<WarehouseService> logger,
AutoCodeService autoCodeService)
{
_context = context;
_cache = cache;
_logger = logger;
_autoCodeService = autoCodeService;
}
#region Articoli
@@ -118,6 +122,16 @@ public class WarehouseService : IWarehouseService
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
if (await _context.WarehouseArticles.AnyAsync(a => a.Code == 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)
{
// 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))
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)
{
// 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))
throw new InvalidOperationException($"Esiste già un magazzino con codice '{warehouse.Code}'");
@@ -464,6 +498,16 @@ public class WarehouseService : IWarehouseService
if (!article.IsBatchManaged)
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
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");
@@ -809,9 +853,15 @@ public class WarehouseService : IWarehouseService
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))
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
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)
{
// Genera codice automaticamente se non specificato
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;
_context.InventoryCounts.Add(inventory);

View File

@@ -19,6 +19,7 @@ builder.Services.AddScoped<EventoCostiService>();
builder.Services.AddScoped<DemoDataService>();
builder.Services.AddScoped<ReportGeneratorService>();
builder.Services.AddScoped<ModuleService>();
builder.Services.AddScoped<AutoCodeService>();
builder.Services.AddSingleton<DataNotificationService>();
// Warehouse Module Services
@@ -100,6 +101,10 @@ using (var scope = app.Services.CreateScope())
// Seed warehouse default data
var warehouseService = scope.ServiceProvider.GetRequiredService<IWarehouseService>();
await warehouseService.SeedDefaultDataAsync();
// Seed AutoCode configurations
var autoCodeService = scope.ServiceProvider.GetRequiredService<AutoCodeService>();
await autoCodeService.SeedDefaultConfigurationsAsync();
}
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
{
/// <summary>
/// Codice articolo - 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 Descrizione { get; set; } = string.Empty;
public int? TipoMaterialeId { 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
{
/// <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? Indirizzo { get; set; }
public string? Cap { get; set; }

View File

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

View File

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

View File

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

View File

@@ -41,6 +41,9 @@ public class AppollinareDbContext : DbContext
public DbSet<AppModule> AppModules => Set<AppModule>();
public DbSet<ModuleSubscription> ModuleSubscriptions => Set<ModuleSubscription>();
// Auto Code system
public DbSet<AutoCode> AutoCodes => Set<AutoCode>();
// Warehouse module entities
public DbSet<WarehouseLocation> WarehouseLocations => Set<WarehouseLocation>();
public DbSet<WarehouseArticle> WarehouseArticles => Set<WarehouseArticle>();
@@ -274,6 +277,13 @@ public class AppollinareDbContext : DbContext
.OnDelete(DeleteBehavior.Cascade);
});
// AutoCode
modelBuilder.Entity<AutoCode>(entity =>
{
entity.HasIndex(e => e.EntityCode).IsUnique();
entity.HasIndex(e => e.ModuleCode);
});
// ===============================================
// 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()
.HasColumnType("TEXT");
b.Property<string>("CodiceAlternativo")
.HasColumnType("TEXT");
b.Property<DateTime?>("CreatedAt")
.HasColumnType("TEXT");
@@ -153,6 +156,79 @@ namespace Apollinare.Infrastructure.Migrations
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 =>
{
b.Property<int>("Id")
@@ -168,6 +244,13 @@ namespace Apollinare.Infrastructure.Migrations
b.Property<string>("Citta")
.HasColumnType("TEXT");
b.Property<string>("Codice")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("CodiceAlternativo")
.HasColumnType("TEXT");
b.Property<string>("CodiceDestinatario")
.HasColumnType("TEXT");
@@ -2170,6 +2253,9 @@ namespace Apollinare.Infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AlternativeCode")
.HasColumnType("TEXT");
b.Property<string>("Barcode")
.HasColumnType("TEXT");
@@ -2315,6 +2401,9 @@ namespace Apollinare.Infrastructure.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AlternativeCode")
.HasColumnType("TEXT");
b.Property<string>("Code")
.IsRequired()
.HasColumnType("TEXT");
@@ -2386,6 +2475,9 @@ namespace Apollinare.Infrastructure.Migrations
b.Property<string>("Address")
.HasColumnType("TEXT");
b.Property<string>("AlternativeCode")
.HasColumnType("TEXT");
b.Property<string>("City")
.HasColumnType("TEXT");