diff --git a/.agent/skills/ui-consistency/SKILL.md b/.agent/skills/ui-consistency/SKILL.md index 0f6069b..3ddf1e2 100644 --- a/.agent/skills/ui-consistency/SKILL.md +++ b/.agent/skills/ui-consistency/SKILL.md @@ -569,6 +569,7 @@ const handlePerPageChange = (value: string) => { --- + ## 7. Badge 與狀態顯示 ### 7.1 基本 Badge @@ -614,6 +615,48 @@ import { Badge } from "@/Components/ui/badge"; ``` +### 7.3 統一狀態標籤 (StatusBadge) + +系統提供統一的 `StatusBadge` 元件來顯示各種業務狀態,確保顏色與樣式的一致性。 + +**引入方式**: + +```tsx +import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge"; +``` + +**支援的變體 (Variant)**: + +| Variant | 顏色 | 適用情境 | +|---|---|---| +| `neutral` | 灰色 | 草稿、取消、關閉、缺貨 | +| `info` | 藍色 | 處理中、啟用中 | +| `warning` | 黃色 | 待審核、庫存預警、週轉慢 | +| `success` | 綠色 | 已完成、已核准、正常 | +| `destructive` | 紅色 | 作廢、駁回、滯銷、異常 | + +**實作模式**: + +建議定義一個 `getStatusVariant` 函式將業務狀態對應到 UI 變體,保持程式碼整潔。 + +```tsx +// 1. 定義狀態映射函式 +const getStatusVariant = (status: string): StatusVariant => { + switch (status) { + case 'normal': return 'success'; // 正常 -> 綠色 + case 'slow': return 'warning'; // 週轉慢 -> 黃色 + case 'dead': return 'destructive'; // 滯銷 -> 紅色 + case 'out_of_stock': return 'neutral';// 缺貨 -> 灰色 + default: return 'neutral'; + } +}; + +// 2. 在表格中使用 + + {item.status_label} + +``` + --- ## 8. 頁面佈局規範 diff --git a/app/Modules/Inventory/Controllers/InventoryAnalysisController.php b/app/Modules/Inventory/Controllers/InventoryAnalysisController.php new file mode 100644 index 0000000..9ce0ed4 --- /dev/null +++ b/app/Modules/Inventory/Controllers/InventoryAnalysisController.php @@ -0,0 +1,38 @@ +turnoverService = $turnoverService; + } + + public function index(Request $request) + { + $filters = $request->only([ + 'warehouse_id', 'category_id', 'search', 'per_page', 'sort_by', 'sort_order', 'status' + ]); + + $analysisData = $this->turnoverService->getAnalysisData($filters, $request->input('per_page', 10)); + $kpis = $this->turnoverService->getKPIs($filters); + + return Inertia::render('Inventory/Analysis/Index', [ + 'analysisData' => $analysisData, + 'kpis' => $kpis, + 'warehouses' => Warehouse::select('id', 'name')->get(), + 'categories' => Category::select('id', 'name')->get(), + 'filters' => $filters, + ]); + } +} diff --git a/app/Modules/Inventory/Routes/web.php b/app/Modules/Inventory/Routes/web.php index 2c2674a..33652b7 100644 --- a/app/Modules/Inventory/Routes/web.php +++ b/app/Modules/Inventory/Routes/web.php @@ -14,6 +14,7 @@ use App\Modules\Inventory\Controllers\AdjustDocController; use App\Modules\Inventory\Controllers\InventoryReportController; use App\Modules\Inventory\Controllers\StockQueryController; +use App\Modules\Inventory\Controllers\InventoryAnalysisController; Route::middleware('auth')->group(function () { @@ -32,6 +33,11 @@ Route::middleware('auth')->group(function () { Route::get('/inventory/report/{product}', [InventoryReportController::class, 'show'])->name('inventory.report.show'); }); + // 庫存分析 (Inventory Analysis) + Route::middleware('permission:inventory_report.view')->group(function () { + Route::get('/inventory/analysis', [InventoryAnalysisController::class, 'index'])->name('inventory.analysis.index'); + }); + // 類別管理 (用於商品對話框) - 需要商品權限 Route::middleware('permission:products.view')->group(function () { Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index'); diff --git a/app/Modules/Inventory/Services/TurnoverService.php b/app/Modules/Inventory/Services/TurnoverService.php new file mode 100644 index 0000000..f74916c --- /dev/null +++ b/app/Modules/Inventory/Services/TurnoverService.php @@ -0,0 +1,247 @@ +select([ + 'products.id', + 'products.code', + 'products.name', + 'categories.name as category_name', + 'products.cost_price', // Assuming cost_price exists for value calculation + ]) + ->leftJoin('categories', 'products.category_id', '=', 'categories.id') + ->leftJoin('inventories', 'products.id', '=', 'inventories.product_id') + ->groupBy(['products.id', 'products.code', 'products.name', 'categories.name', 'products.cost_price']); + + // Filter by Warehouse (Current Inventory) + if ($warehouseId) { + $query->where('inventories.warehouse_id', $warehouseId); + } + + // Filter by Category + if ($categoryId) { + $query->where('products.category_id', $categoryId); + } + + // Filter by Search + if ($search) { + $query->where(function($q) use ($search) { + $q->where('products.name', 'like', "%{$search}%") + ->orWhere('products.code', 'like', "%{$search}%"); + }); + } + + // Add Aggregated Columns + + // 1. Current Inventory Quantity + $query->addSelect(DB::raw('COALESCE(SUM(inventories.quantity), 0) as current_stock')); + + // 2. Sales in last 30 days (Outbound) + // We need a subquery or join for this to be efficient, or we use a separate query and map. + // Given potentially large data, subquery per row might be slow, but for pagination it's okay-ish. + // Better approach: Join with a subquery of aggregated transactions. + + $thirtyDaysAgo = Carbon::now()->subDays(30); + + // Subquery for 30-day sales + $salesSubquery = InventoryTransaction::query() + ->select('inventories.product_id', DB::raw('ABS(SUM(inventory_transactions.quantity)) as sales_qty_30d')) + ->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id') + ->where('inventory_transactions.type', '出庫') // Adjust type as needed based on actual data + ->where('inventory_transactions.actual_time', '>=', $thirtyDaysAgo) + ->groupBy('inventories.product_id'); + + if ($warehouseId) { + $salesSubquery->where('inventories.warehouse_id', $warehouseId); + } + + $query->leftJoinSub($salesSubquery, 'sales_30d', function ($join) { + $join->on('products.id', '=', 'sales_30d.product_id'); + }); + $query->addSelect(DB::raw('COALESCE(sales_30d.sales_qty_30d, 0) as sales_30d')); + + // 3. Last Sale Date + // Use max actual_time from outbound transactions + $lastSaleSubquery = InventoryTransaction::query() + ->select('inventories.product_id', DB::raw('MAX(actual_time) as last_sale_date')) + ->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id') + ->where('inventory_transactions.type', '出庫') + ->groupBy('inventories.product_id'); + + if ($warehouseId) { + $lastSaleSubquery->where('inventories.warehouse_id', $warehouseId); + } + + $query->leftJoinSub($lastSaleSubquery, 'last_sales', function ($join) { + $join->on('products.id', '=', 'last_sales.product_id'); + }); + $query->addSelect('last_sales.last_sale_date'); + + // Apply Status Filter (Dead Stock etc) requires having clauses or wrapper query. + // Dead Stock: stock > 0 AND (last_sale_date < 90 days ago OR last_sale_date IS NULL) + // Slow Moving: turnover days > X? + + // Let's modify query to handle ordering and filtering on calculated fields if possible. + // For simplicity in Laravel, we might fetch and transform, but pagination breaks. + // We'll use HAVING for status filtering if needed. + + // Order by + $sortBy = $filters['sort_by'] ?? 'turnover_days'; // Default sort + $sortOrder = $filters['sort_order'] ?? 'desc'; + + // Turnover Days Calculation in SQL: (stock / (sales_30d / 30)) => (stock * 30) / sales_30d + // Handle division by zero: if sales_30d is 0, turnover is 'Inf' (or very high number like 9999) + $turnoverDaysSql = "CASE WHEN COALESCE(sales_30d.sales_qty_30d, 0) > 0 + THEN (COALESCE(SUM(inventories.quantity), 0) * 30) / sales_30d.sales_qty_30d + ELSE 9999 END"; + + $query->addSelect(DB::raw("$turnoverDaysSql as turnover_days")); + + // Only show items with stock > 0 ? User might want to see out of stock items too? + // Usually analysis focuses on what IS in stock. But Dead Stock needs items with stock. + // Stock-out analysis needs items with 0 stock. + // Let's filter stock > 0 by default for "Turnover Analysis". + // $query->havingRaw('current_stock > 0'); + // Wait, better to let user filter? + // For dead stock, definitive IS stock > 0. + + if ($statusFilter === 'dead') { + $ninetyDaysAgo = Carbon::now()->subDays(90); + $query->havingRaw("current_stock > 0 AND (last_sale_date < ? OR last_sale_date IS NULL)", [$ninetyDaysAgo]); + } + + // Apply Sorting + if ($sortBy === 'turnover_days') { + $query->orderByRaw("$turnoverDaysSql $sortOrder"); + } else if (in_array($sortBy, ['current_stock', 'sales_30d', 'last_sale_date'])) { + $query->orderBy($sortBy, $sortOrder); + } else { + $query->orderBy('products.code', 'asc'); + } + + return $query->paginate($perPage)->withQueryString()->through(function($item) { + // Post-processing for display + $item->turnover_days_display = $item->turnover_days >= 9999 ? '∞' : number_format($item->turnover_days, 1); + + // Determine Status Label + $lastSale = $item->last_sale_date ? Carbon::parse($item->last_sale_date) : null; + $daysSinceSale = $lastSale ? $lastSale->diffInDays(Carbon::now()) : 9999; + + if ($item->current_stock > 0 && $daysSinceSale > 90) { + $item->status = 'dead'; // 滯銷 + $item->status_label = '滯銷'; + } elseif ($item->current_stock > 0 && $item->turnover_days > 60) { + $item->status = 'slow'; // 週轉慢 + $item->status_label = '週轉慢'; + } elseif ($item->current_stock == 0) { + $item->status = 'out_of_stock'; + $item->status_label = '缺貨'; + } else { + $item->status = 'normal'; + $item->status_label = '正常'; + } + + return $item; + }); + } + + public function getKPIs(array $filters) + { + // Calculates aggregate KPIs + $warehouseId = $filters['warehouse_id'] ?? null; + $categoryId = $filters['category_id'] ?? null; + + // Helper to build base inv query + $buildInvQuery = function() use ($warehouseId, $categoryId) { + $q = DB::table('inventories') + ->join('products', 'inventories.product_id', '=', 'products.id') + ->where('inventories.quantity', '>', 0); + if ($warehouseId) $q->where('inventories.warehouse_id', $warehouseId); + if ($categoryId) $q->where('products.category_id', $categoryId); + return $q; + }; + + // 1. Total Inventory Value (Cost) + $totalValue = (clone $buildInvQuery()) + ->sum(DB::raw('inventories.quantity * COALESCE(products.cost_price, 0)')); + + // 2. Dead Stock Value (No sale in 90 days) + // Need last sale date for each product-location or just product? + // Assuming dead stock is product-level logic for simplicity. + + $ninetyDaysAgo = Carbon::now()->subDays(90); + + // Get IDs of products sold in last 90 days + $soldProductIds = InventoryTransaction::query() + ->where('type', '出庫') + ->where('actual_time', '>=', $ninetyDaysAgo) + ->distinct() + ->pluck('inventory_id') // Wait, transaction links to inventory, inventory links to product. + // We need product_id. + ->map(function($id) { + return DB::table('inventories')->where('id', $id)->value('product_id'); + }) + ->filter() + ->unique() + ->toArray(); + // Optimization: Use join in subquery + + $soldProductIdsQuery = DB::table('inventory_transactions') + ->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id') + ->where('inventory_transactions.type', '出庫') + ->where('inventory_transactions.actual_time', '>=', $ninetyDaysAgo) + ->select('inventories.product_id') + ->distinct(); + + $deadStockQuery = (clone $buildInvQuery()) + ->whereNotIn('products.id', $soldProductIdsQuery); + + $deadStockValue = $deadStockQuery->sum(DB::raw('inventories.quantity * COALESCE(products.cost_price, 0)')); + $deadStockCount = $deadStockQuery->count('products.id'); // Count of inventory records (batches) or products? + // Let's count distinct products + $deadStockProductCount = $deadStockQuery->distinct('products.id')->count('products.id'); + + // 3. Average Turnover Days (Company wide) + // Formula: (Avg Inventory / COGS) * 365 ? + // Simplified: (Total Stock / Total Sales 30d) * 30 + + $totalStock = (clone $buildInvQuery())->sum('inventories.quantity'); + + $totalSales30d = DB::table('inventory_transactions') + ->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id') + ->join('products', 'inventories.product_id', '=', 'products.id') + ->where('inventory_transactions.type', '出庫') + ->where('inventory_transactions.actual_time', '>=', Carbon::now()->subDays(30)) + ->when($warehouseId, fn($q) => $q->where('inventories.warehouse_id', $warehouseId)) + ->when($categoryId, fn($q) => $q->where('products.category_id', $categoryId)) + ->sum(DB::raw('ABS(inventory_transactions.quantity)')); + + $avgTurnoverDays = $totalSales30d > 0 ? ($totalStock * 30) / $totalSales30d : 0; + + return [ + 'total_stock_value' => $totalValue, + 'dead_stock_value' => $deadStockValue, + 'dead_stock_count' => $deadStockProductCount, + 'avg_turnover_days' => round($avgTurnoverDays, 1), + ]; + } +} diff --git a/resources/js/Layouts/AuthenticatedLayout.tsx b/resources/js/Layouts/AuthenticatedLayout.tsx index d85dc61..148bd61 100644 --- a/resources/js/Layouts/AuthenticatedLayout.tsx +++ b/resources/js/Layouts/AuthenticatedLayout.tsx @@ -249,6 +249,13 @@ export default function AuthenticatedLayout({ route: "/inventory/report", permission: "inventory_report.view", }, + { + id: "inventory-analysis", + label: "庫存分析", + icon: , + route: "/inventory/analysis", + permission: "inventory_report.view", + }, ], }, { diff --git a/resources/js/Pages/Inventory/Analysis/Index.tsx b/resources/js/Pages/Inventory/Analysis/Index.tsx new file mode 100644 index 0000000..11ae8e6 --- /dev/null +++ b/resources/js/Pages/Inventory/Analysis/Index.tsx @@ -0,0 +1,442 @@ +import { useState, useCallback } from "react"; +import { Button } from "@/Components/ui/button"; +import { Input } from "@/Components/ui/input"; +import { Label } from "@/Components/ui/label"; +import { + Filter, + Package, + RotateCcw, + BarChart3, + AlertTriangle, + CheckCircle2, + Clock, + ArrowUpDown, + ArrowUp, + ArrowDown, + XCircle +} from 'lucide-react'; +import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; +import { Head, router } from "@inertiajs/react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/Components/ui/table"; +import Pagination from "@/Components/shared/Pagination"; +import { SearchableSelect } from "@/Components/ui/searchable-select"; +import { PageProps } from "@/types/global"; + +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/Components/ui/tooltip"; +import { StatusBadge, StatusVariant } from "@/Components/shared/StatusBadge"; + +interface AnalysisItem { + id: number; + code: string; + name: string; + category_name: string; + current_stock: string; // decimal string from DB + sales_30d: string; + last_sale_date: string | null; + turnover_days: number; + turnover_days_display: string; + status: 'dead' | 'slow' | 'normal' | 'out_of_stock'; + status_label: string; +} + +interface KPIProps { + total_stock_value: number; + dead_stock_value: number; + dead_stock_count: number; + avg_turnover_days: number; +} + +interface PagePropsWithData extends PageProps { + analysisData: { + data: AnalysisItem[]; + links: any[]; + total: number; + from: number; + to: number; + current_page: number; + }; + kpis: KPIProps; + warehouses: { id: number; name: string }[]; + categories: { id: number; name: string }[]; + filters: { + warehouse_id?: string; + category_id?: string; + search?: string; + per_page?: string; + sort_by?: string; + sort_order?: 'asc' | 'desc'; + status?: string; + }; +} + +// Define status mapping +const getStatusVariant = (status: string): StatusVariant => { + switch (status) { + case 'dead': return 'destructive'; + case 'slow': return 'warning'; + case 'normal': return 'success'; + case 'out_of_stock': return 'neutral'; + default: return 'neutral'; + } +}; + +const getStatusLabel = (status: string): string => { + switch (status) { + case 'dead': return '滯銷'; + case 'slow': return '週轉慢'; + case 'normal': return '正常'; + case 'out_of_stock': return '缺貨'; + default: return status; + } +}; + +const statusOptions = [ + { label: "全部狀態", value: "all" }, + { label: "滯銷 (>90天)", value: "dead" }, + { label: "週轉慢 (>60天)", value: "slow" }, + { label: "正常", value: "normal" } +]; + +export default function InventoryAnalysisIndex({ analysisData, kpis, warehouses, categories, filters }: PagePropsWithData) { + const [warehouseId, setWarehouseId] = useState(filters.warehouse_id || "all"); + const [categoryId, setCategoryId] = useState(filters.category_id || "all"); + const [search, setSearch] = useState(filters.search || ""); + const [status, setStatus] = useState(filters.status || "all"); + const [perPage, setPerPage] = useState(filters.per_page?.toString() || "10"); + + const handleFilter = useCallback(() => { + router.get( + route("inventory.analysis.index"), + { + warehouse_id: warehouseId === "all" ? "" : warehouseId, + category_id: categoryId === "all" ? "" : categoryId, + status: status === "all" ? "" : status, + search: search, + per_page: perPage, + sort_by: filters.sort_by, + sort_order: filters.sort_order, + }, + { preserveState: true, preserveScroll: true } + ); + }, [warehouseId, categoryId, status, search, perPage, filters.sort_by, filters.sort_order]); + + const handleClearFilters = () => { + setWarehouseId("all"); + setCategoryId("all"); + setStatus("all"); + setSearch(""); + setPerPage("10"); + router.get(route("inventory.analysis.index")); + }; + + const handleSort = (field: string) => { + let newSortBy: string | undefined = field; + let newSortOrder: 'asc' | 'desc' | undefined = 'asc'; + + if (filters.sort_by === field) { + if (filters.sort_order === 'asc') { + newSortOrder = 'desc'; + } else { + newSortBy = undefined; + newSortOrder = undefined; + } + } else { + // Default sort order for numeric fields might be desc + if (['turnover_days', 'current_stock', 'sales_30d'].includes(field)) { + newSortOrder = 'desc'; + } + } + + router.get( + route("inventory.analysis.index"), + { + warehouse_id: warehouseId === "all" ? "" : warehouseId, + category_id: categoryId === "all" ? "" : categoryId, + status: status === "all" ? "" : status, + search: search, + per_page: perPage, + sort_by: newSortBy, + sort_order: newSortOrder, + }, + { preserveState: true, preserveScroll: true } + ); + }; + + const handlePerPageChange = (value: string) => { + setPerPage(value); + // Trigger filter immediately + router.get( + route("inventory.analysis.index"), + { + warehouse_id: warehouseId === "all" ? "" : warehouseId, + category_id: categoryId === "all" ? "" : categoryId, + status: status === "all" ? "" : status, + search: search, + per_page: value, + sort_by: filters.sort_by, + sort_order: filters.sort_order, + }, + { preserveState: true, preserveScroll: true } + ); + }; + + const SortIcon = ({ field }: { field: string }) => { + if (filters.sort_by !== field) { + return ; + } + if (filters.sort_order === "asc") { + return ; + } + return ; + }; + + return ( + + + +
+ {/* Header */} +
+
+

+ + 庫存分析 +

+

+ 分析商品庫存週轉率、滯銷品項與庫存健康度 +

+
+
+ + {/* KPI Cards */} +
+
+
+ +
+
+

平均週轉天數

+

{kpis.avg_turnover_days}

+
+
+ +
+
+ +
+
+

滯銷品項數

+

{kpis.dead_stock_count}

+
+
+ +
+
+ +
+
+

滯銷庫存成本

+ + + +

${Number(kpis.dead_stock_value).toLocaleString()}

+
+ +

在此定義為庫存大於 0 且超過 90 天未銷售的商品成本總和

+
+
+
+
+
+ +
+
+ +
+
+

庫存總成本

+

${Number(kpis.total_stock_value).toLocaleString()}

+
+
+
+ + {/* Filters */} +
+
+ {/* Search */} +
+ + setSearch(e.target.value)} + className="h-9 bg-white" + onKeyDown={(e) => e.key === 'Enter' && handleFilter()} + /> +
+ + {/* Warehouse & Category */} +
+ + ({ label: w.name, value: w.id.toString() }))]} + className="w-full h-9" + placeholder="選擇倉庫..." + /> +
+
+ + ({ label: c.name, value: c.id.toString() }))]} + className="w-full h-9" + placeholder="選擇分類..." + /> +
+ +
+ + +
+ + + + {/* Action Buttons Integrated */} +
+ + +
+
+
+ + {/* Results Table */} +
+ + + + handleSort('products.code')}> +
商品代碼
+
+ handleSort('products.name')}> +
商品名稱
+
+ 分類 + handleSort('current_stock')}> +
現有庫存
+
+ handleSort('sales_30d')}> +
30天銷量
+
+ handleSort('turnover_days')}> +
週轉天數
+
+ handleSort('last_sale_date')}> +
最後銷售
+
+ 狀態 +
+
+ + {analysisData.data.length === 0 ? ( + + +
+ +

無符合條件的資料

+
+
+
+ ) : ( + analysisData.data.map((row) => ( + + + {row.code} + + + {row.name} + + {row.category_name || '-'} + + {Number(row.current_stock).toLocaleString()} + + + {Number(row.sales_30d).toLocaleString()} + + + {row.turnover_days_display} + + + {row.last_sale_date ? row.last_sale_date.split(' ')[0] : '從未銷售'} + + + + {getStatusLabel(row.status)} + + + + )) + )} +
+
+
+ + {/* Pagination Footer */} +
+
+ 每頁顯示 + + +
+
+ +
+
+
+
+ ); +}