This commit is contained in:
2025-11-29 14:52:39 +01:00
parent bb2d0729e1
commit c7dbcde5dd
49 changed files with 23088 additions and 5 deletions

View File

@@ -19,6 +19,8 @@ import ReportTemplatesPage from "./pages/ReportTemplatesPage";
import ReportEditorPage from "./pages/ReportEditorPage";
import ModulesAdminPage from "./pages/ModulesAdminPage";
import ModulePurchasePage from "./pages/ModulePurchasePage";
import WarehouseRoutes from "./modules/warehouse/routes";
import { ModuleGuard } from "./components/ModuleGuard";
import { useRealTimeUpdates } from "./hooks/useRealTimeUpdates";
import { CollaborationProvider } from "./contexts/CollaborationContext";
import { ModuleProvider } from "./contexts/ModuleContext";
@@ -94,6 +96,15 @@ function App() {
path="modules/purchase/:code"
element={<ModulePurchasePage />}
/>
{/* Warehouse Module */}
<Route
path="warehouse/*"
element={
<ModuleGuard moduleCode="warehouse">
<WarehouseRoutes />
</ModuleGuard>
}
/>
</Route>
</Routes>
</RealTimeProvider>

View File

@@ -28,8 +28,10 @@ import {
Print as PrintIcon,
Close as CloseIcon,
Extension as ModulesIcon,
Warehouse as WarehouseIcon,
} from "@mui/icons-material";
import CollaborationIndicator from "./collaboration/CollaborationIndicator";
import { useModules } from "../contexts/ModuleContext";
const DRAWER_WIDTH = 240;
const DRAWER_WIDTH_COLLAPSED = 64;
@@ -42,6 +44,12 @@ const menuItems = [
{ text: "Location", icon: <PlaceIcon />, path: "/location" },
{ text: "Articoli", icon: <InventoryIcon />, path: "/articoli" },
{ text: "Risorse", icon: <PersonIcon />, path: "/risorse" },
{
text: "Magazzino",
icon: <WarehouseIcon />,
path: "/warehouse",
moduleCode: "warehouse",
},
{ text: "Report", icon: <PrintIcon />, path: "/report-templates" },
{ text: "Moduli", icon: <ModulesIcon />, path: "/modules" },
];
@@ -51,6 +59,7 @@ export default function Layout() {
const navigate = useNavigate();
const location = useLocation();
const theme = useTheme();
const { activeModules } = useModules();
// Breakpoints
const isMobile = useMediaQuery(theme.breakpoints.down("sm")); // < 600px
@@ -59,6 +68,12 @@ export default function Layout() {
// Drawer width based on screen size
const drawerWidth = isTablet ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH;
// Filter menu items based on active modules
const activeModuleCodes = activeModules.map((m) => m.code);
const filteredMenuItems = menuItems.filter(
(item) => !item.moduleCode || activeModuleCodes.includes(item.moduleCode),
);
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen);
};
@@ -89,7 +104,7 @@ export default function Layout() {
</Toolbar>
<Divider />
<List sx={{ flex: 1, py: 1 }}>
{menuItems.map((item) => (
{filteredMenuItems.map((item) => (
<ListItem key={item.text} disablePadding sx={{ px: 1 }}>
<ListItemButton
selected={location.pathname === item.path}

View File

@@ -0,0 +1,722 @@
import { createContext, useContext, useState, ReactNode } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
ArticleDto,
WarehouseLocationDto,
CreateArticleDto,
UpdateArticleDto,
CreateWarehouseDto,
UpdateWarehouseDto,
CreateCategoryDto,
UpdateCategoryDto,
CreateMovementDto,
CreateTransferDto,
CreateBatchDto,
CreateSerialDto,
CreateSerialsBulkDto,
CreateInventoryCountDto,
ArticleFilterDto,
MovementFilterDto,
StockLevelFilterDto,
BatchStatus,
SerialStatus,
InventoryStatus,
} from "../types";
import {
articleService,
warehouseLocationService,
categoryService,
movementService,
batchService,
serialService,
stockService,
inventoryService,
} from "../services/warehouseService";
// Query keys for React Query
export const warehouseQueryKeys = {
articles: ["warehouse", "articles"] as const,
article: (id: number) => ["warehouse", "articles", id] as const,
articleStock: (id: number) => ["warehouse", "articles", id, "stock"] as const,
locations: ["warehouse", "locations"] as const,
location: (id: number) => ["warehouse", "locations", id] as const,
categories: ["warehouse", "categories"] as const,
categoryTree: ["warehouse", "categories", "tree"] as const,
movements: ["warehouse", "movements"] as const,
movement: (id: number) => ["warehouse", "movements", id] as const,
batches: ["warehouse", "batches"] as const,
batch: (id: number) => ["warehouse", "batches", id] as const,
serials: ["warehouse", "serials"] as const,
serial: (id: number) => ["warehouse", "serials", id] as const,
stockLevels: ["warehouse", "stock-levels"] as const,
inventories: ["warehouse", "inventories"] as const,
inventory: (id: number) => ["warehouse", "inventories", id] as const,
};
// Context state interface
interface WarehouseContextState {
selectedArticle: ArticleDto | null;
selectedWarehouse: WarehouseLocationDto | null;
setSelectedArticle: (article: ArticleDto | null) => void;
setSelectedWarehouse: (warehouse: WarehouseLocationDto | null) => void;
}
const WarehouseContext = createContext<WarehouseContextState | undefined>(
undefined,
);
export function WarehouseProvider({ children }: { children: ReactNode }) {
const [selectedArticle, setSelectedArticle] = useState<ArticleDto | null>(
null,
);
const [selectedWarehouse, setSelectedWarehouse] =
useState<WarehouseLocationDto | null>(null);
const value: WarehouseContextState = {
selectedArticle,
selectedWarehouse,
setSelectedArticle,
setSelectedWarehouse,
};
return (
<WarehouseContext.Provider value={value}>
{children}
</WarehouseContext.Provider>
);
}
export function useWarehouseContext() {
const context = useContext(WarehouseContext);
if (!context) {
throw new Error(
"useWarehouseContext must be used within a WarehouseProvider",
);
}
return context;
}
// ============================================
// ARTICLES HOOKS
// ============================================
export function useArticles(filter?: ArticleFilterDto) {
return useQuery({
queryKey: [...warehouseQueryKeys.articles, filter],
queryFn: () => articleService.getAll(filter),
});
}
export function useArticle(id: number | undefined) {
return useQuery({
queryKey: warehouseQueryKeys.article(id!),
queryFn: () => articleService.getById(id!),
enabled: !!id,
});
}
export function useArticleStock(id: number | undefined) {
return useQuery({
queryKey: warehouseQueryKeys.articleStock(id!),
queryFn: () => articleService.getStock(id!),
enabled: !!id,
});
}
export function useCreateArticle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateArticleDto) => articleService.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.articles });
},
});
}
export function useUpdateArticle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdateArticleDto }) =>
articleService.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.articles });
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.article(id),
});
},
});
}
export function useDeleteArticle() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => articleService.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.articles });
},
});
}
export function useUploadArticleImage() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, file }: { id: number; file: File }) =>
articleService.uploadImage(id, file),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.article(id),
});
},
});
}
// ============================================
// WAREHOUSE LOCATIONS HOOKS
// ============================================
export function useWarehouses(params?: { active?: boolean }) {
return useQuery({
queryKey: [...warehouseQueryKeys.locations, params],
queryFn: () => warehouseLocationService.getAll(params?.active),
});
}
export function useWarehouse(id: number | undefined) {
return useQuery({
queryKey: warehouseQueryKeys.location(id!),
queryFn: () => warehouseLocationService.getById(id!),
enabled: !!id,
});
}
export function useCreateWarehouse() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateWarehouseDto) =>
warehouseLocationService.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.locations });
},
});
}
export function useUpdateWarehouse() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdateWarehouseDto }) =>
warehouseLocationService.update(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.locations });
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.location(id),
});
},
});
}
export function useDeleteWarehouse() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => warehouseLocationService.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.locations });
},
});
}
export function useSetDefaultWarehouse() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => warehouseLocationService.setDefault(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.locations });
},
});
}
// ============================================
// CATEGORIES HOOKS
// ============================================
export function useCategories() {
return useQuery({
queryKey: warehouseQueryKeys.categories,
queryFn: () => categoryService.getAll(),
});
}
export function useCategoryTree() {
return useQuery({
queryKey: warehouseQueryKeys.categoryTree,
queryFn: () => categoryService.getTree(),
});
}
export function useCreateCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateCategoryDto) => categoryService.create(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.categories,
});
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.categoryTree,
});
},
});
}
export function useUpdateCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdateCategoryDto }) =>
categoryService.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.categories,
});
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.categoryTree,
});
},
});
}
export function useDeleteCategory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => categoryService.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.categories,
});
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.categoryTree,
});
},
});
}
// ============================================
// STOCK MOVEMENTS HOOKS
// ============================================
export function useMovements(filter?: MovementFilterDto) {
return useQuery({
queryKey: [...warehouseQueryKeys.movements, filter],
queryFn: () => movementService.getAll(filter),
});
}
export function useMovement(id: number | undefined) {
return useQuery({
queryKey: warehouseQueryKeys.movement(id!),
queryFn: () => movementService.getById(id!),
enabled: !!id,
});
}
export function useCreateInboundMovement() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateMovementDto) =>
movementService.createInbound(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.movements });
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.stockLevels,
});
},
});
}
export function useCreateOutboundMovement() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateMovementDto) =>
movementService.createOutbound(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.movements });
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.stockLevels,
});
},
});
}
export function useCreateTransferMovement() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateTransferDto) =>
movementService.createTransfer(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.movements });
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.stockLevels,
});
},
});
}
export function useCreateAdjustmentMovement() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateMovementDto) =>
movementService.createAdjustment(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.movements });
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.stockLevels,
});
},
});
}
export function useConfirmMovement() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => movementService.confirm(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.movements });
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.movement(id),
});
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.stockLevels,
});
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.batches });
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.serials });
},
});
}
export function useCancelMovement() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => movementService.cancel(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.movements });
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.movement(id),
});
},
});
}
export function useDeleteMovement() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => movementService.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.movements });
},
});
}
// ============================================
// BATCHES HOOKS
// ============================================
export function useBatches(articleId?: number, status?: BatchStatus) {
return useQuery({
queryKey: [...warehouseQueryKeys.batches, articleId, status],
queryFn: () => batchService.getAll(articleId, status),
});
}
export function useBatch(id: number | undefined) {
return useQuery({
queryKey: warehouseQueryKeys.batch(id!),
queryFn: () => batchService.getById(id!),
enabled: !!id,
});
}
export function useArticleBatches(articleId: number | undefined) {
return useQuery({
queryKey: [...warehouseQueryKeys.batches, "article", articleId],
queryFn: () => batchService.getAll(articleId),
enabled: !!articleId,
});
}
export function useExpiringBatches(days: number = 30) {
return useQuery({
queryKey: [...warehouseQueryKeys.batches, "expiring", days],
queryFn: () => batchService.getExpiring(days),
});
}
export function useCreateBatch() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateBatchDto) => batchService.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.batches });
},
});
}
export function useUpdateBatchStatus() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, status }: { id: number; status: BatchStatus }) =>
batchService.updateStatus(id, status),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.batches });
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.batch(id) });
},
});
}
// ============================================
// SERIALS HOOKS
// ============================================
export function useSerials(articleId?: number, status?: SerialStatus) {
return useQuery({
queryKey: [...warehouseQueryKeys.serials, articleId, status],
queryFn: () => serialService.getAll(articleId, status),
});
}
export function useSerial(id: number | undefined) {
return useQuery({
queryKey: warehouseQueryKeys.serial(id!),
queryFn: () => serialService.getById(id!),
enabled: !!id,
});
}
export function useArticleSerials(articleId: number | undefined) {
return useQuery({
queryKey: [...warehouseQueryKeys.serials, "article", articleId],
queryFn: () => serialService.getAll(articleId),
enabled: !!articleId,
});
}
export function useCreateSerial() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateSerialDto) => serialService.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.serials });
},
});
}
export function useCreateSerialsBulk() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateSerialsBulkDto) => serialService.createBulk(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.serials });
},
});
}
export function useRegisterSerialSale() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
customerId,
saleReference,
}: {
id: number;
customerId?: number;
saleReference?: string;
}) => serialService.registerSale(id, customerId, saleReference),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.serials });
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.serial(id),
});
},
});
}
export function useRegisterSerialReturn() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
id,
warehouseId,
isDefective,
}: {
id: number;
warehouseId: number;
isDefective: boolean;
}) => serialService.registerReturn(id, warehouseId, isDefective),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.serials });
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.serial(id),
});
},
});
}
// ============================================
// STOCK LEVELS HOOKS
// ============================================
export function useStockLevels(filter?: StockLevelFilterDto) {
return useQuery({
queryKey: [...warehouseQueryKeys.stockLevels, filter],
queryFn: () => stockService.getAll(filter),
});
}
export function useArticleStockLevels(articleId: number | undefined) {
return useQuery({
queryKey: [...warehouseQueryKeys.stockLevels, "article", articleId],
queryFn: () => stockService.getAll({ articleId }),
enabled: !!articleId,
});
}
export function useWarehouseStockLevels(warehouseId: number | undefined) {
return useQuery({
queryKey: [...warehouseQueryKeys.stockLevels, "warehouse", warehouseId],
queryFn: () => stockService.getAll({ warehouseId }),
enabled: !!warehouseId,
});
}
export function useStockValuation(warehouseId?: number) {
return useQuery({
queryKey: [...warehouseQueryKeys.stockLevels, "valuation", warehouseId],
queryFn: () => stockService.getValuation(warehouseId || 0),
});
}
export function useClosePeriod() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (period: number) => stockService.closePeriod(period),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.stockLevels,
});
},
});
}
// ============================================
// INVENTORY HOOKS
// ============================================
export function useInventories(status?: InventoryStatus) {
return useQuery({
queryKey: [...warehouseQueryKeys.inventories, status],
queryFn: () => inventoryService.getAll(status),
});
}
export function useInventory(id: number | undefined) {
return useQuery({
queryKey: warehouseQueryKeys.inventory(id!),
queryFn: () => inventoryService.getById(id!),
enabled: !!id,
});
}
export function useCreateInventory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateInventoryCountDto) =>
inventoryService.create(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.inventories,
});
},
});
}
export function useStartInventory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => inventoryService.start(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.inventories,
});
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.inventory(id),
});
},
});
}
export function useUpdateInventoryLine() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
inventoryId: _inventoryId,
lineId,
countedQty,
}: {
inventoryId: number;
lineId: number;
countedQty: number;
}) => inventoryService.updateLine(lineId, countedQty),
onSuccess: (_, { inventoryId: invId }) => {
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.inventory(invId),
});
},
});
}
export function useCompleteInventory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => inventoryService.complete(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.inventories,
});
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.inventory(id),
});
},
});
}
export function useConfirmInventory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => inventoryService.confirm(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.inventories,
});
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.inventory(id),
});
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.stockLevels,
});
queryClient.invalidateQueries({ queryKey: warehouseQueryKeys.movements });
},
});
}
export function useCancelInventory() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: number) => inventoryService.cancel(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.inventories,
});
queryClient.invalidateQueries({
queryKey: warehouseQueryKeys.inventory(id),
});
},
});
}

View File

@@ -0,0 +1,76 @@
// Re-export all hooks from context
export {
useWarehouseContext,
warehouseQueryKeys,
// Articles
useArticles,
useArticle,
useArticleStock,
useCreateArticle,
useUpdateArticle,
useDeleteArticle,
useUploadArticleImage,
// Warehouses
useWarehouses,
useWarehouse,
useCreateWarehouse,
useUpdateWarehouse,
useDeleteWarehouse,
useSetDefaultWarehouse,
// Categories
useCategories,
useCategoryTree,
useCreateCategory,
useUpdateCategory,
useDeleteCategory,
// Movements
useMovements,
useMovement,
useCreateInboundMovement,
useCreateOutboundMovement,
useCreateTransferMovement,
useCreateAdjustmentMovement,
useConfirmMovement,
useCancelMovement,
useDeleteMovement,
// Batches
useBatches,
useBatch,
useArticleBatches,
useExpiringBatches,
useCreateBatch,
useUpdateBatchStatus,
// Serials
useSerials,
useSerial,
useArticleSerials,
useCreateSerial,
useCreateSerialsBulk,
useRegisterSerialSale,
useRegisterSerialReturn,
// Stock Levels
useStockLevels,
useArticleStockLevels,
useWarehouseStockLevels,
useStockValuation,
useClosePeriod,
// Inventory
useInventories,
useInventory,
useCreateInventory,
useStartInventory,
useUpdateInventoryLine,
useCompleteInventory,
useConfirmInventory,
useCancelInventory,
} from "../contexts/WarehouseContext";
// Export navigation hook
export { useWarehouseNavigation } from "./useWarehouseNavigation";
// Export calculation hooks
export {
useStockCalculations,
useArticleAvailability,
calculateValuation,
} from "./useStockCalculations";

View File

@@ -0,0 +1,280 @@
import { useMemo } from "react";
import { StockLevelDto, ArticleDto, ValuationMethod } from "../types";
interface StockSummary {
totalQuantity: number;
totalValue: number;
averageCost: number;
articleCount: number;
lowStockCount: number;
outOfStockCount: number;
}
interface ArticleStockInfo {
totalQuantity: number;
totalValue: number;
availableQuantity: number;
reservedQuantity: number;
warehouseBreakdown: {
warehouseId: number;
warehouseName: string;
quantity: number;
value: number;
}[];
}
/**
* Hook for stock calculations and aggregations
*/
export function useStockCalculations(stockLevels: StockLevelDto[] | undefined) {
const summary = useMemo<StockSummary>(() => {
if (!stockLevels || stockLevels.length === 0) {
return {
totalQuantity: 0,
totalValue: 0,
averageCost: 0,
articleCount: 0,
lowStockCount: 0,
outOfStockCount: 0,
};
}
const articleIds = new Set<number>();
let totalQuantity = 0;
let totalValue = 0;
let lowStockCount = 0;
let outOfStockCount = 0;
for (const level of stockLevels) {
articleIds.add(level.articleId);
totalQuantity += level.quantity;
totalValue += level.stockValue || 0;
if (level.quantity <= 0) {
outOfStockCount++;
} else if (level.isLowStock) {
lowStockCount++;
}
}
return {
totalQuantity,
totalValue,
averageCost: totalQuantity > 0 ? totalValue / totalQuantity : 0,
articleCount: articleIds.size,
lowStockCount,
outOfStockCount,
};
}, [stockLevels]);
const getArticleStock = useMemo(() => {
return (articleId: number): ArticleStockInfo => {
if (!stockLevels) {
return {
totalQuantity: 0,
totalValue: 0,
availableQuantity: 0,
reservedQuantity: 0,
warehouseBreakdown: [],
};
}
const articleLevels = stockLevels.filter(
(l) => l.articleId === articleId,
);
let totalQuantity = 0;
let totalValue = 0;
let reservedQuantity = 0;
const warehouseBreakdown: ArticleStockInfo["warehouseBreakdown"] = [];
for (const level of articleLevels) {
totalQuantity += level.quantity;
totalValue += level.stockValue || 0;
reservedQuantity += level.reservedQuantity;
warehouseBreakdown.push({
warehouseId: level.warehouseId,
warehouseName:
level.warehouseName || `Magazzino ${level.warehouseId}`,
quantity: level.quantity,
value: level.stockValue || 0,
});
}
return {
totalQuantity,
totalValue,
availableQuantity: totalQuantity - reservedQuantity,
reservedQuantity,
warehouseBreakdown,
};
};
}, [stockLevels]);
const groupByWarehouse = useMemo(() => {
if (!stockLevels) return new Map<number, StockLevelDto[]>();
const grouped = new Map<number, StockLevelDto[]>();
for (const level of stockLevels) {
const existing = grouped.get(level.warehouseId) || [];
existing.push(level);
grouped.set(level.warehouseId, existing);
}
return grouped;
}, [stockLevels]);
return {
summary,
getArticleStock,
groupByWarehouse,
};
}
/**
* Calculate valuation based on method
*/
export function calculateValuation(
movements: { quantity: number; unitCost: number; date: string }[],
method: ValuationMethod,
targetQuantity: number,
): { totalCost: number; averageCost: number } {
if (targetQuantity <= 0 || movements.length === 0) {
return { totalCost: 0, averageCost: 0 };
}
// Sort movements by date
const sorted = [...movements].sort(
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
);
switch (method) {
case ValuationMethod.WeightedAverage: {
let totalQty = 0;
let totalCost = 0;
for (const m of sorted) {
totalQty += m.quantity;
totalCost += m.quantity * m.unitCost;
}
const avgCost = totalQty > 0 ? totalCost / totalQty : 0;
return {
totalCost: targetQuantity * avgCost,
averageCost: avgCost,
};
}
case ValuationMethod.FIFO: {
// First In, First Out
let remaining = targetQuantity;
let totalCost = 0;
for (const m of sorted) {
if (remaining <= 0) break;
const take = Math.min(remaining, m.quantity);
totalCost += take * m.unitCost;
remaining -= take;
}
return {
totalCost,
averageCost: targetQuantity > 0 ? totalCost / targetQuantity : 0,
};
}
case ValuationMethod.LIFO: {
// Last In, First Out
const reversed = [...sorted].reverse();
let remaining = targetQuantity;
let totalCost = 0;
for (const m of reversed) {
if (remaining <= 0) break;
const take = Math.min(remaining, m.quantity);
totalCost += take * m.unitCost;
remaining -= take;
}
return {
totalCost,
averageCost: targetQuantity > 0 ? totalCost / targetQuantity : 0,
};
}
case ValuationMethod.StandardCost: {
// Use the most recent cost as standard
const lastMovement = sorted[sorted.length - 1];
const standardCost = lastMovement?.unitCost || 0;
return {
totalCost: targetQuantity * standardCost,
averageCost: standardCost,
};
}
case ValuationMethod.SpecificCost: {
// Specific cost requires batch/serial tracking
// For now, fall back to weighted average
let totalQty = 0;
let totalCost = 0;
for (const m of sorted) {
totalQty += m.quantity;
totalCost += m.quantity * m.unitCost;
}
const avgCost = totalQty > 0 ? totalCost / totalQty : 0;
return {
totalCost: targetQuantity * avgCost,
averageCost: avgCost,
};
}
default:
return { totalCost: 0, averageCost: 0 };
}
}
/**
* Hook for article availability check
*/
export function useArticleAvailability(
article: ArticleDto | undefined,
stockLevels: StockLevelDto[] | undefined,
requestedQuantity: number,
warehouseId?: number,
) {
return useMemo(() => {
if (!article || !stockLevels) {
return {
isAvailable: false,
availableQuantity: 0,
shortageQuantity: requestedQuantity,
message: "Dati non disponibili",
};
}
const relevantLevels = warehouseId
? stockLevels.filter(
(l) => l.articleId === article.id && l.warehouseId === warehouseId,
)
: stockLevels.filter((l) => l.articleId === article.id);
const totalAvailable = relevantLevels.reduce(
(sum, l) =>
sum + (l.availableQuantity || l.quantity - l.reservedQuantity),
0,
);
const isAvailable = totalAvailable >= requestedQuantity;
const shortageQuantity = Math.max(0, requestedQuantity - totalAvailable);
let message: string;
if (isAvailable) {
message = `Disponibile: ${totalAvailable} ${article.unitOfMeasure}`;
} else if (totalAvailable > 0) {
message = `Disponibile parzialmente: ${totalAvailable} ${article.unitOfMeasure} (mancano ${shortageQuantity})`;
} else {
message = "Non disponibile";
}
return {
isAvailable,
availableQuantity: totalAvailable,
shortageQuantity,
message,
};
}, [article, stockLevels, requestedQuantity, warehouseId]);
}

View File

@@ -0,0 +1,160 @@
import { useNavigate } from 'react-router-dom';
import { useCallback } from 'react';
/**
* Hook for navigating within the warehouse module
*/
export function useWarehouseNavigation() {
const navigate = useNavigate();
// Articles
const goToArticles = useCallback(() => {
navigate('/warehouse/articles');
}, [navigate]);
const goToArticle = useCallback((id: number) => {
navigate(`/warehouse/articles/${id}`);
}, [navigate]);
const goToNewArticle = useCallback(() => {
navigate('/warehouse/articles/new');
}, [navigate]);
const goToEditArticle = useCallback((id: number) => {
navigate(`/warehouse/articles/${id}/edit`);
}, [navigate]);
// Warehouses
const goToWarehouses = useCallback(() => {
navigate('/warehouse/locations');
}, [navigate]);
const goToWarehouse = useCallback((id: number) => {
navigate(`/warehouse/locations/${id}`);
}, [navigate]);
const goToNewWarehouse = useCallback(() => {
navigate('/warehouse/locations/new');
}, [navigate]);
// Categories
const goToCategories = useCallback(() => {
navigate('/warehouse/categories');
}, [navigate]);
// Movements
const goToMovements = useCallback(() => {
navigate('/warehouse/movements');
}, [navigate]);
const goToMovement = useCallback((id: number) => {
navigate(`/warehouse/movements/${id}`);
}, [navigate]);
const goToNewInbound = useCallback(() => {
navigate('/warehouse/movements/inbound/new');
}, [navigate]);
const goToNewOutbound = useCallback(() => {
navigate('/warehouse/movements/outbound/new');
}, [navigate]);
const goToNewTransfer = useCallback(() => {
navigate('/warehouse/movements/transfer/new');
}, [navigate]);
const goToNewAdjustment = useCallback(() => {
navigate('/warehouse/movements/adjustment/new');
}, [navigate]);
// Batches
const goToBatches = useCallback(() => {
navigate('/warehouse/batches');
}, [navigate]);
const goToBatch = useCallback((id: number) => {
navigate(`/warehouse/batches/${id}`);
}, [navigate]);
const goToNewBatch = useCallback(() => {
navigate('/warehouse/batches/new');
}, [navigate]);
// Serials
const goToSerials = useCallback(() => {
navigate('/warehouse/serials');
}, [navigate]);
const goToSerial = useCallback((id: number) => {
navigate(`/warehouse/serials/${id}`);
}, [navigate]);
const goToNewSerial = useCallback(() => {
navigate('/warehouse/serials/new');
}, [navigate]);
// Stock
const goToStockLevels = useCallback(() => {
navigate('/warehouse/stock');
}, [navigate]);
const goToValuation = useCallback(() => {
navigate('/warehouse/valuation');
}, [navigate]);
// Inventory
const goToInventories = useCallback(() => {
navigate('/warehouse/inventories');
}, [navigate]);
const goToInventory = useCallback((id: number) => {
navigate(`/warehouse/inventories/${id}`);
}, [navigate]);
const goToNewInventory = useCallback(() => {
navigate('/warehouse/inventories/new');
}, [navigate]);
// Dashboard
const goToDashboard = useCallback(() => {
navigate('/warehouse');
}, [navigate]);
return {
// Articles
goToArticles,
goToArticle,
goToNewArticle,
goToEditArticle,
// Warehouses
goToWarehouses,
goToWarehouse,
goToNewWarehouse,
// Categories
goToCategories,
// Movements
goToMovements,
goToMovement,
goToNewInbound,
goToNewOutbound,
goToNewTransfer,
goToNewAdjustment,
// Batches
goToBatches,
goToBatch,
goToNewBatch,
// Serials
goToSerials,
goToSerial,
goToNewSerial,
// Stock
goToStockLevels,
goToValuation,
// Inventory
goToInventories,
goToInventory,
goToNewInventory,
// Dashboard
goToDashboard,
};
}

View File

@@ -0,0 +1,900 @@
import React, { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import {
Box,
Paper,
Typography,
TextField,
Button,
Grid,
FormControl,
InputLabel,
Select,
MenuItem,
FormControlLabel,
Switch,
Divider,
Alert,
CircularProgress,
IconButton,
Card,
CardMedia,
Tabs,
Tab,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Chip,
InputAdornment,
} from "@mui/material";
import {
ArrowBack as ArrowBackIcon,
Save as SaveIcon,
Upload as UploadIcon,
Delete as DeleteIcon,
Image as ImageIcon,
} from "@mui/icons-material";
import {
useArticle,
useCreateArticle,
useUpdateArticle,
useUploadArticleImage,
useCategoryTree,
useArticleStockLevels,
useArticleBatches,
useArticleSerials,
} from "../hooks";
import {
ValuationMethod,
StockManagementType,
valuationMethodLabels,
stockManagementTypeLabels,
formatCurrency,
formatQuantity,
formatDate,
CreateArticleDto,
UpdateArticleDto,
StockLevelDto,
BatchDto,
SerialDto,
} from "../types";
interface TabPanelProps {
children?: React.ReactNode;
index: number;
value: number;
}
function TabPanel(props: TabPanelProps) {
const { children, value, index, ...other } = props;
return (
<div role="tabpanel" hidden={value !== index} {...other}>
{value === index && <Box sx={{ pt: 3 }}>{children}</Box>}
</div>
);
}
export default function ArticleFormPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const isNew = !id || id === "new";
const articleId = isNew ? undefined : parseInt(id, 10);
const [tabValue, setTabValue] = useState(0);
const [formData, setFormData] = useState({
code: "",
description: "",
shortDescription: "",
categoryId: undefined as number | undefined,
unitOfMeasure: "PZ",
barcode: "",
minimumStock: 0,
maximumStock: 0,
reorderPoint: 0,
reorderQuantity: 0,
standardCost: 0,
stockManagement: StockManagementType.Standard,
valuationMethod: ValuationMethod.WeightedAverage,
isBatchManaged: false,
isSerialManaged: false,
hasExpiry: false,
expiryWarningDays: 30,
isActive: true,
notes: "",
});
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [errors, setErrors] = useState<Record<string, string>>({});
const { data: article, isLoading: loadingArticle } = useArticle(articleId);
const { data: categoryTree } = useCategoryTree();
const { data: stockLevels } = useArticleStockLevels(articleId);
const { data: batches } = useArticleBatches(articleId);
const { data: serials } = useArticleSerials(articleId);
const createMutation = useCreateArticle();
const updateMutation = useUpdateArticle();
const uploadImageMutation = useUploadArticleImage();
// Flatten category tree
const flatCategories = React.useMemo(() => {
const result: { id: number; name: string; level: number }[] = [];
const flatten = (categories: typeof categoryTree, level = 0) => {
if (!categories) return;
for (const cat of categories) {
result.push({ id: cat.id, name: cat.name, level });
if (cat.children) flatten(cat.children, level + 1);
}
};
flatten(categoryTree);
return result;
}, [categoryTree]);
// Load article data
useEffect(() => {
if (article) {
setFormData({
code: article.code,
description: article.description,
shortDescription: article.shortDescription || "",
categoryId: article.categoryId,
unitOfMeasure: article.unitOfMeasure,
barcode: article.barcode || "",
minimumStock: article.minimumStock || 0,
maximumStock: article.maximumStock || 0,
reorderPoint: article.reorderPoint || 0,
reorderQuantity: article.reorderQuantity || 0,
standardCost: article.standardCost || 0,
stockManagement: article.stockManagement,
valuationMethod:
article.valuationMethod || ValuationMethod.WeightedAverage,
isBatchManaged: article.isBatchManaged,
isSerialManaged: article.isSerialManaged,
hasExpiry: article.hasExpiry,
expiryWarningDays: article.expiryWarningDays || 30,
isActive: article.isActive,
notes: article.notes || "",
});
if (article.hasImage) {
setImagePreview(`/api/warehouse/articles/${article.id}/image`);
}
}
}, [article]);
const handleChange = (field: string, value: unknown) => {
setFormData((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: "" }));
}
};
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setImageFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setImagePreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleRemoveImage = () => {
setImageFile(null);
setImagePreview(null);
};
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.code.trim()) {
newErrors.code = "Il codice è obbligatorio";
}
if (!formData.description.trim()) {
newErrors.description = "La descrizione è obbligatoria";
}
if (!formData.unitOfMeasure.trim()) {
newErrors.unitOfMeasure = "L'unità di misura è obbligatoria";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate()) return;
try {
let savedId: number;
if (isNew) {
const createData: CreateArticleDto = {
code: formData.code,
description: formData.description,
shortDescription: formData.shortDescription || undefined,
categoryId: formData.categoryId,
unitOfMeasure: formData.unitOfMeasure,
barcode: formData.barcode || undefined,
minimumStock: formData.minimumStock,
maximumStock: formData.maximumStock,
reorderPoint: formData.reorderPoint,
reorderQuantity: formData.reorderQuantity,
standardCost: formData.standardCost,
stockManagement: formData.stockManagement,
valuationMethod: formData.valuationMethod,
isBatchManaged: formData.isBatchManaged,
isSerialManaged: formData.isSerialManaged,
hasExpiry: formData.hasExpiry,
expiryWarningDays: formData.expiryWarningDays,
notes: formData.notes || undefined,
};
const result = await createMutation.mutateAsync(createData);
savedId = result.id;
} else {
const updateData: UpdateArticleDto = {
code: formData.code,
description: formData.description,
shortDescription: formData.shortDescription || undefined,
categoryId: formData.categoryId,
unitOfMeasure: formData.unitOfMeasure,
barcode: formData.barcode || undefined,
minimumStock: formData.minimumStock,
maximumStock: formData.maximumStock,
reorderPoint: formData.reorderPoint,
reorderQuantity: formData.reorderQuantity,
standardCost: formData.standardCost,
stockManagement: formData.stockManagement,
valuationMethod: formData.valuationMethod,
isBatchManaged: formData.isBatchManaged,
isSerialManaged: formData.isSerialManaged,
hasExpiry: formData.hasExpiry,
expiryWarningDays: formData.expiryWarningDays,
isActive: formData.isActive,
notes: formData.notes || undefined,
};
await updateMutation.mutateAsync({ id: articleId!, data: updateData });
savedId = articleId!;
}
// Upload image if selected
if (imageFile) {
await uploadImageMutation.mutateAsync({ id: savedId, file: imageFile });
}
navigate(`/warehouse/articles/${savedId}`);
} catch (error) {
console.error("Errore salvataggio:", error);
}
};
const isPending =
createMutation.isPending ||
updateMutation.isPending ||
uploadImageMutation.isPending;
if (!isNew && loadingArticle) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
minHeight={400}
>
<CircularProgress />
</Box>
);
}
return (
<Box>
{/* Header */}
<Box sx={{ mb: 3, display: "flex", alignItems: "center", gap: 2 }}>
<IconButton onClick={() => navigate(-1)}>
<ArrowBackIcon />
</IconButton>
<Typography variant="h5" fontWeight="bold">
{isNew ? "Nuovo Articolo" : `Articolo: ${article?.code}`}
</Typography>
</Box>
{(createMutation.error || updateMutation.error) && (
<Alert severity="error" sx={{ mb: 3 }}>
Errore durante il salvataggio:{" "}
{((createMutation.error || updateMutation.error) as Error).message}
</Alert>
)}
{!isNew && (
<Box sx={{ borderBottom: 1, borderColor: "divider", mb: 3 }}>
<Tabs value={tabValue} onChange={(_, v) => setTabValue(v)}>
<Tab label="Dati Generali" />
<Tab label="Giacenze" />
{article?.isBatchManaged && <Tab label="Lotti" />}
{article?.isSerialManaged && <Tab label="Matricole" />}
</Tabs>
</Box>
)}
<TabPanel value={tabValue} index={0}>
<form onSubmit={handleSubmit}>
<Grid container spacing={3}>
{/* Left Column - Form */}
<Grid size={{ xs: 12, md: 8 }}>
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Informazioni Base
</Typography>
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 4 }}>
<TextField
fullWidth
label="Codice"
value={formData.code}
onChange={(e) => handleChange("code", e.target.value)}
error={!!errors.code}
helperText={errors.code}
required
disabled={!isNew}
/>
</Grid>
<Grid size={{ xs: 12, sm: 8 }}>
<TextField
fullWidth
label="Descrizione"
value={formData.description}
onChange={(e) =>
handleChange("description", e.target.value)
}
error={!!errors.description}
helperText={errors.description}
required
/>
</Grid>
<Grid size={12}>
<TextField
fullWidth
label="Descrizione Breve"
value={formData.shortDescription}
onChange={(e) =>
handleChange("shortDescription", e.target.value)
}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<FormControl fullWidth>
<InputLabel>Categoria</InputLabel>
<Select
value={formData.categoryId || ""}
label="Categoria"
onChange={(e) =>
handleChange(
"categoryId",
e.target.value || undefined,
)
}
>
<MenuItem value="">
<em>Nessuna</em>
</MenuItem>
{flatCategories.map((cat) => (
<MenuItem key={cat.id} value={cat.id}>
{"—".repeat(cat.level)} {cat.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, sm: 3 }}>
<TextField
fullWidth
label="Unità di Misura"
value={formData.unitOfMeasure}
onChange={(e) =>
handleChange("unitOfMeasure", e.target.value)
}
error={!!errors.unitOfMeasure}
helperText={errors.unitOfMeasure}
required
/>
</Grid>
<Grid size={{ xs: 12, sm: 3 }}>
<TextField
fullWidth
label="Codice a Barre"
value={formData.barcode}
onChange={(e) => handleChange("barcode", e.target.value)}
/>
</Grid>
</Grid>
<Divider sx={{ my: 3 }} />
<Typography variant="h6" gutterBottom>
Livelli di Scorta
</Typography>
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 3 }}>
<TextField
fullWidth
label="Scorta Minima"
type="number"
value={formData.minimumStock}
onChange={(e) =>
handleChange(
"minimumStock",
parseFloat(e.target.value) || 0,
)
}
InputProps={{ inputProps: { min: 0, step: 0.01 } }}
/>
</Grid>
<Grid size={{ xs: 12, sm: 3 }}>
<TextField
fullWidth
label="Scorta Massima"
type="number"
value={formData.maximumStock}
onChange={(e) =>
handleChange(
"maximumStock",
parseFloat(e.target.value) || 0,
)
}
InputProps={{ inputProps: { min: 0, step: 0.01 } }}
/>
</Grid>
<Grid size={{ xs: 12, sm: 3 }}>
<TextField
fullWidth
label="Punto di Riordino"
type="number"
value={formData.reorderPoint}
onChange={(e) =>
handleChange(
"reorderPoint",
parseFloat(e.target.value) || 0,
)
}
InputProps={{ inputProps: { min: 0, step: 0.01 } }}
/>
</Grid>
<Grid size={{ xs: 12, sm: 3 }}>
<TextField
fullWidth
label="Quantità Riordino"
type="number"
value={formData.reorderQuantity}
onChange={(e) =>
handleChange(
"reorderQuantity",
parseFloat(e.target.value) || 0,
)
}
InputProps={{ inputProps: { min: 0, step: 0.01 } }}
/>
</Grid>
</Grid>
<Divider sx={{ my: 3 }} />
<Typography variant="h6" gutterBottom>
Costi e Valorizzazione
</Typography>
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 4 }}>
<TextField
fullWidth
label="Costo Standard"
type="number"
value={formData.standardCost}
onChange={(e) =>
handleChange(
"standardCost",
parseFloat(e.target.value) || 0,
)
}
InputProps={{
startAdornment: (
<InputAdornment position="start"></InputAdornment>
),
inputProps: { min: 0, step: 0.01 },
}}
/>
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<FormControl fullWidth>
<InputLabel>Gestione Stock</InputLabel>
<Select
value={formData.stockManagement}
label="Gestione Stock"
onChange={(e) =>
handleChange("stockManagement", e.target.value)
}
>
{Object.entries(stockManagementTypeLabels).map(
([value, label]) => (
<MenuItem key={value} value={parseInt(value, 10)}>
{label}
</MenuItem>
),
)}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<FormControl fullWidth>
<InputLabel>Metodo di Valorizzazione</InputLabel>
<Select
value={formData.valuationMethod}
label="Metodo di Valorizzazione"
onChange={(e) =>
handleChange("valuationMethod", e.target.value)
}
>
{Object.entries(valuationMethodLabels).map(
([value, label]) => (
<MenuItem key={value} value={parseInt(value, 10)}>
{label}
</MenuItem>
),
)}
</Select>
</FormControl>
</Grid>
</Grid>
<Divider sx={{ my: 3 }} />
<Typography variant="h6" gutterBottom>
Tracciabilità
</Typography>
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 4 }}>
<FormControlLabel
control={
<Switch
checked={formData.isBatchManaged}
onChange={(e) =>
handleChange("isBatchManaged", e.target.checked)
}
disabled={!isNew && article?.isBatchManaged}
/>
}
label="Gestione Lotti"
/>
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<FormControlLabel
control={
<Switch
checked={formData.isSerialManaged}
onChange={(e) =>
handleChange("isSerialManaged", e.target.checked)
}
disabled={!isNew && article?.isSerialManaged}
/>
}
label="Gestione Matricole"
/>
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<FormControlLabel
control={
<Switch
checked={formData.hasExpiry}
onChange={(e) =>
handleChange("hasExpiry", e.target.checked)
}
/>
}
label="Gestione Scadenza"
/>
</Grid>
<Grid size={12}>
<FormControlLabel
control={
<Switch
checked={formData.isActive}
onChange={(e) =>
handleChange("isActive", e.target.checked)
}
/>
}
label="Articolo Attivo"
/>
</Grid>
</Grid>
<Divider sx={{ my: 3 }} />
<TextField
fullWidth
label="Note"
value={formData.notes}
onChange={(e) => handleChange("notes", e.target.value)}
multiline
rows={3}
/>
</Paper>
</Grid>
{/* Right Column - Image */}
<Grid size={{ xs: 12, md: 4 }}>
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Immagine
</Typography>
{imagePreview ? (
<Card sx={{ mb: 2 }}>
<CardMedia
component="img"
image={imagePreview}
alt="Anteprima"
sx={{
height: 200,
objectFit: "contain",
bgcolor: "grey.100",
}}
/>
</Card>
) : (
<Box
sx={{
height: 200,
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "grey.100",
borderRadius: 1,
mb: 2,
}}
>
<ImageIcon sx={{ fontSize: 64, color: "grey.400" }} />
</Box>
)}
<Box sx={{ display: "flex", gap: 1 }}>
<Button
variant="outlined"
component="label"
startIcon={<UploadIcon />}
fullWidth
>
Carica
<input
type="file"
hidden
accept="image/*"
onChange={handleImageChange}
/>
</Button>
{imagePreview && (
<IconButton color="error" onClick={handleRemoveImage}>
<DeleteIcon />
</IconButton>
)}
</Box>
</Paper>
{/* Summary Card (only for existing articles) */}
{!isNew && article && (
<Paper sx={{ p: 3, mt: 3 }}>
<Typography variant="h6" gutterBottom>
Riepilogo
</Typography>
<Box
sx={{ display: "flex", flexDirection: "column", gap: 1 }}
>
<Box
sx={{ display: "flex", justifyContent: "space-between" }}
>
<Typography color="text.secondary">
Costo Medio:
</Typography>
<Typography fontWeight="medium">
{formatCurrency(article.weightedAverageCost || 0)}
</Typography>
</Box>
<Box
sx={{ display: "flex", justifyContent: "space-between" }}
>
<Typography color="text.secondary">
Ultimo Acquisto:
</Typography>
<Typography fontWeight="medium">
{formatCurrency(article.lastPurchaseCost || 0)}
</Typography>
</Box>
</Box>
</Paper>
)}
</Grid>
{/* Submit Button */}
<Grid size={12}>
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 2 }}>
<Button onClick={() => navigate(-1)}>Annulla</Button>
<Button
type="submit"
variant="contained"
startIcon={
isPending ? <CircularProgress size={20} /> : <SaveIcon />
}
disabled={isPending}
>
{isPending ? "Salvataggio..." : "Salva"}
</Button>
</Box>
</Grid>
</Grid>
</form>
</TabPanel>
{/* Stock Levels Tab */}
<TabPanel value={tabValue} index={1}>
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Giacenze per Magazzino
</Typography>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Magazzino</TableCell>
<TableCell align="right">Quantità</TableCell>
<TableCell align="right">Riservata</TableCell>
<TableCell align="right">Disponibile</TableCell>
<TableCell align="right">Valore</TableCell>
</TableRow>
</TableHead>
<TableBody>
{!stockLevels || stockLevels.length === 0 ? (
<TableRow>
<TableCell colSpan={5} align="center">
<Typography color="text.secondary">
Nessuna giacenza
</Typography>
</TableCell>
</TableRow>
) : (
stockLevels.map((level: StockLevelDto) => (
<TableRow key={level.id}>
<TableCell>{level.warehouseName}</TableCell>
<TableCell align="right">
{formatQuantity(level.quantity)}{" "}
{article?.unitOfMeasure}
</TableCell>
<TableCell align="right">
{formatQuantity(level.reservedQuantity)}{" "}
{article?.unitOfMeasure}
</TableCell>
<TableCell align="right">
{formatQuantity(level.availableQuantity)}{" "}
{article?.unitOfMeasure}
</TableCell>
<TableCell align="right">
{formatCurrency(level.stockValue)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
</TabPanel>
{/* Batches Tab */}
{article?.isBatchManaged && (
<TabPanel value={tabValue} index={2}>
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Lotti
</Typography>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Numero Lotto</TableCell>
<TableCell align="right">Quantità</TableCell>
<TableCell>Data Scadenza</TableCell>
<TableCell>Stato</TableCell>
</TableRow>
</TableHead>
<TableBody>
{!batches || batches.length === 0 ? (
<TableRow>
<TableCell colSpan={4} align="center">
<Typography color="text.secondary">
Nessun lotto
</Typography>
</TableCell>
</TableRow>
) : (
batches.map((batch: BatchDto) => (
<TableRow key={batch.id}>
<TableCell>{batch.batchNumber}</TableCell>
<TableCell align="right">
{formatQuantity(batch.currentQuantity)}{" "}
{article?.unitOfMeasure}
</TableCell>
<TableCell>
{batch.expiryDate
? formatDate(batch.expiryDate)
: "-"}
</TableCell>
<TableCell>
<Chip
label={batch.isExpired ? "Scaduto" : "Disponibile"}
size="small"
color={batch.isExpired ? "error" : "success"}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
</TabPanel>
)}
{/* Serials Tab */}
{article?.isSerialManaged && (
<TabPanel value={tabValue} index={article?.isBatchManaged ? 3 : 2}>
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Matricole
</Typography>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Matricola</TableCell>
<TableCell>Magazzino</TableCell>
<TableCell>Lotto</TableCell>
<TableCell>Stato</TableCell>
</TableRow>
</TableHead>
<TableBody>
{!serials || serials.length === 0 ? (
<TableRow>
<TableCell colSpan={4} align="center">
<Typography color="text.secondary">
Nessuna matricola
</Typography>
</TableCell>
</TableRow>
) : (
serials.map((serial: SerialDto) => (
<TableRow key={serial.id}>
<TableCell>{serial.serialNumber}</TableCell>
<TableCell>
{serial.currentWarehouseName || "-"}
</TableCell>
<TableCell>{serial.batchNumber || "-"}</TableCell>
<TableCell>
<Chip
label={
serial.status === 0
? "Disponibile"
: "Non disponibile"
}
size="small"
color={serial.status === 0 ? "success" : "default"}
/>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
</Paper>
</TabPanel>
)}
</Box>
);
}

View File

@@ -0,0 +1,523 @@
import React, { useState, useMemo } from "react";
import {
Box,
Paper,
Typography,
Button,
TextField,
InputAdornment,
IconButton,
Chip,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
Tooltip,
Card,
CardContent,
CardMedia,
CardActions,
Grid,
ToggleButton,
ToggleButtonGroup,
FormControl,
InputLabel,
Select,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Alert,
Skeleton,
} from "@mui/material";
import {
Add as AddIcon,
Search as SearchIcon,
Clear as ClearIcon,
ViewList as ViewListIcon,
ViewModule as ViewModuleIcon,
MoreVert as MoreVertIcon,
Edit as EditIcon,
Delete as DeleteIcon,
Inventory as InventoryIcon,
FilterList as FilterListIcon,
Image as ImageIcon,
} from "@mui/icons-material";
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { useArticles, useDeleteArticle, useCategoryTree } from "../hooks";
import { useWarehouseNavigation } from "../hooks/useWarehouseNavigation";
import { ArticleDto, formatCurrency } from "../types";
type ViewMode = "list" | "grid";
export default function ArticlesPage() {
const [viewMode, setViewMode] = useState<ViewMode>("list");
const [search, setSearch] = useState("");
const [categoryId, setCategoryId] = useState<number | "">("");
const [showInactive, setShowInactive] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [articleToDelete, setArticleToDelete] = useState<ArticleDto | null>(
null,
);
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null);
const [menuArticle, setMenuArticle] = useState<ArticleDto | null>(null);
const nav = useWarehouseNavigation();
const {
data: articles,
isLoading,
error,
} = useArticles({
categoryId: categoryId || undefined,
search: search || undefined,
isActive: showInactive ? undefined : true,
});
const { data: categoryTree } = useCategoryTree();
const deleteMutation = useDeleteArticle();
// Flatten category tree for select
const flatCategories = useMemo(() => {
const result: { id: number; name: string; level: number }[] = [];
const flatten = (categories: typeof categoryTree, level = 0) => {
if (!categories) return;
for (const cat of categories) {
result.push({ id: cat.id, name: cat.name, level });
if (cat.children) flatten(cat.children, level + 1);
}
};
flatten(categoryTree);
return result;
}, [categoryTree]);
const handleMenuOpen = (
event: React.MouseEvent<HTMLElement>,
article: ArticleDto,
) => {
event.stopPropagation();
setMenuAnchor(event.currentTarget);
setMenuArticle(article);
};
const handleMenuClose = () => {
setMenuAnchor(null);
setMenuArticle(null);
};
const handleEdit = () => {
if (menuArticle) {
nav.goToEditArticle(menuArticle.id);
}
handleMenuClose();
};
const handleDeleteClick = () => {
setArticleToDelete(menuArticle);
setDeleteDialogOpen(true);
handleMenuClose();
};
const handleDeleteConfirm = async () => {
if (articleToDelete) {
await deleteMutation.mutateAsync(articleToDelete.id);
setDeleteDialogOpen(false);
setArticleToDelete(null);
}
};
const handleViewStock = () => {
if (menuArticle) {
nav.goToArticle(menuArticle.id);
}
handleMenuClose();
};
const columns: GridColDef[] = [
{
field: "hasImage",
headerName: "",
width: 60,
sortable: false,
renderCell: (params: GridRenderCellParams<ArticleDto>) => (
<Box
sx={{
width: 40,
height: 40,
borderRadius: 1,
overflow: "hidden",
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "grey.100",
}}
>
{params.row.hasImage ? (
<img
src={`/api/warehouse/articles/${params.row.id}/image`}
alt={params.row.description}
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
) : (
<ImageIcon sx={{ color: "grey.400" }} />
)}
</Box>
),
},
{
field: "code",
headerName: "Codice",
width: 120,
renderCell: (params: GridRenderCellParams<ArticleDto>) => (
<Typography variant="body2" fontWeight="medium">
{params.value}
</Typography>
),
},
{
field: "description",
headerName: "Descrizione",
flex: 1,
minWidth: 200,
},
{
field: "categoryName",
headerName: "Categoria",
width: 150,
},
{
field: "unitOfMeasure",
headerName: "U.M.",
width: 80,
align: "center",
},
{
field: "weightedAverageCost",
headerName: "Costo Medio",
width: 120,
align: "right",
renderCell: (params: GridRenderCellParams<ArticleDto>) =>
formatCurrency(params.value || 0),
},
{
field: "isActive",
headerName: "Stato",
width: 100,
renderCell: (params: GridRenderCellParams<ArticleDto>) => (
<Chip
label={params.value ? "Attivo" : "Inattivo"}
size="small"
color={params.value ? "success" : "default"}
/>
),
},
{
field: "actions",
headerName: "",
width: 60,
sortable: false,
renderCell: (params: GridRenderCellParams<ArticleDto>) => (
<IconButton size="small" onClick={(e) => handleMenuOpen(e, params.row)}>
<MoreVertIcon />
</IconButton>
),
},
];
if (error) {
return (
<Box p={3}>
<Alert severity="error">
Errore nel caricamento degli articoli: {(error as Error).message}
</Alert>
</Box>
);
}
return (
<Box>
{/* Header */}
<Box
sx={{
mb: 3,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography variant="h5" fontWeight="bold">
Anagrafica Articoli
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={nav.goToNewArticle}
>
Nuovo Articolo
</Button>
</Box>
{/* Filters */}
<Paper sx={{ p: 2, mb: 3 }}>
<Grid container spacing={2} alignItems="center">
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
<TextField
fullWidth
size="small"
placeholder="Cerca per codice o descrizione..."
value={search}
onChange={(e) => setSearch(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
endAdornment: search && (
<InputAdornment position="end">
<IconButton size="small" onClick={() => setSearch("")}>
<ClearIcon />
</IconButton>
</InputAdornment>
),
}}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<FormControl fullWidth size="small">
<InputLabel>Categoria</InputLabel>
<Select
value={categoryId}
label="Categoria"
onChange={(e) => setCategoryId(e.target.value as number | "")}
>
<MenuItem value="">
<em>Tutte</em>
</MenuItem>
{flatCategories.map((cat) => (
<MenuItem key={cat.id} value={cat.id}>
{"—".repeat(cat.level)} {cat.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 2 }}>
<Button
variant={showInactive ? "contained" : "outlined"}
size="small"
startIcon={<FilterListIcon />}
onClick={() => setShowInactive(!showInactive)}
fullWidth
>
{showInactive ? "Mostra Tutti" : "Solo Attivi"}
</Button>
</Grid>
<Grid
size={{ xs: 12, sm: 6, md: 3 }}
sx={{ display: "flex", justifyContent: "flex-end" }}
>
<ToggleButtonGroup
value={viewMode}
exclusive
onChange={(_, value) => value && setViewMode(value)}
size="small"
>
<ToggleButton value="list">
<Tooltip title="Vista Lista">
<ViewListIcon />
</Tooltip>
</ToggleButton>
<ToggleButton value="grid">
<Tooltip title="Vista Griglia">
<ViewModuleIcon />
</Tooltip>
</ToggleButton>
</ToggleButtonGroup>
</Grid>
</Grid>
</Paper>
{/* Content */}
{viewMode === "list" ? (
<Paper sx={{ height: 600 }}>
<DataGrid
rows={articles || []}
columns={columns}
loading={isLoading}
pageSizeOptions={[25, 50, 100]}
initialState={{
pagination: { paginationModel: { pageSize: 25 } },
sorting: { sortModel: [{ field: "code", sort: "asc" }] },
}}
onRowDoubleClick={(params) => nav.goToArticle(params.row.id)}
disableRowSelectionOnClick
sx={{
"& .MuiDataGrid-row:hover": {
cursor: "pointer",
},
}}
/>
</Paper>
) : (
<Grid container spacing={2}>
{isLoading ? (
Array.from({ length: 8 }).map((_, i) => (
<Grid key={i} size={{ xs: 12, sm: 6, md: 4, lg: 3 }}>
<Card>
<Skeleton variant="rectangular" height={140} />
<CardContent>
<Skeleton variant="text" width="60%" />
<Skeleton variant="text" width="80%" />
</CardContent>
</Card>
</Grid>
))
) : articles?.length === 0 ? (
<Grid size={12}>
<Paper sx={{ p: 4, textAlign: "center" }}>
<InventoryIcon
sx={{ fontSize: 48, color: "grey.400", mb: 2 }}
/>
<Typography color="text.secondary">
Nessun articolo trovato
</Typography>
</Paper>
</Grid>
) : (
articles?.map((article) => (
<Grid key={article.id} size={{ xs: 12, sm: 6, md: 4, lg: 3 }}>
<Card
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
cursor: "pointer",
"&:hover": { boxShadow: 4 },
}}
onClick={() => nav.goToArticle(article.id)}
>
{article.hasImage ? (
<CardMedia
component="img"
height="140"
image={`/api/warehouse/articles/${article.id}/image`}
alt={article.description}
sx={{ objectFit: "cover" }}
/>
) : (
<Box
sx={{
height: 140,
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "grey.100",
}}
>
<ImageIcon sx={{ fontSize: 48, color: "grey.400" }} />
</Box>
)}
<CardContent sx={{ flexGrow: 1 }}>
<Typography variant="caption" color="text.secondary">
{article.code}
</Typography>
<Typography
variant="subtitle1"
fontWeight="medium"
gutterBottom
noWrap
>
{article.description}
</Typography>
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
{article.categoryName && (
<Chip label={article.categoryName} size="small" />
)}
</Box>
</CardContent>
<CardActions>
<Button
size="small"
onClick={(e) => {
e.stopPropagation();
nav.goToEditArticle(article.id);
}}
>
Modifica
</Button>
<IconButton
size="small"
onClick={(e) => handleMenuOpen(e, article)}
sx={{ ml: "auto" }}
>
<MoreVertIcon />
</IconButton>
</CardActions>
</Card>
</Grid>
))
)}
</Grid>
)}
{/* Context Menu */}
<Menu
anchorEl={menuAnchor}
open={Boolean(menuAnchor)}
onClose={handleMenuClose}
>
<MenuItem onClick={handleEdit}>
<ListItemIcon>
<EditIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Modifica</ListItemText>
</MenuItem>
<MenuItem onClick={handleViewStock}>
<ListItemIcon>
<InventoryIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Visualizza Giacenze</ListItemText>
</MenuItem>
<MenuItem onClick={handleDeleteClick} sx={{ color: "error.main" }}>
<ListItemIcon>
<DeleteIcon fontSize="small" color="error" />
</ListItemIcon>
<ListItemText>Elimina</ListItemText>
</MenuItem>
</Menu>
{/* Delete Confirmation Dialog */}
<Dialog
open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
>
<DialogTitle>Conferma Eliminazione</DialogTitle>
<DialogContent>
<Typography>
Sei sicuro di voler eliminare l'articolo{" "}
<strong>
{articleToDelete?.code} - {articleToDelete?.description}
</strong>
?
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Questa azione non può essere annullata.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>Annulla</Button>
<Button
onClick={handleDeleteConfirm}
color="error"
variant="contained"
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? "Eliminazione..." : "Elimina"}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -0,0 +1,454 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Box,
Paper,
Typography,
TextField,
Button,
Grid,
FormControl,
InputLabel,
Select,
MenuItem,
IconButton,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Alert,
CircularProgress,
Autocomplete,
InputAdornment,
Divider,
Chip,
} from "@mui/material";
import {
ArrowBack as ArrowBackIcon,
Save as SaveIcon,
Add as AddIcon,
Delete as DeleteIcon,
Check as ConfirmIcon,
} from "@mui/icons-material";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import dayjs, { Dayjs } from "dayjs";
import "dayjs/locale/it";
import {
useWarehouses,
useArticles,
useCreateInboundMovement,
useConfirmMovement,
} from "../hooks";
import { ArticleDto, CreateMovementDto, formatCurrency } from "../types";
interface MovementLine {
id: string;
article: ArticleDto | null;
quantity: number;
unitCost: number;
notes?: string;
}
export default function InboundMovementPage() {
const navigate = useNavigate();
const [movementDate, setMovementDate] = useState<Dayjs | null>(dayjs());
const [warehouseId, setWarehouseId] = useState<number | "">("");
const [documentNumber, setDocumentNumber] = useState("");
const [externalReference, setExternalReference] = useState("");
const [notes, setNotes] = useState("");
const [lines, setLines] = useState<MovementLine[]>([
{ id: crypto.randomUUID(), article: null, quantity: 1, unitCost: 0 },
]);
const [errors, setErrors] = useState<Record<string, string>>({});
const { data: warehouses } = useWarehouses({ active: true });
const { data: articles } = useArticles({ isActive: true });
const createMutation = useCreateInboundMovement();
const confirmMutation = useConfirmMovement();
// Set default warehouse
React.useEffect(() => {
if (warehouses && warehouseId === "") {
const defaultWarehouse = warehouses.find((w) => w.isDefault);
if (defaultWarehouse) {
setWarehouseId(defaultWarehouse.id);
}
}
}, [warehouses, warehouseId]);
const handleAddLine = () => {
setLines([
...lines,
{ id: crypto.randomUUID(), article: null, quantity: 1, unitCost: 0 },
]);
};
const handleRemoveLine = (id: string) => {
if (lines.length > 1) {
setLines(lines.filter((l) => l.id !== id));
}
};
const handleLineChange = (
id: string,
field: keyof MovementLine,
value: unknown,
) => {
setLines(
lines.map((l) => {
if (l.id === id) {
const updated = { ...l, [field]: value };
// Auto-fill unit cost from article
if (field === "article" && value) {
const article = value as ArticleDto;
updated.unitCost =
article.lastPurchaseCost || article.weightedAverageCost || 0;
}
return updated;
}
return l;
}),
);
};
const totalQuantity = lines.reduce((sum, l) => sum + (l.quantity || 0), 0);
const totalValue = lines.reduce(
(sum, l) => sum + (l.quantity || 0) * (l.unitCost || 0),
0,
);
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!warehouseId) {
newErrors.warehouseId = "Seleziona un magazzino";
}
if (!movementDate) {
newErrors.movementDate = "Inserisci la data";
}
const validLines = lines.filter((l) => l.article && l.quantity > 0);
if (validLines.length === 0) {
newErrors.lines = "Inserisci almeno una riga con articolo e quantità";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (andConfirm: boolean = false) => {
if (!validate()) return;
const data: CreateMovementDto = {
warehouseId: warehouseId as number,
movementDate: movementDate!.format("YYYY-MM-DD"),
documentNumber: documentNumber || undefined,
externalReference: externalReference || undefined,
notes: notes || undefined,
lines: lines
.filter((l) => l.article && l.quantity > 0)
.map((l) => ({
articleId: l.article!.id,
quantity: l.quantity,
unitCost: l.unitCost,
notes: l.notes,
})),
};
try {
const result = await createMutation.mutateAsync(data);
if (andConfirm) {
await confirmMutation.mutateAsync(result.id);
}
navigate(`/warehouse/movements/${result.id}`);
} catch (error) {
console.error("Errore salvataggio:", error);
}
};
const isPending = createMutation.isPending || confirmMutation.isPending;
return (
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="it">
<Box>
{/* Header */}
<Box sx={{ mb: 3, display: "flex", alignItems: "center", gap: 2 }}>
<IconButton onClick={() => navigate(-1)}>
<ArrowBackIcon />
</IconButton>
<Box>
<Typography variant="h5" fontWeight="bold">
Nuovo Carico
</Typography>
<Typography variant="body2" color="text.secondary">
Movimento di entrata merce in magazzino
</Typography>
</Box>
</Box>
{(createMutation.error || confirmMutation.error) && (
<Alert severity="error" sx={{ mb: 3 }}>
Errore:{" "}
{((createMutation.error || confirmMutation.error) as Error).message}
</Alert>
)}
{/* Form Header */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom>
Dati Movimento
</Typography>
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<DatePicker
label="Data Movimento"
value={movementDate}
onChange={setMovementDate}
slotProps={{
textField: {
fullWidth: true,
required: true,
error: !!errors.movementDate,
helperText: errors.movementDate,
},
}}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<FormControl fullWidth required error={!!errors.warehouseId}>
<InputLabel>Magazzino</InputLabel>
<Select
value={warehouseId}
label="Magazzino"
onChange={(e) => setWarehouseId(e.target.value as number)}
>
{warehouses?.map((w) => (
<MenuItem key={w.id} value={w.id}>
{w.name}
{w.isDefault && (
<Chip label="Default" size="small" sx={{ ml: 1 }} />
)}
</MenuItem>
))}
</Select>
{errors.warehouseId && (
<Typography variant="caption" color="error">
{errors.warehouseId}
</Typography>
)}
</FormControl>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField
fullWidth
label="Numero Documento"
value={documentNumber}
onChange={(e) => setDocumentNumber(e.target.value)}
placeholder="DDT, Fattura, etc."
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField
fullWidth
label="Riferimento Esterno"
value={externalReference}
onChange={(e) => setExternalReference(e.target.value)}
placeholder="Ordine, Fornitore, etc."
/>
</Grid>
<Grid size={12}>
<TextField
fullWidth
label="Note"
value={notes}
onChange={(e) => setNotes(e.target.value)}
multiline
rows={2}
/>
</Grid>
</Grid>
</Paper>
{/* Lines */}
<Paper sx={{ p: 3, mb: 3 }}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 2,
}}
>
<Typography variant="h6">Righe Movimento</Typography>
<Button startIcon={<AddIcon />} onClick={handleAddLine}>
Aggiungi Riga
</Button>
</Box>
{errors.lines && (
<Alert severity="error" sx={{ mb: 2 }}>
{errors.lines}
</Alert>
)}
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell sx={{ width: "45%" }}>Articolo</TableCell>
<TableCell sx={{ width: "15%" }} align="right">
Quantità
</TableCell>
<TableCell sx={{ width: "15%" }} align="right">
Costo Unitario
</TableCell>
<TableCell sx={{ width: "15%" }} align="right">
Totale
</TableCell>
<TableCell sx={{ width: 60 }}></TableCell>
</TableRow>
</TableHead>
<TableBody>
{lines.map((line) => (
<TableRow key={line.id}>
<TableCell>
<Autocomplete
value={line.article}
onChange={(_, value) =>
handleLineChange(line.id, "article", value)
}
options={articles || []}
getOptionLabel={(option) =>
`${option.code} - ${option.description}`
}
renderInput={(params) => (
<TextField
{...params}
size="small"
placeholder="Seleziona articolo"
/>
)}
isOptionEqualToValue={(option, value) =>
option.id === value.id
}
/>
</TableCell>
<TableCell>
<TextField
size="small"
type="number"
value={line.quantity}
onChange={(e) =>
handleLineChange(
line.id,
"quantity",
parseFloat(e.target.value) || 0,
)
}
slotProps={{
htmlInput: { min: 0, step: 0.01 },
input: {
endAdornment: line.article && (
<InputAdornment position="end">
{line.article.unitOfMeasure}
</InputAdornment>
),
},
}}
fullWidth
/>
</TableCell>
<TableCell>
<TextField
size="small"
type="number"
value={line.unitCost}
onChange={(e) =>
handleLineChange(
line.id,
"unitCost",
parseFloat(e.target.value) || 0,
)
}
slotProps={{
htmlInput: { min: 0, step: 0.01 },
input: {
startAdornment: (
<InputAdornment position="start">
</InputAdornment>
),
},
}}
fullWidth
/>
</TableCell>
<TableCell align="right">
<Typography fontWeight="medium">
{formatCurrency(line.quantity * line.unitCost)}
</Typography>
</TableCell>
<TableCell>
<IconButton
size="small"
onClick={() => handleRemoveLine(line.id)}
disabled={lines.length === 1}
>
<DeleteIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Divider sx={{ my: 2 }} />
{/* Totals */}
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 4 }}>
<Box>
<Typography variant="body2" color="text.secondary">
Totale Quantità
</Typography>
<Typography variant="h6">{totalQuantity.toFixed(2)}</Typography>
</Box>
<Box>
<Typography variant="body2" color="text.secondary">
Totale Valore
</Typography>
<Typography variant="h6">{formatCurrency(totalValue)}</Typography>
</Box>
</Box>
</Paper>
{/* Actions */}
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 2 }}>
<Button onClick={() => navigate(-1)}>Annulla</Button>
<Button
variant="outlined"
startIcon={
isPending ? <CircularProgress size={20} /> : <SaveIcon />
}
onClick={() => handleSubmit(false)}
disabled={isPending}
>
Salva Bozza
</Button>
<Button
variant="contained"
startIcon={
isPending ? <CircularProgress size={20} /> : <ConfirmIcon />
}
onClick={() => handleSubmit(true)}
disabled={isPending}
color="success"
>
Salva e Conferma
</Button>
</Box>
</Box>
</LocalizationProvider>
);
}

View File

@@ -0,0 +1,650 @@
import React, { useState } from "react";
import {
Box,
Paper,
Typography,
Button,
TextField,
InputAdornment,
IconButton,
Chip,
Menu,
MenuItem,
ListItemIcon,
ListItemText,
FormControl,
InputLabel,
Select,
Grid,
Alert,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
SpeedDial,
SpeedDialAction,
SpeedDialIcon,
} from "@mui/material";
import {
Search as SearchIcon,
Clear as ClearIcon,
MoreVert as MoreVertIcon,
Visibility as ViewIcon,
Check as ConfirmIcon,
Close as CancelIcon,
Delete as DeleteIcon,
Add as AddIcon,
Download as DownloadIcon,
Upload as UploadIcon,
SwapHoriz as TransferIcon,
Build as AdjustmentIcon,
FilterList as FilterIcon,
} from "@mui/icons-material";
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import { Dayjs } from "dayjs";
import "dayjs/locale/it";
import {
useMovements,
useWarehouses,
useConfirmMovement,
useCancelMovement,
useDeleteMovement,
} from "../hooks";
import { useWarehouseNavigation } from "../hooks/useWarehouseNavigation";
import {
MovementDto,
MovementType,
MovementStatus,
movementTypeLabels,
movementStatusLabels,
getMovementTypeColor,
getMovementStatusColor,
formatDate,
} from "../types";
export default function MovementsPage() {
const [search, setSearch] = useState("");
const [warehouseId, setWarehouseId] = useState<number | "">("");
const [movementType, setMovementType] = useState<MovementType | "">("");
const [status, setStatus] = useState<MovementStatus | "">("");
const [dateFrom, setDateFrom] = useState<Dayjs | null>(null);
const [dateTo, setDateTo] = useState<Dayjs | null>(null);
const [menuAnchor, setMenuAnchor] = useState<null | HTMLElement>(null);
const [menuMovement, setMenuMovement] = useState<MovementDto | null>(null);
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [speedDialOpen, setSpeedDialOpen] = useState(false);
const nav = useWarehouseNavigation();
const {
data: movements,
isLoading,
error,
} = useMovements({
warehouseId: warehouseId || undefined,
type: movementType !== "" ? movementType : undefined,
status: status !== "" ? status : undefined,
dateFrom: dateFrom?.format("YYYY-MM-DD"),
dateTo: dateTo?.format("YYYY-MM-DD"),
});
const { data: warehouses } = useWarehouses();
const confirmMutation = useConfirmMovement();
const cancelMutation = useCancelMovement();
const deleteMutation = useDeleteMovement();
// Filter movements by search
const filteredMovements = React.useMemo(() => {
if (!movements) return [];
if (!search) return movements;
const lower = search.toLowerCase();
return movements.filter(
(m) =>
m.documentNumber?.toLowerCase().includes(lower) ||
m.externalReference?.toLowerCase().includes(lower) ||
m.notes?.toLowerCase().includes(lower),
);
}, [movements, search]);
const handleMenuOpen = (
event: React.MouseEvent<HTMLElement>,
movement: MovementDto,
) => {
event.stopPropagation();
setMenuAnchor(event.currentTarget);
setMenuMovement(movement);
};
const handleMenuClose = () => {
setMenuAnchor(null);
setMenuMovement(null);
};
const handleView = () => {
if (menuMovement) {
nav.goToMovement(menuMovement.id);
}
handleMenuClose();
};
const handleConfirmClick = () => {
setConfirmDialogOpen(true);
setMenuAnchor(null);
};
const handleCancelClick = () => {
setCancelDialogOpen(true);
setMenuAnchor(null);
};
const handleDeleteClick = () => {
setDeleteDialogOpen(true);
setMenuAnchor(null);
};
const handleConfirmMovement = async () => {
if (menuMovement) {
await confirmMutation.mutateAsync(menuMovement.id);
setConfirmDialogOpen(false);
setMenuMovement(null);
}
};
const handleCancelMovement = async () => {
if (menuMovement) {
await cancelMutation.mutateAsync(menuMovement.id);
setCancelDialogOpen(false);
setMenuMovement(null);
}
};
const handleDeleteMovement = async () => {
if (menuMovement) {
await deleteMutation.mutateAsync(menuMovement.id);
setDeleteDialogOpen(false);
setMenuMovement(null);
}
};
const clearFilters = () => {
setSearch("");
setWarehouseId("");
setMovementType("");
setStatus("");
setDateFrom(null);
setDateTo(null);
};
const columns: GridColDef<MovementDto>[] = [
{
field: "documentNumber",
headerName: "Documento",
width: 140,
renderCell: (params: GridRenderCellParams<MovementDto>) => (
<Typography variant="body2" fontWeight="medium">
{params.value || "-"}
</Typography>
),
},
{
field: "movementDate",
headerName: "Data",
width: 110,
renderCell: (params: GridRenderCellParams<MovementDto>) =>
formatDate(params.value),
},
{
field: "type",
headerName: "Tipo",
width: 130,
renderCell: (params: GridRenderCellParams<MovementDto>) => (
<Chip
label={movementTypeLabels[params.value as MovementType]}
size="small"
color={
getMovementTypeColor(params.value as MovementType) as
| "success"
| "error"
| "info"
| "warning"
| "default"
}
/>
),
},
{
field: "status",
headerName: "Stato",
width: 120,
renderCell: (params: GridRenderCellParams<MovementDto>) => (
<Chip
label={movementStatusLabels[params.value as MovementStatus]}
size="small"
color={
getMovementStatusColor(params.value as MovementStatus) as
| "success"
| "error"
| "warning"
| "default"
}
variant={
params.value === MovementStatus.Draft ? "outlined" : "filled"
}
/>
),
},
{
field: "sourceWarehouseName",
headerName: "Magazzino",
width: 150,
renderCell: (params: GridRenderCellParams<MovementDto>) =>
params.row.sourceWarehouseName ||
params.row.destinationWarehouseName ||
"-",
},
{
field: "destinationWarehouseName",
headerName: "Destinazione",
width: 150,
renderCell: (params: GridRenderCellParams<MovementDto>) => {
// Show destination only for transfers
if (params.row.type === MovementType.Transfer) {
return params.row.destinationWarehouseName || "-";
}
return "-";
},
},
{
field: "reasonDescription",
headerName: "Causale",
width: 150,
renderCell: (params: GridRenderCellParams<MovementDto>) =>
params.value || "-",
},
{
field: "lineCount",
headerName: "Righe",
width: 80,
align: "center",
},
{
field: "totalValue",
headerName: "Valore",
width: 100,
align: "right",
renderCell: (params: GridRenderCellParams<MovementDto>) =>
params.value != null
? new Intl.NumberFormat("it-IT", {
style: "currency",
currency: "EUR",
}).format(params.value)
: "-",
},
{
field: "externalReference",
headerName: "Riferimento",
width: 140,
renderCell: (params: GridRenderCellParams<MovementDto>) =>
params.value || "-",
},
{
field: "actions",
headerName: "",
width: 60,
sortable: false,
renderCell: (params: GridRenderCellParams<MovementDto>) => (
<IconButton size="small" onClick={(e) => handleMenuOpen(e, params.row)}>
<MoreVertIcon />
</IconButton>
),
},
];
const speedDialActions = [
{ icon: <DownloadIcon />, name: "Carico", action: nav.goToNewInbound },
{ icon: <UploadIcon />, name: "Scarico", action: nav.goToNewOutbound },
{
icon: <TransferIcon />,
name: "Trasferimento",
action: nav.goToNewTransfer,
},
{
icon: <AdjustmentIcon />,
name: "Rettifica",
action: nav.goToNewAdjustment,
},
];
if (error) {
return (
<Box p={3}>
<Alert severity="error">
Errore nel caricamento dei movimenti: {(error as Error).message}
</Alert>
</Box>
);
}
return (
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="it">
<Box>
{/* Header */}
<Box
sx={{
mb: 3,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography variant="h5" fontWeight="bold">
Movimenti di Magazzino
</Typography>
</Box>
{/* Filters */}
<Paper sx={{ p: 2, mb: 3 }}>
<Grid container spacing={2} alignItems="center">
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField
fullWidth
size="small"
placeholder="Cerca documento, riferimento..."
value={search}
onChange={(e) => setSearch(e.target.value)}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
endAdornment: search && (
<InputAdornment position="end">
<IconButton size="small" onClick={() => setSearch("")}>
<ClearIcon />
</IconButton>
</InputAdornment>
),
},
}}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>Magazzino</InputLabel>
<Select
value={warehouseId}
label="Magazzino"
onChange={(e) =>
setWarehouseId(e.target.value as number | "")
}
>
<MenuItem value="">
<em>Tutti</em>
</MenuItem>
{warehouses?.map((w) => (
<MenuItem key={w.id} value={w.id}>
{w.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 6, sm: 4, md: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>Tipo</InputLabel>
<Select
value={movementType}
label="Tipo"
onChange={(e) =>
setMovementType(e.target.value as MovementType | "")
}
>
<MenuItem value="">
<em>Tutti</em>
</MenuItem>
{Object.entries(movementTypeLabels).map(([value, label]) => (
<MenuItem key={value} value={parseInt(value, 10)}>
{label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 6, sm: 4, md: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>Stato</InputLabel>
<Select
value={status}
label="Stato"
onChange={(e) =>
setStatus(e.target.value as MovementStatus | "")
}
>
<MenuItem value="">
<em>Tutti</em>
</MenuItem>
{Object.entries(movementStatusLabels).map(
([value, label]) => (
<MenuItem key={value} value={parseInt(value, 10)}>
{label}
</MenuItem>
),
)}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 6, sm: 4, md: 1.5 }}>
<DatePicker
label="Da"
value={dateFrom}
onChange={setDateFrom}
slotProps={{ textField: { size: "small", fullWidth: true } }}
/>
</Grid>
<Grid size={{ xs: 6, sm: 4, md: 1.5 }}>
<DatePicker
label="A"
value={dateTo}
onChange={setDateTo}
slotProps={{ textField: { size: "small", fullWidth: true } }}
/>
</Grid>
{(search ||
warehouseId ||
movementType !== "" ||
status !== "" ||
dateFrom ||
dateTo) && (
<Grid size="auto">
<Button
size="small"
startIcon={<FilterIcon />}
onClick={clearFilters}
>
Reset
</Button>
</Grid>
)}
</Grid>
</Paper>
{/* Data Grid */}
<Paper sx={{ height: 600 }}>
<DataGrid
rows={filteredMovements}
columns={columns}
loading={isLoading}
pageSizeOptions={[25, 50, 100]}
initialState={{
pagination: { paginationModel: { pageSize: 25 } },
sorting: { sortModel: [{ field: "movementDate", sort: "desc" }] },
}}
onRowDoubleClick={(params) => nav.goToMovement(params.row.id)}
disableRowSelectionOnClick
sx={{
"& .MuiDataGrid-row:hover": {
cursor: "pointer",
},
}}
/>
</Paper>
{/* Speed Dial for New Movements */}
<SpeedDial
ariaLabel="Nuovo Movimento"
sx={{ position: "fixed", bottom: 24, right: 24 }}
icon={<SpeedDialIcon openIcon={<AddIcon />} />}
open={speedDialOpen}
onOpen={() => setSpeedDialOpen(true)}
onClose={() => setSpeedDialOpen(false)}
>
{speedDialActions.map((action) => (
<SpeedDialAction
key={action.name}
icon={action.icon}
tooltipTitle={action.name}
tooltipOpen
onClick={() => {
setSpeedDialOpen(false);
action.action();
}}
/>
))}
</SpeedDial>
{/* Context Menu */}
<Menu
anchorEl={menuAnchor}
open={Boolean(menuAnchor)}
onClose={handleMenuClose}
>
<MenuItem onClick={handleView}>
<ListItemIcon>
<ViewIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Visualizza</ListItemText>
</MenuItem>
{menuMovement?.status === MovementStatus.Draft && (
<>
<MenuItem onClick={handleConfirmClick}>
<ListItemIcon>
<ConfirmIcon fontSize="small" color="success" />
</ListItemIcon>
<ListItemText>Conferma</ListItemText>
</MenuItem>
<MenuItem onClick={handleCancelClick}>
<ListItemIcon>
<CancelIcon fontSize="small" color="warning" />
</ListItemIcon>
<ListItemText>Annulla</ListItemText>
</MenuItem>
<MenuItem
onClick={handleDeleteClick}
sx={{ color: "error.main" }}
>
<ListItemIcon>
<DeleteIcon fontSize="small" color="error" />
</ListItemIcon>
<ListItemText>Elimina</ListItemText>
</MenuItem>
</>
)}
</Menu>
{/* Confirm Movement Dialog */}
<Dialog
open={confirmDialogOpen}
onClose={() => setConfirmDialogOpen(false)}
>
<DialogTitle>Conferma Movimento</DialogTitle>
<DialogContent>
<Typography>
Confermare il movimento{" "}
<strong>{menuMovement?.documentNumber}</strong>?
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Le giacenze verranno aggiornate e il movimento non potrà più
essere modificato.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmDialogOpen(false)}>Annulla</Button>
<Button
onClick={handleConfirmMovement}
color="success"
variant="contained"
disabled={confirmMutation.isPending}
>
{confirmMutation.isPending ? "Conferma..." : "Conferma"}
</Button>
</DialogActions>
</Dialog>
{/* Cancel Movement Dialog */}
<Dialog
open={cancelDialogOpen}
onClose={() => setCancelDialogOpen(false)}
>
<DialogTitle>Annulla Movimento</DialogTitle>
<DialogContent>
<Typography>
Annullare il movimento{" "}
<strong>{menuMovement?.documentNumber}</strong>?
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Il movimento verrà marcato come annullato ma non eliminato.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setCancelDialogOpen(false)}>Indietro</Button>
<Button
onClick={handleCancelMovement}
color="warning"
variant="contained"
disabled={cancelMutation.isPending}
>
{cancelMutation.isPending
? "Annullamento..."
: "Annulla Movimento"}
</Button>
</DialogActions>
</Dialog>
{/* Delete Movement Dialog */}
<Dialog
open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
>
<DialogTitle>Elimina Movimento</DialogTitle>
<DialogContent>
<Typography>
Eliminare definitivamente il movimento{" "}
<strong>{menuMovement?.documentNumber}</strong>?
</Typography>
<Typography variant="body2" color="error" sx={{ mt: 1 }}>
Questa azione non può essere annullata.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>Annulla</Button>
<Button
onClick={handleDeleteMovement}
color="error"
variant="contained"
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? "Eliminazione..." : "Elimina"}
</Button>
</DialogActions>
</Dialog>
</Box>
</LocalizationProvider>
);
}

View File

@@ -0,0 +1,481 @@
import React, { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Box,
Paper,
Typography,
TextField,
Button,
Grid,
FormControl,
InputLabel,
Select,
MenuItem,
IconButton,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Alert,
CircularProgress,
Autocomplete,
InputAdornment,
Divider,
Chip,
Tooltip,
} from "@mui/material";
import {
ArrowBack as ArrowBackIcon,
Save as SaveIcon,
Add as AddIcon,
Delete as DeleteIcon,
Check as ConfirmIcon,
Warning as WarningIcon,
} from "@mui/icons-material";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import dayjs, { Dayjs } from "dayjs";
import "dayjs/locale/it";
import {
useWarehouses,
useArticles,
useStockLevels,
useCreateOutboundMovement,
useConfirmMovement,
} from "../hooks";
import { ArticleDto, CreateMovementDto, formatQuantity } from "../types";
interface MovementLine {
id: string;
article: ArticleDto | null;
quantity: number;
notes?: string;
availableQty?: number;
}
export default function OutboundMovementPage() {
const navigate = useNavigate();
const [movementDate, setMovementDate] = useState<Dayjs | null>(dayjs());
const [warehouseId, setWarehouseId] = useState<number | "">("");
const [documentNumber, setDocumentNumber] = useState("");
const [externalReference, setExternalReference] = useState("");
const [notes, setNotes] = useState("");
const [lines, setLines] = useState<MovementLine[]>([
{ id: crypto.randomUUID(), article: null, quantity: 1 },
]);
const [errors, setErrors] = useState<Record<string, string>>({});
const { data: warehouses } = useWarehouses({ active: true });
const { data: articles } = useArticles({ isActive: true });
const { data: stockLevels } = useStockLevels({
warehouseId: warehouseId || undefined,
});
const createMutation = useCreateOutboundMovement();
const confirmMutation = useConfirmMovement();
// Set default warehouse
React.useEffect(() => {
if (warehouses && warehouseId === "") {
const defaultWarehouse = warehouses.find((w) => w.isDefault);
if (defaultWarehouse) {
setWarehouseId(defaultWarehouse.id);
}
}
}, [warehouses, warehouseId]);
// Get available quantity for an article
const getAvailableQty = (articleId: number): number => {
if (!stockLevels) return 0;
const level = stockLevels.find((l) => l.articleId === articleId);
return level ? level.availableQuantity : 0;
};
const handleAddLine = () => {
setLines([
...lines,
{ id: crypto.randomUUID(), article: null, quantity: 1 },
]);
};
const handleRemoveLine = (id: string) => {
if (lines.length > 1) {
setLines(lines.filter((l) => l.id !== id));
}
};
const handleLineChange = (
id: string,
field: keyof MovementLine,
value: unknown,
) => {
setLines(
lines.map((l) => {
if (l.id === id) {
const updated = { ...l, [field]: value };
// Update available qty when article changes
if (field === "article" && value) {
const article = value as ArticleDto;
updated.availableQty = getAvailableQty(article.id);
}
return updated;
}
return l;
}),
);
};
// Check for stock issues
const hasStockIssues = lines.some(
(l) =>
l.article && l.availableQty !== undefined && l.quantity > l.availableQty,
);
const totalQuantity = lines.reduce((sum, l) => sum + (l.quantity || 0), 0);
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!warehouseId) {
newErrors.warehouseId = "Seleziona un magazzino";
}
if (!movementDate) {
newErrors.movementDate = "Inserisci la data";
}
const validLines = lines.filter((l) => l.article && l.quantity > 0);
if (validLines.length === 0) {
newErrors.lines = "Inserisci almeno una riga con articolo e quantità";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (andConfirm: boolean = false) => {
if (!validate()) return;
const data: CreateMovementDto = {
warehouseId: warehouseId as number,
movementDate: movementDate!.format("YYYY-MM-DD"),
documentNumber: documentNumber || undefined,
externalReference: externalReference || undefined,
notes: notes || undefined,
lines: lines
.filter((l) => l.article && l.quantity > 0)
.map((l) => ({
articleId: l.article!.id,
quantity: l.quantity,
notes: l.notes,
})),
};
try {
const result = await createMutation.mutateAsync(data);
if (andConfirm) {
await confirmMutation.mutateAsync(result.id);
}
navigate(`/warehouse/movements/${result.id}`);
} catch (error) {
console.error("Errore salvataggio:", error);
}
};
const isPending = createMutation.isPending || confirmMutation.isPending;
return (
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="it">
<Box>
{/* Header */}
<Box sx={{ mb: 3, display: "flex", alignItems: "center", gap: 2 }}>
<IconButton onClick={() => navigate(-1)}>
<ArrowBackIcon />
</IconButton>
<Box>
<Typography variant="h5" fontWeight="bold">
Nuovo Scarico
</Typography>
<Typography variant="body2" color="text.secondary">
Movimento di uscita merce da magazzino
</Typography>
</Box>
</Box>
{(createMutation.error || confirmMutation.error) && (
<Alert severity="error" sx={{ mb: 3 }}>
Errore:{" "}
{((createMutation.error || confirmMutation.error) as Error).message}
</Alert>
)}
{hasStockIssues && (
<Alert severity="warning" sx={{ mb: 3 }} icon={<WarningIcon />}>
Attenzione: alcune righe superano la disponibilità in magazzino
</Alert>
)}
{/* Form Header */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom>
Dati Movimento
</Typography>
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<DatePicker
label="Data Movimento"
value={movementDate}
onChange={setMovementDate}
slotProps={{
textField: {
fullWidth: true,
required: true,
error: !!errors.movementDate,
helperText: errors.movementDate,
},
}}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<FormControl fullWidth required error={!!errors.warehouseId}>
<InputLabel>Magazzino</InputLabel>
<Select
value={warehouseId}
label="Magazzino"
onChange={(e) => setWarehouseId(e.target.value as number)}
>
{warehouses?.map((w) => (
<MenuItem key={w.id} value={w.id}>
{w.name}
{w.isDefault && (
<Chip label="Default" size="small" sx={{ ml: 1 }} />
)}
</MenuItem>
))}
</Select>
{errors.warehouseId && (
<Typography variant="caption" color="error">
{errors.warehouseId}
</Typography>
)}
</FormControl>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField
fullWidth
label="Numero Documento"
value={documentNumber}
onChange={(e) => setDocumentNumber(e.target.value)}
placeholder="DDT, Bolla, etc."
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField
fullWidth
label="Riferimento Esterno"
value={externalReference}
onChange={(e) => setExternalReference(e.target.value)}
placeholder="Ordine, Cliente, etc."
/>
</Grid>
<Grid size={12}>
<TextField
fullWidth
label="Note"
value={notes}
onChange={(e) => setNotes(e.target.value)}
multiline
rows={2}
/>
</Grid>
</Grid>
</Paper>
{/* Lines */}
<Paper sx={{ p: 3, mb: 3 }}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 2,
}}
>
<Typography variant="h6">Righe Movimento</Typography>
<Button startIcon={<AddIcon />} onClick={handleAddLine}>
Aggiungi Riga
</Button>
</Box>
{errors.lines && (
<Alert severity="error" sx={{ mb: 2 }}>
{errors.lines}
</Alert>
)}
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell sx={{ width: "40%" }}>Articolo</TableCell>
<TableCell sx={{ width: "15%" }} align="right">
Disponibile
</TableCell>
<TableCell sx={{ width: "15%" }} align="right">
Quantità
</TableCell>
<TableCell sx={{ width: "20%" }}>Note</TableCell>
<TableCell sx={{ width: 60 }}></TableCell>
</TableRow>
</TableHead>
<TableBody>
{lines.map((line) => {
const isOverStock =
line.article &&
line.availableQty !== undefined &&
line.quantity > line.availableQty;
return (
<TableRow
key={line.id}
sx={isOverStock ? { bgcolor: "warning.light" } : {}}
>
<TableCell>
<Autocomplete
value={line.article}
onChange={(_, value) =>
handleLineChange(line.id, "article", value)
}
options={articles || []}
getOptionLabel={(option) =>
`${option.code} - ${option.description}`
}
renderInput={(params) => (
<TextField
{...params}
size="small"
placeholder="Seleziona articolo"
/>
)}
isOptionEqualToValue={(option, value) =>
option.id === value.id
}
/>
</TableCell>
<TableCell align="right">
{line.article ? (
<Chip
label={`${formatQuantity(line.availableQty || 0)} ${line.article.unitOfMeasure}`}
size="small"
color={
(line.availableQty || 0) <= 0
? "error"
: "default"
}
/>
) : (
"-"
)}
</TableCell>
<TableCell>
<Box sx={{ display: "flex", alignItems: "center" }}>
<TextField
size="small"
type="number"
value={line.quantity}
onChange={(e) =>
handleLineChange(
line.id,
"quantity",
parseFloat(e.target.value) || 0,
)
}
slotProps={{
htmlInput: { min: 0, step: 0.01 },
input: {
endAdornment: line.article && (
<InputAdornment position="end">
{line.article.unitOfMeasure}
</InputAdornment>
),
},
}}
error={isOverStock ?? undefined}
fullWidth
/>
{isOverStock && (
<Tooltip title="Quantità superiore alla disponibilità">
<WarningIcon
color="warning"
sx={{ fontSize: 16, ml: 1 }}
/>
</Tooltip>
)}
</Box>
</TableCell>
<TableCell>
<TextField
size="small"
placeholder="Note"
value={line.notes || ""}
onChange={(e) =>
handleLineChange(line.id, "notes", e.target.value)
}
fullWidth
/>
</TableCell>
<TableCell>
<IconButton
size="small"
onClick={() => handleRemoveLine(line.id)}
disabled={lines.length === 1}
>
<DeleteIcon />
</IconButton>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
<Divider sx={{ my: 2 }} />
{/* Totals */}
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Box>
<Typography variant="body2" color="text.secondary">
Totale Quantità
</Typography>
<Typography variant="h6">{totalQuantity.toFixed(2)}</Typography>
</Box>
</Box>
</Paper>
{/* Actions */}
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 2 }}>
<Button onClick={() => navigate(-1)}>Annulla</Button>
<Button
variant="outlined"
startIcon={
isPending ? <CircularProgress size={20} /> : <SaveIcon />
}
onClick={() => handleSubmit(false)}
disabled={isPending}
>
Salva Bozza
</Button>
<Button
variant="contained"
startIcon={
isPending ? <CircularProgress size={20} /> : <ConfirmIcon />
}
onClick={() => handleSubmit(true)}
disabled={isPending || hasStockIssues}
color="success"
>
Salva e Conferma
</Button>
</Box>
</Box>
</LocalizationProvider>
);
}

View File

@@ -0,0 +1,355 @@
import React, { useState } from "react";
import {
Box,
Paper,
Typography,
TextField,
InputAdornment,
IconButton,
FormControl,
InputLabel,
Select,
MenuItem,
Grid,
Alert,
Chip,
FormControlLabel,
Switch,
Button,
Card,
CardContent,
} from "@mui/material";
import {
Search as SearchIcon,
Clear as ClearIcon,
Warning as WarningIcon,
TrendingUp as TrendingUpIcon,
} from "@mui/icons-material";
import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid";
import { useStockLevels, useWarehouses, useCategoryTree } from "../hooks";
import { useStockCalculations } from "../hooks/useStockCalculations";
import { useWarehouseNavigation } from "../hooks/useWarehouseNavigation";
import { StockLevelDto, formatCurrency, formatQuantity } from "../types";
export default function StockLevelsPage() {
const [search, setSearch] = useState("");
const [warehouseId, setWarehouseId] = useState<number | "">("");
const [categoryId, setCategoryId] = useState<number | "">("");
const [lowStockOnly, setLowStockOnly] = useState(false);
const nav = useWarehouseNavigation();
const {
data: stockLevels,
isLoading,
error,
} = useStockLevels({
warehouseId: warehouseId || undefined,
categoryId: categoryId || undefined,
onlyLowStock: lowStockOnly || undefined,
});
const { data: warehouses } = useWarehouses();
const { data: categoryTree } = useCategoryTree();
const { summary } = useStockCalculations(stockLevels);
// Flatten categories
const flatCategories = React.useMemo(() => {
const result: { id: number; name: string; level: number }[] = [];
const flatten = (cats: typeof categoryTree, level = 0) => {
if (!cats) return;
for (const cat of cats) {
result.push({ id: cat.id, name: cat.name, level });
if (cat.children) flatten(cat.children, level + 1);
}
};
flatten(categoryTree);
return result;
}, [categoryTree]);
// Filter by search
const filteredLevels = React.useMemo(() => {
if (!stockLevels) return [];
if (!search) return stockLevels;
const lower = search.toLowerCase();
return stockLevels.filter(
(l) =>
l.articleCode?.toLowerCase().includes(lower) ||
l.articleDescription?.toLowerCase().includes(lower),
);
}, [stockLevels, search]);
const columns: GridColDef[] = [
{
field: "articleCode",
headerName: "Codice",
width: 120,
renderCell: (params: GridRenderCellParams<StockLevelDto>) => (
<Typography variant="body2" fontWeight="medium">
{params.value}
</Typography>
),
},
{
field: "articleDescription",
headerName: "Articolo",
flex: 1,
minWidth: 200,
},
{
field: "warehouseName",
headerName: "Magazzino",
width: 150,
},
{
field: "categoryName",
headerName: "Categoria",
width: 140,
},
{
field: "quantity",
headerName: "Giacenza",
width: 120,
align: "right",
renderCell: (params: GridRenderCellParams<StockLevelDto>) => {
const qty = params.value || 0;
const isLow = params.row.isLowStock;
return (
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
{isLow && <WarningIcon color="warning" sx={{ fontSize: 16 }} />}
<Chip
label={formatQuantity(qty)}
size="small"
color={qty <= 0 ? "error" : isLow ? "warning" : "default"}
/>
</Box>
);
},
},
{
field: "reservedQuantity",
headerName: "Riservata",
width: 100,
align: "right",
renderCell: (params: GridRenderCellParams<StockLevelDto>) =>
formatQuantity(params.value || 0),
},
{
field: "availableQuantity",
headerName: "Disponibile",
width: 110,
align: "right",
renderCell: (params: GridRenderCellParams<StockLevelDto>) => {
const available =
params.row.availableQuantity ||
params.row.quantity - params.row.reservedQuantity;
return (
<Typography
fontWeight="medium"
color={available <= 0 ? "error.main" : "text.primary"}
>
{formatQuantity(available)}
</Typography>
);
},
},
{
field: "unitCost",
headerName: "Costo Medio",
width: 120,
align: "right",
renderCell: (params: GridRenderCellParams<StockLevelDto>) =>
formatCurrency(params.value || 0),
},
{
field: "stockValue",
headerName: "Valore",
width: 130,
align: "right",
renderCell: (params: GridRenderCellParams<StockLevelDto>) => (
<Typography fontWeight="medium">
{formatCurrency(params.value || 0)}
</Typography>
),
},
];
if (error) {
return (
<Box p={3}>
<Alert severity="error">Errore: {(error as Error).message}</Alert>
</Box>
);
}
return (
<Box>
{/* Header */}
<Box
sx={{
mb: 3,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography variant="h5" fontWeight="bold">
Giacenze di Magazzino
</Typography>
<Button
variant="outlined"
startIcon={<TrendingUpIcon />}
onClick={nav.goToValuation}
>
Valorizzazione
</Button>
</Box>
{/* Summary Cards */}
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid size={{ xs: 6, sm: 3 }}>
<Card>
<CardContent sx={{ py: 2 }}>
<Typography variant="body2" color="text.secondary">
Articoli
</Typography>
<Typography variant="h5" fontWeight="bold">
{summary.articleCount}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
<Card>
<CardContent sx={{ py: 2 }}>
<Typography variant="body2" color="text.secondary">
Quantità Totale
</Typography>
<Typography variant="h5" fontWeight="bold">
{formatQuantity(summary.totalQuantity)}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
<Card>
<CardContent sx={{ py: 2 }}>
<Typography variant="body2" color="text.secondary">
Valore Totale
</Typography>
<Typography variant="h5" fontWeight="bold" color="success.main">
{formatCurrency(summary.totalValue)}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
<Card>
<CardContent sx={{ py: 2 }}>
<Typography variant="body2" color="text.secondary">
Sotto Scorta
</Typography>
<Typography variant="h5" fontWeight="bold" color="warning.main">
{summary.lowStockCount + summary.outOfStockCount}
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
{/* Filters */}
<Paper sx={{ p: 2, mb: 3 }}>
<Grid container spacing={2} alignItems="center">
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField
fullWidth
size="small"
placeholder="Cerca articolo..."
value={search}
onChange={(e) => setSearch(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
endAdornment: search && (
<InputAdornment position="end">
<IconButton size="small" onClick={() => setSearch("")}>
<ClearIcon />
</IconButton>
</InputAdornment>
),
}}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<FormControl fullWidth size="small">
<InputLabel>Magazzino</InputLabel>
<Select
value={warehouseId}
label="Magazzino"
onChange={(e) => setWarehouseId(e.target.value as number | "")}
>
<MenuItem value="">
<em>Tutti</em>
</MenuItem>
{warehouses?.map((w) => (
<MenuItem key={w.id} value={w.id}>
{w.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<FormControl fullWidth size="small">
<InputLabel>Categoria</InputLabel>
<Select
value={categoryId}
label="Categoria"
onChange={(e) => setCategoryId(e.target.value as number | "")}
>
<MenuItem value="">
<em>Tutte</em>
</MenuItem>
{flatCategories.map((c) => (
<MenuItem key={c.id} value={c.id}>
{"—".repeat(c.level)} {c.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<FormControlLabel
control={
<Switch
checked={lowStockOnly}
onChange={(e) => setLowStockOnly(e.target.checked)}
/>
}
label="Solo sotto scorta"
/>
</Grid>
</Grid>
</Paper>
{/* Data Grid */}
<Paper sx={{ height: 500 }}>
<DataGrid
rows={filteredLevels}
columns={columns}
loading={isLoading}
pageSizeOptions={[25, 50, 100]}
initialState={{
pagination: { paginationModel: { pageSize: 25 } },
sorting: { sortModel: [{ field: "articleCode", sort: "asc" }] },
}}
onRowDoubleClick={(params) => nav.goToArticle(params.row.articleId)}
disableRowSelectionOnClick
/>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,447 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Box,
Paper,
Typography,
TextField,
Button,
Grid,
FormControl,
InputLabel,
Select,
MenuItem,
IconButton,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Alert,
CircularProgress,
Autocomplete,
InputAdornment,
Divider,
Chip,
} from "@mui/material";
import {
ArrowBack as ArrowBackIcon,
Save as SaveIcon,
Add as AddIcon,
Delete as DeleteIcon,
Check as ConfirmIcon,
SwapHoriz as TransferIcon,
} from "@mui/icons-material";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import dayjs, { Dayjs } from "dayjs";
import "dayjs/locale/it";
import {
useWarehouses,
useArticles,
useStockLevels,
useCreateTransferMovement,
useConfirmMovement,
} from "../hooks";
import { ArticleDto, CreateTransferDto, formatQuantity } from "../types";
interface MovementLine {
id: string;
article: ArticleDto | null;
quantity: number;
notes?: string;
availableQty?: number;
}
export default function TransferMovementPage() {
const navigate = useNavigate();
const [movementDate, setMovementDate] = useState<Dayjs | null>(dayjs());
const [sourceWarehouseId, setSourceWarehouseId] = useState<number | "">("");
const [destWarehouseId, setDestWarehouseId] = useState<number | "">("");
const [documentNumber, setDocumentNumber] = useState("");
const [externalReference, setExternalReference] = useState("");
const [notes, setNotes] = useState("");
const [lines, setLines] = useState<MovementLine[]>([
{ id: crypto.randomUUID(), article: null, quantity: 1 },
]);
const [errors, setErrors] = useState<Record<string, string>>({});
const { data: warehouses } = useWarehouses({ active: true });
const { data: articles } = useArticles({ isActive: true });
const { data: stockLevels } = useStockLevels({
warehouseId: sourceWarehouseId || undefined,
});
const createMutation = useCreateTransferMovement();
const confirmMutation = useConfirmMovement();
const getAvailableQty = (articleId: number): number => {
if (!stockLevels) return 0;
const level = stockLevels.find((l) => l.articleId === articleId);
return level ? level.availableQuantity : 0;
};
const handleAddLine = () => {
setLines([
...lines,
{ id: crypto.randomUUID(), article: null, quantity: 1 },
]);
};
const handleRemoveLine = (id: string) => {
if (lines.length > 1) {
setLines(lines.filter((l) => l.id !== id));
}
};
const handleLineChange = (
id: string,
field: keyof MovementLine,
value: unknown,
) => {
setLines(
lines.map((l) => {
if (l.id === id) {
const updated = { ...l, [field]: value };
if (field === "article" && value) {
const article = value as ArticleDto;
updated.availableQty = getAvailableQty(article.id);
}
return updated;
}
return l;
}),
);
};
const totalQuantity = lines.reduce((sum, l) => sum + (l.quantity || 0), 0);
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!sourceWarehouseId) {
newErrors.sourceWarehouseId = "Seleziona magazzino origine";
}
if (!destWarehouseId) {
newErrors.destWarehouseId = "Seleziona magazzino destinazione";
}
if (
sourceWarehouseId &&
destWarehouseId &&
sourceWarehouseId === destWarehouseId
) {
newErrors.destWarehouseId =
"Origine e destinazione devono essere diversi";
}
if (!movementDate) {
newErrors.movementDate = "Inserisci la data";
}
const validLines = lines.filter((l) => l.article && l.quantity > 0);
if (validLines.length === 0) {
newErrors.lines = "Inserisci almeno una riga";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (andConfirm: boolean = false) => {
if (!validate()) return;
const data: CreateTransferDto = {
sourceWarehouseId: sourceWarehouseId as number,
destinationWarehouseId: destWarehouseId as number,
movementDate: movementDate!.format("YYYY-MM-DD"),
documentNumber: documentNumber || undefined,
externalReference: externalReference || undefined,
notes: notes || undefined,
lines: lines
.filter((l) => l.article && l.quantity > 0)
.map((l) => ({
articleId: l.article!.id,
quantity: l.quantity,
notes: l.notes,
})),
};
try {
const result = await createMutation.mutateAsync(data);
if (andConfirm) {
await confirmMutation.mutateAsync(result.id);
}
navigate(`/warehouse/movements/${result.id}`);
} catch (error) {
console.error("Errore:", error);
}
};
const isPending = createMutation.isPending || confirmMutation.isPending;
return (
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="it">
<Box>
{/* Header */}
<Box sx={{ mb: 3, display: "flex", alignItems: "center", gap: 2 }}>
<IconButton onClick={() => navigate(-1)}>
<ArrowBackIcon />
</IconButton>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<TransferIcon color="primary" />
<Box>
<Typography variant="h5" fontWeight="bold">
Trasferimento tra Magazzini
</Typography>
<Typography variant="body2" color="text.secondary">
Sposta merce da un magazzino all'altro
</Typography>
</Box>
</Box>
</Box>
{(createMutation.error || confirmMutation.error) && (
<Alert severity="error" sx={{ mb: 3 }}>
Errore:{" "}
{((createMutation.error || confirmMutation.error) as Error).message}
</Alert>
)}
{/* Form */}
<Paper sx={{ p: 3, mb: 3 }}>
<Typography variant="h6" gutterBottom>
Dati Trasferimento
</Typography>
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<DatePicker
label="Data"
value={movementDate}
onChange={setMovementDate}
slotProps={{
textField: {
fullWidth: true,
required: true,
error: !!errors.movementDate,
helperText: errors.movementDate,
},
}}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<FormControl
fullWidth
required
error={!!errors.sourceWarehouseId}
>
<InputLabel>Magazzino Origine</InputLabel>
<Select
value={sourceWarehouseId}
label="Magazzino Origine"
onChange={(e) =>
setSourceWarehouseId(e.target.value as number)
}
>
{warehouses
?.filter((w) => w.id !== destWarehouseId)
.map((w) => (
<MenuItem key={w.id} value={w.id}>
{w.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<FormControl fullWidth required error={!!errors.destWarehouseId}>
<InputLabel>Magazzino Destinazione</InputLabel>
<Select
value={destWarehouseId}
label="Magazzino Destinazione"
onChange={(e) => setDestWarehouseId(e.target.value as number)}
>
{warehouses
?.filter((w) => w.id !== sourceWarehouseId)
.map((w) => (
<MenuItem key={w.id} value={w.id}>
{w.name}
</MenuItem>
))}
</Select>
{errors.destWarehouseId && (
<Typography variant="caption" color="error">
{errors.destWarehouseId}
</Typography>
)}
</FormControl>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField
fullWidth
label="Documento"
value={documentNumber}
onChange={(e) => setDocumentNumber(e.target.value)}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
fullWidth
label="Riferimento Esterno"
value={externalReference}
onChange={(e) => setExternalReference(e.target.value)}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
fullWidth
label="Note"
value={notes}
onChange={(e) => setNotes(e.target.value)}
/>
</Grid>
</Grid>
</Paper>
{/* Lines */}
<Paper sx={{ p: 3, mb: 3 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 2 }}>
<Typography variant="h6">Articoli da Trasferire</Typography>
<Button startIcon={<AddIcon />} onClick={handleAddLine}>
Aggiungi
</Button>
</Box>
{errors.lines && (
<Alert severity="error" sx={{ mb: 2 }}>
{errors.lines}
</Alert>
)}
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Articolo</TableCell>
<TableCell align="right">Disponibile</TableCell>
<TableCell align="right">Quantità</TableCell>
<TableCell>Note</TableCell>
<TableCell width={60}></TableCell>
</TableRow>
</TableHead>
<TableBody>
{lines.map((line) => (
<TableRow key={line.id}>
<TableCell>
<Autocomplete
value={line.article}
onChange={(_, v) =>
handleLineChange(line.id, "article", v)
}
options={articles || []}
getOptionLabel={(o) => `${o.code} - ${o.description}`}
renderInput={(params) => (
<TextField
{...params}
size="small"
placeholder="Articolo"
/>
)}
isOptionEqualToValue={(o, v) => o.id === v.id}
/>
</TableCell>
<TableCell align="right">
{line.article && (
<Chip
label={`${formatQuantity(line.availableQty || 0)} ${line.article.unitOfMeasure}`}
size="small"
color={
(line.availableQty || 0) <= 0 ? "error" : "default"
}
/>
)}
</TableCell>
<TableCell>
<TextField
size="small"
type="number"
value={line.quantity}
onChange={(e) =>
handleLineChange(
line.id,
"quantity",
parseFloat(e.target.value) || 0,
)
}
slotProps={{
htmlInput: { min: 0, step: 0.01 },
input: {
endAdornment: line.article && (
<InputAdornment position="end">
{line.article.unitOfMeasure}
</InputAdornment>
),
},
}}
fullWidth
/>
</TableCell>
<TableCell>
<TextField
size="small"
value={line.notes || ""}
onChange={(e) =>
handleLineChange(line.id, "notes", e.target.value)
}
placeholder="Note"
fullWidth
/>
</TableCell>
<TableCell>
<IconButton
size="small"
onClick={() => handleRemoveLine(line.id)}
disabled={lines.length === 1}
>
<DeleteIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Divider sx={{ my: 2 }} />
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Typography variant="h6">
Totale: {formatQuantity(totalQuantity)}
</Typography>
</Box>
</Paper>
{/* Actions */}
<Box sx={{ display: "flex", justifyContent: "flex-end", gap: 2 }}>
<Button onClick={() => navigate(-1)}>Annulla</Button>
<Button
variant="outlined"
startIcon={
isPending ? <CircularProgress size={20} /> : <SaveIcon />
}
onClick={() => handleSubmit(false)}
disabled={isPending}
>
Salva Bozza
</Button>
<Button
variant="contained"
color="success"
startIcon={
isPending ? <CircularProgress size={20} /> : <ConfirmIcon />
}
onClick={() => handleSubmit(true)}
disabled={isPending}
>
Salva e Conferma
</Button>
</Box>
</Box>
</LocalizationProvider>
);
}

View File

@@ -0,0 +1,539 @@
import React from "react";
import {
Box,
Paper,
Typography,
Grid,
Card,
CardContent,
Button,
List,
ListItem,
ListItemIcon,
ListItemText,
Chip,
Divider,
Skeleton,
Alert,
} from "@mui/material";
import {
Inventory as InventoryIcon,
Warehouse as WarehouseIcon,
TrendingUp as TrendingUpIcon,
TrendingDown as TrendingDownIcon,
Warning as WarningIcon,
Schedule as ScheduleIcon,
ArrowForward as ArrowForwardIcon,
Add as AddIcon,
Assessment as AssessmentIcon,
} from "@mui/icons-material";
import {
useArticles,
useWarehouses,
useMovements,
useStockLevels,
useExpiringBatches,
} from "../hooks";
import { useWarehouseNavigation } from "../hooks/useWarehouseNavigation";
import {
MovementStatus,
MovementType,
formatCurrency,
formatQuantity,
formatDate,
movementTypeLabels,
getMovementTypeColor,
} from "../types";
interface StatCardProps {
title: string;
value: string | number;
subtitle?: string;
icon: React.ReactNode;
color?: string;
loading?: boolean;
}
function StatCard({
title,
value,
subtitle,
icon,
color = "primary.main",
loading,
}: StatCardProps) {
return (
<Card>
<CardContent>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
}}
>
<Box>
<Typography variant="body2" color="text.secondary" gutterBottom>
{title}
</Typography>
{loading ? (
<Skeleton width={100} height={40} />
) : (
<Typography variant="h4" fontWeight="bold" sx={{ color }}>
{value}
</Typography>
)}
{subtitle && (
<Typography variant="caption" color="text.secondary">
{subtitle}
</Typography>
)}
</Box>
<Box
sx={{
p: 1,
borderRadius: 2,
bgcolor: `${color}15`,
color,
}}
>
{icon}
</Box>
</Box>
</CardContent>
</Card>
);
}
export default function WarehouseDashboard() {
const nav = useWarehouseNavigation();
const { data: articles, isLoading: loadingArticles } = useArticles({
isActive: true,
});
const { data: warehouses, isLoading: loadingWarehouses } = useWarehouses({
active: true,
});
const { data: stockLevels, isLoading: loadingStock } = useStockLevels();
const { data: recentMovements, isLoading: loadingMovements } = useMovements();
const { data: expiringBatches } = useExpiringBatches(30);
// Calculate statistics
const totalArticles = articles?.length || 0;
const totalWarehouses = warehouses?.length || 0;
const stockStats = React.useMemo(() => {
if (!stockLevels) return { totalValue: 0, lowStock: 0, outOfStock: 0 };
let totalValue = 0;
let lowStock = 0;
let outOfStock = 0;
for (const level of stockLevels) {
totalValue += level.stockValue || 0;
if (level.quantity <= 0) outOfStock++;
else if (level.isLowStock) lowStock++;
}
return { totalValue, lowStock, outOfStock };
}, [stockLevels]);
// Recent movements (last 10)
const lastMovements = React.useMemo(() => {
if (!recentMovements) return [];
return recentMovements
.filter((m) => m.status === MovementStatus.Confirmed)
.slice(0, 10);
}, [recentMovements]);
// Pending movements (drafts)
const pendingMovements = React.useMemo(() => {
if (!recentMovements) return [];
return recentMovements.filter((m) => m.status === MovementStatus.Draft);
}, [recentMovements]);
// Low stock articles
const lowStockArticles = React.useMemo(() => {
if (!stockLevels || !articles) return [];
const lowIds = new Set(
stockLevels
.filter((l) => l.isLowStock || l.quantity <= 0)
.map((l) => l.articleId),
);
return articles.filter((a) => lowIds.has(a.id)).slice(0, 5);
}, [stockLevels, articles]);
// Get stock quantity for an article
const getArticleStock = (articleId: number) => {
if (!stockLevels) return 0;
return stockLevels
.filter((l) => l.articleId === articleId)
.reduce((sum, l) => sum + l.quantity, 0);
};
return (
<Box>
{/* Header */}
<Box
sx={{
mb: 4,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Box>
<Typography variant="h4" fontWeight="bold">
Magazzino
</Typography>
<Typography variant="body1" color="text.secondary">
Dashboard e panoramica giacenze
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 1 }}>
<Button
variant="outlined"
startIcon={<AddIcon />}
onClick={nav.goToNewInbound}
>
Nuovo Carico
</Button>
<Button
variant="contained"
startIcon={<AssessmentIcon />}
onClick={nav.goToStockLevels}
>
Giacenze
</Button>
</Box>
</Box>
{/* Stats Cards */}
<Grid container spacing={3} sx={{ mb: 4 }}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard
title="Articoli Attivi"
value={totalArticles}
icon={<InventoryIcon />}
loading={loadingArticles}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard
title="Magazzini"
value={totalWarehouses}
icon={<WarehouseIcon />}
loading={loadingWarehouses}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard
title="Valore Totale"
value={formatCurrency(stockStats.totalValue)}
icon={<TrendingUpIcon />}
color="success.main"
loading={loadingStock}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard
title="Sotto Scorta"
value={stockStats.lowStock + stockStats.outOfStock}
subtitle={`${stockStats.outOfStock} esauriti`}
icon={<WarningIcon />}
color="warning.main"
loading={loadingStock}
/>
</Grid>
</Grid>
{/* Main Content */}
<Grid container spacing={3}>
{/* Recent Movements */}
<Grid size={{ xs: 12, md: 6 }}>
<Paper sx={{ p: 3, height: "100%" }}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 2,
}}
>
<Typography variant="h6">Ultimi Movimenti</Typography>
<Button
size="small"
endIcon={<ArrowForwardIcon />}
onClick={nav.goToMovements}
>
Vedi tutti
</Button>
</Box>
{loadingMovements ? (
<Box>
{[1, 2, 3].map((i) => (
<Skeleton key={i} height={60} sx={{ mb: 1 }} />
))}
</Box>
) : lastMovements.length === 0 ? (
<Typography color="text.secondary" textAlign="center" py={4}>
Nessun movimento recente
</Typography>
) : (
<List disablePadding>
{lastMovements.map((movement, index) => (
<React.Fragment key={movement.id}>
<ListItem
sx={{ px: 0, cursor: "pointer" }}
onClick={() => nav.goToMovement(movement.id)}
>
<ListItemIcon>
{movement.type === MovementType.Inbound ? (
<TrendingUpIcon color="success" />
) : movement.type === MovementType.Outbound ? (
<TrendingDownIcon color="error" />
) : (
<WarehouseIcon color="primary" />
)}
</ListItemIcon>
<ListItemText
primary={
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
}}
>
<Typography variant="body2" fontWeight="medium">
{movement.documentNumber || `MOV-${movement.id}`}
</Typography>
<Chip
label={movementTypeLabels[movement.type]}
size="small"
color={
getMovementTypeColor(movement.type) as
| "success"
| "error"
| "info"
| "warning"
| "default"
}
/>
</Box>
}
secondary={`${movement.sourceWarehouseName || movement.destinationWarehouseName || "-"} - ${formatDate(movement.movementDate)}`}
/>
<Typography variant="body2" color="text.secondary">
{formatQuantity(movement.lineCount)} righe
</Typography>
</ListItem>
{index < lastMovements.length - 1 && <Divider />}
</React.Fragment>
))}
</List>
)}
</Paper>
</Grid>
{/* Alerts Section */}
<Grid size={{ xs: 12, md: 6 }}>
<Grid container spacing={3}>
{/* Pending Movements */}
{pendingMovements.length > 0 && (
<Grid size={12}>
<Alert
severity="info"
icon={<ScheduleIcon />}
action={
<Button size="small" onClick={nav.goToMovements}>
Gestisci
</Button>
}
>
<strong>{pendingMovements.length}</strong> movimenti in bozza
da confermare
</Alert>
</Grid>
)}
{/* Expiring Batches */}
{expiringBatches && expiringBatches.length > 0 && (
<Grid size={12}>
<Alert
severity="warning"
icon={<ScheduleIcon />}
action={
<Button size="small" onClick={nav.goToBatches}>
Visualizza
</Button>
}
>
<strong>{expiringBatches.length}</strong> lotti in scadenza
nei prossimi 30 giorni
</Alert>
</Grid>
)}
{/* Low Stock Articles */}
<Grid size={12}>
<Paper sx={{ p: 3 }}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 2,
}}
>
<Typography variant="h6">Articoli Sotto Scorta</Typography>
<Button
size="small"
endIcon={<ArrowForwardIcon />}
onClick={nav.goToArticles}
>
Vedi tutti
</Button>
</Box>
{lowStockArticles.length === 0 ? (
<Typography color="text.secondary" textAlign="center" py={2}>
Nessun articolo sotto scorta
</Typography>
) : (
<List disablePadding>
{lowStockArticles.map((article, index) => {
const currentStock = getArticleStock(article.id);
return (
<React.Fragment key={article.id}>
<ListItem
sx={{ px: 0, cursor: "pointer" }}
onClick={() => nav.goToArticle(article.id)}
>
<ListItemIcon>
<WarningIcon color="warning" />
</ListItemIcon>
<ListItemText
primary={article.description}
secondary={article.code}
/>
<Chip
label={`${formatQuantity(currentStock)} ${article.unitOfMeasure}`}
size="small"
color={currentStock <= 0 ? "error" : "warning"}
/>
</ListItem>
{index < lowStockArticles.length - 1 && <Divider />}
</React.Fragment>
);
})}
</List>
)}
</Paper>
</Grid>
</Grid>
</Grid>
{/* Quick Actions */}
<Grid size={12}>
<Paper sx={{ p: 3 }}>
<Typography variant="h6" gutterBottom>
Azioni Rapide
</Typography>
<Grid container spacing={2}>
<Grid size={{ xs: 6, sm: 4, md: 2 }}>
<Card
sx={{
textAlign: "center",
cursor: "pointer",
"&:hover": { boxShadow: 3 },
}}
onClick={nav.goToNewInbound}
>
<CardContent>
<TrendingUpIcon color="success" sx={{ fontSize: 40 }} />
<Typography variant="body2">Carico</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 6, sm: 4, md: 2 }}>
<Card
sx={{
textAlign: "center",
cursor: "pointer",
"&:hover": { boxShadow: 3 },
}}
onClick={nav.goToNewOutbound}
>
<CardContent>
<TrendingDownIcon color="error" sx={{ fontSize: 40 }} />
<Typography variant="body2">Scarico</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 6, sm: 4, md: 2 }}>
<Card
sx={{
textAlign: "center",
cursor: "pointer",
"&:hover": { boxShadow: 3 },
}}
onClick={nav.goToNewTransfer}
>
<CardContent>
<WarehouseIcon color="primary" sx={{ fontSize: 40 }} />
<Typography variant="body2">Trasferimento</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 6, sm: 4, md: 2 }}>
<Card
sx={{
textAlign: "center",
cursor: "pointer",
"&:hover": { boxShadow: 3 },
}}
onClick={nav.goToNewArticle}
>
<CardContent>
<InventoryIcon color="info" sx={{ fontSize: 40 }} />
<Typography variant="body2">Nuovo Articolo</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 6, sm: 4, md: 2 }}>
<Card
sx={{
textAlign: "center",
cursor: "pointer",
"&:hover": { boxShadow: 3 },
}}
onClick={nav.goToNewInventory}
>
<CardContent>
<AssessmentIcon color="secondary" sx={{ fontSize: 40 }} />
<Typography variant="body2">Inventario</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 6, sm: 4, md: 2 }}>
<Card
sx={{
textAlign: "center",
cursor: "pointer",
"&:hover": { boxShadow: 3 },
}}
onClick={nav.goToValuation}
>
<CardContent>
<TrendingUpIcon color="warning" sx={{ fontSize: 40 }} />
<Typography variant="body2">Valorizzazione</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
</Paper>
</Grid>
</Grid>
</Box>
);
}

View File

@@ -0,0 +1,496 @@
import { useState } from "react";
import {
Box,
Paper,
Typography,
Button,
IconButton,
Chip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
FormControlLabel,
Switch,
Grid,
Alert,
Card,
CardContent,
CardActions,
Tooltip,
} from "@mui/material";
import {
Add as AddIcon,
Edit as EditIcon,
Delete as DeleteIcon,
Star as StarIcon,
StarBorder as StarBorderIcon,
Warehouse as WarehouseIcon,
} from "@mui/icons-material";
import {
useWarehouses,
useCreateWarehouse,
useUpdateWarehouse,
useDeleteWarehouse,
useSetDefaultWarehouse,
} from "../hooks";
import {
WarehouseLocationDto,
WarehouseType,
warehouseTypeLabels,
} from "../types";
const initialFormData = {
code: "",
name: "",
description: "",
type: WarehouseType.Physical,
address: "",
isDefault: false,
isActive: true,
sortOrder: 0,
};
export default function WarehouseLocationsPage() {
const [dialogOpen, setDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [editingWarehouse, setEditingWarehouse] =
useState<WarehouseLocationDto | null>(null);
const [warehouseToDelete, setWarehouseToDelete] =
useState<WarehouseLocationDto | null>(null);
const [formData, setFormData] = useState(initialFormData);
const [errors, setErrors] = useState<Record<string, string>>({});
const { data: warehouses, isLoading, error } = useWarehouses();
const createMutation = useCreateWarehouse();
const updateMutation = useUpdateWarehouse();
const deleteMutation = useDeleteWarehouse();
const setDefaultMutation = useSetDefaultWarehouse();
const handleOpenDialog = (warehouse?: WarehouseLocationDto) => {
if (warehouse) {
setEditingWarehouse(warehouse);
setFormData({
code: warehouse.code,
name: warehouse.name,
description: warehouse.description || "",
type: warehouse.type,
address: warehouse.address || "",
isDefault: warehouse.isDefault,
isActive: warehouse.isActive,
sortOrder: warehouse.sortOrder,
});
} else {
setEditingWarehouse(null);
setFormData(initialFormData);
}
setErrors({});
setDialogOpen(true);
};
const handleCloseDialog = () => {
setDialogOpen(false);
setEditingWarehouse(null);
setFormData(initialFormData);
setErrors({});
};
const handleChange = (field: string, value: unknown) => {
setFormData((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: "" }));
}
};
const validate = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.code.trim()) {
newErrors.code = "Il codice è obbligatorio";
}
if (!formData.name.trim()) {
newErrors.name = "Il nome è obbligatorio";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async () => {
if (!validate()) return;
try {
if (editingWarehouse) {
await updateMutation.mutateAsync({
id: editingWarehouse.id,
data: formData,
});
} else {
await createMutation.mutateAsync(formData);
}
handleCloseDialog();
} catch (error) {
console.error("Errore salvataggio:", error);
}
};
const handleDeleteClick = (warehouse: WarehouseLocationDto) => {
setWarehouseToDelete(warehouse);
setDeleteDialogOpen(true);
};
const handleDeleteConfirm = async () => {
if (warehouseToDelete) {
await deleteMutation.mutateAsync(warehouseToDelete.id);
setDeleteDialogOpen(false);
setWarehouseToDelete(null);
}
};
const handleSetDefault = async (warehouse: WarehouseLocationDto) => {
if (!warehouse.isDefault) {
await setDefaultMutation.mutateAsync(warehouse.id);
}
};
const getTypeColor = (
type: WarehouseType,
): "primary" | "warning" | "error" | "secondary" | "info" | "default" => {
switch (type) {
case WarehouseType.Physical:
return "primary";
case WarehouseType.Transit:
return "warning";
case WarehouseType.Returns:
return "error";
case WarehouseType.Defective:
return "secondary";
case WarehouseType.Subcontract:
return "info";
default:
return "default";
}
};
if (error) {
return (
<Box p={3}>
<Alert severity="error">
Errore nel caricamento dei magazzini: {(error as Error).message}
</Alert>
</Box>
);
}
return (
<Box>
{/* Header */}
<Box
sx={{
mb: 3,
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography variant="h5" fontWeight="bold">
Gestione Magazzini
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => handleOpenDialog()}
>
Nuovo Magazzino
</Button>
</Box>
{/* Warehouse Cards */}
<Grid container spacing={3}>
{isLoading ? (
Array.from({ length: 4 }).map((_, i) => (
<Grid key={i} size={{ xs: 12, sm: 6, md: 4, lg: 3 }}>
<Card sx={{ height: "100%" }}>
<CardContent>
<Box
sx={{ height: 120, bgcolor: "grey.100", borderRadius: 1 }}
/>
</CardContent>
</Card>
</Grid>
))
) : warehouses?.length === 0 ? (
<Grid size={12}>
<Paper sx={{ p: 4, textAlign: "center" }}>
<WarehouseIcon sx={{ fontSize: 48, color: "grey.400", mb: 2 }} />
<Typography color="text.secondary">
Nessun magazzino configurato
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => handleOpenDialog()}
sx={{ mt: 2 }}
>
Aggiungi il primo magazzino
</Button>
</Paper>
</Grid>
) : (
warehouses?.map((warehouse) => (
<Grid key={warehouse.id} size={{ xs: 12, sm: 6, md: 4, lg: 3 }}>
<Card
sx={{
height: "100%",
display: "flex",
flexDirection: "column",
position: "relative",
opacity: warehouse.isActive ? 1 : 0.6,
}}
>
{warehouse.isDefault && (
<Tooltip title="Magazzino Predefinito">
<StarIcon
sx={{
position: "absolute",
top: 8,
right: 8,
color: "warning.main",
}}
/>
</Tooltip>
)}
<CardContent sx={{ flexGrow: 1 }}>
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
mb: 2,
}}
>
<WarehouseIcon color="primary" />
<Typography variant="h6" component="div">
{warehouse.code}
</Typography>
</Box>
<Typography variant="body1" fontWeight="medium" gutterBottom>
{warehouse.name}
</Typography>
{warehouse.description && (
<Typography
variant="body2"
color="text.secondary"
gutterBottom
>
{warehouse.description}
</Typography>
)}
<Box
sx={{ mt: 2, display: "flex", gap: 1, flexWrap: "wrap" }}
>
<Chip
label={warehouseTypeLabels[warehouse.type]}
size="small"
color={getTypeColor(warehouse.type)}
/>
{!warehouse.isActive && (
<Chip label="Inattivo" size="small" color="default" />
)}
</Box>
{warehouse.address && (
<Typography
variant="caption"
color="text.secondary"
display="block"
sx={{ mt: 1 }}
>
{warehouse.address}
</Typography>
)}
</CardContent>
<CardActions>
<Tooltip title="Imposta come predefinito">
<IconButton
size="small"
onClick={() => handleSetDefault(warehouse)}
disabled={
warehouse.isDefault || setDefaultMutation.isPending
}
>
{warehouse.isDefault ? (
<StarIcon color="warning" />
) : (
<StarBorderIcon />
)}
</IconButton>
</Tooltip>
<Tooltip title="Modifica">
<IconButton
size="small"
onClick={() => handleOpenDialog(warehouse)}
>
<EditIcon />
</IconButton>
</Tooltip>
<Tooltip title="Elimina">
<IconButton
size="small"
onClick={() => handleDeleteClick(warehouse)}
disabled={warehouse.isDefault}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</CardActions>
</Card>
</Grid>
))
)}
</Grid>
{/* Create/Edit Dialog */}
<Dialog
open={dialogOpen}
onClose={handleCloseDialog}
maxWidth="sm"
fullWidth
>
<DialogTitle>
{editingWarehouse ? "Modifica Magazzino" : "Nuovo Magazzino"}
</DialogTitle>
<DialogContent>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid size={{ xs: 12, sm: 4 }}>
<TextField
fullWidth
label="Codice"
value={formData.code}
onChange={(e) => handleChange("code", e.target.value)}
error={!!errors.code}
helperText={errors.code}
required
disabled={!!editingWarehouse}
/>
</Grid>
<Grid size={{ xs: 12, sm: 8 }}>
<TextField
fullWidth
label="Nome"
value={formData.name}
onChange={(e) => handleChange("name", e.target.value)}
error={!!errors.name}
helperText={errors.name}
required
/>
</Grid>
<Grid size={12}>
<TextField
fullWidth
label="Descrizione"
value={formData.description}
onChange={(e) => handleChange("description", e.target.value)}
multiline
rows={2}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<FormControl fullWidth>
<InputLabel>Tipo</InputLabel>
<Select
value={formData.type}
label="Tipo"
onChange={(e) => handleChange("type", e.target.value)}
>
{Object.entries(warehouseTypeLabels).map(([value, label]) => (
<MenuItem key={value} value={parseInt(value, 10)}>
{label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
fullWidth
label="Indirizzo"
value={formData.address}
onChange={(e) => handleChange("address", e.target.value)}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<FormControlLabel
control={
<Switch
checked={formData.isDefault}
onChange={(e) =>
handleChange("isDefault", e.target.checked)
}
/>
}
label="Magazzino Predefinito"
/>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<FormControlLabel
control={
<Switch
checked={formData.isActive}
onChange={(e) => handleChange("isActive", e.target.checked)}
/>
}
label="Attivo"
/>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>Annulla</Button>
<Button
onClick={handleSubmit}
variant="contained"
disabled={createMutation.isPending || updateMutation.isPending}
>
{createMutation.isPending || updateMutation.isPending
? "Salvataggio..."
: "Salva"}
</Button>
</DialogActions>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog
open={deleteDialogOpen}
onClose={() => setDeleteDialogOpen(false)}
>
<DialogTitle>Conferma Eliminazione</DialogTitle>
<DialogContent>
<Typography>
Sei sicuro di voler eliminare il magazzino{" "}
<strong>
{warehouseToDelete?.code} - {warehouseToDelete?.name}
</strong>
?
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Questa azione non può essere annullata.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialogOpen(false)}>Annulla</Button>
<Button
onClick={handleDeleteConfirm}
color="error"
variant="contained"
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? "Eliminazione..." : "Elimina"}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View File

@@ -0,0 +1,18 @@
// Dashboard
export { default as WarehouseDashboard } from './WarehouseDashboard';
// Articles
export { default as ArticlesPage } from './ArticlesPage';
export { default as ArticleFormPage } from './ArticleFormPage';
// Warehouse Locations
export { default as WarehouseLocationsPage } from './WarehouseLocationsPage';
// Movements
export { default as MovementsPage } from './MovementsPage';
export { default as InboundMovementPage } from './InboundMovementPage';
export { default as OutboundMovementPage } from './OutboundMovementPage';
export { default as TransferMovementPage } from './TransferMovementPage';
// Stock
export { default as StockLevelsPage } from './StockLevelsPage';

View File

@@ -0,0 +1,52 @@
import { Routes, Route } from "react-router-dom";
import { WarehouseProvider } from "./contexts/WarehouseContext";
import {
WarehouseDashboard,
ArticlesPage,
ArticleFormPage,
WarehouseLocationsPage,
MovementsPage,
InboundMovementPage,
OutboundMovementPage,
TransferMovementPage,
StockLevelsPage,
} from "./pages";
export default function WarehouseRoutes() {
return (
<WarehouseProvider>
<Routes>
{/* Dashboard */}
<Route index element={<WarehouseDashboard />} />
{/* Articles */}
<Route path="articles" element={<ArticlesPage />} />
<Route path="articles/new" element={<ArticleFormPage />} />
<Route path="articles/:id" element={<ArticleFormPage />} />
<Route path="articles/:id/edit" element={<ArticleFormPage />} />
{/* Warehouse Locations */}
<Route path="locations" element={<WarehouseLocationsPage />} />
{/* Movements */}
<Route path="movements" element={<MovementsPage />} />
<Route path="movements/:id" element={<MovementsPage />} />
<Route path="movements/inbound/new" element={<InboundMovementPage />} />
<Route
path="movements/outbound/new"
element={<OutboundMovementPage />}
/>
<Route
path="movements/transfer/new"
element={<TransferMovementPage />}
/>
{/* Stock */}
<Route path="stock" element={<StockLevelsPage />} />
{/* Fallback */}
<Route path="*" element={<WarehouseDashboard />} />
</Routes>
</WarehouseProvider>
);
}

View File

@@ -0,0 +1,571 @@
import api from "../../../services/api";
import {
ArticleDto,
ArticleFilterDto,
ArticleStockDto,
ArticleValuationDto,
BatchDto,
BatchStatus,
CategoryDto,
CategoryTreeDto,
CreateArticleDto,
CreateBatchDto,
CreateCategoryDto,
CreateInventoryCountDto,
CreateMovementDto,
CreateSerialDto,
CreateSerialsBulkDto,
CreateTransferDto,
CreateAdjustmentDto,
CreateWarehouseDto,
InventoryCountDetailDto,
InventoryCountDto,
InventoryCountLineDto,
InventoryStatus,
MovementDetailDto,
MovementDto,
MovementFilterDto,
MovementReasonDto,
MovementType,
PeriodValuationDto,
QualityStatus,
SerialDto,
SerialStatus,
StockLevelDto,
StockLevelFilterDto,
StockSummaryDto,
UpdateArticleDto,
UpdateBatchDto,
UpdateCategoryDto,
UpdateWarehouseDto,
ValuationMethod,
WarehouseLocationDto,
} from "../types";
const BASE_URL = "/warehouse";
// ===============================================
// WAREHOUSE LOCATIONS
// ===============================================
export const warehouseLocationService = {
getAll: async (includeInactive = false): Promise<WarehouseLocationDto[]> => {
const response = await api.get(`${BASE_URL}/locations`, {
params: { includeInactive },
});
return response.data;
},
getById: async (id: number): Promise<WarehouseLocationDto> => {
const response = await api.get(`${BASE_URL}/locations/${id}`);
return response.data;
},
getDefault: async (): Promise<WarehouseLocationDto> => {
const response = await api.get(`${BASE_URL}/locations/default`);
return response.data;
},
create: async (data: CreateWarehouseDto): Promise<WarehouseLocationDto> => {
const response = await api.post(`${BASE_URL}/locations`, data);
return response.data;
},
update: async (
id: number,
data: UpdateWarehouseDto,
): Promise<WarehouseLocationDto> => {
const response = await api.put(`${BASE_URL}/locations/${id}`, data);
return response.data;
},
delete: async (id: number): Promise<void> => {
await api.delete(`${BASE_URL}/locations/${id}`);
},
setDefault: async (id: number): Promise<void> => {
await api.put(`${BASE_URL}/locations/${id}/set-default`);
},
};
// ===============================================
// ARTICLE CATEGORIES
// ===============================================
export const categoryService = {
getAll: async (includeInactive = false): Promise<CategoryDto[]> => {
const response = await api.get(`${BASE_URL}/categories`, {
params: { includeInactive },
});
return response.data;
},
getTree: async (): Promise<CategoryTreeDto[]> => {
const response = await api.get(`${BASE_URL}/categories/tree`);
return response.data;
},
getById: async (id: number): Promise<CategoryDto> => {
const response = await api.get(`${BASE_URL}/categories/${id}`);
return response.data;
},
create: async (data: CreateCategoryDto): Promise<CategoryDto> => {
const response = await api.post(`${BASE_URL}/categories`, data);
return response.data;
},
update: async (id: number, data: UpdateCategoryDto): Promise<CategoryDto> => {
const response = await api.put(`${BASE_URL}/categories/${id}`, data);
return response.data;
},
delete: async (id: number): Promise<void> => {
await api.delete(`${BASE_URL}/categories/${id}`);
},
};
// ===============================================
// ARTICLES
// ===============================================
export const articleService = {
getAll: async (filter?: ArticleFilterDto): Promise<ArticleDto[]> => {
const response = await api.get(`${BASE_URL}/articles`, { params: filter });
return response.data;
},
getById: async (id: number): Promise<ArticleDto> => {
const response = await api.get(`${BASE_URL}/articles/${id}`);
return response.data;
},
getByCode: async (code: string): Promise<ArticleDto> => {
const response = await api.get(`${BASE_URL}/articles/by-code/${code}`);
return response.data;
},
getByBarcode: async (barcode: string): Promise<ArticleDto> => {
const response = await api.get(
`${BASE_URL}/articles/by-barcode/${barcode}`,
);
return response.data;
},
create: async (data: CreateArticleDto): Promise<ArticleDto> => {
const response = await api.post(`${BASE_URL}/articles`, data);
return response.data;
},
update: async (id: number, data: UpdateArticleDto): Promise<ArticleDto> => {
const response = await api.put(`${BASE_URL}/articles/${id}`, data);
return response.data;
},
delete: async (id: number): Promise<void> => {
await api.delete(`${BASE_URL}/articles/${id}`);
},
uploadImage: async (id: number, file: File): Promise<void> => {
const formData = new FormData();
formData.append("file", file);
await api.post(`${BASE_URL}/articles/${id}/image`, formData, {
headers: { "Content-Type": "multipart/form-data" },
});
},
getImageUrl: (id: number): string => {
return `${api.defaults.baseURL}${BASE_URL}/articles/${id}/image`;
},
getStock: async (id: number): Promise<ArticleStockDto> => {
const response = await api.get(`${BASE_URL}/articles/${id}/stock`);
return response.data;
},
};
// ===============================================
// BATCHES
// ===============================================
export const batchService = {
getAll: async (
articleId?: number,
status?: BatchStatus,
): Promise<BatchDto[]> => {
const response = await api.get(`${BASE_URL}/batches`, {
params: { articleId, status },
});
return response.data;
},
getById: async (id: number): Promise<BatchDto> => {
const response = await api.get(`${BASE_URL}/batches/${id}`);
return response.data;
},
getByNumber: async (
articleId: number,
batchNumber: string,
): Promise<BatchDto> => {
const response = await api.get(
`${BASE_URL}/batches/by-number/${articleId}/${batchNumber}`,
);
return response.data;
},
create: async (data: CreateBatchDto): Promise<BatchDto> => {
const response = await api.post(`${BASE_URL}/batches`, data);
return response.data;
},
update: async (id: number, data: UpdateBatchDto): Promise<BatchDto> => {
const response = await api.put(`${BASE_URL}/batches/${id}`, data);
return response.data;
},
updateStatus: async (id: number, status: BatchStatus): Promise<void> => {
await api.put(`${BASE_URL}/batches/${id}/status`, { status });
},
getExpiring: async (daysThreshold = 30): Promise<BatchDto[]> => {
const response = await api.get(`${BASE_URL}/batches/expiring`, {
params: { daysThreshold },
});
return response.data;
},
recordQualityCheck: async (
id: number,
qualityStatus: QualityStatus,
notes?: string,
): Promise<BatchDto> => {
const response = await api.post(`${BASE_URL}/batches/${id}/quality-check`, {
qualityStatus,
notes,
});
return response.data;
},
};
// ===============================================
// SERIALS
// ===============================================
export const serialService = {
getAll: async (
articleId?: number,
status?: SerialStatus,
): Promise<SerialDto[]> => {
const response = await api.get(`${BASE_URL}/serials`, {
params: { articleId, status },
});
return response.data;
},
getById: async (id: number): Promise<SerialDto> => {
const response = await api.get(`${BASE_URL}/serials/${id}`);
return response.data;
},
getByNumber: async (
articleId: number,
serialNumber: string,
): Promise<SerialDto> => {
const response = await api.get(
`${BASE_URL}/serials/by-number/${articleId}/${serialNumber}`,
);
return response.data;
},
create: async (data: CreateSerialDto): Promise<SerialDto> => {
const response = await api.post(`${BASE_URL}/serials`, data);
return response.data;
},
createBulk: async (data: CreateSerialsBulkDto): Promise<SerialDto[]> => {
const response = await api.post(`${BASE_URL}/serials/bulk`, data);
return response.data;
},
updateStatus: async (id: number, status: SerialStatus): Promise<void> => {
await api.put(`${BASE_URL}/serials/${id}/status`, { status });
},
registerSale: async (
id: number,
customerId?: number,
salesReference?: string,
): Promise<SerialDto> => {
const response = await api.post(`${BASE_URL}/serials/${id}/sell`, {
customerId,
salesReference,
});
return response.data;
},
registerReturn: async (
id: number,
warehouseId: number,
isDefective: boolean,
): Promise<SerialDto> => {
const response = await api.post(`${BASE_URL}/serials/${id}/return`, {
warehouseId,
isDefective,
});
return response.data;
},
};
// ===============================================
// STOCK LEVELS
// ===============================================
export const stockService = {
getAll: async (filter?: StockLevelFilterDto): Promise<StockLevelDto[]> => {
const response = await api.get(`${BASE_URL}/stock`, { params: filter });
return response.data;
},
get: async (
articleId: number,
warehouseId: number,
batchId?: number,
): Promise<StockLevelDto> => {
const response = await api.get(
`${BASE_URL}/stock/${articleId}/${warehouseId}`,
{
params: { batchId },
},
);
return response.data;
},
getLowStock: async (): Promise<StockLevelDto[]> => {
const response = await api.get(`${BASE_URL}/stock/low-stock`);
return response.data;
},
getSummary: async (articleId: number): Promise<StockSummaryDto> => {
const response = await api.get(`${BASE_URL}/stock/summary/${articleId}`);
return response.data;
},
getValuation: async (
articleId: number,
method?: ValuationMethod,
): Promise<ArticleValuationDto> => {
const response = await api.get(`${BASE_URL}/stock/valuation/${articleId}`, {
params: { method },
});
return response.data;
},
getPeriodValuation: async (
period: number,
warehouseId?: number,
): Promise<PeriodValuationDto[]> => {
const response = await api.get(
`${BASE_URL}/stock/valuation/period/${period}`,
{
params: { warehouseId },
},
);
return response.data;
},
calculatePeriodValuation: async (
articleId: number,
period: number,
warehouseId?: number,
): Promise<PeriodValuationDto> => {
const response = await api.post(`${BASE_URL}/stock/valuation/calculate`, {
articleId,
period,
warehouseId,
});
return response.data;
},
closePeriod: async (period: number): Promise<void> => {
await api.post(`${BASE_URL}/stock/valuation/close-period/${period}`);
},
recalculateAverageCost: async (
articleId: number,
): Promise<{ articleId: number; weightedAverageCost: number }> => {
const response = await api.post(
`${BASE_URL}/stock/recalculate-average/${articleId}`,
);
return response.data;
},
};
// ===============================================
// MOVEMENTS
// ===============================================
export const movementService = {
getAll: async (filter?: MovementFilterDto): Promise<MovementDto[]> => {
const response = await api.get(`${BASE_URL}/movements`, { params: filter });
return response.data;
},
getById: async (id: number): Promise<MovementDetailDto> => {
const response = await api.get(`${BASE_URL}/movements/${id}`);
return response.data;
},
getByDocumentNumber: async (
documentNumber: string,
): Promise<MovementDetailDto> => {
const response = await api.get(
`${BASE_URL}/movements/by-document/${documentNumber}`,
);
return response.data;
},
createInbound: async (
data: CreateMovementDto,
): Promise<MovementDetailDto> => {
const response = await api.post(`${BASE_URL}/movements/inbound`, data);
return response.data;
},
createOutbound: async (
data: CreateMovementDto,
): Promise<MovementDetailDto> => {
const response = await api.post(`${BASE_URL}/movements/outbound`, data);
return response.data;
},
createTransfer: async (
data: CreateTransferDto,
): Promise<MovementDetailDto> => {
const response = await api.post(`${BASE_URL}/movements/transfer`, data);
return response.data;
},
createAdjustment: async (
data: CreateAdjustmentDto,
): Promise<MovementDetailDto> => {
const response = await api.post(`${BASE_URL}/movements/adjustment`, data);
return response.data;
},
confirm: async (id: number): Promise<MovementDetailDto> => {
const response = await api.post(`${BASE_URL}/movements/${id}/confirm`);
return response.data;
},
cancel: async (id: number): Promise<MovementDetailDto> => {
const response = await api.post(`${BASE_URL}/movements/${id}/cancel`);
return response.data;
},
delete: async (id: number): Promise<void> => {
await api.delete(`${BASE_URL}/movements/${id}`);
},
generateDocumentNumber: async (type: MovementType): Promise<string> => {
const response = await api.get(
`${BASE_URL}/movements/generate-number/${type}`,
);
return response.data.documentNumber;
},
getReasons: async (
type?: MovementType,
includeInactive = false,
): Promise<MovementReasonDto[]> => {
const response = await api.get(`${BASE_URL}/movements/reasons`, {
params: { type, includeInactive },
});
return response.data;
},
};
// ===============================================
// INVENTORY
// ===============================================
export const inventoryService = {
getAll: async (status?: InventoryStatus): Promise<InventoryCountDto[]> => {
const response = await api.get(`${BASE_URL}/inventory`, {
params: { status },
});
return response.data;
},
getById: async (id: number): Promise<InventoryCountDetailDto> => {
const response = await api.get(`${BASE_URL}/inventory/${id}`);
return response.data;
},
create: async (data: CreateInventoryCountDto): Promise<InventoryCountDto> => {
const response = await api.post(`${BASE_URL}/inventory`, data);
return response.data;
},
start: async (id: number): Promise<InventoryCountDetailDto> => {
const response = await api.post(`${BASE_URL}/inventory/${id}/start`);
return response.data;
},
complete: async (id: number): Promise<InventoryCountDetailDto> => {
const response = await api.post(`${BASE_URL}/inventory/${id}/complete`);
return response.data;
},
confirm: async (id: number): Promise<InventoryCountDetailDto> => {
const response = await api.post(`${BASE_URL}/inventory/${id}/confirm`);
return response.data;
},
cancel: async (id: number): Promise<InventoryCountDto> => {
const response = await api.post(`${BASE_URL}/inventory/${id}/cancel`);
return response.data;
},
updateLine: async (
lineId: number,
countedQuantity: number,
countedBy?: string,
): Promise<InventoryCountLineDto> => {
const response = await api.put(
`${BASE_URL}/inventory/lines/${lineId}/count`,
{
countedQuantity,
countedBy,
},
);
return response.data;
},
updateLinesBatch: async (
inventoryId: number,
lines: { lineId: number; countedQuantity: number }[],
countedBy?: string,
): Promise<InventoryCountLineDto[]> => {
const response = await api.put(
`${BASE_URL}/inventory/${inventoryId}/count-batch`,
{
countedBy,
lines,
},
);
return response.data;
},
};
// Export all services
export default {
locations: warehouseLocationService,
categories: categoryService,
articles: articleService,
batches: batchService,
serials: serialService,
stock: stockService,
movements: movementService,
inventory: inventoryService,
};

View File

@@ -0,0 +1,921 @@
// =====================================================
// WAREHOUSE MODULE - TYPESCRIPT TYPES
// =====================================================
// ===============================================
// ENUMS
// ===============================================
export enum WarehouseType {
Physical = 0,
Logical = 1,
Transit = 2,
Returns = 3,
Defective = 4,
Subcontract = 5,
}
export enum StockManagementType {
Standard = 0,
NotManaged = 1,
VariableWeight = 2,
Kit = 3,
}
export enum ValuationMethod {
WeightedAverage = 0,
FIFO = 1,
LIFO = 2,
StandardCost = 3,
SpecificCost = 4,
}
export enum BatchStatus {
Available = 0,
Quarantine = 1,
Blocked = 2,
Expired = 3,
Depleted = 4,
}
export enum QualityStatus {
NotChecked = 0,
Approved = 1,
Rejected = 2,
ConditionallyApproved = 3,
}
export enum SerialStatus {
Available = 0,
Reserved = 1,
Sold = 2,
InRepair = 3,
Defective = 4,
Returned = 5,
Disposed = 6,
}
export enum MovementType {
Inbound = 0,
Outbound = 1,
Transfer = 2,
Adjustment = 3,
Production = 4,
Consumption = 5,
SupplierReturn = 6,
CustomerReturn = 7,
}
export enum MovementStatus {
Draft = 0,
Confirmed = 1,
Cancelled = 2,
}
export enum ExternalDocumentType {
PurchaseOrder = 0,
InboundDeliveryNote = 1,
PurchaseInvoice = 2,
SalesOrder = 3,
OutboundDeliveryNote = 4,
SalesInvoice = 5,
ProductionOrder = 6,
InventoryDocument = 7,
}
export enum BarcodeType {
EAN13 = 0,
EAN8 = 1,
UPCA = 2,
UPCE = 3,
Code128 = 4,
Code39 = 5,
QRCode = 6,
DataMatrix = 7,
Internal = 8,
}
export enum InventoryType {
Full = 0,
Partial = 1,
Cyclic = 2,
Sample = 3,
}
export enum InventoryStatus {
Draft = 0,
InProgress = 1,
Completed = 2,
Confirmed = 3,
Cancelled = 4,
}
// ===============================================
// WAREHOUSE LOCATION
// ===============================================
export interface WarehouseLocationDto {
id: number;
code: string;
name: string;
description?: string;
address?: string;
city?: string;
province?: string;
postalCode?: string;
country?: string;
type: WarehouseType;
isDefault: boolean;
isActive: boolean;
sortOrder: number;
notes?: string;
createdAt?: string;
updatedAt?: string;
}
export interface CreateWarehouseDto {
code: string;
name: string;
description?: string;
address?: string;
city?: string;
province?: string;
postalCode?: string;
country?: string;
type: WarehouseType;
isDefault: boolean;
sortOrder: number;
notes?: string;
}
export interface UpdateWarehouseDto extends CreateWarehouseDto {
isActive: boolean;
}
// ===============================================
// ARTICLE CATEGORY
// ===============================================
export interface CategoryDto {
id: number;
code: string;
name: string;
description?: string;
parentCategoryId?: number;
parentCategoryName?: string;
level: number;
fullPath?: string;
icon?: string;
color?: string;
defaultValuationMethod?: ValuationMethod;
sortOrder: number;
isActive: boolean;
notes?: string;
createdAt?: string;
updatedAt?: string;
}
export interface CategoryTreeDto {
id: number;
code: string;
name: string;
description?: string;
level: number;
fullPath?: string;
icon?: string;
color?: string;
isActive: boolean;
children: CategoryTreeDto[];
}
export interface CreateCategoryDto {
code: string;
name: string;
description?: string;
parentCategoryId?: number;
icon?: string;
color?: string;
defaultValuationMethod?: ValuationMethod;
sortOrder: number;
notes?: string;
}
export interface UpdateCategoryDto {
code: string;
name: string;
description?: string;
icon?: string;
color?: string;
defaultValuationMethod?: ValuationMethod;
sortOrder: number;
isActive: boolean;
notes?: string;
}
// ===============================================
// ARTICLE
// ===============================================
export interface ArticleDto {
id: number;
code: string;
description: string;
shortDescription?: string;
barcode?: string;
manufacturerCode?: string;
categoryId?: number;
categoryName?: string;
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;
lastPurchaseCost?: number;
weightedAverageCost?: number;
baseSellingPrice?: number;
weight?: number;
volume?: number;
isActive: boolean;
notes?: string;
hasImage: boolean;
createdAt?: string;
updatedAt?: string;
}
export interface CreateArticleDto {
code: string;
description: string;
shortDescription?: 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;
}
export interface UpdateArticleDto extends CreateArticleDto {
isActive: boolean;
}
export interface ArticleFilterDto {
search?: string;
categoryId?: number;
isActive?: boolean;
isBatchManaged?: boolean;
isSerialManaged?: boolean;
skip?: number;
take?: number;
orderBy?: string;
orderDescending?: boolean;
}
export interface ArticleStockDto {
articleId: number;
articleCode: string;
articleDescription: string;
totalStock: number;
availableStock: number;
unitOfMeasure: string;
minimumStock?: number;
maximumStock?: number;
reorderPoint?: number;
isLowStock: boolean;
stockByWarehouse: WarehouseStockDto[];
}
export interface WarehouseStockDto {
warehouseId: number;
warehouseCode: string;
warehouseName: string;
quantity: number;
reservedQuantity: number;
availableQuantity: number;
unitCost?: number;
stockValue?: number;
batchId?: number;
batchNumber?: string;
}
// ===============================================
// BATCH
// ===============================================
export interface BatchDto {
id: number;
articleId: number;
articleCode?: string;
articleDescription?: string;
batchNumber: string;
productionDate?: string;
expiryDate?: string;
supplierBatch?: string;
supplierId?: number;
unitCost?: number;
initialQuantity: number;
currentQuantity: number;
reservedQuantity: number;
availableQuantity: number;
status: BatchStatus;
qualityStatus?: QualityStatus;
lastQualityCheckDate?: string;
certifications?: string;
notes?: string;
isExpired: boolean;
daysToExpiry?: number;
createdAt?: string;
updatedAt?: string;
}
export interface CreateBatchDto {
articleId: number;
batchNumber: string;
productionDate?: string;
expiryDate?: string;
supplierBatch?: string;
supplierId?: number;
unitCost?: number;
initialQuantity: number;
certifications?: string;
notes?: string;
}
export interface UpdateBatchDto {
productionDate?: string;
expiryDate?: string;
supplierBatch?: string;
unitCost?: number;
certifications?: string;
notes?: string;
}
// ===============================================
// SERIAL
// ===============================================
export interface SerialDto {
id: number;
articleId: number;
articleCode?: string;
articleDescription?: string;
batchId?: number;
batchNumber?: string;
serialNumber: string;
manufacturerSerial?: string;
productionDate?: string;
warrantyExpiryDate?: string;
currentWarehouseId?: number;
currentWarehouseCode?: string;
currentWarehouseName?: string;
status: SerialStatus;
unitCost?: number;
supplierId?: number;
customerId?: number;
soldDate?: string;
salesReference?: string;
attributes?: string;
notes?: string;
isWarrantyValid: boolean;
daysToWarrantyExpiry?: number;
createdAt?: string;
updatedAt?: string;
}
export interface CreateSerialDto {
articleId: number;
batchId?: number;
serialNumber: string;
manufacturerSerial?: string;
productionDate?: string;
warrantyExpiryDate?: string;
warehouseId?: number;
unitCost?: number;
supplierId?: number;
attributes?: string;
notes?: string;
}
export interface CreateSerialsBulkDto {
articleId: number;
batchId?: number;
serialNumbers: string[];
productionDate?: string;
warrantyExpiryDate?: string;
warehouseId?: number;
unitCost?: number;
supplierId?: number;
}
// ===============================================
// STOCK LEVEL
// ===============================================
export interface StockLevelDto {
id: number;
articleId: number;
articleCode: string;
articleDescription: string;
categoryName?: string;
warehouseId: number;
warehouseCode: string;
warehouseName: string;
batchId?: number;
batchNumber?: string;
batchExpiryDate?: string;
quantity: number;
reservedQuantity: number;
availableQuantity: number;
onOrderQuantity: number;
unitCost?: number;
stockValue?: number;
locationCode?: string;
lastMovementDate?: string;
lastInventoryDate?: string;
minimumStock?: number;
isLowStock: boolean;
}
export interface StockLevelFilterDto {
articleId?: number;
warehouseId?: number;
batchId?: number;
categoryId?: number;
onlyWithStock?: boolean;
onlyLowStock?: boolean;
skip?: number;
take?: number;
}
export interface StockSummaryDto {
articleId: number;
articleCode: string;
articleDescription: string;
unitOfMeasure: string;
totalStock: number;
availableStock: number;
minimumStock?: number;
maximumStock?: number;
reorderPoint?: number;
isLowStock: boolean;
totalValue: number;
warehouseCount: number;
stockByWarehouse: StockLevelDto[];
}
// ===============================================
// MOVEMENT
// ===============================================
export interface MovementDto {
id: number;
documentNumber: string;
movementDate: string;
type: MovementType;
status: MovementStatus;
sourceWarehouseId?: number;
sourceWarehouseCode?: string;
sourceWarehouseName?: string;
destinationWarehouseId?: number;
destinationWarehouseCode?: string;
destinationWarehouseName?: string;
reasonId?: number;
reasonDescription?: string;
externalReference?: string;
totalValue?: number;
lineCount: number;
confirmedDate?: string;
notes?: string;
createdAt?: string;
}
export interface MovementDetailDto extends MovementDto {
externalDocumentType?: ExternalDocumentType;
supplierId?: number;
customerId?: number;
confirmedBy?: string;
updatedAt?: string;
lines: MovementLineDto[];
}
export interface MovementLineDto {
id: number;
lineNumber: number;
articleId: number;
articleCode: string;
articleDescription: string;
batchId?: number;
batchNumber?: string;
serialId?: number;
serialNumber?: string;
quantity: number;
unitOfMeasure: string;
unitCost?: number;
lineValue?: number;
sourceLocationCode?: string;
destinationLocationCode?: string;
notes?: string;
}
export interface CreateMovementDto {
documentNumber?: string;
movementDate?: string;
reasonId?: number;
warehouseId: number;
externalReference?: string;
externalDocumentType?: ExternalDocumentType;
supplierId?: number;
customerId?: number;
notes?: string;
lines: CreateMovementLineDto[];
}
export interface CreateTransferDto {
documentNumber?: string;
movementDate?: string;
reasonId?: number;
sourceWarehouseId: number;
destinationWarehouseId: number;
externalReference?: string;
notes?: string;
lines: CreateMovementLineDto[];
}
export interface CreateAdjustmentDto {
documentNumber?: string;
movementDate?: string;
reasonId?: number;
warehouseId: number;
externalReference?: string;
notes?: string;
lines: CreateMovementLineDto[];
}
export interface CreateMovementLineDto {
articleId: number;
batchId?: number;
serialId?: number;
quantity: number;
unitOfMeasure?: string;
unitCost?: number;
sourceLocationCode?: string;
destinationLocationCode?: string;
externalLineReference?: string;
notes?: string;
}
export interface MovementFilterDto {
dateFrom?: string;
dateTo?: string;
type?: MovementType;
status?: MovementStatus;
warehouseId?: number;
articleId?: number;
reasonId?: number;
externalReference?: string;
skip?: number;
take?: number;
orderBy?: string;
orderDescending?: boolean;
}
export interface MovementReasonDto {
id: number;
code: string;
description: string;
movementType: MovementType;
stockSign: number;
requiresExternalReference: boolean;
requiresValuation: boolean;
updatesAverageCost: boolean;
isSystem: boolean;
isActive: boolean;
}
// ===============================================
// VALUATION
// ===============================================
export interface ArticleValuationDto {
articleId: number;
articleCode: string;
articleDescription: string;
method: ValuationMethod;
totalQuantity: number;
unitOfMeasure: string;
weightedAverageCost: number;
standardCost?: number;
lastPurchaseCost?: number;
totalValue: number;
}
export interface PeriodValuationDto {
id: number;
period: number;
valuationDate: string;
articleId: number;
articleCode: string;
articleDescription: string;
warehouseId?: number;
warehouseCode?: string;
warehouseName?: string;
quantity: number;
method: ValuationMethod;
unitCost: number;
totalValue: number;
inboundQuantity: number;
inboundValue: number;
outboundQuantity: number;
outboundValue: number;
isClosed: boolean;
}
// ===============================================
// INVENTORY
// ===============================================
export interface InventoryCountDto {
id: number;
code: string;
description: string;
inventoryDate: string;
warehouseId?: number;
warehouseCode?: string;
warehouseName?: string;
categoryId?: number;
categoryName?: string;
type: InventoryType;
status: InventoryStatus;
startDate?: string;
endDate?: string;
confirmedDate?: string;
adjustmentMovementId?: number;
positiveDifferenceValue?: number;
negativeDifferenceValue?: number;
lineCount: number;
countedLineCount: number;
notes?: string;
createdAt?: string;
}
export interface InventoryCountDetailDto extends InventoryCountDto {
confirmedBy?: string;
updatedAt?: string;
lines: InventoryCountLineDto[];
}
export interface InventoryCountLineDto {
id: number;
articleId: number;
articleCode: string;
articleDescription: string;
warehouseId: number;
warehouseCode: string;
batchId?: number;
batchNumber?: string;
locationCode?: string;
theoreticalQuantity: number;
countedQuantity?: number;
difference?: number;
unitCost?: number;
differenceValue?: number;
countedAt?: string;
countedBy?: string;
secondCountQuantity?: number;
secondCountBy?: string;
notes?: string;
}
export interface CreateInventoryCountDto {
code?: string;
description: string;
inventoryDate?: string;
warehouseId?: number;
categoryId?: number;
type: InventoryType;
notes?: string;
}
// ===============================================
// HELPER FUNCTIONS
// ===============================================
export const warehouseTypeLabels: Record<WarehouseType, string> = {
[WarehouseType.Physical]: "Fisico",
[WarehouseType.Logical]: "Logico",
[WarehouseType.Transit]: "Transito",
[WarehouseType.Returns]: "Resi",
[WarehouseType.Defective]: "Difettosi",
[WarehouseType.Subcontract]: "Conto Lavoro",
};
export const stockManagementTypeLabels: Record<StockManagementType, string> = {
[StockManagementType.Standard]: "Standard",
[StockManagementType.NotManaged]: "Non Gestito",
[StockManagementType.VariableWeight]: "Peso Variabile",
[StockManagementType.Kit]: "Kit",
};
export const valuationMethodLabels: Record<ValuationMethod, string> = {
[ValuationMethod.WeightedAverage]: "Costo Medio Ponderato",
[ValuationMethod.FIFO]: "FIFO",
[ValuationMethod.LIFO]: "LIFO",
[ValuationMethod.StandardCost]: "Costo Standard",
[ValuationMethod.SpecificCost]: "Costo Specifico",
};
export const batchStatusLabels: Record<BatchStatus, string> = {
[BatchStatus.Available]: "Disponibile",
[BatchStatus.Quarantine]: "Quarantena",
[BatchStatus.Blocked]: "Bloccato",
[BatchStatus.Expired]: "Scaduto",
[BatchStatus.Depleted]: "Esaurito",
};
export const qualityStatusLabels: Record<QualityStatus, string> = {
[QualityStatus.NotChecked]: "Non Controllato",
[QualityStatus.Approved]: "Approvato",
[QualityStatus.Rejected]: "Respinto",
[QualityStatus.ConditionallyApproved]: "Approvato con Riserva",
};
export const serialStatusLabels: Record<SerialStatus, string> = {
[SerialStatus.Available]: "Disponibile",
[SerialStatus.Reserved]: "Riservato",
[SerialStatus.Sold]: "Venduto",
[SerialStatus.InRepair]: "In Riparazione",
[SerialStatus.Defective]: "Difettoso",
[SerialStatus.Returned]: "Reso",
[SerialStatus.Disposed]: "Dismesso",
};
export const movementTypeLabels: Record<MovementType, string> = {
[MovementType.Inbound]: "Carico",
[MovementType.Outbound]: "Scarico",
[MovementType.Transfer]: "Trasferimento",
[MovementType.Adjustment]: "Rettifica",
[MovementType.Production]: "Produzione",
[MovementType.Consumption]: "Consumo",
[MovementType.SupplierReturn]: "Reso a Fornitore",
[MovementType.CustomerReturn]: "Reso da Cliente",
};
export const movementStatusLabels: Record<MovementStatus, string> = {
[MovementStatus.Draft]: "Bozza",
[MovementStatus.Confirmed]: "Confermato",
[MovementStatus.Cancelled]: "Annullato",
};
export const inventoryTypeLabels: Record<InventoryType, string> = {
[InventoryType.Full]: "Completo",
[InventoryType.Partial]: "Parziale",
[InventoryType.Cyclic]: "Ciclico",
[InventoryType.Sample]: "A Campione",
};
export const inventoryStatusLabels: Record<InventoryStatus, string> = {
[InventoryStatus.Draft]: "Bozza",
[InventoryStatus.InProgress]: "In Corso",
[InventoryStatus.Completed]: "Completato",
[InventoryStatus.Confirmed]: "Confermato",
[InventoryStatus.Cancelled]: "Annullato",
};
export function formatCurrency(value: number | undefined | null): string {
if (value === undefined || value === null) return "-";
return new Intl.NumberFormat("it-IT", {
style: "currency",
currency: "EUR",
}).format(value);
}
export function formatQuantity(
value: number | undefined | null,
decimals: number = 2
): string {
if (value === undefined || value === null) return "-";
return new Intl.NumberFormat("it-IT", {
minimumFractionDigits: 0,
maximumFractionDigits: decimals,
}).format(value);
}
export function formatDate(dateString: string | undefined | null): string {
if (!dateString) return "-";
return new Date(dateString).toLocaleDateString("it-IT");
}
export function formatDateTime(dateString: string | undefined | null): string {
if (!dateString) return "-";
return new Date(dateString).toLocaleString("it-IT");
}
export function getMovementTypeColor(type: MovementType): string {
switch (type) {
case MovementType.Inbound:
case MovementType.Production:
case MovementType.CustomerReturn:
return "success";
case MovementType.Outbound:
case MovementType.Consumption:
case MovementType.SupplierReturn:
return "error";
case MovementType.Transfer:
return "info";
case MovementType.Adjustment:
return "warning";
default:
return "default";
}
}
export function getMovementStatusColor(status: MovementStatus): string {
switch (status) {
case MovementStatus.Draft:
return "warning";
case MovementStatus.Confirmed:
return "success";
case MovementStatus.Cancelled:
return "error";
default:
return "default";
}
}
export function getBatchStatusColor(status: BatchStatus): string {
switch (status) {
case BatchStatus.Available:
return "success";
case BatchStatus.Quarantine:
return "warning";
case BatchStatus.Blocked:
case BatchStatus.Expired:
return "error";
case BatchStatus.Depleted:
return "default";
default:
return "default";
}
}
export function getSerialStatusColor(status: SerialStatus): string {
switch (status) {
case SerialStatus.Available:
return "success";
case SerialStatus.Reserved:
return "info";
case SerialStatus.Sold:
return "primary";
case SerialStatus.Returned:
return "warning";
case SerialStatus.InRepair:
case SerialStatus.Defective:
return "error";
case SerialStatus.Disposed:
return "default";
default:
return "default";
}
}
export function getInventoryStatusColor(status: InventoryStatus): string {
switch (status) {
case InventoryStatus.Draft:
return "default";
case InventoryStatus.InProgress:
return "info";
case InventoryStatus.Completed:
return "warning";
case InventoryStatus.Confirmed:
return "success";
case InventoryStatus.Cancelled:
return "error";
default:
return "default";
}
}

View File

@@ -0,0 +1,288 @@
using Apollinare.API.Modules.Warehouse.Services;
using Apollinare.Domain.Entities.Warehouse;
using Microsoft.AspNetCore.Mvc;
namespace Apollinare.API.Modules.Warehouse.Controllers;
/// <summary>
/// Controller per la gestione delle partite/lotti
/// </summary>
[ApiController]
[Route("api/warehouse/batches")]
public class BatchesController : ControllerBase
{
private readonly IWarehouseService _warehouseService;
private readonly ILogger<BatchesController> _logger;
public BatchesController(
IWarehouseService warehouseService,
ILogger<BatchesController> logger)
{
_warehouseService = warehouseService;
_logger = logger;
}
/// <summary>
/// Ottiene la lista delle partite con filtri opzionali
/// </summary>
[HttpGet]
public async Task<ActionResult<List<BatchDto>>> GetBatches(
[FromQuery] int? articleId = null,
[FromQuery] BatchStatus? status = null)
{
var batches = await _warehouseService.GetBatchesAsync(articleId, status);
return Ok(batches.Select(MapToDto));
}
/// <summary>
/// Ottiene una partita per ID
/// </summary>
[HttpGet("{id}")]
public async Task<ActionResult<BatchDto>> GetBatch(int id)
{
var batch = await _warehouseService.GetBatchByIdAsync(id);
if (batch == null)
return NotFound();
return Ok(MapToDto(batch));
}
/// <summary>
/// Ottiene una partita per articolo e numero lotto
/// </summary>
[HttpGet("by-number/{articleId}/{batchNumber}")]
public async Task<ActionResult<BatchDto>> GetBatchByNumber(int articleId, string batchNumber)
{
var batch = await _warehouseService.GetBatchByNumberAsync(articleId, batchNumber);
if (batch == null)
return NotFound();
return Ok(MapToDto(batch));
}
/// <summary>
/// Crea una nuova partita
/// </summary>
[HttpPost]
public async Task<ActionResult<BatchDto>> CreateBatch([FromBody] CreateBatchDto dto)
{
try
{
var batch = new ArticleBatch
{
ArticleId = dto.ArticleId,
BatchNumber = dto.BatchNumber,
ProductionDate = dto.ProductionDate,
ExpiryDate = dto.ExpiryDate,
SupplierBatch = dto.SupplierBatch,
SupplierId = dto.SupplierId,
UnitCost = dto.UnitCost,
InitialQuantity = dto.InitialQuantity,
CurrentQuantity = dto.InitialQuantity,
Status = BatchStatus.Available,
Certifications = dto.Certifications,
Notes = dto.Notes
};
var created = await _warehouseService.CreateBatchAsync(batch);
return CreatedAtAction(nameof(GetBatch), new { id = created.Id }, MapToDto(created));
}
catch (ArgumentException ex)
{
return NotFound(new { error = ex.Message });
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Aggiorna una partita esistente
/// </summary>
[HttpPut("{id}")]
public async Task<ActionResult<BatchDto>> UpdateBatch(int id, [FromBody] UpdateBatchDto dto)
{
try
{
var existing = await _warehouseService.GetBatchByIdAsync(id);
if (existing == null)
return NotFound();
if (dto.ProductionDate.HasValue)
existing.ProductionDate = dto.ProductionDate;
if (dto.ExpiryDate.HasValue)
existing.ExpiryDate = dto.ExpiryDate;
if (dto.SupplierBatch != null)
existing.SupplierBatch = dto.SupplierBatch;
if (dto.UnitCost.HasValue)
existing.UnitCost = dto.UnitCost;
if (dto.Certifications != null)
existing.Certifications = dto.Certifications;
if (dto.Notes != null)
existing.Notes = dto.Notes;
var updated = await _warehouseService.UpdateBatchAsync(existing);
return Ok(MapToDto(updated));
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Aggiorna lo stato di una partita
/// </summary>
[HttpPut("{id}/status")]
public async Task<ActionResult> UpdateBatchStatus(int id, [FromBody] UpdateBatchStatusDto dto)
{
try
{
await _warehouseService.UpdateBatchStatusAsync(id, dto.Status);
return Ok();
}
catch (ArgumentException ex)
{
return NotFound(new { error = ex.Message });
}
}
/// <summary>
/// Ottiene le partite in scadenza
/// </summary>
[HttpGet("expiring")]
public async Task<ActionResult<List<BatchDto>>> GetExpiringBatches([FromQuery] int daysThreshold = 30)
{
var batches = await _warehouseService.GetExpiringBatchesAsync(daysThreshold);
return Ok(batches.Select(MapToDto));
}
/// <summary>
/// Registra un controllo qualità sulla partita
/// </summary>
[HttpPost("{id}/quality-check")]
public async Task<ActionResult<BatchDto>> RecordQualityCheck(int id, [FromBody] QualityCheckDto dto)
{
try
{
var batch = await _warehouseService.GetBatchByIdAsync(id);
if (batch == null)
return NotFound();
batch.QualityStatus = dto.QualityStatus;
batch.LastQualityCheckDate = DateTime.UtcNow;
// Aggiorna lo stato del lotto in base al risultato
if (dto.QualityStatus == QualityStatus.Rejected)
{
batch.Status = BatchStatus.Blocked;
}
else if (dto.QualityStatus == QualityStatus.Approved && batch.Status == BatchStatus.Quarantine)
{
batch.Status = BatchStatus.Available;
}
var updated = await _warehouseService.UpdateBatchAsync(batch);
return Ok(MapToDto(updated));
}
catch (ArgumentException ex)
{
return NotFound(new { error = ex.Message });
}
}
#region DTOs
public record BatchDto(
int Id,
int ArticleId,
string? ArticleCode,
string? ArticleDescription,
string BatchNumber,
DateTime? ProductionDate,
DateTime? ExpiryDate,
string? SupplierBatch,
int? SupplierId,
decimal? UnitCost,
decimal InitialQuantity,
decimal CurrentQuantity,
decimal ReservedQuantity,
decimal AvailableQuantity,
BatchStatus Status,
QualityStatus? QualityStatus,
DateTime? LastQualityCheckDate,
string? Certifications,
string? Notes,
bool IsExpired,
int? DaysToExpiry,
DateTime? CreatedAt,
DateTime? UpdatedAt
);
public record CreateBatchDto(
int ArticleId,
string BatchNumber,
DateTime? ProductionDate,
DateTime? ExpiryDate,
string? SupplierBatch,
int? SupplierId,
decimal? UnitCost,
decimal InitialQuantity,
string? Certifications,
string? Notes
);
public record UpdateBatchDto(
DateTime? ProductionDate,
DateTime? ExpiryDate,
string? SupplierBatch,
decimal? UnitCost,
string? Certifications,
string? Notes
);
public record UpdateBatchStatusDto(BatchStatus Status);
public record QualityCheckDto(QualityStatus QualityStatus, string? Notes);
#endregion
#region Mapping
private static BatchDto MapToDto(ArticleBatch batch)
{
var isExpired = batch.ExpiryDate.HasValue && batch.ExpiryDate.Value < DateTime.UtcNow;
var daysToExpiry = batch.ExpiryDate.HasValue
? (int?)Math.Max(0, (batch.ExpiryDate.Value - DateTime.UtcNow).Days)
: null;
return new BatchDto(
batch.Id,
batch.ArticleId,
batch.Article?.Code,
batch.Article?.Description,
batch.BatchNumber,
batch.ProductionDate,
batch.ExpiryDate,
batch.SupplierBatch,
batch.SupplierId,
batch.UnitCost,
batch.InitialQuantity,
batch.CurrentQuantity,
batch.ReservedQuantity,
batch.CurrentQuantity - batch.ReservedQuantity,
batch.Status,
batch.QualityStatus,
batch.LastQualityCheckDate,
batch.Certifications,
batch.Notes,
isExpired,
daysToExpiry,
batch.CreatedAt,
batch.UpdatedAt
);
}
#endregion
}

View File

@@ -0,0 +1,428 @@
using Apollinare.API.Modules.Warehouse.Services;
using Apollinare.Domain.Entities.Warehouse;
using Microsoft.AspNetCore.Mvc;
namespace Apollinare.API.Modules.Warehouse.Controllers;
/// <summary>
/// Controller per la gestione degli inventari fisici
/// </summary>
[ApiController]
[Route("api/warehouse/inventory")]
public class InventoryController : ControllerBase
{
private readonly IWarehouseService _warehouseService;
private readonly ILogger<InventoryController> _logger;
public InventoryController(
IWarehouseService warehouseService,
ILogger<InventoryController> logger)
{
_warehouseService = warehouseService;
_logger = logger;
}
/// <summary>
/// Ottiene la lista degli inventari
/// </summary>
[HttpGet]
public async Task<ActionResult<List<InventoryCountDto>>> GetInventoryCounts([FromQuery] InventoryStatus? status = null)
{
var inventories = await _warehouseService.GetInventoryCountsAsync(status);
return Ok(inventories.Select(MapToDto));
}
/// <summary>
/// Ottiene un inventario per ID
/// </summary>
[HttpGet("{id}")]
public async Task<ActionResult<InventoryCountDetailDto>> GetInventoryCount(int id)
{
var inventory = await _warehouseService.GetInventoryCountByIdAsync(id);
if (inventory == null)
return NotFound();
return Ok(MapToDetailDto(inventory));
}
/// <summary>
/// Crea un nuovo inventario
/// </summary>
[HttpPost]
public async Task<ActionResult<InventoryCountDto>> CreateInventoryCount([FromBody] CreateInventoryCountDto dto)
{
try
{
var inventory = new InventoryCount
{
Code = dto.Code ?? "",
Description = dto.Description,
InventoryDate = dto.InventoryDate ?? DateTime.UtcNow.Date,
WarehouseId = dto.WarehouseId,
CategoryId = dto.CategoryId,
Type = dto.Type,
Notes = dto.Notes,
Status = InventoryStatus.Draft
};
var created = await _warehouseService.CreateInventoryCountAsync(inventory);
return CreatedAtAction(nameof(GetInventoryCount), new { id = created.Id }, MapToDto(created));
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Aggiorna un inventario esistente (solo bozze)
/// </summary>
[HttpPut("{id}")]
public async Task<ActionResult<InventoryCountDto>> UpdateInventoryCount(int id, [FromBody] UpdateInventoryCountDto dto)
{
try
{
var existing = await _warehouseService.GetInventoryCountByIdAsync(id);
if (existing == null)
return NotFound();
if (dto.Description != null)
existing.Description = dto.Description;
if (dto.InventoryDate.HasValue)
existing.InventoryDate = dto.InventoryDate.Value;
if (dto.WarehouseId.HasValue)
existing.WarehouseId = dto.WarehouseId;
if (dto.CategoryId.HasValue)
existing.CategoryId = dto.CategoryId;
if (dto.Type.HasValue)
existing.Type = dto.Type.Value;
if (dto.Notes != null)
existing.Notes = dto.Notes;
var updated = await _warehouseService.UpdateInventoryCountAsync(existing);
return Ok(MapToDto(updated));
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Avvia un inventario (genera righe da contare)
/// </summary>
[HttpPost("{id}/start")]
public async Task<ActionResult<InventoryCountDetailDto>> StartInventoryCount(int id)
{
try
{
var inventory = await _warehouseService.StartInventoryCountAsync(id);
return Ok(MapToDetailDto(inventory));
}
catch (ArgumentException ex)
{
return NotFound(new { error = ex.Message });
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Completa un inventario (tutti i conteggi effettuati)
/// </summary>
[HttpPost("{id}/complete")]
public async Task<ActionResult<InventoryCountDetailDto>> CompleteInventoryCount(int id)
{
try
{
var inventory = await _warehouseService.CompleteInventoryCountAsync(id);
return Ok(MapToDetailDto(inventory));
}
catch (ArgumentException ex)
{
return NotFound(new { error = ex.Message });
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Conferma un inventario (applica rettifiche)
/// </summary>
[HttpPost("{id}/confirm")]
public async Task<ActionResult<InventoryCountDetailDto>> ConfirmInventoryCount(int id)
{
try
{
var inventory = await _warehouseService.ConfirmInventoryCountAsync(id);
return Ok(MapToDetailDto(inventory));
}
catch (ArgumentException ex)
{
return NotFound(new { error = ex.Message });
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Annulla un inventario
/// </summary>
[HttpPost("{id}/cancel")]
public async Task<ActionResult<InventoryCountDto>> CancelInventoryCount(int id)
{
try
{
var inventory = await _warehouseService.CancelInventoryCountAsync(id);
return Ok(MapToDto(inventory));
}
catch (ArgumentException ex)
{
return NotFound(new { error = ex.Message });
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Registra il conteggio di una riga
/// </summary>
[HttpPut("lines/{lineId}/count")]
public async Task<ActionResult<InventoryCountLineDto>> UpdateCountLine(int lineId, [FromBody] UpdateCountLineDto dto)
{
try
{
var line = await _warehouseService.UpdateCountLineAsync(lineId, dto.CountedQuantity, dto.CountedBy);
return Ok(MapLineToDto(line));
}
catch (ArgumentException ex)
{
return NotFound(new { error = ex.Message });
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Registra conteggi multipli in batch
/// </summary>
[HttpPut("{id}/count-batch")]
public async Task<ActionResult> UpdateCountLinesBatch(int id, [FromBody] UpdateCountLinesBatchDto dto)
{
try
{
var results = new List<InventoryCountLineDto>();
foreach (var lineUpdate in dto.Lines)
{
var line = await _warehouseService.UpdateCountLineAsync(
lineUpdate.LineId,
lineUpdate.CountedQuantity,
dto.CountedBy);
results.Add(MapLineToDto(line));
}
return Ok(results);
}
catch (ArgumentException ex)
{
return NotFound(new { error = ex.Message });
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
#region DTOs
public record InventoryCountDto(
int Id,
string Code,
string Description,
DateTime InventoryDate,
int? WarehouseId,
string? WarehouseCode,
string? WarehouseName,
int? CategoryId,
string? CategoryName,
InventoryType Type,
InventoryStatus Status,
DateTime? StartDate,
DateTime? EndDate,
DateTime? ConfirmedDate,
int? AdjustmentMovementId,
decimal? PositiveDifferenceValue,
decimal? NegativeDifferenceValue,
int LineCount,
int CountedLineCount,
string? Notes,
DateTime? CreatedAt
);
public record InventoryCountDetailDto(
int Id,
string Code,
string Description,
DateTime InventoryDate,
int? WarehouseId,
string? WarehouseCode,
string? WarehouseName,
int? CategoryId,
string? CategoryName,
InventoryType Type,
InventoryStatus Status,
DateTime? StartDate,
DateTime? EndDate,
DateTime? ConfirmedDate,
string? ConfirmedBy,
int? AdjustmentMovementId,
decimal? PositiveDifferenceValue,
decimal? NegativeDifferenceValue,
string? Notes,
DateTime? CreatedAt,
DateTime? UpdatedAt,
List<InventoryCountLineDto> Lines
);
public record InventoryCountLineDto(
int Id,
int ArticleId,
string ArticleCode,
string ArticleDescription,
int WarehouseId,
string WarehouseCode,
int? BatchId,
string? BatchNumber,
string? LocationCode,
decimal TheoreticalQuantity,
decimal? CountedQuantity,
decimal? Difference,
decimal? UnitCost,
decimal? DifferenceValue,
DateTime? CountedAt,
string? CountedBy,
decimal? SecondCountQuantity,
string? SecondCountBy,
string? Notes
);
public record CreateInventoryCountDto(
string? Code,
string Description,
DateTime? InventoryDate,
int? WarehouseId,
int? CategoryId,
InventoryType Type,
string? Notes
);
public record UpdateInventoryCountDto(
string? Description,
DateTime? InventoryDate,
int? WarehouseId,
int? CategoryId,
InventoryType? Type,
string? Notes
);
public record UpdateCountLineDto(
decimal CountedQuantity,
string? CountedBy
);
public record UpdateCountLinesBatchDto(
string? CountedBy,
List<CountLineUpdate> Lines
);
public record CountLineUpdate(
int LineId,
decimal CountedQuantity
);
#endregion
#region Mapping
private static InventoryCountDto MapToDto(InventoryCount inventory) => new(
inventory.Id,
inventory.Code,
inventory.Description,
inventory.InventoryDate,
inventory.WarehouseId,
inventory.Warehouse?.Code,
inventory.Warehouse?.Name,
inventory.CategoryId,
inventory.Category?.Name,
inventory.Type,
inventory.Status,
inventory.StartDate,
inventory.EndDate,
inventory.ConfirmedDate,
inventory.AdjustmentMovementId,
inventory.PositiveDifferenceValue,
inventory.NegativeDifferenceValue,
inventory.Lines.Count,
inventory.Lines.Count(l => l.CountedQuantity.HasValue),
inventory.Notes,
inventory.CreatedAt
);
private static InventoryCountDetailDto MapToDetailDto(InventoryCount inventory) => new(
inventory.Id,
inventory.Code,
inventory.Description,
inventory.InventoryDate,
inventory.WarehouseId,
inventory.Warehouse?.Code,
inventory.Warehouse?.Name,
inventory.CategoryId,
inventory.Category?.Name,
inventory.Type,
inventory.Status,
inventory.StartDate,
inventory.EndDate,
inventory.ConfirmedDate,
inventory.ConfirmedBy,
inventory.AdjustmentMovementId,
inventory.PositiveDifferenceValue,
inventory.NegativeDifferenceValue,
inventory.Notes,
inventory.CreatedAt,
inventory.UpdatedAt,
inventory.Lines.Select(MapLineToDto).ToList()
);
private static InventoryCountLineDto MapLineToDto(InventoryCountLine line) => new(
line.Id,
line.ArticleId,
line.Article?.Code ?? "",
line.Article?.Description ?? "",
line.WarehouseId,
line.Warehouse?.Code ?? "",
line.BatchId,
line.Batch?.BatchNumber,
line.LocationCode,
line.TheoreticalQuantity,
line.CountedQuantity,
line.Difference,
line.UnitCost,
line.DifferenceValue,
line.CountedAt,
line.CountedBy,
line.SecondCountQuantity,
line.SecondCountBy,
line.Notes
);
#endregion
}

View File

@@ -0,0 +1,366 @@
using Apollinare.API.Modules.Warehouse.Services;
using Apollinare.Domain.Entities.Warehouse;
using Microsoft.AspNetCore.Mvc;
namespace Apollinare.API.Modules.Warehouse.Controllers;
/// <summary>
/// Controller per la gestione dei seriali/matricole
/// </summary>
[ApiController]
[Route("api/warehouse/serials")]
public class SerialsController : ControllerBase
{
private readonly IWarehouseService _warehouseService;
private readonly ILogger<SerialsController> _logger;
public SerialsController(
IWarehouseService warehouseService,
ILogger<SerialsController> logger)
{
_warehouseService = warehouseService;
_logger = logger;
}
/// <summary>
/// Ottiene la lista dei seriali con filtri opzionali
/// </summary>
[HttpGet]
public async Task<ActionResult<List<SerialDto>>> GetSerials(
[FromQuery] int? articleId = null,
[FromQuery] SerialStatus? status = null)
{
var serials = await _warehouseService.GetSerialsAsync(articleId, status);
return Ok(serials.Select(MapToDto));
}
/// <summary>
/// Ottiene un seriale per ID
/// </summary>
[HttpGet("{id}")]
public async Task<ActionResult<SerialDto>> GetSerial(int id)
{
var serial = await _warehouseService.GetSerialByIdAsync(id);
if (serial == null)
return NotFound();
return Ok(MapToDto(serial));
}
/// <summary>
/// Ottiene un seriale per articolo e numero seriale
/// </summary>
[HttpGet("by-number/{articleId}/{serialNumber}")]
public async Task<ActionResult<SerialDto>> GetSerialByNumber(int articleId, string serialNumber)
{
var serial = await _warehouseService.GetSerialByNumberAsync(articleId, serialNumber);
if (serial == null)
return NotFound();
return Ok(MapToDto(serial));
}
/// <summary>
/// Crea un nuovo seriale
/// </summary>
[HttpPost]
public async Task<ActionResult<SerialDto>> CreateSerial([FromBody] CreateSerialDto dto)
{
try
{
var serial = new ArticleSerial
{
ArticleId = dto.ArticleId,
BatchId = dto.BatchId,
SerialNumber = dto.SerialNumber,
ManufacturerSerial = dto.ManufacturerSerial,
ProductionDate = dto.ProductionDate,
WarrantyExpiryDate = dto.WarrantyExpiryDate,
CurrentWarehouseId = dto.WarehouseId,
UnitCost = dto.UnitCost,
SupplierId = dto.SupplierId,
Attributes = dto.Attributes,
Notes = dto.Notes,
Status = SerialStatus.Available
};
var created = await _warehouseService.CreateSerialAsync(serial);
return CreatedAtAction(nameof(GetSerial), new { id = created.Id }, MapToDto(created));
}
catch (ArgumentException ex)
{
return NotFound(new { error = ex.Message });
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Crea più seriali in batch
/// </summary>
[HttpPost("bulk")]
public async Task<ActionResult<List<SerialDto>>> CreateSerialsBulk([FromBody] CreateSerialsBulkDto dto)
{
try
{
var createdSerials = new List<ArticleSerial>();
foreach (var serialNumber in dto.SerialNumbers)
{
var serial = new ArticleSerial
{
ArticleId = dto.ArticleId,
BatchId = dto.BatchId,
SerialNumber = serialNumber,
ProductionDate = dto.ProductionDate,
WarrantyExpiryDate = dto.WarrantyExpiryDate,
CurrentWarehouseId = dto.WarehouseId,
UnitCost = dto.UnitCost,
SupplierId = dto.SupplierId,
Status = SerialStatus.Available
};
var created = await _warehouseService.CreateSerialAsync(serial);
createdSerials.Add(created);
}
return Ok(createdSerials.Select(MapToDto));
}
catch (ArgumentException ex)
{
return NotFound(new { error = ex.Message });
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Aggiorna un seriale esistente
/// </summary>
[HttpPut("{id}")]
public async Task<ActionResult<SerialDto>> UpdateSerial(int id, [FromBody] UpdateSerialDto dto)
{
try
{
var existing = await _warehouseService.GetSerialByIdAsync(id);
if (existing == null)
return NotFound();
if (dto.ManufacturerSerial != null)
existing.ManufacturerSerial = dto.ManufacturerSerial;
if (dto.ProductionDate.HasValue)
existing.ProductionDate = dto.ProductionDate;
if (dto.WarrantyExpiryDate.HasValue)
existing.WarrantyExpiryDate = dto.WarrantyExpiryDate;
if (dto.UnitCost.HasValue)
existing.UnitCost = dto.UnitCost;
if (dto.Attributes != null)
existing.Attributes = dto.Attributes;
if (dto.Notes != null)
existing.Notes = dto.Notes;
var updated = await _warehouseService.UpdateSerialAsync(existing);
return Ok(MapToDto(updated));
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Aggiorna lo stato di un seriale
/// </summary>
[HttpPut("{id}/status")]
public async Task<ActionResult> UpdateSerialStatus(int id, [FromBody] UpdateSerialStatusDto dto)
{
try
{
await _warehouseService.UpdateSerialStatusAsync(id, dto.Status);
return Ok();
}
catch (ArgumentException ex)
{
return NotFound(new { error = ex.Message });
}
}
/// <summary>
/// Registra la vendita di un seriale
/// </summary>
[HttpPost("{id}/sell")]
public async Task<ActionResult<SerialDto>> RegisterSale(int id, [FromBody] RegisterSaleDto dto)
{
try
{
var serial = await _warehouseService.GetSerialByIdAsync(id);
if (serial == null)
return NotFound();
if (serial.Status != SerialStatus.Available && serial.Status != SerialStatus.Reserved)
return BadRequest(new { error = "Il seriale non è disponibile per la vendita" });
serial.Status = SerialStatus.Sold;
serial.CustomerId = dto.CustomerId;
serial.SoldDate = DateTime.UtcNow;
serial.SalesReference = dto.SalesReference;
serial.CurrentWarehouseId = null;
var updated = await _warehouseService.UpdateSerialAsync(serial);
return Ok(MapToDto(updated));
}
catch (ArgumentException ex)
{
return NotFound(new { error = ex.Message });
}
}
/// <summary>
/// Registra un reso di un seriale
/// </summary>
[HttpPost("{id}/return")]
public async Task<ActionResult<SerialDto>> RegisterReturn(int id, [FromBody] RegisterReturnDto dto)
{
try
{
var serial = await _warehouseService.GetSerialByIdAsync(id);
if (serial == null)
return NotFound();
if (serial.Status != SerialStatus.Sold)
return BadRequest(new { error = "Solo i seriali venduti possono essere resi" });
serial.Status = dto.IsDefective ? SerialStatus.Defective : SerialStatus.Returned;
serial.CurrentWarehouseId = dto.WarehouseId;
var updated = await _warehouseService.UpdateSerialAsync(serial);
return Ok(MapToDto(updated));
}
catch (ArgumentException ex)
{
return NotFound(new { error = ex.Message });
}
}
#region DTOs
public record SerialDto(
int Id,
int ArticleId,
string? ArticleCode,
string? ArticleDescription,
int? BatchId,
string? BatchNumber,
string SerialNumber,
string? ManufacturerSerial,
DateTime? ProductionDate,
DateTime? WarrantyExpiryDate,
int? CurrentWarehouseId,
string? CurrentWarehouseCode,
string? CurrentWarehouseName,
SerialStatus Status,
decimal? UnitCost,
int? SupplierId,
int? CustomerId,
DateTime? SoldDate,
string? SalesReference,
string? Attributes,
string? Notes,
bool IsWarrantyValid,
int? DaysToWarrantyExpiry,
DateTime? CreatedAt,
DateTime? UpdatedAt
);
public record CreateSerialDto(
int ArticleId,
int? BatchId,
string SerialNumber,
string? ManufacturerSerial,
DateTime? ProductionDate,
DateTime? WarrantyExpiryDate,
int? WarehouseId,
decimal? UnitCost,
int? SupplierId,
string? Attributes,
string? Notes
);
public record CreateSerialsBulkDto(
int ArticleId,
int? BatchId,
List<string> SerialNumbers,
DateTime? ProductionDate,
DateTime? WarrantyExpiryDate,
int? WarehouseId,
decimal? UnitCost,
int? SupplierId
);
public record UpdateSerialDto(
string? ManufacturerSerial,
DateTime? ProductionDate,
DateTime? WarrantyExpiryDate,
decimal? UnitCost,
string? Attributes,
string? Notes
);
public record UpdateSerialStatusDto(SerialStatus Status);
public record RegisterSaleDto(
int? CustomerId,
string? SalesReference
);
public record RegisterReturnDto(
int WarehouseId,
bool IsDefective
);
#endregion
#region Mapping
private static SerialDto MapToDto(ArticleSerial serial)
{
var isWarrantyValid = serial.WarrantyExpiryDate.HasValue && serial.WarrantyExpiryDate.Value > DateTime.UtcNow;
var daysToWarrantyExpiry = serial.WarrantyExpiryDate.HasValue
? (int?)Math.Max(0, (serial.WarrantyExpiryDate.Value - DateTime.UtcNow).Days)
: null;
return new SerialDto(
serial.Id,
serial.ArticleId,
serial.Article?.Code,
serial.Article?.Description,
serial.BatchId,
serial.Batch?.BatchNumber,
serial.SerialNumber,
serial.ManufacturerSerial,
serial.ProductionDate,
serial.WarrantyExpiryDate,
serial.CurrentWarehouseId,
serial.CurrentWarehouse?.Code,
serial.CurrentWarehouse?.Name,
serial.Status,
serial.UnitCost,
serial.SupplierId,
serial.CustomerId,
serial.SoldDate,
serial.SalesReference,
serial.Attributes,
serial.Notes,
isWarrantyValid,
daysToWarrantyExpiry,
serial.CreatedAt,
serial.UpdatedAt
);
}
#endregion
}

View File

@@ -0,0 +1,360 @@
using Apollinare.API.Modules.Warehouse.Services;
using Apollinare.Domain.Entities.Warehouse;
using Microsoft.AspNetCore.Mvc;
namespace Apollinare.API.Modules.Warehouse.Controllers;
/// <summary>
/// Controller per la gestione delle giacenze e valorizzazione
/// </summary>
[ApiController]
[Route("api/warehouse/stock")]
public class StockLevelsController : ControllerBase
{
private readonly IWarehouseService _warehouseService;
private readonly ILogger<StockLevelsController> _logger;
public StockLevelsController(
IWarehouseService warehouseService,
ILogger<StockLevelsController> logger)
{
_warehouseService = warehouseService;
_logger = logger;
}
/// <summary>
/// Ottiene le giacenze con filtri opzionali
/// </summary>
[HttpGet]
public async Task<ActionResult<List<StockLevelDto>>> GetStockLevels([FromQuery] StockLevelFilterDto? filter)
{
var stockFilter = filter != null ? new StockLevelFilter
{
ArticleId = filter.ArticleId,
WarehouseId = filter.WarehouseId,
BatchId = filter.BatchId,
CategoryId = filter.CategoryId,
OnlyWithStock = filter.OnlyWithStock,
OnlyLowStock = filter.OnlyLowStock,
Skip = filter.Skip,
Take = filter.Take
} : null;
var stockLevels = await _warehouseService.GetStockLevelsAsync(stockFilter);
return Ok(stockLevels.Select(MapToDto));
}
/// <summary>
/// Ottiene la giacenza per articolo/magazzino/batch
/// </summary>
[HttpGet("{articleId}/{warehouseId}")]
public async Task<ActionResult<StockLevelDto>> GetStockLevel(int articleId, int warehouseId, [FromQuery] int? batchId = null)
{
var stockLevel = await _warehouseService.GetStockLevelAsync(articleId, warehouseId, batchId);
if (stockLevel == null)
return NotFound();
return Ok(MapToDto(stockLevel));
}
/// <summary>
/// Ottiene gli articoli sotto scorta
/// </summary>
[HttpGet("low-stock")]
public async Task<ActionResult<List<StockLevelDto>>> GetLowStockArticles()
{
var lowStock = await _warehouseService.GetLowStockArticlesAsync();
return Ok(lowStock.Select(MapToDto));
}
/// <summary>
/// Ottiene il riepilogo giacenze per articolo
/// </summary>
[HttpGet("summary/{articleId}")]
public async Task<ActionResult<StockSummaryDto>> GetStockSummary(int articleId)
{
var article = await _warehouseService.GetArticleByIdAsync(articleId);
if (article == null)
return NotFound();
var totalStock = await _warehouseService.GetTotalStockAsync(articleId);
var availableStock = await _warehouseService.GetAvailableStockAsync(articleId);
var stockLevels = await _warehouseService.GetStockLevelsAsync(new StockLevelFilter { ArticleId = articleId });
return Ok(new StockSummaryDto(
articleId,
article.Code,
article.Description,
article.UnitOfMeasure,
totalStock,
availableStock,
article.MinimumStock,
article.MaximumStock,
article.ReorderPoint,
article.MinimumStock.HasValue && totalStock <= article.MinimumStock.Value,
stockLevels.Sum(s => s.StockValue ?? 0),
stockLevels.Count,
stockLevels.Select(MapToDto).ToList()
));
}
/// <summary>
/// Calcola la valorizzazione di un articolo
/// </summary>
[HttpGet("valuation/{articleId}")]
public async Task<ActionResult<ArticleValuationDto>> GetArticleValuation(int articleId, [FromQuery] ValuationMethod? method = null)
{
try
{
var article = await _warehouseService.GetArticleByIdAsync(articleId);
if (article == null)
return NotFound();
var effectiveMethod = method ?? article.ValuationMethod ?? ValuationMethod.WeightedAverage;
var totalValue = await _warehouseService.CalculateArticleValueAsync(articleId, effectiveMethod);
var totalStock = await _warehouseService.GetTotalStockAsync(articleId);
var avgCost = await _warehouseService.GetWeightedAverageCostAsync(articleId);
return Ok(new ArticleValuationDto(
articleId,
article.Code,
article.Description,
effectiveMethod,
totalStock,
article.UnitOfMeasure,
avgCost,
article.StandardCost,
article.LastPurchaseCost,
totalValue
));
}
catch (ArgumentException ex)
{
return NotFound(new { error = ex.Message });
}
}
/// <summary>
/// Calcola la valorizzazione di periodo
/// </summary>
[HttpGet("valuation/period/{period}")]
public async Task<ActionResult<List<PeriodValuationDto>>> GetPeriodValuation(int period, [FromQuery] int? warehouseId = null)
{
var valuations = await _warehouseService.GetValuationsAsync(period, warehouseId);
return Ok(valuations.Select(v => new PeriodValuationDto(
v.Id,
v.Period,
v.ValuationDate,
v.ArticleId,
v.Article?.Code ?? "",
v.Article?.Description ?? "",
v.WarehouseId,
v.Warehouse?.Code,
v.Warehouse?.Name,
v.Quantity,
v.Method,
v.UnitCost,
v.TotalValue,
v.InboundQuantity,
v.InboundValue,
v.OutboundQuantity,
v.OutboundValue,
v.IsClosed
)));
}
/// <summary>
/// Genera la valorizzazione per un articolo e periodo
/// </summary>
[HttpPost("valuation/calculate")]
public async Task<ActionResult<PeriodValuationDto>> CalculatePeriodValuation([FromBody] CalculateValuationDto dto)
{
try
{
var valuation = await _warehouseService.CalculatePeriodValuationAsync(dto.ArticleId, dto.Period, dto.WarehouseId);
return Ok(new PeriodValuationDto(
valuation.Id,
valuation.Period,
valuation.ValuationDate,
valuation.ArticleId,
valuation.Article?.Code ?? "",
valuation.Article?.Description ?? "",
valuation.WarehouseId,
valuation.Warehouse?.Code,
valuation.Warehouse?.Name,
valuation.Quantity,
valuation.Method,
valuation.UnitCost,
valuation.TotalValue,
valuation.InboundQuantity,
valuation.InboundValue,
valuation.OutboundQuantity,
valuation.OutboundValue,
valuation.IsClosed
));
}
catch (ArgumentException ex)
{
return NotFound(new { error = ex.Message });
}
}
/// <summary>
/// Chiude un periodo (blocca modifiche)
/// </summary>
[HttpPost("valuation/close-period/{period}")]
public async Task<ActionResult> ClosePeriod(int period)
{
await _warehouseService.ClosePeriodAsync(period);
return Ok(new { message = $"Periodo {period} chiuso correttamente" });
}
/// <summary>
/// Ricalcola il costo medio ponderato di un articolo
/// </summary>
[HttpPost("recalculate-average/{articleId}")]
public async Task<ActionResult> RecalculateAverageCost(int articleId)
{
try
{
await _warehouseService.UpdateWeightedAverageCostAsync(articleId);
var avgCost = await _warehouseService.GetWeightedAverageCostAsync(articleId);
return Ok(new { articleId, weightedAverageCost = avgCost });
}
catch (Exception ex)
{
return BadRequest(new { error = ex.Message });
}
}
#region DTOs
public record StockLevelFilterDto(
int? ArticleId,
int? WarehouseId,
int? BatchId,
int? CategoryId,
bool? OnlyWithStock,
bool? OnlyLowStock,
int Skip = 0,
int Take = 100
);
public record StockLevelDto(
int Id,
int ArticleId,
string ArticleCode,
string ArticleDescription,
string? CategoryName,
int WarehouseId,
string WarehouseCode,
string WarehouseName,
int? BatchId,
string? BatchNumber,
DateTime? BatchExpiryDate,
decimal Quantity,
decimal ReservedQuantity,
decimal AvailableQuantity,
decimal OnOrderQuantity,
decimal? UnitCost,
decimal? StockValue,
string? LocationCode,
DateTime? LastMovementDate,
DateTime? LastInventoryDate,
decimal? MinimumStock,
bool IsLowStock
);
public record StockSummaryDto(
int ArticleId,
string ArticleCode,
string ArticleDescription,
string UnitOfMeasure,
decimal TotalStock,
decimal AvailableStock,
decimal? MinimumStock,
decimal? MaximumStock,
decimal? ReorderPoint,
bool IsLowStock,
decimal TotalValue,
int WarehouseCount,
List<StockLevelDto> StockByWarehouse
);
public record ArticleValuationDto(
int ArticleId,
string ArticleCode,
string ArticleDescription,
ValuationMethod Method,
decimal TotalQuantity,
string UnitOfMeasure,
decimal WeightedAverageCost,
decimal? StandardCost,
decimal? LastPurchaseCost,
decimal TotalValue
);
public record PeriodValuationDto(
int Id,
int Period,
DateTime ValuationDate,
int ArticleId,
string ArticleCode,
string ArticleDescription,
int? WarehouseId,
string? WarehouseCode,
string? WarehouseName,
decimal Quantity,
ValuationMethod Method,
decimal UnitCost,
decimal TotalValue,
decimal InboundQuantity,
decimal InboundValue,
decimal OutboundQuantity,
decimal OutboundValue,
bool IsClosed
);
public record CalculateValuationDto(
int ArticleId,
int Period,
int? WarehouseId
);
#endregion
#region Mapping
private static StockLevelDto MapToDto(StockLevel stock)
{
var isLowStock = stock.Article?.MinimumStock.HasValue == true &&
stock.Quantity <= stock.Article.MinimumStock.Value;
return new StockLevelDto(
stock.Id,
stock.ArticleId,
stock.Article?.Code ?? "",
stock.Article?.Description ?? "",
stock.Article?.Category?.Name,
stock.WarehouseId,
stock.Warehouse?.Code ?? "",
stock.Warehouse?.Name ?? "",
stock.BatchId,
stock.Batch?.BatchNumber,
stock.Batch?.ExpiryDate,
stock.Quantity,
stock.ReservedQuantity,
stock.AvailableQuantity,
stock.OnOrderQuantity,
stock.UnitCost,
stock.StockValue,
stock.LocationCode,
stock.LastMovementDate,
stock.LastInventoryDate,
stock.Article?.MinimumStock,
isLowStock
);
}
#endregion
}

View File

@@ -0,0 +1,564 @@
using Apollinare.API.Modules.Warehouse.Services;
using Apollinare.Domain.Entities.Warehouse;
using Microsoft.AspNetCore.Mvc;
namespace Apollinare.API.Modules.Warehouse.Controllers;
/// <summary>
/// Controller per la gestione dei movimenti di magazzino
/// </summary>
[ApiController]
[Route("api/warehouse/movements")]
public class StockMovementsController : ControllerBase
{
private readonly IWarehouseService _warehouseService;
private readonly ILogger<StockMovementsController> _logger;
public StockMovementsController(
IWarehouseService warehouseService,
ILogger<StockMovementsController> logger)
{
_warehouseService = warehouseService;
_logger = logger;
}
/// <summary>
/// Ottiene la lista dei movimenti con filtri opzionali
/// </summary>
[HttpGet]
public async Task<ActionResult<List<MovementDto>>> GetMovements([FromQuery] MovementFilterDto? filter)
{
var movementFilter = filter != null ? new MovementFilter
{
DateFrom = filter.DateFrom,
DateTo = filter.DateTo,
Type = filter.Type,
Status = filter.Status,
WarehouseId = filter.WarehouseId,
ArticleId = filter.ArticleId,
ReasonId = filter.ReasonId,
ExternalReference = filter.ExternalReference,
Skip = filter.Skip,
Take = filter.Take,
OrderBy = filter.OrderBy,
OrderDescending = filter.OrderDescending
} : null;
var movements = await _warehouseService.GetMovementsAsync(movementFilter);
return Ok(movements.Select(MapToDto));
}
/// <summary>
/// Ottiene un movimento per ID
/// </summary>
[HttpGet("{id}")]
public async Task<ActionResult<MovementDetailDto>> GetMovement(int id)
{
var movement = await _warehouseService.GetMovementByIdAsync(id);
if (movement == null)
return NotFound();
return Ok(MapToDetailDto(movement));
}
/// <summary>
/// Ottiene un movimento per numero documento
/// </summary>
[HttpGet("by-document/{documentNumber}")]
public async Task<ActionResult<MovementDetailDto>> GetMovementByDocumentNumber(string documentNumber)
{
var movement = await _warehouseService.GetMovementByDocumentNumberAsync(documentNumber);
if (movement == null)
return NotFound();
return Ok(MapToDetailDto(movement));
}
/// <summary>
/// Crea un nuovo movimento (carico)
/// </summary>
[HttpPost("inbound")]
public async Task<ActionResult<MovementDetailDto>> CreateInboundMovement([FromBody] CreateMovementDto dto)
{
return await CreateMovement(dto, MovementType.Inbound);
}
/// <summary>
/// Crea un nuovo movimento (scarico)
/// </summary>
[HttpPost("outbound")]
public async Task<ActionResult<MovementDetailDto>> CreateOutboundMovement([FromBody] CreateMovementDto dto)
{
return await CreateMovement(dto, MovementType.Outbound);
}
/// <summary>
/// Crea un nuovo movimento (trasferimento)
/// </summary>
[HttpPost("transfer")]
public async Task<ActionResult<MovementDetailDto>> CreateTransferMovement([FromBody] CreateTransferDto dto)
{
try
{
var movement = new StockMovement
{
DocumentNumber = dto.DocumentNumber ?? "",
MovementDate = dto.MovementDate ?? DateTime.UtcNow,
Type = MovementType.Transfer,
ReasonId = dto.ReasonId,
SourceWarehouseId = dto.SourceWarehouseId,
DestinationWarehouseId = dto.DestinationWarehouseId,
ExternalReference = dto.ExternalReference,
Notes = dto.Notes,
Status = MovementStatus.Draft,
Lines = dto.Lines.Select((l, i) => new StockMovementLine
{
LineNumber = i + 1,
ArticleId = l.ArticleId,
BatchId = l.BatchId,
SerialId = l.SerialId,
Quantity = l.Quantity,
UnitOfMeasure = l.UnitOfMeasure ?? "PZ",
Notes = l.Notes
}).ToList()
};
var created = await _warehouseService.CreateMovementAsync(movement);
return CreatedAtAction(nameof(GetMovement), new { id = created.Id }, MapToDetailDto(created));
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Crea un nuovo movimento (rettifica)
/// </summary>
[HttpPost("adjustment")]
public async Task<ActionResult<MovementDetailDto>> CreateAdjustmentMovement([FromBody] CreateAdjustmentDto dto)
{
try
{
var movement = new StockMovement
{
DocumentNumber = dto.DocumentNumber ?? "",
MovementDate = dto.MovementDate ?? DateTime.UtcNow,
Type = MovementType.Adjustment,
ReasonId = dto.ReasonId,
DestinationWarehouseId = dto.WarehouseId, // Per rettifiche positive
SourceWarehouseId = dto.WarehouseId, // Per rettifiche negative
ExternalReference = dto.ExternalReference,
Notes = dto.Notes,
Status = MovementStatus.Draft,
Lines = dto.Lines.Select((l, i) => new StockMovementLine
{
LineNumber = i + 1,
ArticleId = l.ArticleId,
BatchId = l.BatchId,
SerialId = l.SerialId,
Quantity = l.Quantity, // Positiva o negativa
UnitOfMeasure = l.UnitOfMeasure ?? "PZ",
UnitCost = l.UnitCost,
LineValue = l.Quantity * (l.UnitCost ?? 0),
Notes = l.Notes
}).ToList()
};
var created = await _warehouseService.CreateMovementAsync(movement);
return CreatedAtAction(nameof(GetMovement), new { id = created.Id }, MapToDetailDto(created));
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
private async Task<ActionResult<MovementDetailDto>> CreateMovement(CreateMovementDto dto, MovementType type)
{
try
{
var movement = new StockMovement
{
DocumentNumber = dto.DocumentNumber ?? "",
MovementDate = dto.MovementDate ?? DateTime.UtcNow,
Type = type,
ReasonId = dto.ReasonId,
SourceWarehouseId = type == MovementType.Outbound ? dto.WarehouseId : null,
DestinationWarehouseId = type == MovementType.Inbound ? dto.WarehouseId : null,
ExternalReference = dto.ExternalReference,
ExternalDocumentType = dto.ExternalDocumentType,
SupplierId = dto.SupplierId,
CustomerId = dto.CustomerId,
Notes = dto.Notes,
Status = MovementStatus.Draft,
Lines = dto.Lines.Select((l, i) => new StockMovementLine
{
LineNumber = i + 1,
ArticleId = l.ArticleId,
BatchId = l.BatchId,
SerialId = l.SerialId,
Quantity = l.Quantity,
UnitOfMeasure = l.UnitOfMeasure ?? "PZ",
UnitCost = l.UnitCost,
LineValue = l.Quantity * (l.UnitCost ?? 0),
SourceLocationCode = l.SourceLocationCode,
DestinationLocationCode = l.DestinationLocationCode,
ExternalLineReference = l.ExternalLineReference,
Notes = l.Notes
}).ToList()
};
var created = await _warehouseService.CreateMovementAsync(movement);
return CreatedAtAction(nameof(GetMovement), new { id = created.Id }, MapToDetailDto(created));
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Aggiorna un movimento esistente (solo bozze)
/// </summary>
[HttpPut("{id}")]
public async Task<ActionResult<MovementDetailDto>> UpdateMovement(int id, [FromBody] UpdateMovementDto dto)
{
try
{
var existing = await _warehouseService.GetMovementByIdAsync(id);
if (existing == null)
return NotFound();
existing.MovementDate = dto.MovementDate ?? existing.MovementDate;
existing.ReasonId = dto.ReasonId ?? existing.ReasonId;
existing.ExternalReference = dto.ExternalReference ?? existing.ExternalReference;
existing.Notes = dto.Notes ?? existing.Notes;
if (dto.Lines != null)
{
existing.Lines = dto.Lines.Select((l, i) => new StockMovementLine
{
MovementId = id,
LineNumber = i + 1,
ArticleId = l.ArticleId,
BatchId = l.BatchId,
SerialId = l.SerialId,
Quantity = l.Quantity,
UnitOfMeasure = l.UnitOfMeasure ?? "PZ",
UnitCost = l.UnitCost,
LineValue = l.Quantity * (l.UnitCost ?? 0),
SourceLocationCode = l.SourceLocationCode,
DestinationLocationCode = l.DestinationLocationCode,
ExternalLineReference = l.ExternalLineReference,
Notes = l.Notes
}).ToList();
}
var updated = await _warehouseService.UpdateMovementAsync(existing);
return Ok(MapToDetailDto(updated));
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Conferma un movimento (applica alle giacenze)
/// </summary>
[HttpPost("{id}/confirm")]
public async Task<ActionResult<MovementDetailDto>> ConfirmMovement(int id)
{
try
{
var movement = await _warehouseService.ConfirmMovementAsync(id);
return Ok(MapToDetailDto(movement));
}
catch (ArgumentException ex)
{
return NotFound(new { error = ex.Message });
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Annulla un movimento
/// </summary>
[HttpPost("{id}/cancel")]
public async Task<ActionResult<MovementDetailDto>> CancelMovement(int id)
{
try
{
var movement = await _warehouseService.CancelMovementAsync(id);
return Ok(MapToDetailDto(movement));
}
catch (ArgumentException ex)
{
return NotFound(new { error = ex.Message });
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Genera un nuovo numero documento
/// </summary>
[HttpGet("generate-number/{type}")]
public async Task<ActionResult<string>> GenerateDocumentNumber(MovementType type)
{
var number = await _warehouseService.GenerateDocumentNumberAsync(type);
return Ok(new { documentNumber = number });
}
/// <summary>
/// Ottiene le causali movimento
/// </summary>
[HttpGet("reasons")]
public async Task<ActionResult<List<MovementReasonDto>>> GetMovementReasons([FromQuery] MovementType? type = null, [FromQuery] bool includeInactive = false)
{
var reasons = await _warehouseService.GetMovementReasonsAsync(type, includeInactive);
return Ok(reasons.Select(r => new MovementReasonDto(
r.Id,
r.Code,
r.Description,
r.MovementType,
r.StockSign,
r.RequiresExternalReference,
r.RequiresValuation,
r.UpdatesAverageCost,
r.IsSystem,
r.IsActive
)));
}
#region DTOs
public record MovementFilterDto(
DateTime? DateFrom,
DateTime? DateTo,
MovementType? Type,
MovementStatus? Status,
int? WarehouseId,
int? ArticleId,
int? ReasonId,
string? ExternalReference,
int Skip = 0,
int Take = 100,
string? OrderBy = null,
bool OrderDescending = true
);
public record MovementDto(
int Id,
string DocumentNumber,
DateTime MovementDate,
MovementType Type,
MovementStatus Status,
int? SourceWarehouseId,
string? SourceWarehouseCode,
string? SourceWarehouseName,
int? DestinationWarehouseId,
string? DestinationWarehouseCode,
string? DestinationWarehouseName,
int? ReasonId,
string? ReasonDescription,
string? ExternalReference,
decimal? TotalValue,
int LineCount,
DateTime? ConfirmedDate,
string? Notes,
DateTime? CreatedAt
);
public record MovementDetailDto(
int Id,
string DocumentNumber,
DateTime MovementDate,
MovementType Type,
MovementStatus Status,
int? SourceWarehouseId,
string? SourceWarehouseCode,
string? SourceWarehouseName,
int? DestinationWarehouseId,
string? DestinationWarehouseCode,
string? DestinationWarehouseName,
int? ReasonId,
string? ReasonDescription,
string? ExternalReference,
ExternalDocumentType? ExternalDocumentType,
int? SupplierId,
int? CustomerId,
decimal? TotalValue,
DateTime? ConfirmedDate,
string? ConfirmedBy,
string? Notes,
DateTime? CreatedAt,
DateTime? UpdatedAt,
List<MovementLineDto> Lines
);
public record MovementLineDto(
int Id,
int LineNumber,
int ArticleId,
string ArticleCode,
string ArticleDescription,
int? BatchId,
string? BatchNumber,
int? SerialId,
string? SerialNumber,
decimal Quantity,
string UnitOfMeasure,
decimal? UnitCost,
decimal? LineValue,
string? SourceLocationCode,
string? DestinationLocationCode,
string? Notes
);
public record CreateMovementDto(
string? DocumentNumber,
DateTime? MovementDate,
int? ReasonId,
int WarehouseId,
string? ExternalReference,
ExternalDocumentType? ExternalDocumentType,
int? SupplierId,
int? CustomerId,
string? Notes,
List<CreateMovementLineDto> Lines
);
public record CreateTransferDto(
string? DocumentNumber,
DateTime? MovementDate,
int? ReasonId,
int SourceWarehouseId,
int DestinationWarehouseId,
string? ExternalReference,
string? Notes,
List<CreateMovementLineDto> Lines
);
public record CreateAdjustmentDto(
string? DocumentNumber,
DateTime? MovementDate,
int? ReasonId,
int WarehouseId,
string? ExternalReference,
string? Notes,
List<CreateMovementLineDto> Lines
);
public record CreateMovementLineDto(
int ArticleId,
int? BatchId,
int? SerialId,
decimal Quantity,
string? UnitOfMeasure,
decimal? UnitCost,
string? SourceLocationCode,
string? DestinationLocationCode,
string? ExternalLineReference,
string? Notes
);
public record UpdateMovementDto(
DateTime? MovementDate,
int? ReasonId,
string? ExternalReference,
string? Notes,
List<CreateMovementLineDto>? Lines
);
public record MovementReasonDto(
int Id,
string Code,
string Description,
MovementType MovementType,
int StockSign,
bool RequiresExternalReference,
bool RequiresValuation,
bool UpdatesAverageCost,
bool IsSystem,
bool IsActive
);
#endregion
#region Mapping
private static MovementDto MapToDto(StockMovement movement) => new(
movement.Id,
movement.DocumentNumber,
movement.MovementDate,
movement.Type,
movement.Status,
movement.SourceWarehouseId,
movement.SourceWarehouse?.Code,
movement.SourceWarehouse?.Name,
movement.DestinationWarehouseId,
movement.DestinationWarehouse?.Code,
movement.DestinationWarehouse?.Name,
movement.ReasonId,
movement.Reason?.Description,
movement.ExternalReference,
movement.TotalValue,
movement.Lines.Count,
movement.ConfirmedDate,
movement.Notes,
movement.CreatedAt
);
private static MovementDetailDto MapToDetailDto(StockMovement movement) => new(
movement.Id,
movement.DocumentNumber,
movement.MovementDate,
movement.Type,
movement.Status,
movement.SourceWarehouseId,
movement.SourceWarehouse?.Code,
movement.SourceWarehouse?.Name,
movement.DestinationWarehouseId,
movement.DestinationWarehouse?.Code,
movement.DestinationWarehouse?.Name,
movement.ReasonId,
movement.Reason?.Description,
movement.ExternalReference,
movement.ExternalDocumentType,
movement.SupplierId,
movement.CustomerId,
movement.TotalValue,
movement.ConfirmedDate,
movement.ConfirmedBy,
movement.Notes,
movement.CreatedAt,
movement.UpdatedAt,
movement.Lines.Select(l => new MovementLineDto(
l.Id,
l.LineNumber,
l.ArticleId,
l.Article?.Code ?? "",
l.Article?.Description ?? "",
l.BatchId,
l.Batch?.BatchNumber,
l.SerialId,
l.Serial?.SerialNumber,
l.Quantity,
l.UnitOfMeasure,
l.UnitCost,
l.LineValue,
l.SourceLocationCode,
l.DestinationLocationCode,
l.Notes
)).ToList()
);
#endregion
}

View File

@@ -0,0 +1,460 @@
using Apollinare.API.Modules.Warehouse.Services;
using Apollinare.Domain.Entities.Warehouse;
using Microsoft.AspNetCore.Mvc;
namespace Apollinare.API.Modules.Warehouse.Controllers;
/// <summary>
/// Controller per la gestione degli articoli di magazzino
/// </summary>
[ApiController]
[Route("api/warehouse/articles")]
public class WarehouseArticlesController : ControllerBase
{
private readonly IWarehouseService _warehouseService;
private readonly ILogger<WarehouseArticlesController> _logger;
public WarehouseArticlesController(
IWarehouseService warehouseService,
ILogger<WarehouseArticlesController> logger)
{
_warehouseService = warehouseService;
_logger = logger;
}
/// <summary>
/// Ottiene la lista degli articoli con filtri opzionali
/// </summary>
[HttpGet]
public async Task<ActionResult<List<ArticleDto>>> GetArticles([FromQuery] ArticleFilterDto? filter)
{
var articleFilter = filter != null ? new ArticleFilter
{
SearchText = filter.Search,
CategoryId = filter.CategoryId,
IsActive = filter.IsActive,
IsBatchManaged = filter.IsBatchManaged,
IsSerialManaged = filter.IsSerialManaged,
Skip = filter.Skip,
Take = filter.Take,
OrderBy = filter.OrderBy,
OrderDescending = filter.OrderDescending
} : null;
var articles = await _warehouseService.GetArticlesAsync(articleFilter);
return Ok(articles.Select(MapToDto));
}
/// <summary>
/// Ottiene un articolo per ID
/// </summary>
[HttpGet("{id}")]
public async Task<ActionResult<ArticleDto>> GetArticle(int id)
{
var article = await _warehouseService.GetArticleByIdAsync(id);
if (article == null)
return NotFound();
return Ok(MapToDto(article));
}
/// <summary>
/// Ottiene un articolo per codice
/// </summary>
[HttpGet("by-code/{code}")]
public async Task<ActionResult<ArticleDto>> GetArticleByCode(string code)
{
var article = await _warehouseService.GetArticleByCodeAsync(code);
if (article == null)
return NotFound();
return Ok(MapToDto(article));
}
/// <summary>
/// Ottiene un articolo per barcode
/// </summary>
[HttpGet("by-barcode/{barcode}")]
public async Task<ActionResult<ArticleDto>> GetArticleByBarcode(string barcode)
{
var article = await _warehouseService.GetArticleByBarcodeAsync(barcode);
if (article == null)
return NotFound();
return Ok(MapToDto(article));
}
/// <summary>
/// Crea un nuovo articolo
/// </summary>
[HttpPost]
public async Task<ActionResult<ArticleDto>> CreateArticle([FromBody] CreateArticleDto dto)
{
try
{
var article = MapFromDto(dto);
var created = await _warehouseService.CreateArticleAsync(article);
return CreatedAtAction(nameof(GetArticle), new { id = created.Id }, MapToDto(created));
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Aggiorna un articolo esistente
/// </summary>
[HttpPut("{id}")]
public async Task<ActionResult<ArticleDto>> UpdateArticle(int id, [FromBody] UpdateArticleDto dto)
{
try
{
var existing = await _warehouseService.GetArticleByIdAsync(id);
if (existing == null)
return NotFound();
UpdateFromDto(existing, dto);
var updated = await _warehouseService.UpdateArticleAsync(existing);
return Ok(MapToDto(updated));
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Elimina un articolo
/// </summary>
[HttpDelete("{id}")]
public async Task<ActionResult> DeleteArticle(int id)
{
try
{
await _warehouseService.DeleteArticleAsync(id);
return NoContent();
}
catch (ArgumentException ex)
{
return NotFound(new { error = ex.Message });
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Carica l'immagine di un articolo
/// </summary>
[HttpPost("{id}/image")]
public async Task<ActionResult> UploadImage(int id, IFormFile file)
{
var article = await _warehouseService.GetArticleByIdAsync(id);
if (article == null)
return NotFound();
if (file.Length > 5 * 1024 * 1024) // 5MB max
return BadRequest(new { error = "Il file è troppo grande (max 5MB)" });
using var memoryStream = new MemoryStream();
await file.CopyToAsync(memoryStream);
article.Image = memoryStream.ToArray();
article.ImageMimeType = file.ContentType;
await _warehouseService.UpdateArticleAsync(article);
return Ok();
}
/// <summary>
/// Ottiene l'immagine di un articolo
/// </summary>
[HttpGet("{id}/image")]
public async Task<ActionResult> GetImage(int id)
{
var article = await _warehouseService.GetArticleByIdAsync(id);
if (article == null || article.Image == null)
return NotFound();
return File(article.Image, article.ImageMimeType ?? "image/jpeg");
}
/// <summary>
/// Ottiene la giacenza totale di un articolo
/// </summary>
[HttpGet("{id}/stock")]
public async Task<ActionResult<ArticleStockDto>> GetArticleStock(int id)
{
var article = await _warehouseService.GetArticleByIdAsync(id);
if (article == null)
return NotFound();
var totalStock = await _warehouseService.GetTotalStockAsync(id);
var availableStock = await _warehouseService.GetAvailableStockAsync(id);
var stockLevels = await _warehouseService.GetStockLevelsAsync(new StockLevelFilter { ArticleId = id });
return Ok(new ArticleStockDto(
ArticleId: id,
ArticleCode: article.Code,
ArticleDescription: article.Description,
TotalStock: totalStock,
AvailableStock: availableStock,
UnitOfMeasure: article.UnitOfMeasure,
MinimumStock: article.MinimumStock,
MaximumStock: article.MaximumStock,
ReorderPoint: article.ReorderPoint,
IsLowStock: article.MinimumStock.HasValue && totalStock <= article.MinimumStock.Value,
StockByWarehouse: stockLevels.Select(s => new WarehouseStockDto(
WarehouseId: s.WarehouseId,
WarehouseCode: s.Warehouse?.Code ?? "",
WarehouseName: s.Warehouse?.Name ?? "",
Quantity: s.Quantity,
ReservedQuantity: s.ReservedQuantity,
AvailableQuantity: s.AvailableQuantity,
UnitCost: s.UnitCost,
StockValue: s.StockValue,
BatchId: s.BatchId,
BatchNumber: s.Batch?.BatchNumber
)).ToList()
));
}
#region DTOs
public record ArticleFilterDto(
string? Search,
int? CategoryId,
bool? IsActive,
bool? IsBatchManaged,
bool? IsSerialManaged,
int Skip = 0,
int Take = 100,
string? OrderBy = null,
bool OrderDescending = false
);
public record ArticleDto(
int Id,
string Code,
string Description,
string? ShortDescription,
string? Barcode,
string? ManufacturerCode,
int? CategoryId,
string? CategoryName,
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? LastPurchaseCost,
decimal? WeightedAverageCost,
decimal? BaseSellingPrice,
decimal? Weight,
decimal? Volume,
bool IsActive,
string? Notes,
bool HasImage,
DateTime? CreatedAt,
DateTime? UpdatedAt
);
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
);
public record UpdateArticleDto(
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,
bool IsActive,
string? Notes
);
public record ArticleStockDto(
int ArticleId,
string ArticleCode,
string ArticleDescription,
decimal TotalStock,
decimal AvailableStock,
string UnitOfMeasure,
decimal? MinimumStock,
decimal? MaximumStock,
decimal? ReorderPoint,
bool IsLowStock,
List<WarehouseStockDto> StockByWarehouse
);
public record WarehouseStockDto(
int WarehouseId,
string WarehouseCode,
string WarehouseName,
decimal Quantity,
decimal ReservedQuantity,
decimal AvailableQuantity,
decimal? UnitCost,
decimal? StockValue,
int? BatchId,
string? BatchNumber
);
#endregion
#region Mapping
private static ArticleDto MapToDto(WarehouseArticle article) => new(
article.Id,
article.Code,
article.Description,
article.ShortDescription,
article.Barcode,
article.ManufacturerCode,
article.CategoryId,
article.Category?.Name,
article.UnitOfMeasure,
article.SecondaryUnitOfMeasure,
article.UnitConversionFactor,
article.StockManagement,
article.IsBatchManaged,
article.IsSerialManaged,
article.HasExpiry,
article.ExpiryWarningDays,
article.MinimumStock,
article.MaximumStock,
article.ReorderPoint,
article.ReorderQuantity,
article.LeadTimeDays,
article.ValuationMethod,
article.StandardCost,
article.LastPurchaseCost,
article.WeightedAverageCost,
article.BaseSellingPrice,
article.Weight,
article.Volume,
article.IsActive,
article.Notes,
article.Image != null,
article.CreatedAt,
article.UpdatedAt
);
private static WarehouseArticle MapFromDto(CreateArticleDto dto) => new()
{
Code = dto.Code,
Description = dto.Description,
ShortDescription = dto.ShortDescription,
Barcode = dto.Barcode,
ManufacturerCode = dto.ManufacturerCode,
CategoryId = dto.CategoryId,
UnitOfMeasure = dto.UnitOfMeasure,
SecondaryUnitOfMeasure = dto.SecondaryUnitOfMeasure,
UnitConversionFactor = dto.UnitConversionFactor,
StockManagement = dto.StockManagement,
IsBatchManaged = dto.IsBatchManaged,
IsSerialManaged = dto.IsSerialManaged,
HasExpiry = dto.HasExpiry,
ExpiryWarningDays = dto.ExpiryWarningDays,
MinimumStock = dto.MinimumStock,
MaximumStock = dto.MaximumStock,
ReorderPoint = dto.ReorderPoint,
ReorderQuantity = dto.ReorderQuantity,
LeadTimeDays = dto.LeadTimeDays,
ValuationMethod = dto.ValuationMethod,
StandardCost = dto.StandardCost,
BaseSellingPrice = dto.BaseSellingPrice,
Weight = dto.Weight,
Volume = dto.Volume,
Notes = dto.Notes,
IsActive = true
};
private static void UpdateFromDto(WarehouseArticle article, UpdateArticleDto dto)
{
article.Code = dto.Code;
article.Description = dto.Description;
article.ShortDescription = dto.ShortDescription;
article.Barcode = dto.Barcode;
article.ManufacturerCode = dto.ManufacturerCode;
article.CategoryId = dto.CategoryId;
article.UnitOfMeasure = dto.UnitOfMeasure;
article.SecondaryUnitOfMeasure = dto.SecondaryUnitOfMeasure;
article.UnitConversionFactor = dto.UnitConversionFactor;
article.StockManagement = dto.StockManagement;
article.IsBatchManaged = dto.IsBatchManaged;
article.IsSerialManaged = dto.IsSerialManaged;
article.HasExpiry = dto.HasExpiry;
article.ExpiryWarningDays = dto.ExpiryWarningDays;
article.MinimumStock = dto.MinimumStock;
article.MaximumStock = dto.MaximumStock;
article.ReorderPoint = dto.ReorderPoint;
article.ReorderQuantity = dto.ReorderQuantity;
article.LeadTimeDays = dto.LeadTimeDays;
article.ValuationMethod = dto.ValuationMethod;
article.StandardCost = dto.StandardCost;
article.BaseSellingPrice = dto.BaseSellingPrice;
article.Weight = dto.Weight;
article.Volume = dto.Volume;
article.IsActive = dto.IsActive;
article.Notes = dto.Notes;
}
#endregion
}

View File

@@ -0,0 +1,240 @@
using Apollinare.API.Modules.Warehouse.Services;
using Apollinare.Domain.Entities.Warehouse;
using Microsoft.AspNetCore.Mvc;
namespace Apollinare.API.Modules.Warehouse.Controllers;
/// <summary>
/// Controller per la gestione delle categorie articoli
/// </summary>
[ApiController]
[Route("api/warehouse/categories")]
public class WarehouseCategoriesController : ControllerBase
{
private readonly IWarehouseService _warehouseService;
private readonly ILogger<WarehouseCategoriesController> _logger;
public WarehouseCategoriesController(
IWarehouseService warehouseService,
ILogger<WarehouseCategoriesController> logger)
{
_warehouseService = warehouseService;
_logger = logger;
}
/// <summary>
/// Ottiene la lista delle categorie
/// </summary>
[HttpGet]
public async Task<ActionResult<List<CategoryDto>>> GetCategories([FromQuery] bool includeInactive = false)
{
var categories = await _warehouseService.GetCategoriesAsync(includeInactive);
return Ok(categories.Select(MapToDto));
}
/// <summary>
/// Ottiene le categorie in formato albero
/// </summary>
[HttpGet("tree")]
public async Task<ActionResult<List<CategoryTreeDto>>> GetCategoryTree()
{
var categories = await _warehouseService.GetCategoryTreeAsync();
return Ok(categories.Select(MapToTreeDto));
}
/// <summary>
/// Ottiene una categoria per ID
/// </summary>
[HttpGet("{id}")]
public async Task<ActionResult<CategoryDto>> GetCategory(int id)
{
var category = await _warehouseService.GetCategoryByIdAsync(id);
if (category == null)
return NotFound();
return Ok(MapToDto(category));
}
/// <summary>
/// Crea una nuova categoria
/// </summary>
[HttpPost]
public async Task<ActionResult<CategoryDto>> CreateCategory([FromBody] CreateCategoryDto dto)
{
try
{
var category = new WarehouseArticleCategory
{
Code = dto.Code,
Name = dto.Name,
Description = dto.Description,
ParentCategoryId = dto.ParentCategoryId,
Icon = dto.Icon,
Color = dto.Color,
DefaultValuationMethod = dto.DefaultValuationMethod,
SortOrder = dto.SortOrder,
Notes = dto.Notes,
IsActive = true
};
var created = await _warehouseService.CreateCategoryAsync(category);
return CreatedAtAction(nameof(GetCategory), new { id = created.Id }, MapToDto(created));
}
catch (ArgumentException ex)
{
return NotFound(new { error = ex.Message });
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Aggiorna una categoria esistente
/// </summary>
[HttpPut("{id}")]
public async Task<ActionResult<CategoryDto>> UpdateCategory(int id, [FromBody] UpdateCategoryDto dto)
{
try
{
var existing = await _warehouseService.GetCategoryByIdAsync(id);
if (existing == null)
return NotFound();
existing.Code = dto.Code;
existing.Name = dto.Name;
existing.Description = dto.Description;
existing.Icon = dto.Icon;
existing.Color = dto.Color;
existing.DefaultValuationMethod = dto.DefaultValuationMethod;
existing.SortOrder = dto.SortOrder;
existing.IsActive = dto.IsActive;
existing.Notes = dto.Notes;
var updated = await _warehouseService.UpdateCategoryAsync(existing);
return Ok(MapToDto(updated));
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Elimina una categoria
/// </summary>
[HttpDelete("{id}")]
public async Task<ActionResult> DeleteCategory(int id)
{
try
{
await _warehouseService.DeleteCategoryAsync(id);
return NoContent();
}
catch (ArgumentException ex)
{
return NotFound(new { error = ex.Message });
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
#region DTOs
public record CategoryDto(
int Id,
string Code,
string Name,
string? Description,
int? ParentCategoryId,
string? ParentCategoryName,
int Level,
string? FullPath,
string? Icon,
string? Color,
ValuationMethod? DefaultValuationMethod,
int SortOrder,
bool IsActive,
string? Notes,
DateTime? CreatedAt,
DateTime? UpdatedAt
);
public record CategoryTreeDto(
int Id,
string Code,
string Name,
string? Description,
int Level,
string? FullPath,
string? Icon,
string? Color,
bool IsActive,
List<CategoryTreeDto> Children
);
public record CreateCategoryDto(
string Code,
string Name,
string? Description,
int? ParentCategoryId,
string? Icon,
string? Color,
ValuationMethod? DefaultValuationMethod,
int SortOrder,
string? Notes
);
public record UpdateCategoryDto(
string Code,
string Name,
string? Description,
string? Icon,
string? Color,
ValuationMethod? DefaultValuationMethod,
int SortOrder,
bool IsActive,
string? Notes
);
#endregion
#region Mapping
private static CategoryDto MapToDto(WarehouseArticleCategory category) => new(
category.Id,
category.Code,
category.Name,
category.Description,
category.ParentCategoryId,
category.ParentCategory?.Name,
category.Level,
category.FullPath,
category.Icon,
category.Color,
category.DefaultValuationMethod,
category.SortOrder,
category.IsActive,
category.Notes,
category.CreatedAt,
category.UpdatedAt
);
private static CategoryTreeDto MapToTreeDto(WarehouseArticleCategory category) => new(
category.Id,
category.Code,
category.Name,
category.Description,
category.Level,
category.FullPath,
category.Icon,
category.Color,
category.IsActive,
category.ChildCategories.Select(MapToTreeDto).ToList()
);
#endregion
}

View File

@@ -0,0 +1,249 @@
using Apollinare.API.Modules.Warehouse.Services;
using Apollinare.Domain.Entities.Warehouse;
using Microsoft.AspNetCore.Mvc;
namespace Apollinare.API.Modules.Warehouse.Controllers;
/// <summary>
/// Controller per la gestione dei magazzini
/// </summary>
[ApiController]
[Route("api/warehouse/locations")]
public class WarehouseLocationsController : ControllerBase
{
private readonly IWarehouseService _warehouseService;
private readonly ILogger<WarehouseLocationsController> _logger;
public WarehouseLocationsController(
IWarehouseService warehouseService,
ILogger<WarehouseLocationsController> logger)
{
_warehouseService = warehouseService;
_logger = logger;
}
/// <summary>
/// Ottiene la lista dei magazzini
/// </summary>
[HttpGet]
public async Task<ActionResult<List<WarehouseLocationDto>>> GetWarehouses([FromQuery] bool includeInactive = false)
{
var warehouses = await _warehouseService.GetWarehousesAsync(includeInactive);
return Ok(warehouses.Select(MapToDto));
}
/// <summary>
/// Ottiene un magazzino per ID
/// </summary>
[HttpGet("{id}")]
public async Task<ActionResult<WarehouseLocationDto>> GetWarehouse(int id)
{
var warehouse = await _warehouseService.GetWarehouseByIdAsync(id);
if (warehouse == null)
return NotFound();
return Ok(MapToDto(warehouse));
}
/// <summary>
/// Ottiene il magazzino predefinito
/// </summary>
[HttpGet("default")]
public async Task<ActionResult<WarehouseLocationDto>> GetDefaultWarehouse()
{
var warehouse = await _warehouseService.GetDefaultWarehouseAsync();
if (warehouse == null)
return NotFound(new { error = "Nessun magazzino predefinito configurato" });
return Ok(MapToDto(warehouse));
}
/// <summary>
/// Crea un nuovo magazzino
/// </summary>
[HttpPost]
public async Task<ActionResult<WarehouseLocationDto>> CreateWarehouse([FromBody] CreateWarehouseDto dto)
{
try
{
var warehouse = MapFromDto(dto);
var created = await _warehouseService.CreateWarehouseAsync(warehouse);
return CreatedAtAction(nameof(GetWarehouse), new { id = created.Id }, MapToDto(created));
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Aggiorna un magazzino esistente
/// </summary>
[HttpPut("{id}")]
public async Task<ActionResult<WarehouseLocationDto>> UpdateWarehouse(int id, [FromBody] UpdateWarehouseDto dto)
{
try
{
var existing = await _warehouseService.GetWarehouseByIdAsync(id);
if (existing == null)
return NotFound();
UpdateFromDto(existing, dto);
var updated = await _warehouseService.UpdateWarehouseAsync(existing);
return Ok(MapToDto(updated));
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Elimina un magazzino
/// </summary>
[HttpDelete("{id}")]
public async Task<ActionResult> DeleteWarehouse(int id)
{
try
{
await _warehouseService.DeleteWarehouseAsync(id);
return NoContent();
}
catch (ArgumentException ex)
{
return NotFound(new { error = ex.Message });
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
/// <summary>
/// Imposta un magazzino come predefinito
/// </summary>
[HttpPut("{id}/set-default")]
public async Task<ActionResult> SetDefaultWarehouse(int id)
{
try
{
await _warehouseService.SetDefaultWarehouseAsync(id);
return Ok();
}
catch (ArgumentException ex)
{
return NotFound(new { error = ex.Message });
}
}
#region DTOs
public record WarehouseLocationDto(
int Id,
string Code,
string Name,
string? Description,
string? Address,
string? City,
string? Province,
string? PostalCode,
string? Country,
WarehouseType Type,
bool IsDefault,
bool IsActive,
int SortOrder,
string? Notes,
DateTime? CreatedAt,
DateTime? UpdatedAt
);
public record CreateWarehouseDto(
string Code,
string Name,
string? Description,
string? Address,
string? City,
string? Province,
string? PostalCode,
string? Country,
WarehouseType Type,
bool IsDefault,
int SortOrder,
string? Notes
);
public record UpdateWarehouseDto(
string Code,
string Name,
string? Description,
string? Address,
string? City,
string? Province,
string? PostalCode,
string? Country,
WarehouseType Type,
bool IsDefault,
bool IsActive,
int SortOrder,
string? Notes
);
#endregion
#region Mapping
private static WarehouseLocationDto MapToDto(WarehouseLocation warehouse) => new(
warehouse.Id,
warehouse.Code,
warehouse.Name,
warehouse.Description,
warehouse.Address,
warehouse.City,
warehouse.Province,
warehouse.PostalCode,
warehouse.Country,
warehouse.Type,
warehouse.IsDefault,
warehouse.IsActive,
warehouse.SortOrder,
warehouse.Notes,
warehouse.CreatedAt,
warehouse.UpdatedAt
);
private static WarehouseLocation MapFromDto(CreateWarehouseDto dto) => new()
{
Code = dto.Code,
Name = dto.Name,
Description = dto.Description,
Address = dto.Address,
City = dto.City,
Province = dto.Province,
PostalCode = dto.PostalCode,
Country = dto.Country ?? "Italia",
Type = dto.Type,
IsDefault = dto.IsDefault,
SortOrder = dto.SortOrder,
Notes = dto.Notes,
IsActive = true
};
private static void UpdateFromDto(WarehouseLocation warehouse, UpdateWarehouseDto dto)
{
warehouse.Code = dto.Code;
warehouse.Name = dto.Name;
warehouse.Description = dto.Description;
warehouse.Address = dto.Address;
warehouse.City = dto.City;
warehouse.Province = dto.Province;
warehouse.PostalCode = dto.PostalCode;
warehouse.Country = dto.Country;
warehouse.Type = dto.Type;
warehouse.IsDefault = dto.IsDefault;
warehouse.IsActive = dto.IsActive;
warehouse.SortOrder = dto.SortOrder;
warehouse.Notes = dto.Notes;
}
#endregion
}

View File

@@ -0,0 +1,172 @@
using Apollinare.Domain.Entities.Warehouse;
namespace Apollinare.API.Modules.Warehouse.Services;
/// <summary>
/// Interfaccia servizio principale per il modulo Magazzino
/// </summary>
public interface IWarehouseService
{
// ===============================================
// ARTICOLI
// ===============================================
Task<List<WarehouseArticle>> GetArticlesAsync(ArticleFilter? filter = null);
Task<WarehouseArticle?> GetArticleByIdAsync(int id);
Task<WarehouseArticle?> GetArticleByCodeAsync(string code);
Task<WarehouseArticle?> GetArticleByBarcodeAsync(string barcode);
Task<WarehouseArticle> CreateArticleAsync(WarehouseArticle article);
Task<WarehouseArticle> UpdateArticleAsync(WarehouseArticle article);
Task DeleteArticleAsync(int id);
// ===============================================
// CATEGORIE
// ===============================================
Task<List<WarehouseArticleCategory>> GetCategoriesAsync(bool includeInactive = false);
Task<List<WarehouseArticleCategory>> GetCategoryTreeAsync();
Task<WarehouseArticleCategory?> GetCategoryByIdAsync(int id);
Task<WarehouseArticleCategory> CreateCategoryAsync(WarehouseArticleCategory category);
Task<WarehouseArticleCategory> UpdateCategoryAsync(WarehouseArticleCategory category);
Task DeleteCategoryAsync(int id);
// ===============================================
// MAGAZZINI
// ===============================================
Task<List<WarehouseLocation>> GetWarehousesAsync(bool includeInactive = false);
Task<WarehouseLocation?> GetWarehouseByIdAsync(int id);
Task<WarehouseLocation?> GetDefaultWarehouseAsync();
Task<WarehouseLocation> CreateWarehouseAsync(WarehouseLocation warehouse);
Task<WarehouseLocation> UpdateWarehouseAsync(WarehouseLocation warehouse);
Task DeleteWarehouseAsync(int id);
Task SetDefaultWarehouseAsync(int id);
// ===============================================
// PARTITE (BATCH)
// ===============================================
Task<List<ArticleBatch>> GetBatchesAsync(int? articleId = null, BatchStatus? status = null);
Task<ArticleBatch?> GetBatchByIdAsync(int id);
Task<ArticleBatch?> GetBatchByNumberAsync(int articleId, string batchNumber);
Task<ArticleBatch> CreateBatchAsync(ArticleBatch batch);
Task<ArticleBatch> UpdateBatchAsync(ArticleBatch batch);
Task<List<ArticleBatch>> GetExpiringBatchesAsync(int daysThreshold = 30);
Task UpdateBatchStatusAsync(int id, BatchStatus status);
// ===============================================
// SERIALI
// ===============================================
Task<List<ArticleSerial>> GetSerialsAsync(int? articleId = null, SerialStatus? status = null);
Task<ArticleSerial?> GetSerialByIdAsync(int id);
Task<ArticleSerial?> GetSerialByNumberAsync(int articleId, string serialNumber);
Task<ArticleSerial> CreateSerialAsync(ArticleSerial serial);
Task<ArticleSerial> UpdateSerialAsync(ArticleSerial serial);
Task UpdateSerialStatusAsync(int id, SerialStatus status);
// ===============================================
// GIACENZE
// ===============================================
Task<List<StockLevel>> GetStockLevelsAsync(StockLevelFilter? filter = null);
Task<StockLevel?> GetStockLevelAsync(int articleId, int warehouseId, int? batchId = null);
Task<decimal> GetTotalStockAsync(int articleId);
Task<decimal> GetAvailableStockAsync(int articleId, int? warehouseId = null);
Task<List<StockLevel>> GetLowStockArticlesAsync();
Task UpdateStockLevelAsync(int articleId, int warehouseId, decimal quantity, int? batchId = null, decimal? unitCost = null);
// ===============================================
// MOVIMENTI
// ===============================================
Task<List<StockMovement>> GetMovementsAsync(MovementFilter? filter = null);
Task<StockMovement?> GetMovementByIdAsync(int id);
Task<StockMovement?> GetMovementByDocumentNumberAsync(string documentNumber);
Task<StockMovement> CreateMovementAsync(StockMovement movement);
Task<StockMovement> UpdateMovementAsync(StockMovement movement);
Task<StockMovement> ConfirmMovementAsync(int id);
Task<StockMovement> CancelMovementAsync(int id);
Task<string> GenerateDocumentNumberAsync(MovementType type);
// ===============================================
// CAUSALI
// ===============================================
Task<List<MovementReason>> GetMovementReasonsAsync(MovementType? type = null, bool includeInactive = false);
Task<MovementReason?> GetMovementReasonByIdAsync(int id);
Task<MovementReason> CreateMovementReasonAsync(MovementReason reason);
Task<MovementReason> UpdateMovementReasonAsync(MovementReason reason);
// ===============================================
// VALORIZZAZIONE
// ===============================================
Task<decimal> CalculateArticleValueAsync(int articleId, ValuationMethod? method = null);
Task<StockValuation> CalculatePeriodValuationAsync(int articleId, int period, int? warehouseId = null);
Task<List<StockValuation>> GetValuationsAsync(int period, int? warehouseId = null);
Task ClosePeriodAsync(int period);
Task<decimal> GetWeightedAverageCostAsync(int articleId);
Task UpdateWeightedAverageCostAsync(int articleId);
// ===============================================
// INVENTARIO
// ===============================================
Task<List<InventoryCount>> GetInventoryCountsAsync(InventoryStatus? status = null);
Task<InventoryCount?> GetInventoryCountByIdAsync(int id);
Task<InventoryCount> CreateInventoryCountAsync(InventoryCount inventory);
Task<InventoryCount> UpdateInventoryCountAsync(InventoryCount inventory);
Task<InventoryCount> StartInventoryCountAsync(int id);
Task<InventoryCount> CompleteInventoryCountAsync(int id);
Task<InventoryCount> ConfirmInventoryCountAsync(int id);
Task<InventoryCount> CancelInventoryCountAsync(int id);
Task<InventoryCountLine> UpdateCountLineAsync(int lineId, decimal countedQuantity, string? countedBy = null);
// ===============================================
// SEED DATA
// ===============================================
Task SeedDefaultDataAsync();
}
/// <summary>
/// Filtro per ricerca articoli
/// </summary>
public class ArticleFilter
{
public string? SearchText { get; set; }
public int? CategoryId { get; set; }
public bool? IsActive { get; set; }
public bool? IsBatchManaged { get; set; }
public bool? IsSerialManaged { get; set; }
public StockManagementType? StockManagement { get; set; }
public bool? HasLowStock { get; set; }
public int Skip { get; set; } = 0;
public int Take { get; set; } = 100;
public string? OrderBy { get; set; }
public bool OrderDescending { get; set; }
}
/// <summary>
/// Filtro per ricerca giacenze
/// </summary>
public class StockLevelFilter
{
public int? ArticleId { get; set; }
public int? WarehouseId { get; set; }
public int? BatchId { get; set; }
public int? CategoryId { get; set; }
public bool? OnlyWithStock { get; set; }
public bool? OnlyLowStock { get; set; }
public int Skip { get; set; } = 0;
public int Take { get; set; } = 100;
}
/// <summary>
/// Filtro per ricerca movimenti
/// </summary>
public class MovementFilter
{
public DateTime? DateFrom { get; set; }
public DateTime? DateTo { get; set; }
public MovementType? Type { get; set; }
public MovementStatus? Status { get; set; }
public int? WarehouseId { get; set; }
public int? ArticleId { get; set; }
public int? ReasonId { get; set; }
public string? ExternalReference { get; set; }
public int Skip { get; set; } = 0;
public int Take { get; set; } = 100;
public string? OrderBy { get; set; }
public bool OrderDescending { get; set; } = true;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
using Apollinare.API.Hubs;
using Apollinare.API.Services;
using Apollinare.API.Services.Reports;
using Apollinare.API.Modules.Warehouse.Services;
using Apollinare.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using System.Text.Json.Serialization;
@@ -20,6 +21,9 @@ builder.Services.AddScoped<ReportGeneratorService>();
builder.Services.AddScoped<ModuleService>();
builder.Services.AddSingleton<DataNotificationService>();
// Warehouse Module Services
builder.Services.AddScoped<IWarehouseService, WarehouseService>();
// Memory cache for module state
builder.Services.AddMemoryCache();
@@ -58,18 +62,48 @@ builder.Services.AddOpenApi();
var app = builder.Build();
// Initialize database
if (app.Environment.IsDevelopment())
// Apply pending migrations automatically on startup
using (var scope = app.Services.CreateScope())
{
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppollinareDbContext>();
db.Database.EnsureCreated();
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
try
{
var pendingMigrations = await db.Database.GetPendingMigrationsAsync();
if (pendingMigrations.Any())
{
logger.LogInformation("Applying {Count} pending migrations: {Migrations}",
pendingMigrations.Count(),
string.Join(", ", pendingMigrations));
await db.Database.MigrateAsync();
logger.LogInformation("Migrations applied successfully");
}
else
{
logger.LogInformation("Database is up to date, no migrations to apply");
}
}
catch (Exception ex)
{
logger.LogError(ex, "Error applying migrations");
throw;
}
// Seed data (only in development or if database is empty)
DbSeeder.Seed(db);
// Seed default modules
var moduleService = scope.ServiceProvider.GetRequiredService<ModuleService>();
await moduleService.SeedDefaultModulesAsync();
// Seed warehouse default data
var warehouseService = scope.ServiceProvider.GetRequiredService<IWarehouseService>();
await warehouseService.SeedDefaultDataAsync();
}
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,96 @@
namespace Apollinare.Domain.Entities.Warehouse;
/// <summary>
/// Codici a barre aggiuntivi per un articolo (multi-barcode support)
/// </summary>
public class ArticleBarcode : BaseEntity
{
/// <summary>
/// Articolo di riferimento
/// </summary>
public int ArticleId { get; set; }
/// <summary>
/// Codice a barre
/// </summary>
public string Barcode { get; set; } = string.Empty;
/// <summary>
/// Tipo codice a barre
/// </summary>
public BarcodeType Type { get; set; } = BarcodeType.EAN13;
/// <summary>
/// Descrizione (es. "Confezione da 6", "Pallet")
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Quantità associata al barcode (es. 6 per confezione da 6)
/// </summary>
public decimal Quantity { get; set; } = 1;
/// <summary>
/// Se è il barcode principale
/// </summary>
public bool IsPrimary { get; set; }
/// <summary>
/// Se attivo
/// </summary>
public bool IsActive { get; set; } = true;
// Navigation properties
public WarehouseArticle? Article { get; set; }
}
/// <summary>
/// Tipo di codice a barre
/// </summary>
public enum BarcodeType
{
/// <summary>
/// EAN-13 (European Article Number)
/// </summary>
EAN13 = 0,
/// <summary>
/// EAN-8
/// </summary>
EAN8 = 1,
/// <summary>
/// UPC-A (Universal Product Code)
/// </summary>
UPCA = 2,
/// <summary>
/// UPC-E
/// </summary>
UPCE = 3,
/// <summary>
/// Code 128
/// </summary>
Code128 = 4,
/// <summary>
/// Code 39
/// </summary>
Code39 = 5,
/// <summary>
/// QR Code
/// </summary>
QRCode = 6,
/// <summary>
/// DataMatrix
/// </summary>
DataMatrix = 7,
/// <summary>
/// Codice interno
/// </summary>
Internal = 8
}

View File

@@ -0,0 +1,145 @@
namespace Apollinare.Domain.Entities.Warehouse;
/// <summary>
/// Partita/Lotto di un articolo
/// </summary>
public class ArticleBatch : BaseEntity
{
/// <summary>
/// Articolo di riferimento
/// </summary>
public int ArticleId { get; set; }
/// <summary>
/// Codice partita/lotto
/// </summary>
public string BatchNumber { get; set; } = string.Empty;
/// <summary>
/// Data di produzione
/// </summary>
public DateTime? ProductionDate { get; set; }
/// <summary>
/// Data di scadenza
/// </summary>
public DateTime? ExpiryDate { get; set; }
/// <summary>
/// Lotto fornitore
/// </summary>
public string? SupplierBatch { get; set; }
/// <summary>
/// ID fornitore di origine
/// </summary>
public int? SupplierId { get; set; }
/// <summary>
/// Costo specifico della partita
/// </summary>
public decimal? UnitCost { get; set; }
/// <summary>
/// Quantità iniziale del lotto
/// </summary>
public decimal InitialQuantity { get; set; }
/// <summary>
/// Quantità corrente disponibile
/// </summary>
public decimal CurrentQuantity { get; set; }
/// <summary>
/// Quantità riservata
/// </summary>
public decimal ReservedQuantity { get; set; }
/// <summary>
/// Stato della partita
/// </summary>
public BatchStatus Status { get; set; } = BatchStatus.Available;
/// <summary>
/// Risultato controllo qualità
/// </summary>
public QualityStatus? QualityStatus { get; set; }
/// <summary>
/// Data ultimo controllo qualità
/// </summary>
public DateTime? LastQualityCheckDate { get; set; }
/// <summary>
/// Certificazioni associate (JSON array)
/// </summary>
public string? Certifications { get; set; }
/// <summary>
/// Note sulla partita
/// </summary>
public string? Notes { get; set; }
// Navigation properties
public WarehouseArticle? Article { get; set; }
public ICollection<StockLevel> StockLevels { get; set; } = new List<StockLevel>();
public ICollection<StockMovementLine> MovementLines { get; set; } = new List<StockMovementLine>();
public ICollection<ArticleSerial> Serials { get; set; } = new List<ArticleSerial>();
}
/// <summary>
/// Stato della partita/lotto
/// </summary>
public enum BatchStatus
{
/// <summary>
/// Disponibile per utilizzo
/// </summary>
Available = 0,
/// <summary>
/// In quarantena (in attesa controllo qualità)
/// </summary>
Quarantine = 1,
/// <summary>
/// Bloccato (non utilizzabile)
/// </summary>
Blocked = 2,
/// <summary>
/// Scaduto
/// </summary>
Expired = 3,
/// <summary>
/// Esaurito
/// </summary>
Depleted = 4
}
/// <summary>
/// Stato qualità della partita
/// </summary>
public enum QualityStatus
{
/// <summary>
/// Non controllato
/// </summary>
NotChecked = 0,
/// <summary>
/// Approvato
/// </summary>
Approved = 1,
/// <summary>
/// Respinto
/// </summary>
Rejected = 2,
/// <summary>
/// Approvato con riserva
/// </summary>
ConditionallyApproved = 3
}

View File

@@ -0,0 +1,129 @@
namespace Apollinare.Domain.Entities.Warehouse;
/// <summary>
/// Seriale/Matricola di un articolo
/// </summary>
public class ArticleSerial : BaseEntity
{
/// <summary>
/// Articolo di riferimento
/// </summary>
public int ArticleId { get; set; }
/// <summary>
/// Partita di appartenenza (opzionale)
/// </summary>
public int? BatchId { get; set; }
/// <summary>
/// Numero seriale/matricola
/// </summary>
public string SerialNumber { get; set; } = string.Empty;
/// <summary>
/// Seriale del produttore (se diverso)
/// </summary>
public string? ManufacturerSerial { get; set; }
/// <summary>
/// Data di produzione
/// </summary>
public DateTime? ProductionDate { get; set; }
/// <summary>
/// Data di scadenza garanzia
/// </summary>
public DateTime? WarrantyExpiryDate { get; set; }
/// <summary>
/// Magazzino corrente
/// </summary>
public int? CurrentWarehouseId { get; set; }
/// <summary>
/// Stato del seriale
/// </summary>
public SerialStatus Status { get; set; } = SerialStatus.Available;
/// <summary>
/// Costo specifico del seriale
/// </summary>
public decimal? UnitCost { get; set; }
/// <summary>
/// ID fornitore di origine
/// </summary>
public int? SupplierId { get; set; }
/// <summary>
/// ID cliente (se venduto)
/// </summary>
public int? CustomerId { get; set; }
/// <summary>
/// Data di vendita
/// </summary>
public DateTime? SoldDate { get; set; }
/// <summary>
/// Riferimento documento di vendita
/// </summary>
public string? SalesReference { get; set; }
/// <summary>
/// Attributi aggiuntivi (JSON)
/// </summary>
public string? Attributes { get; set; }
/// <summary>
/// Note
/// </summary>
public string? Notes { get; set; }
// Navigation properties
public WarehouseArticle? Article { get; set; }
public ArticleBatch? Batch { get; set; }
public WarehouseLocation? CurrentWarehouse { get; set; }
public ICollection<StockMovementLine> MovementLines { get; set; } = new List<StockMovementLine>();
}
/// <summary>
/// Stato del seriale
/// </summary>
public enum SerialStatus
{
/// <summary>
/// Disponibile in magazzino
/// </summary>
Available = 0,
/// <summary>
/// Riservato (impegnato per ordine)
/// </summary>
Reserved = 1,
/// <summary>
/// Venduto
/// </summary>
Sold = 2,
/// <summary>
/// In riparazione
/// </summary>
InRepair = 3,
/// <summary>
/// Difettoso/Danneggiato
/// </summary>
Defective = 4,
/// <summary>
/// Restituito
/// </summary>
Returned = 5,
/// <summary>
/// Dismesso
/// </summary>
Disposed = 6
}

View File

@@ -0,0 +1,236 @@
namespace Apollinare.Domain.Entities.Warehouse;
/// <summary>
/// Testata inventario fisico
/// </summary>
public class InventoryCount : BaseEntity
{
/// <summary>
/// Codice inventario
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary>
/// Descrizione inventario
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// Data inventario
/// </summary>
public DateTime InventoryDate { get; set; }
/// <summary>
/// Magazzino (null = tutti i magazzini)
/// </summary>
public int? WarehouseId { get; set; }
/// <summary>
/// Categoria articoli (null = tutte)
/// </summary>
public int? CategoryId { get; set; }
/// <summary>
/// Tipo di inventario
/// </summary>
public InventoryType Type { get; set; } = InventoryType.Full;
/// <summary>
/// Stato inventario
/// </summary>
public InventoryStatus Status { get; set; } = InventoryStatus.Draft;
/// <summary>
/// Data inizio conteggio
/// </summary>
public DateTime? StartDate { get; set; }
/// <summary>
/// Data fine conteggio
/// </summary>
public DateTime? EndDate { get; set; }
/// <summary>
/// Data conferma
/// </summary>
public DateTime? ConfirmedDate { get; set; }
/// <summary>
/// Utente che ha confermato
/// </summary>
public string? ConfirmedBy { get; set; }
/// <summary>
/// ID movimento di rettifica generato
/// </summary>
public int? AdjustmentMovementId { get; set; }
/// <summary>
/// Valore differenze positive
/// </summary>
public decimal? PositiveDifferenceValue { get; set; }
/// <summary>
/// Valore differenze negative
/// </summary>
public decimal? NegativeDifferenceValue { get; set; }
/// <summary>
/// Note
/// </summary>
public string? Notes { get; set; }
// Navigation properties
public WarehouseLocation? Warehouse { get; set; }
public WarehouseArticleCategory? Category { get; set; }
public StockMovement? AdjustmentMovement { get; set; }
public ICollection<InventoryCountLine> Lines { get; set; } = new List<InventoryCountLine>();
}
/// <summary>
/// Riga dettaglio inventario
/// </summary>
public class InventoryCountLine : BaseEntity
{
/// <summary>
/// Inventario padre
/// </summary>
public int InventoryCountId { get; set; }
/// <summary>
/// Articolo
/// </summary>
public int ArticleId { get; set; }
/// <summary>
/// Magazzino
/// </summary>
public int WarehouseId { get; set; }
/// <summary>
/// Partita (se gestito a lotti)
/// </summary>
public int? BatchId { get; set; }
/// <summary>
/// Ubicazione
/// </summary>
public string? LocationCode { get; set; }
/// <summary>
/// Quantità teorica (da sistema)
/// </summary>
public decimal TheoreticalQuantity { get; set; }
/// <summary>
/// Quantità contata
/// </summary>
public decimal? CountedQuantity { get; set; }
/// <summary>
/// Differenza = CountedQuantity - TheoreticalQuantity
/// </summary>
public decimal? Difference => CountedQuantity.HasValue
? CountedQuantity.Value - TheoreticalQuantity
: null;
/// <summary>
/// Costo unitario per valorizzazione differenza
/// </summary>
public decimal? UnitCost { get; set; }
/// <summary>
/// Valore differenza
/// </summary>
public decimal? DifferenceValue => Difference.HasValue && UnitCost.HasValue
? Difference.Value * UnitCost.Value
: null;
/// <summary>
/// Data/ora del conteggio
/// </summary>
public DateTime? CountedAt { get; set; }
/// <summary>
/// Utente che ha effettuato il conteggio
/// </summary>
public string? CountedBy { get; set; }
/// <summary>
/// Secondo conteggio (per verifica discrepanze)
/// </summary>
public decimal? SecondCountQuantity { get; set; }
/// <summary>
/// Utente secondo conteggio
/// </summary>
public string? SecondCountBy { get; set; }
/// <summary>
/// Note riga
/// </summary>
public string? Notes { get; set; }
// Navigation properties
public InventoryCount? InventoryCount { get; set; }
public WarehouseArticle? Article { get; set; }
public WarehouseLocation? Warehouse { get; set; }
public ArticleBatch? Batch { get; set; }
}
/// <summary>
/// Tipo di inventario
/// </summary>
public enum InventoryType
{
/// <summary>
/// Inventario completo
/// </summary>
Full = 0,
/// <summary>
/// Inventario parziale (per categoria/ubicazione)
/// </summary>
Partial = 1,
/// <summary>
/// Inventario rotativo (ciclico)
/// </summary>
Cyclic = 2,
/// <summary>
/// Inventario a campione
/// </summary>
Sample = 3
}
/// <summary>
/// Stato dell'inventario
/// </summary>
public enum InventoryStatus
{
/// <summary>
/// Bozza
/// </summary>
Draft = 0,
/// <summary>
/// In corso
/// </summary>
InProgress = 1,
/// <summary>
/// Completato (in attesa conferma)
/// </summary>
Completed = 2,
/// <summary>
/// Confermato (rettifiche applicate)
/// </summary>
Confirmed = 3,
/// <summary>
/// Annullato
/// </summary>
Cancelled = 4
}

View File

@@ -0,0 +1,65 @@
namespace Apollinare.Domain.Entities.Warehouse;
/// <summary>
/// Causale movimento di magazzino
/// </summary>
public class MovementReason : BaseEntity
{
/// <summary>
/// Codice causale
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary>
/// Descrizione causale
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// Tipo movimento associato
/// </summary>
public MovementType MovementType { get; set; }
/// <summary>
/// Segno del movimento sulla giacenza (+1 carico, -1 scarico)
/// </summary>
public int StockSign { get; set; }
/// <summary>
/// Se true, richiede riferimento documento esterno
/// </summary>
public bool RequiresExternalReference { get; set; }
/// <summary>
/// Se true, richiede valorizzazione
/// </summary>
public bool RequiresValuation { get; set; } = true;
/// <summary>
/// Se true, aggiorna il costo medio
/// </summary>
public bool UpdatesAverageCost { get; set; } = true;
/// <summary>
/// Se true, è una causale di sistema (non modificabile)
/// </summary>
public bool IsSystem { get; set; }
/// <summary>
/// Se attiva
/// </summary>
public bool IsActive { get; set; } = true;
/// <summary>
/// Ordine visualizzazione
/// </summary>
public int SortOrder { get; set; }
/// <summary>
/// Note
/// </summary>
public string? Notes { get; set; }
// Navigation properties
public ICollection<StockMovement> Movements { get; set; } = new List<StockMovement>();
}

View File

@@ -0,0 +1,72 @@
namespace Apollinare.Domain.Entities.Warehouse;
/// <summary>
/// Giacenza articolo per magazzino, partita
/// </summary>
public class StockLevel : BaseEntity
{
/// <summary>
/// Articolo
/// </summary>
public int ArticleId { get; set; }
/// <summary>
/// Magazzino
/// </summary>
public int WarehouseId { get; set; }
/// <summary>
/// Partita (opzionale, se gestito a lotti)
/// </summary>
public int? BatchId { get; set; }
/// <summary>
/// Quantità fisica in giacenza
/// </summary>
public decimal Quantity { get; set; }
/// <summary>
/// Quantità riservata (impegnata per ordini)
/// </summary>
public decimal ReservedQuantity { get; set; }
/// <summary>
/// Quantità disponibile = Quantity - ReservedQuantity
/// </summary>
public decimal AvailableQuantity => Quantity - ReservedQuantity;
/// <summary>
/// Quantità in ordine (in arrivo)
/// </summary>
public decimal OnOrderQuantity { get; set; }
/// <summary>
/// Valore totale della giacenza (calcolato)
/// </summary>
public decimal? StockValue { get; set; }
/// <summary>
/// Costo unitario medio per questa giacenza
/// </summary>
public decimal? UnitCost { get; set; }
/// <summary>
/// Data ultimo movimento
/// </summary>
public DateTime? LastMovementDate { get; set; }
/// <summary>
/// Data ultimo inventario
/// </summary>
public DateTime? LastInventoryDate { get; set; }
/// <summary>
/// Ubicazione specifica nel magazzino (scaffale, corridoio, etc.)
/// </summary>
public string? LocationCode { get; set; }
// Navigation properties
public WarehouseArticle? Article { get; set; }
public WarehouseLocation? Warehouse { get; set; }
public ArticleBatch? Batch { get; set; }
}

View File

@@ -0,0 +1,201 @@
namespace Apollinare.Domain.Entities.Warehouse;
/// <summary>
/// Testata movimento di magazzino (carico, scarico, trasferimento, rettifica)
/// </summary>
public class StockMovement : BaseEntity
{
/// <summary>
/// Numero documento movimento
/// </summary>
public string DocumentNumber { get; set; } = string.Empty;
/// <summary>
/// Data movimento
/// </summary>
public DateTime MovementDate { get; set; }
/// <summary>
/// Tipo movimento
/// </summary>
public MovementType Type { get; set; }
/// <summary>
/// Causale movimento
/// </summary>
public int? ReasonId { get; set; }
/// <summary>
/// Magazzino di origine (per scarichi e trasferimenti)
/// </summary>
public int? SourceWarehouseId { get; set; }
/// <summary>
/// Magazzino di destinazione (per carichi e trasferimenti)
/// </summary>
public int? DestinationWarehouseId { get; set; }
/// <summary>
/// Riferimento documento esterno (DDT, fattura, ordine)
/// </summary>
public string? ExternalReference { get; set; }
/// <summary>
/// Tipo documento esterno
/// </summary>
public ExternalDocumentType? ExternalDocumentType { get; set; }
/// <summary>
/// ID fornitore (per carichi da acquisto)
/// </summary>
public int? SupplierId { get; set; }
/// <summary>
/// ID cliente (per scarichi per vendita)
/// </summary>
public int? CustomerId { get; set; }
/// <summary>
/// Stato del movimento
/// </summary>
public MovementStatus Status { get; set; } = MovementStatus.Draft;
/// <summary>
/// Data conferma movimento
/// </summary>
public DateTime? ConfirmedDate { get; set; }
/// <summary>
/// Utente che ha confermato
/// </summary>
public string? ConfirmedBy { get; set; }
/// <summary>
/// Valore totale movimento
/// </summary>
public decimal? TotalValue { get; set; }
/// <summary>
/// Note movimento
/// </summary>
public string? Notes { get; set; }
// Navigation properties
public WarehouseLocation? SourceWarehouse { get; set; }
public WarehouseLocation? DestinationWarehouse { get; set; }
public MovementReason? Reason { get; set; }
public ICollection<StockMovementLine> Lines { get; set; } = new List<StockMovementLine>();
}
/// <summary>
/// Tipo di movimento magazzino
/// </summary>
public enum MovementType
{
/// <summary>
/// Carico (aumento giacenza)
/// </summary>
Inbound = 0,
/// <summary>
/// Scarico (diminuzione giacenza)
/// </summary>
Outbound = 1,
/// <summary>
/// Trasferimento tra magazzini
/// </summary>
Transfer = 2,
/// <summary>
/// Rettifica inventariale (positiva o negativa)
/// </summary>
Adjustment = 3,
/// <summary>
/// Produzione (carico da ciclo produttivo)
/// </summary>
Production = 4,
/// <summary>
/// Consumo (scarico per produzione)
/// </summary>
Consumption = 5,
/// <summary>
/// Reso a fornitore
/// </summary>
SupplierReturn = 6,
/// <summary>
/// Reso da cliente
/// </summary>
CustomerReturn = 7
}
/// <summary>
/// Stato del movimento
/// </summary>
public enum MovementStatus
{
/// <summary>
/// Bozza (non ancora confermato)
/// </summary>
Draft = 0,
/// <summary>
/// Confermato (giacenze aggiornate)
/// </summary>
Confirmed = 1,
/// <summary>
/// Annullato
/// </summary>
Cancelled = 2
}
/// <summary>
/// Tipo documento esterno collegato
/// </summary>
public enum ExternalDocumentType
{
/// <summary>
/// Ordine fornitore
/// </summary>
PurchaseOrder = 0,
/// <summary>
/// DDT entrata
/// </summary>
InboundDeliveryNote = 1,
/// <summary>
/// Fattura acquisto
/// </summary>
PurchaseInvoice = 2,
/// <summary>
/// Ordine cliente
/// </summary>
SalesOrder = 3,
/// <summary>
/// DDT uscita
/// </summary>
OutboundDeliveryNote = 4,
/// <summary>
/// Fattura vendita
/// </summary>
SalesInvoice = 5,
/// <summary>
/// Ordine di produzione
/// </summary>
ProductionOrder = 6,
/// <summary>
/// Documento inventario
/// </summary>
InventoryDocument = 7
}

View File

@@ -0,0 +1,78 @@
namespace Apollinare.Domain.Entities.Warehouse;
/// <summary>
/// Riga dettaglio movimento di magazzino
/// </summary>
public class StockMovementLine : BaseEntity
{
/// <summary>
/// Movimento padre
/// </summary>
public int MovementId { get; set; }
/// <summary>
/// Numero riga
/// </summary>
public int LineNumber { get; set; }
/// <summary>
/// Articolo
/// </summary>
public int ArticleId { get; set; }
/// <summary>
/// Partita (se gestito a lotti)
/// </summary>
public int? BatchId { get; set; }
/// <summary>
/// Seriale (se gestito a seriali) - per movimenti di singoli pezzi
/// </summary>
public int? SerialId { get; set; }
/// <summary>
/// Quantità movimentata
/// </summary>
public decimal Quantity { get; set; }
/// <summary>
/// Unità di misura
/// </summary>
public string UnitOfMeasure { get; set; } = "PZ";
/// <summary>
/// Costo unitario (per valorizzazione)
/// </summary>
public decimal? UnitCost { get; set; }
/// <summary>
/// Valore totale riga
/// </summary>
public decimal? LineValue { get; set; }
/// <summary>
/// Ubicazione origine (per prelievi specifici)
/// </summary>
public string? SourceLocationCode { get; set; }
/// <summary>
/// Ubicazione destinazione (per posizionamenti specifici)
/// </summary>
public string? DestinationLocationCode { get; set; }
/// <summary>
/// Riferimento riga documento esterno
/// </summary>
public string? ExternalLineReference { get; set; }
/// <summary>
/// Note riga
/// </summary>
public string? Notes { get; set; }
// Navigation properties
public StockMovement? Movement { get; set; }
public WarehouseArticle? Article { get; set; }
public ArticleBatch? Batch { get; set; }
public ArticleSerial? Serial { get; set; }
}

View File

@@ -0,0 +1,153 @@
namespace Apollinare.Domain.Entities.Warehouse;
/// <summary>
/// Storico valorizzazione magazzino per periodo
/// </summary>
public class StockValuation : BaseEntity
{
/// <summary>
/// Data della valorizzazione
/// </summary>
public DateTime ValuationDate { get; set; }
/// <summary>
/// Periodo di riferimento (YYYYMM)
/// </summary>
public int Period { get; set; }
/// <summary>
/// Articolo
/// </summary>
public int ArticleId { get; set; }
/// <summary>
/// Magazzino (null = totale tutti i magazzini)
/// </summary>
public int? WarehouseId { get; set; }
/// <summary>
/// Quantità a fine periodo
/// </summary>
public decimal Quantity { get; set; }
/// <summary>
/// Metodo di valorizzazione usato
/// </summary>
public ValuationMethod Method { get; set; }
/// <summary>
/// Costo unitario calcolato
/// </summary>
public decimal UnitCost { get; set; }
/// <summary>
/// Valore totale = Quantity * UnitCost
/// </summary>
public decimal TotalValue { get; set; }
/// <summary>
/// Quantità in carico nel periodo
/// </summary>
public decimal InboundQuantity { get; set; }
/// <summary>
/// Valore carichi nel periodo
/// </summary>
public decimal InboundValue { get; set; }
/// <summary>
/// Quantità in scarico nel periodo
/// </summary>
public decimal OutboundQuantity { get; set; }
/// <summary>
/// Valore scarichi nel periodo
/// </summary>
public decimal OutboundValue { get; set; }
/// <summary>
/// Se è una chiusura definitiva (non più modificabile)
/// </summary>
public bool IsClosed { get; set; }
/// <summary>
/// Data chiusura
/// </summary>
public DateTime? ClosedDate { get; set; }
/// <summary>
/// Utente che ha chiuso
/// </summary>
public string? ClosedBy { get; set; }
/// <summary>
/// Note
/// </summary>
public string? Notes { get; set; }
// Navigation properties
public WarehouseArticle? Article { get; set; }
public WarehouseLocation? Warehouse { get; set; }
}
/// <summary>
/// Dettaglio valorizzazione FIFO/LIFO per layer
/// </summary>
public class StockValuationLayer : BaseEntity
{
/// <summary>
/// Articolo
/// </summary>
public int ArticleId { get; set; }
/// <summary>
/// Magazzino
/// </summary>
public int WarehouseId { get; set; }
/// <summary>
/// Partita (opzionale)
/// </summary>
public int? BatchId { get; set; }
/// <summary>
/// Data del layer (data carico originale)
/// </summary>
public DateTime LayerDate { get; set; }
/// <summary>
/// Movimento di carico che ha creato il layer
/// </summary>
public int? SourceMovementId { get; set; }
/// <summary>
/// Quantità originale del layer
/// </summary>
public decimal OriginalQuantity { get; set; }
/// <summary>
/// Quantità residua nel layer
/// </summary>
public decimal RemainingQuantity { get; set; }
/// <summary>
/// Costo unitario del layer
/// </summary>
public decimal UnitCost { get; set; }
/// <summary>
/// Valore residuo = RemainingQuantity * UnitCost
/// </summary>
public decimal RemainingValue => RemainingQuantity * UnitCost;
/// <summary>
/// Se il layer è esaurito
/// </summary>
public bool IsExhausted { get; set; }
// Navigation properties
public WarehouseArticle? Article { get; set; }
public WarehouseLocation? Warehouse { get; set; }
public ArticleBatch? Batch { get; set; }
public StockMovement? SourceMovement { get; set; }
}

View File

@@ -0,0 +1,237 @@
namespace Apollinare.Domain.Entities.Warehouse;
/// <summary>
/// Articolo del modulo magazzino con gestione completa di partite e seriali
/// </summary>
public class WarehouseArticle : BaseEntity
{
/// <summary>
/// Codice univoco articolo (SKU)
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary>
/// Descrizione articolo
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// Descrizione breve per etichette
/// </summary>
public string? ShortDescription { get; set; }
/// <summary>
/// Codice a barre principale (EAN/UPC)
/// </summary>
public string? Barcode { get; set; }
/// <summary>
/// Codice fornitore/produttore
/// </summary>
public string? ManufacturerCode { get; set; }
/// <summary>
/// Categoria articolo
/// </summary>
public int? CategoryId { get; set; }
/// <summary>
/// Unità di misura principale (es. PZ, KG, LT, MT)
/// </summary>
public string UnitOfMeasure { get; set; } = "PZ";
/// <summary>
/// Unità di misura secondaria per conversione
/// </summary>
public string? SecondaryUnitOfMeasure { get; set; }
/// <summary>
/// Fattore di conversione tra UoM primaria e secondaria
/// </summary>
public decimal? UnitConversionFactor { get; set; }
/// <summary>
/// Tipo di gestione magazzino
/// </summary>
public StockManagementType StockManagement { get; set; } = StockManagementType.Standard;
/// <summary>
/// Se true, l'articolo è gestito a partite (lotti)
/// </summary>
public bool IsBatchManaged { get; set; }
/// <summary>
/// Se true, l'articolo è gestito a seriali
/// </summary>
public bool IsSerialManaged { get; set; }
/// <summary>
/// Se true, l'articolo ha scadenza
/// </summary>
public bool HasExpiry { get; set; }
/// <summary>
/// Giorni di preavviso scadenza
/// </summary>
public int? ExpiryWarningDays { get; set; }
/// <summary>
/// Scorta minima (sotto questo livello scatta alert)
/// </summary>
public decimal? MinimumStock { get; set; }
/// <summary>
/// Scorta massima
/// </summary>
public decimal? MaximumStock { get; set; }
/// <summary>
/// Punto di riordino
/// </summary>
public decimal? ReorderPoint { get; set; }
/// <summary>
/// Quantità di riordino standard
/// </summary>
public decimal? ReorderQuantity { get; set; }
/// <summary>
/// Lead time in giorni per approvvigionamento
/// </summary>
public int? LeadTimeDays { get; set; }
/// <summary>
/// Metodo di valorizzazione per questo articolo (override del default)
/// </summary>
public ValuationMethod? ValuationMethod { get; set; }
/// <summary>
/// Costo standard (per valorizzazione a costo standard)
/// </summary>
public decimal? StandardCost { get; set; }
/// <summary>
/// Ultimo costo di acquisto
/// </summary>
public decimal? LastPurchaseCost { get; set; }
/// <summary>
/// Costo medio ponderato corrente
/// </summary>
public decimal? WeightedAverageCost { get; set; }
/// <summary>
/// Prezzo di vendita base
/// </summary>
public decimal? BaseSellingPrice { get; set; }
/// <summary>
/// Peso in Kg
/// </summary>
public decimal? Weight { get; set; }
/// <summary>
/// Volume in metri cubi
/// </summary>
public decimal? Volume { get; set; }
/// <summary>
/// Larghezza in cm
/// </summary>
public decimal? Width { get; set; }
/// <summary>
/// Altezza in cm
/// </summary>
public decimal? Height { get; set; }
/// <summary>
/// Profondità in cm
/// </summary>
public decimal? Depth { get; set; }
/// <summary>
/// Immagine principale
/// </summary>
public byte[]? Image { get; set; }
/// <summary>
/// Mime type immagine
/// </summary>
public string? ImageMimeType { get; set; }
/// <summary>
/// Se attivo, l'articolo può essere movimentato
/// </summary>
public bool IsActive { get; set; } = true;
/// <summary>
/// Note aggiuntive
/// </summary>
public string? Notes { get; set; }
// Navigation properties
public WarehouseArticleCategory? Category { get; set; }
public ICollection<StockLevel> StockLevels { get; set; } = new List<StockLevel>();
public ICollection<StockMovementLine> MovementLines { get; set; } = new List<StockMovementLine>();
public ICollection<ArticleBatch> Batches { get; set; } = new List<ArticleBatch>();
public ICollection<ArticleSerial> Serials { get; set; } = new List<ArticleSerial>();
public ICollection<ArticleBarcode> Barcodes { get; set; } = new List<ArticleBarcode>();
}
/// <summary>
/// Tipo di gestione magazzino per l'articolo
/// </summary>
public enum StockManagementType
{
/// <summary>
/// Gestione standard (quantità)
/// </summary>
Standard = 0,
/// <summary>
/// Non gestito a magazzino (servizi, ecc.)
/// </summary>
NotManaged = 1,
/// <summary>
/// Gestione a peso variabile
/// </summary>
VariableWeight = 2,
/// <summary>
/// Kit/Distinta base
/// </summary>
Kit = 3
}
/// <summary>
/// Metodo di valorizzazione magazzino
/// </summary>
public enum ValuationMethod
{
/// <summary>
/// Costo medio ponderato
/// </summary>
WeightedAverage = 0,
/// <summary>
/// First In First Out
/// </summary>
FIFO = 1,
/// <summary>
/// Last In First Out
/// </summary>
LIFO = 2,
/// <summary>
/// Costo standard
/// </summary>
StandardCost = 3,
/// <summary>
/// Costo specifico (per partita/seriale)
/// </summary>
SpecificCost = 4
}

View File

@@ -0,0 +1,72 @@
namespace Apollinare.Domain.Entities.Warehouse;
/// <summary>
/// Categoria articoli magazzino con struttura gerarchica
/// </summary>
public class WarehouseArticleCategory : BaseEntity
{
/// <summary>
/// Codice categoria
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary>
/// Nome categoria
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Descrizione
/// </summary>
public string? Description { get; set; }
/// <summary>
/// ID categoria padre (per gerarchia)
/// </summary>
public int? ParentCategoryId { get; set; }
/// <summary>
/// Livello nella gerarchia (0 = root)
/// </summary>
public int Level { get; set; }
/// <summary>
/// Path completo codici (es. "001.002.003")
/// </summary>
public string? FullPath { get; set; }
/// <summary>
/// Icona categoria (nome icona MUI)
/// </summary>
public string? Icon { get; set; }
/// <summary>
/// Colore categoria (hex)
/// </summary>
public string? Color { get; set; }
/// <summary>
/// Metodo di valorizzazione default per articoli di questa categoria
/// </summary>
public ValuationMethod? DefaultValuationMethod { get; set; }
/// <summary>
/// Ordine di visualizzazione
/// </summary>
public int SortOrder { get; set; }
/// <summary>
/// Se attiva
/// </summary>
public bool IsActive { get; set; } = true;
/// <summary>
/// Note
/// </summary>
public string? Notes { get; set; }
// Navigation properties
public WarehouseArticleCategory? ParentCategory { get; set; }
public ICollection<WarehouseArticleCategory> ChildCategories { get; set; } = new List<WarehouseArticleCategory>();
public ICollection<WarehouseArticle> Articles { get; set; } = new List<WarehouseArticle>();
}

View File

@@ -0,0 +1,113 @@
namespace Apollinare.Domain.Entities.Warehouse;
/// <summary>
/// Rappresenta un magazzino fisico o logico
/// </summary>
public class WarehouseLocation : BaseEntity
{
/// <summary>
/// Codice univoco del magazzino (es. "MAG01", "CENTRALE")
/// </summary>
public string Code { get; set; } = string.Empty;
/// <summary>
/// Nome descrittivo del magazzino
/// </summary>
public string Name { get; set; } = string.Empty;
/// <summary>
/// Descrizione estesa
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Indirizzo del magazzino
/// </summary>
public string? Address { get; set; }
/// <summary>
/// Città
/// </summary>
public string? City { get; set; }
/// <summary>
/// Provincia
/// </summary>
public string? Province { get; set; }
/// <summary>
/// CAP
/// </summary>
public string? PostalCode { get; set; }
/// <summary>
/// Nazione
/// </summary>
public string? Country { get; set; } = "Italia";
/// <summary>
/// Tipo di magazzino (fisico, logico, transito, reso, etc.)
/// </summary>
public WarehouseType Type { get; set; } = WarehouseType.Physical;
/// <summary>
/// Se true, è il magazzino predefinito per carichi/scarichi
/// </summary>
public bool IsDefault { get; set; }
/// <summary>
/// Se attivo può ricevere movimenti
/// </summary>
public bool IsActive { get; set; } = true;
/// <summary>
/// Ordine di visualizzazione
/// </summary>
public int SortOrder { get; set; }
/// <summary>
/// Note aggiuntive
/// </summary>
public string? Notes { get; set; }
// Navigation properties
public ICollection<StockLevel> StockLevels { get; set; } = new List<StockLevel>();
public ICollection<StockMovement> SourceMovements { get; set; } = new List<StockMovement>();
public ICollection<StockMovement> DestinationMovements { get; set; } = new List<StockMovement>();
}
/// <summary>
/// Tipo di magazzino
/// </summary>
public enum WarehouseType
{
/// <summary>
/// Magazzino fisico standard
/// </summary>
Physical = 0,
/// <summary>
/// Magazzino logico (virtuale)
/// </summary>
Logical = 1,
/// <summary>
/// Magazzino di transito
/// </summary>
Transit = 2,
/// <summary>
/// Magazzino resi
/// </summary>
Returns = 3,
/// <summary>
/// Magazzino scarti/difettosi
/// </summary>
Defective = 4,
/// <summary>
/// Magazzino conto lavoro
/// </summary>
Subcontract = 5
}

View File

@@ -1,4 +1,5 @@
using Apollinare.Domain.Entities;
using Apollinare.Domain.Entities.Warehouse;
using Microsoft.EntityFrameworkCore;
namespace Apollinare.Infrastructure.Data;
@@ -40,6 +41,22 @@ public class AppollinareDbContext : DbContext
public DbSet<AppModule> AppModules => Set<AppModule>();
public DbSet<ModuleSubscription> ModuleSubscriptions => Set<ModuleSubscription>();
// Warehouse module entities
public DbSet<WarehouseLocation> WarehouseLocations => Set<WarehouseLocation>();
public DbSet<WarehouseArticle> WarehouseArticles => Set<WarehouseArticle>();
public DbSet<WarehouseArticleCategory> WarehouseArticleCategories => Set<WarehouseArticleCategory>();
public DbSet<ArticleBatch> ArticleBatches => Set<ArticleBatch>();
public DbSet<ArticleSerial> ArticleSerials => Set<ArticleSerial>();
public DbSet<ArticleBarcode> ArticleBarcodes => Set<ArticleBarcode>();
public DbSet<StockLevel> StockLevels => Set<StockLevel>();
public DbSet<StockMovement> StockMovements => Set<StockMovement>();
public DbSet<StockMovementLine> StockMovementLines => Set<StockMovementLine>();
public DbSet<MovementReason> MovementReasons => Set<MovementReason>();
public DbSet<StockValuation> StockValuations => Set<StockValuation>();
public DbSet<StockValuationLayer> StockValuationLayers => Set<StockValuationLayer>();
public DbSet<InventoryCount> InventoryCounts => Set<InventoryCount>();
public DbSet<InventoryCountLine> InventoryCountLines => Set<InventoryCountLine>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@@ -256,5 +273,339 @@ public class AppollinareDbContext : DbContext
.HasForeignKey<ModuleSubscription>(e => e.ModuleId)
.OnDelete(DeleteBehavior.Cascade);
});
// ===============================================
// WAREHOUSE MODULE ENTITIES
// ===============================================
// WarehouseLocation
modelBuilder.Entity<WarehouseLocation>(entity =>
{
entity.ToTable("WarehouseLocations");
entity.HasIndex(e => e.Code).IsUnique();
entity.HasIndex(e => e.IsDefault);
entity.HasIndex(e => e.IsActive);
});
// WarehouseArticleCategory
modelBuilder.Entity<WarehouseArticleCategory>(entity =>
{
entity.ToTable("WarehouseArticleCategories");
entity.HasIndex(e => e.Code).IsUnique();
entity.HasIndex(e => e.ParentCategoryId);
entity.HasIndex(e => e.FullPath);
entity.HasOne(e => e.ParentCategory)
.WithMany(c => c.ChildCategories)
.HasForeignKey(e => e.ParentCategoryId)
.OnDelete(DeleteBehavior.Restrict);
});
// WarehouseArticle
modelBuilder.Entity<WarehouseArticle>(entity =>
{
entity.ToTable("WarehouseArticles");
entity.HasIndex(e => e.Code).IsUnique();
entity.HasIndex(e => e.Barcode);
entity.HasIndex(e => e.CategoryId);
entity.HasIndex(e => e.IsActive);
entity.Property(e => e.StandardCost).HasPrecision(18, 4);
entity.Property(e => e.LastPurchaseCost).HasPrecision(18, 4);
entity.Property(e => e.WeightedAverageCost).HasPrecision(18, 4);
entity.Property(e => e.BaseSellingPrice).HasPrecision(18, 4);
entity.Property(e => e.MinimumStock).HasPrecision(18, 4);
entity.Property(e => e.MaximumStock).HasPrecision(18, 4);
entity.Property(e => e.ReorderPoint).HasPrecision(18, 4);
entity.Property(e => e.ReorderQuantity).HasPrecision(18, 4);
entity.Property(e => e.UnitConversionFactor).HasPrecision(18, 6);
entity.Property(e => e.Weight).HasPrecision(18, 4);
entity.Property(e => e.Volume).HasPrecision(18, 6);
entity.HasOne(e => e.Category)
.WithMany(c => c.Articles)
.HasForeignKey(e => e.CategoryId)
.OnDelete(DeleteBehavior.SetNull);
});
// ArticleBatch
modelBuilder.Entity<ArticleBatch>(entity =>
{
entity.ToTable("ArticleBatches");
entity.HasIndex(e => new { e.ArticleId, e.BatchNumber }).IsUnique();
entity.HasIndex(e => e.ExpiryDate);
entity.HasIndex(e => e.Status);
entity.Property(e => e.UnitCost).HasPrecision(18, 4);
entity.Property(e => e.InitialQuantity).HasPrecision(18, 4);
entity.Property(e => e.CurrentQuantity).HasPrecision(18, 4);
entity.Property(e => e.ReservedQuantity).HasPrecision(18, 4);
entity.HasOne(e => e.Article)
.WithMany(a => a.Batches)
.HasForeignKey(e => e.ArticleId)
.OnDelete(DeleteBehavior.Cascade);
});
// ArticleSerial
modelBuilder.Entity<ArticleSerial>(entity =>
{
entity.ToTable("ArticleSerials");
entity.HasIndex(e => new { e.ArticleId, e.SerialNumber }).IsUnique();
entity.HasIndex(e => e.Status);
entity.HasIndex(e => e.CurrentWarehouseId);
entity.Property(e => e.UnitCost).HasPrecision(18, 4);
entity.HasOne(e => e.Article)
.WithMany(a => a.Serials)
.HasForeignKey(e => e.ArticleId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Batch)
.WithMany(b => b.Serials)
.HasForeignKey(e => e.BatchId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.CurrentWarehouse)
.WithMany()
.HasForeignKey(e => e.CurrentWarehouseId)
.OnDelete(DeleteBehavior.SetNull);
});
// ArticleBarcode
modelBuilder.Entity<ArticleBarcode>(entity =>
{
entity.ToTable("ArticleBarcodes");
entity.HasIndex(e => e.Barcode).IsUnique();
entity.HasIndex(e => e.ArticleId);
entity.Property(e => e.Quantity).HasPrecision(18, 4);
entity.HasOne(e => e.Article)
.WithMany(a => a.Barcodes)
.HasForeignKey(e => e.ArticleId)
.OnDelete(DeleteBehavior.Cascade);
});
// StockLevel
modelBuilder.Entity<StockLevel>(entity =>
{
entity.ToTable("StockLevels");
entity.HasIndex(e => new { e.ArticleId, e.WarehouseId, e.BatchId }).IsUnique();
entity.HasIndex(e => e.WarehouseId);
entity.HasIndex(e => e.LocationCode);
entity.Property(e => e.Quantity).HasPrecision(18, 4);
entity.Property(e => e.ReservedQuantity).HasPrecision(18, 4);
entity.Property(e => e.OnOrderQuantity).HasPrecision(18, 4);
entity.Property(e => e.StockValue).HasPrecision(18, 4);
entity.Property(e => e.UnitCost).HasPrecision(18, 4);
entity.HasOne(e => e.Article)
.WithMany(a => a.StockLevels)
.HasForeignKey(e => e.ArticleId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Warehouse)
.WithMany(w => w.StockLevels)
.HasForeignKey(e => e.WarehouseId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Batch)
.WithMany(b => b.StockLevels)
.HasForeignKey(e => e.BatchId)
.OnDelete(DeleteBehavior.SetNull);
});
// MovementReason
modelBuilder.Entity<MovementReason>(entity =>
{
entity.ToTable("MovementReasons");
entity.HasIndex(e => e.Code).IsUnique();
entity.HasIndex(e => e.MovementType);
entity.HasIndex(e => e.IsActive);
});
// StockMovement
modelBuilder.Entity<StockMovement>(entity =>
{
entity.ToTable("StockMovements");
entity.HasIndex(e => e.DocumentNumber).IsUnique();
entity.HasIndex(e => e.MovementDate);
entity.HasIndex(e => e.Type);
entity.HasIndex(e => e.Status);
entity.HasIndex(e => e.ExternalReference);
entity.Property(e => e.TotalValue).HasPrecision(18, 4);
entity.HasOne(e => e.SourceWarehouse)
.WithMany(w => w.SourceMovements)
.HasForeignKey(e => e.SourceWarehouseId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.DestinationWarehouse)
.WithMany(w => w.DestinationMovements)
.HasForeignKey(e => e.DestinationWarehouseId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.Reason)
.WithMany(r => r.Movements)
.HasForeignKey(e => e.ReasonId)
.OnDelete(DeleteBehavior.SetNull);
});
// StockMovementLine
modelBuilder.Entity<StockMovementLine>(entity =>
{
entity.ToTable("StockMovementLines");
entity.HasIndex(e => new { e.MovementId, e.LineNumber }).IsUnique();
entity.HasIndex(e => e.ArticleId);
entity.HasIndex(e => e.BatchId);
entity.HasIndex(e => e.SerialId);
entity.Property(e => e.Quantity).HasPrecision(18, 4);
entity.Property(e => e.UnitCost).HasPrecision(18, 4);
entity.Property(e => e.LineValue).HasPrecision(18, 4);
entity.HasOne(e => e.Movement)
.WithMany(m => m.Lines)
.HasForeignKey(e => e.MovementId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Article)
.WithMany(a => a.MovementLines)
.HasForeignKey(e => e.ArticleId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.Batch)
.WithMany(b => b.MovementLines)
.HasForeignKey(e => e.BatchId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.Serial)
.WithMany(s => s.MovementLines)
.HasForeignKey(e => e.SerialId)
.OnDelete(DeleteBehavior.SetNull);
});
// StockValuation
modelBuilder.Entity<StockValuation>(entity =>
{
entity.ToTable("StockValuations");
entity.HasIndex(e => new { e.Period, e.ArticleId, e.WarehouseId }).IsUnique();
entity.HasIndex(e => e.ValuationDate);
entity.HasIndex(e => e.IsClosed);
entity.Property(e => e.Quantity).HasPrecision(18, 4);
entity.Property(e => e.UnitCost).HasPrecision(18, 4);
entity.Property(e => e.TotalValue).HasPrecision(18, 4);
entity.Property(e => e.InboundQuantity).HasPrecision(18, 4);
entity.Property(e => e.InboundValue).HasPrecision(18, 4);
entity.Property(e => e.OutboundQuantity).HasPrecision(18, 4);
entity.Property(e => e.OutboundValue).HasPrecision(18, 4);
entity.HasOne(e => e.Article)
.WithMany()
.HasForeignKey(e => e.ArticleId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Warehouse)
.WithMany()
.HasForeignKey(e => e.WarehouseId)
.OnDelete(DeleteBehavior.SetNull);
});
// StockValuationLayer
modelBuilder.Entity<StockValuationLayer>(entity =>
{
entity.ToTable("StockValuationLayers");
entity.HasIndex(e => new { e.ArticleId, e.WarehouseId, e.LayerDate });
entity.HasIndex(e => e.IsExhausted);
entity.Property(e => e.OriginalQuantity).HasPrecision(18, 4);
entity.Property(e => e.RemainingQuantity).HasPrecision(18, 4);
entity.Property(e => e.UnitCost).HasPrecision(18, 4);
entity.HasOne(e => e.Article)
.WithMany()
.HasForeignKey(e => e.ArticleId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Warehouse)
.WithMany()
.HasForeignKey(e => e.WarehouseId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Batch)
.WithMany()
.HasForeignKey(e => e.BatchId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.SourceMovement)
.WithMany()
.HasForeignKey(e => e.SourceMovementId)
.OnDelete(DeleteBehavior.SetNull);
});
// InventoryCount
modelBuilder.Entity<InventoryCount>(entity =>
{
entity.ToTable("InventoryCounts");
entity.HasIndex(e => e.Code).IsUnique();
entity.HasIndex(e => e.InventoryDate);
entity.HasIndex(e => e.Status);
entity.Property(e => e.PositiveDifferenceValue).HasPrecision(18, 4);
entity.Property(e => e.NegativeDifferenceValue).HasPrecision(18, 4);
entity.HasOne(e => e.Warehouse)
.WithMany()
.HasForeignKey(e => e.WarehouseId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.Category)
.WithMany()
.HasForeignKey(e => e.CategoryId)
.OnDelete(DeleteBehavior.SetNull);
entity.HasOne(e => e.AdjustmentMovement)
.WithMany()
.HasForeignKey(e => e.AdjustmentMovementId)
.OnDelete(DeleteBehavior.SetNull);
});
// InventoryCountLine
modelBuilder.Entity<InventoryCountLine>(entity =>
{
entity.ToTable("InventoryCountLines");
entity.HasIndex(e => new { e.InventoryCountId, e.ArticleId, e.WarehouseId, e.BatchId }).IsUnique();
entity.HasIndex(e => e.ArticleId);
entity.Property(e => e.TheoreticalQuantity).HasPrecision(18, 4);
entity.Property(e => e.CountedQuantity).HasPrecision(18, 4);
entity.Property(e => e.SecondCountQuantity).HasPrecision(18, 4);
entity.Property(e => e.UnitCost).HasPrecision(18, 4);
entity.HasOne(e => e.InventoryCount)
.WithMany(i => i.Lines)
.HasForeignKey(e => e.InventoryCountId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(e => e.Article)
.WithMany()
.HasForeignKey(e => e.ArticleId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.Warehouse)
.WithMany()
.HasForeignKey(e => e.WarehouseId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(e => e.Batch)
.WithMany()
.HasForeignKey(e => e.BatchId)
.OnDelete(DeleteBehavior.SetNull);
});
}
}

View File

@@ -0,0 +1,421 @@
-- =====================================================
-- APOLLINARE WAREHOUSE MODULE - DATABASE TABLES
-- SQLite
-- =====================================================
-- Magazzini
CREATE TABLE IF NOT EXISTS WarehouseLocations (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Code TEXT NOT NULL UNIQUE,
Name TEXT NOT NULL,
Description TEXT,
Address TEXT,
City TEXT,
Province TEXT,
PostalCode TEXT,
Country TEXT DEFAULT 'Italia',
Type INTEGER NOT NULL DEFAULT 0,
IsDefault INTEGER NOT NULL DEFAULT 0,
IsActive INTEGER NOT NULL DEFAULT 1,
SortOrder INTEGER NOT NULL DEFAULT 0,
Notes TEXT,
CreatedAt TEXT,
CreatedBy TEXT,
UpdatedAt TEXT,
UpdatedBy TEXT
);
CREATE INDEX IF NOT EXISTS IX_WarehouseLocations_Code ON WarehouseLocations(Code);
CREATE INDEX IF NOT EXISTS IX_WarehouseLocations_IsDefault ON WarehouseLocations(IsDefault);
CREATE INDEX IF NOT EXISTS IX_WarehouseLocations_IsActive ON WarehouseLocations(IsActive);
-- Categorie Articoli
CREATE TABLE IF NOT EXISTS WarehouseArticleCategories (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Code TEXT NOT NULL UNIQUE,
Name TEXT NOT NULL,
Description TEXT,
ParentCategoryId INTEGER,
Level INTEGER NOT NULL DEFAULT 0,
FullPath TEXT,
Icon TEXT,
Color TEXT,
DefaultValuationMethod INTEGER,
SortOrder INTEGER NOT NULL DEFAULT 0,
IsActive INTEGER NOT NULL DEFAULT 1,
Notes TEXT,
CreatedAt TEXT,
CreatedBy TEXT,
UpdatedAt TEXT,
UpdatedBy TEXT,
FOREIGN KEY (ParentCategoryId) REFERENCES WarehouseArticleCategories(Id) ON DELETE RESTRICT
);
CREATE INDEX IF NOT EXISTS IX_WarehouseArticleCategories_Code ON WarehouseArticleCategories(Code);
CREATE INDEX IF NOT EXISTS IX_WarehouseArticleCategories_ParentCategoryId ON WarehouseArticleCategories(ParentCategoryId);
CREATE INDEX IF NOT EXISTS IX_WarehouseArticleCategories_FullPath ON WarehouseArticleCategories(FullPath);
-- Articoli Magazzino
CREATE TABLE IF NOT EXISTS WarehouseArticles (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Code TEXT NOT NULL UNIQUE,
Description TEXT NOT NULL,
ShortDescription TEXT,
Barcode TEXT,
ManufacturerCode TEXT,
CategoryId INTEGER,
UnitOfMeasure TEXT NOT NULL DEFAULT 'PZ',
SecondaryUnitOfMeasure TEXT,
UnitConversionFactor REAL,
StockManagement INTEGER NOT NULL DEFAULT 0,
IsBatchManaged INTEGER NOT NULL DEFAULT 0,
IsSerialManaged INTEGER NOT NULL DEFAULT 0,
HasExpiry INTEGER NOT NULL DEFAULT 0,
ExpiryWarningDays INTEGER,
MinimumStock REAL,
MaximumStock REAL,
ReorderPoint REAL,
ReorderQuantity REAL,
LeadTimeDays INTEGER,
ValuationMethod INTEGER,
StandardCost REAL,
LastPurchaseCost REAL,
WeightedAverageCost REAL,
BaseSellingPrice REAL,
Weight REAL,
Volume REAL,
Width REAL,
Height REAL,
Depth REAL,
Image BLOB,
ImageMimeType TEXT,
IsActive INTEGER NOT NULL DEFAULT 1,
Notes TEXT,
CreatedAt TEXT,
CreatedBy TEXT,
UpdatedAt TEXT,
UpdatedBy TEXT,
FOREIGN KEY (CategoryId) REFERENCES WarehouseArticleCategories(Id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS IX_WarehouseArticles_Code ON WarehouseArticles(Code);
CREATE INDEX IF NOT EXISTS IX_WarehouseArticles_Barcode ON WarehouseArticles(Barcode);
CREATE INDEX IF NOT EXISTS IX_WarehouseArticles_CategoryId ON WarehouseArticles(CategoryId);
CREATE INDEX IF NOT EXISTS IX_WarehouseArticles_IsActive ON WarehouseArticles(IsActive);
-- Partite/Lotti
CREATE TABLE IF NOT EXISTS ArticleBatches (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
ArticleId INTEGER NOT NULL,
BatchNumber TEXT NOT NULL,
ProductionDate TEXT,
ExpiryDate TEXT,
SupplierBatch TEXT,
SupplierId INTEGER,
UnitCost REAL,
InitialQuantity REAL NOT NULL DEFAULT 0,
CurrentQuantity REAL NOT NULL DEFAULT 0,
ReservedQuantity REAL NOT NULL DEFAULT 0,
Status INTEGER NOT NULL DEFAULT 0,
QualityStatus INTEGER,
LastQualityCheckDate TEXT,
Certifications TEXT,
Notes TEXT,
CreatedAt TEXT,
CreatedBy TEXT,
UpdatedAt TEXT,
UpdatedBy TEXT,
FOREIGN KEY (ArticleId) REFERENCES WarehouseArticles(Id) ON DELETE CASCADE,
UNIQUE(ArticleId, BatchNumber)
);
CREATE INDEX IF NOT EXISTS IX_ArticleBatches_ArticleId_BatchNumber ON ArticleBatches(ArticleId, BatchNumber);
CREATE INDEX IF NOT EXISTS IX_ArticleBatches_ExpiryDate ON ArticleBatches(ExpiryDate);
CREATE INDEX IF NOT EXISTS IX_ArticleBatches_Status ON ArticleBatches(Status);
-- Seriali/Matricole
CREATE TABLE IF NOT EXISTS ArticleSerials (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
ArticleId INTEGER NOT NULL,
BatchId INTEGER,
SerialNumber TEXT NOT NULL,
ManufacturerSerial TEXT,
ProductionDate TEXT,
WarrantyExpiryDate TEXT,
CurrentWarehouseId INTEGER,
Status INTEGER NOT NULL DEFAULT 0,
UnitCost REAL,
SupplierId INTEGER,
CustomerId INTEGER,
SoldDate TEXT,
SalesReference TEXT,
Attributes TEXT,
Notes TEXT,
CreatedAt TEXT,
CreatedBy TEXT,
UpdatedAt TEXT,
UpdatedBy TEXT,
FOREIGN KEY (ArticleId) REFERENCES WarehouseArticles(Id) ON DELETE CASCADE,
FOREIGN KEY (BatchId) REFERENCES ArticleBatches(Id) ON DELETE SET NULL,
FOREIGN KEY (CurrentWarehouseId) REFERENCES WarehouseLocations(Id) ON DELETE SET NULL,
UNIQUE(ArticleId, SerialNumber)
);
CREATE INDEX IF NOT EXISTS IX_ArticleSerials_ArticleId_SerialNumber ON ArticleSerials(ArticleId, SerialNumber);
CREATE INDEX IF NOT EXISTS IX_ArticleSerials_Status ON ArticleSerials(Status);
CREATE INDEX IF NOT EXISTS IX_ArticleSerials_CurrentWarehouseId ON ArticleSerials(CurrentWarehouseId);
-- Barcode aggiuntivi
CREATE TABLE IF NOT EXISTS ArticleBarcodes (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
ArticleId INTEGER NOT NULL,
Barcode TEXT NOT NULL UNIQUE,
Type INTEGER NOT NULL DEFAULT 0,
Description TEXT,
Quantity REAL NOT NULL DEFAULT 1,
IsPrimary INTEGER NOT NULL DEFAULT 0,
IsActive INTEGER NOT NULL DEFAULT 1,
CreatedAt TEXT,
CreatedBy TEXT,
UpdatedAt TEXT,
UpdatedBy TEXT,
FOREIGN KEY (ArticleId) REFERENCES WarehouseArticles(Id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS IX_ArticleBarcodes_Barcode ON ArticleBarcodes(Barcode);
CREATE INDEX IF NOT EXISTS IX_ArticleBarcodes_ArticleId ON ArticleBarcodes(ArticleId);
-- Giacenze
CREATE TABLE IF NOT EXISTS StockLevels (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
ArticleId INTEGER NOT NULL,
WarehouseId INTEGER NOT NULL,
BatchId INTEGER,
Quantity REAL NOT NULL DEFAULT 0,
ReservedQuantity REAL NOT NULL DEFAULT 0,
OnOrderQuantity REAL NOT NULL DEFAULT 0,
StockValue REAL,
UnitCost REAL,
LastMovementDate TEXT,
LastInventoryDate TEXT,
LocationCode TEXT,
CreatedAt TEXT,
CreatedBy TEXT,
UpdatedAt TEXT,
UpdatedBy TEXT,
FOREIGN KEY (ArticleId) REFERENCES WarehouseArticles(Id) ON DELETE CASCADE,
FOREIGN KEY (WarehouseId) REFERENCES WarehouseLocations(Id) ON DELETE CASCADE,
FOREIGN KEY (BatchId) REFERENCES ArticleBatches(Id) ON DELETE SET NULL,
UNIQUE(ArticleId, WarehouseId, BatchId)
);
CREATE INDEX IF NOT EXISTS IX_StockLevels_ArticleId_WarehouseId_BatchId ON StockLevels(ArticleId, WarehouseId, BatchId);
CREATE INDEX IF NOT EXISTS IX_StockLevels_WarehouseId ON StockLevels(WarehouseId);
CREATE INDEX IF NOT EXISTS IX_StockLevels_LocationCode ON StockLevels(LocationCode);
-- Causali Movimento
CREATE TABLE IF NOT EXISTS MovementReasons (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Code TEXT NOT NULL UNIQUE,
Description TEXT NOT NULL,
MovementType INTEGER NOT NULL,
StockSign INTEGER NOT NULL,
RequiresExternalReference INTEGER NOT NULL DEFAULT 0,
RequiresValuation INTEGER NOT NULL DEFAULT 1,
UpdatesAverageCost INTEGER NOT NULL DEFAULT 1,
IsSystem INTEGER NOT NULL DEFAULT 0,
IsActive INTEGER NOT NULL DEFAULT 1,
SortOrder INTEGER NOT NULL DEFAULT 0,
Notes TEXT,
CreatedAt TEXT,
CreatedBy TEXT,
UpdatedAt TEXT,
UpdatedBy TEXT
);
CREATE INDEX IF NOT EXISTS IX_MovementReasons_Code ON MovementReasons(Code);
CREATE INDEX IF NOT EXISTS IX_MovementReasons_MovementType ON MovementReasons(MovementType);
CREATE INDEX IF NOT EXISTS IX_MovementReasons_IsActive ON MovementReasons(IsActive);
-- Movimenti di Magazzino (Testata)
CREATE TABLE IF NOT EXISTS StockMovements (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
DocumentNumber TEXT NOT NULL UNIQUE,
MovementDate TEXT NOT NULL,
Type INTEGER NOT NULL,
ReasonId INTEGER,
SourceWarehouseId INTEGER,
DestinationWarehouseId INTEGER,
ExternalReference TEXT,
ExternalDocumentType INTEGER,
SupplierId INTEGER,
CustomerId INTEGER,
Status INTEGER NOT NULL DEFAULT 0,
ConfirmedDate TEXT,
ConfirmedBy TEXT,
TotalValue REAL,
Notes TEXT,
CreatedAt TEXT,
CreatedBy TEXT,
UpdatedAt TEXT,
UpdatedBy TEXT,
FOREIGN KEY (ReasonId) REFERENCES MovementReasons(Id) ON DELETE SET NULL,
FOREIGN KEY (SourceWarehouseId) REFERENCES WarehouseLocations(Id) ON DELETE RESTRICT,
FOREIGN KEY (DestinationWarehouseId) REFERENCES WarehouseLocations(Id) ON DELETE RESTRICT
);
CREATE INDEX IF NOT EXISTS IX_StockMovements_DocumentNumber ON StockMovements(DocumentNumber);
CREATE INDEX IF NOT EXISTS IX_StockMovements_MovementDate ON StockMovements(MovementDate);
CREATE INDEX IF NOT EXISTS IX_StockMovements_Type ON StockMovements(Type);
CREATE INDEX IF NOT EXISTS IX_StockMovements_Status ON StockMovements(Status);
CREATE INDEX IF NOT EXISTS IX_StockMovements_ExternalReference ON StockMovements(ExternalReference);
-- Movimenti di Magazzino (Righe)
CREATE TABLE IF NOT EXISTS StockMovementLines (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
MovementId INTEGER NOT NULL,
LineNumber INTEGER NOT NULL,
ArticleId INTEGER NOT NULL,
BatchId INTEGER,
SerialId INTEGER,
Quantity REAL NOT NULL,
UnitOfMeasure TEXT NOT NULL DEFAULT 'PZ',
UnitCost REAL,
LineValue REAL,
SourceLocationCode TEXT,
DestinationLocationCode TEXT,
ExternalLineReference TEXT,
Notes TEXT,
CreatedAt TEXT,
CreatedBy TEXT,
UpdatedAt TEXT,
UpdatedBy TEXT,
FOREIGN KEY (MovementId) REFERENCES StockMovements(Id) ON DELETE CASCADE,
FOREIGN KEY (ArticleId) REFERENCES WarehouseArticles(Id) ON DELETE RESTRICT,
FOREIGN KEY (BatchId) REFERENCES ArticleBatches(Id) ON DELETE SET NULL,
FOREIGN KEY (SerialId) REFERENCES ArticleSerials(Id) ON DELETE SET NULL,
UNIQUE(MovementId, LineNumber)
);
CREATE INDEX IF NOT EXISTS IX_StockMovementLines_MovementId_LineNumber ON StockMovementLines(MovementId, LineNumber);
CREATE INDEX IF NOT EXISTS IX_StockMovementLines_ArticleId ON StockMovementLines(ArticleId);
CREATE INDEX IF NOT EXISTS IX_StockMovementLines_BatchId ON StockMovementLines(BatchId);
CREATE INDEX IF NOT EXISTS IX_StockMovementLines_SerialId ON StockMovementLines(SerialId);
-- Valorizzazione Magazzino per Periodo
CREATE TABLE IF NOT EXISTS StockValuations (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
ValuationDate TEXT NOT NULL,
Period INTEGER NOT NULL,
ArticleId INTEGER NOT NULL,
WarehouseId INTEGER,
Quantity REAL NOT NULL DEFAULT 0,
Method INTEGER NOT NULL DEFAULT 0,
UnitCost REAL NOT NULL DEFAULT 0,
TotalValue REAL NOT NULL DEFAULT 0,
InboundQuantity REAL NOT NULL DEFAULT 0,
InboundValue REAL NOT NULL DEFAULT 0,
OutboundQuantity REAL NOT NULL DEFAULT 0,
OutboundValue REAL NOT NULL DEFAULT 0,
IsClosed INTEGER NOT NULL DEFAULT 0,
ClosedDate TEXT,
ClosedBy TEXT,
Notes TEXT,
CreatedAt TEXT,
CreatedBy TEXT,
UpdatedAt TEXT,
UpdatedBy TEXT,
FOREIGN KEY (ArticleId) REFERENCES WarehouseArticles(Id) ON DELETE CASCADE,
FOREIGN KEY (WarehouseId) REFERENCES WarehouseLocations(Id) ON DELETE SET NULL,
UNIQUE(Period, ArticleId, WarehouseId)
);
CREATE INDEX IF NOT EXISTS IX_StockValuations_Period_ArticleId_WarehouseId ON StockValuations(Period, ArticleId, WarehouseId);
CREATE INDEX IF NOT EXISTS IX_StockValuations_ValuationDate ON StockValuations(ValuationDate);
CREATE INDEX IF NOT EXISTS IX_StockValuations_IsClosed ON StockValuations(IsClosed);
-- Layer Valorizzazione FIFO/LIFO
CREATE TABLE IF NOT EXISTS StockValuationLayers (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
ArticleId INTEGER NOT NULL,
WarehouseId INTEGER NOT NULL,
BatchId INTEGER,
LayerDate TEXT NOT NULL,
SourceMovementId INTEGER,
OriginalQuantity REAL NOT NULL DEFAULT 0,
RemainingQuantity REAL NOT NULL DEFAULT 0,
UnitCost REAL NOT NULL DEFAULT 0,
IsExhausted INTEGER NOT NULL DEFAULT 0,
CreatedAt TEXT,
CreatedBy TEXT,
UpdatedAt TEXT,
UpdatedBy TEXT,
FOREIGN KEY (ArticleId) REFERENCES WarehouseArticles(Id) ON DELETE CASCADE,
FOREIGN KEY (WarehouseId) REFERENCES WarehouseLocations(Id) ON DELETE CASCADE,
FOREIGN KEY (BatchId) REFERENCES ArticleBatches(Id) ON DELETE SET NULL,
FOREIGN KEY (SourceMovementId) REFERENCES StockMovements(Id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS IX_StockValuationLayers_ArticleId_WarehouseId_LayerDate ON StockValuationLayers(ArticleId, WarehouseId, LayerDate);
CREATE INDEX IF NOT EXISTS IX_StockValuationLayers_IsExhausted ON StockValuationLayers(IsExhausted);
-- Inventari Fisici (Testata)
CREATE TABLE IF NOT EXISTS InventoryCounts (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
Code TEXT NOT NULL UNIQUE,
Description TEXT NOT NULL,
InventoryDate TEXT NOT NULL,
WarehouseId INTEGER,
CategoryId INTEGER,
Type INTEGER NOT NULL DEFAULT 0,
Status INTEGER NOT NULL DEFAULT 0,
StartDate TEXT,
EndDate TEXT,
ConfirmedDate TEXT,
ConfirmedBy TEXT,
AdjustmentMovementId INTEGER,
PositiveDifferenceValue REAL,
NegativeDifferenceValue REAL,
Notes TEXT,
CreatedAt TEXT,
CreatedBy TEXT,
UpdatedAt TEXT,
UpdatedBy TEXT,
FOREIGN KEY (WarehouseId) REFERENCES WarehouseLocations(Id) ON DELETE SET NULL,
FOREIGN KEY (CategoryId) REFERENCES WarehouseArticleCategories(Id) ON DELETE SET NULL,
FOREIGN KEY (AdjustmentMovementId) REFERENCES StockMovements(Id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS IX_InventoryCounts_Code ON InventoryCounts(Code);
CREATE INDEX IF NOT EXISTS IX_InventoryCounts_InventoryDate ON InventoryCounts(InventoryDate);
CREATE INDEX IF NOT EXISTS IX_InventoryCounts_Status ON InventoryCounts(Status);
-- Inventari Fisici (Righe)
CREATE TABLE IF NOT EXISTS InventoryCountLines (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
InventoryCountId INTEGER NOT NULL,
ArticleId INTEGER NOT NULL,
WarehouseId INTEGER NOT NULL,
BatchId INTEGER,
LocationCode TEXT,
TheoreticalQuantity REAL NOT NULL DEFAULT 0,
CountedQuantity REAL,
UnitCost REAL,
CountedAt TEXT,
CountedBy TEXT,
SecondCountQuantity REAL,
SecondCountBy TEXT,
Notes TEXT,
CreatedAt TEXT,
CreatedBy TEXT,
UpdatedAt TEXT,
UpdatedBy TEXT,
FOREIGN KEY (InventoryCountId) REFERENCES InventoryCounts(Id) ON DELETE CASCADE,
FOREIGN KEY (ArticleId) REFERENCES WarehouseArticles(Id) ON DELETE RESTRICT,
FOREIGN KEY (WarehouseId) REFERENCES WarehouseLocations(Id) ON DELETE RESTRICT,
FOREIGN KEY (BatchId) REFERENCES ArticleBatches(Id) ON DELETE SET NULL,
UNIQUE(InventoryCountId, ArticleId, WarehouseId, BatchId)
);
CREATE INDEX IF NOT EXISTS IX_InventoryCountLines_InventoryCountId_ArticleId ON InventoryCountLines(InventoryCountId, ArticleId, WarehouseId, BatchId);
CREATE INDEX IF NOT EXISTS IX_InventoryCountLines_ArticleId ON InventoryCountLines(ArticleId);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff