-
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
722
frontend/src/modules/warehouse/contexts/WarehouseContext.tsx
Normal file
722
frontend/src/modules/warehouse/contexts/WarehouseContext.tsx
Normal 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),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
76
frontend/src/modules/warehouse/hooks/index.ts
Normal file
76
frontend/src/modules/warehouse/hooks/index.ts
Normal 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";
|
||||
280
frontend/src/modules/warehouse/hooks/useStockCalculations.ts
Normal file
280
frontend/src/modules/warehouse/hooks/useStockCalculations.ts
Normal 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]);
|
||||
}
|
||||
160
frontend/src/modules/warehouse/hooks/useWarehouseNavigation.ts
Normal file
160
frontend/src/modules/warehouse/hooks/useWarehouseNavigation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
900
frontend/src/modules/warehouse/pages/ArticleFormPage.tsx
Normal file
900
frontend/src/modules/warehouse/pages/ArticleFormPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
523
frontend/src/modules/warehouse/pages/ArticlesPage.tsx
Normal file
523
frontend/src/modules/warehouse/pages/ArticlesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
454
frontend/src/modules/warehouse/pages/InboundMovementPage.tsx
Normal file
454
frontend/src/modules/warehouse/pages/InboundMovementPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
650
frontend/src/modules/warehouse/pages/MovementsPage.tsx
Normal file
650
frontend/src/modules/warehouse/pages/MovementsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
481
frontend/src/modules/warehouse/pages/OutboundMovementPage.tsx
Normal file
481
frontend/src/modules/warehouse/pages/OutboundMovementPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
355
frontend/src/modules/warehouse/pages/StockLevelsPage.tsx
Normal file
355
frontend/src/modules/warehouse/pages/StockLevelsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
447
frontend/src/modules/warehouse/pages/TransferMovementPage.tsx
Normal file
447
frontend/src/modules/warehouse/pages/TransferMovementPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
539
frontend/src/modules/warehouse/pages/WarehouseDashboard.tsx
Normal file
539
frontend/src/modules/warehouse/pages/WarehouseDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
496
frontend/src/modules/warehouse/pages/WarehouseLocationsPage.tsx
Normal file
496
frontend/src/modules/warehouse/pages/WarehouseLocationsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
frontend/src/modules/warehouse/pages/index.ts
Normal file
18
frontend/src/modules/warehouse/pages/index.ts
Normal 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';
|
||||
52
frontend/src/modules/warehouse/routes.tsx
Normal file
52
frontend/src/modules/warehouse/routes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
571
frontend/src/modules/warehouse/services/warehouseService.ts
Normal file
571
frontend/src/modules/warehouse/services/warehouseService.ts
Normal 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,
|
||||
};
|
||||
921
frontend/src/modules/warehouse/types/index.ts
Normal file
921
frontend/src/modules/warehouse/types/index.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
1897
src/Apollinare.API/Modules/Warehouse/Services/WarehouseService.cs
Normal file
1897
src/Apollinare.API/Modules/Warehouse/Services/WarehouseService.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
BIN
src/Apollinare.API/apollinare.db-shm
Normal file
BIN
src/Apollinare.API/apollinare.db-shm
Normal file
Binary file not shown.
BIN
src/Apollinare.API/apollinare.db-wal
Normal file
BIN
src/Apollinare.API/apollinare.db-wal
Normal file
Binary file not shown.
96
src/Apollinare.Domain/Entities/Warehouse/ArticleBarcode.cs
Normal file
96
src/Apollinare.Domain/Entities/Warehouse/ArticleBarcode.cs
Normal 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
|
||||
}
|
||||
145
src/Apollinare.Domain/Entities/Warehouse/ArticleBatch.cs
Normal file
145
src/Apollinare.Domain/Entities/Warehouse/ArticleBatch.cs
Normal 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
|
||||
}
|
||||
129
src/Apollinare.Domain/Entities/Warehouse/ArticleSerial.cs
Normal file
129
src/Apollinare.Domain/Entities/Warehouse/ArticleSerial.cs
Normal 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
|
||||
}
|
||||
236
src/Apollinare.Domain/Entities/Warehouse/InventoryCount.cs
Normal file
236
src/Apollinare.Domain/Entities/Warehouse/InventoryCount.cs
Normal 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
|
||||
}
|
||||
65
src/Apollinare.Domain/Entities/Warehouse/MovementReason.cs
Normal file
65
src/Apollinare.Domain/Entities/Warehouse/MovementReason.cs
Normal 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>();
|
||||
}
|
||||
72
src/Apollinare.Domain/Entities/Warehouse/StockLevel.cs
Normal file
72
src/Apollinare.Domain/Entities/Warehouse/StockLevel.cs
Normal 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; }
|
||||
}
|
||||
201
src/Apollinare.Domain/Entities/Warehouse/StockMovement.cs
Normal file
201
src/Apollinare.Domain/Entities/Warehouse/StockMovement.cs
Normal 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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
153
src/Apollinare.Domain/Entities/Warehouse/StockValuation.cs
Normal file
153
src/Apollinare.Domain/Entities/Warehouse/StockValuation.cs
Normal 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; }
|
||||
}
|
||||
237
src/Apollinare.Domain/Entities/Warehouse/WarehouseArticle.cs
Normal file
237
src/Apollinare.Domain/Entities/Warehouse/WarehouseArticle.cs
Normal 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
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
113
src/Apollinare.Domain/Entities/Warehouse/WarehouseLocation.cs
Normal file
113
src/Apollinare.Domain/Entities/Warehouse/WarehouseLocation.cs
Normal 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
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
421
src/Apollinare.Infrastructure/Data/warehouse_tables.sql
Normal file
421
src/Apollinare.Infrastructure/Data/warehouse_tables.sql
Normal 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);
|
||||
3018
src/Apollinare.Infrastructure/Migrations/20251129134709_InitialCreate.Designer.cs
generated
Normal file
3018
src/Apollinare.Infrastructure/Migrations/20251129134709_InitialCreate.Designer.cs
generated
Normal file
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
Reference in New Issue
Block a user