From a6393e03d8b04d4992d77423315fa89cb058e246 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Tue, 10 Feb 2026 10:47:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AF=A6=E4=BD=9C=E5=8D=B3=E6=99=82?= =?UTF-8?q?=E5=BA=AB=E5=AD=98=E6=9F=A5=E8=A9=A2=E5=8A=9F=E8=83=BD=E3=80=81?= =?UTF-8?q?=E5=84=80=E8=A1=A8=E6=9D=BF=E5=BA=AB=E5=AD=98=E5=B0=8E=E7=9B=A4?= =?UTF-8?q?=EF=BC=8C=E5=8F=8A=E5=84=AA=E5=8C=96=E6=89=8B=E5=8B=95=E5=85=A5?= =?UTF-8?q?=E5=BA=AB=E6=89=B9=E8=99=9F=E8=88=87=E5=84=B2=E4=BD=8D=E9=80=A3?= =?UTF-8?q?=E5=8B=95=E8=88=87=E9=81=B8=E5=96=AE=E9=A1=AF=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/Controllers/DashboardController.php | 20 +- .../Contracts/InventoryServiceInterface.php | 9 + .../Controllers/InventoryController.php | 3 +- .../Controllers/StockQueryController.php | 59 ++ .../Inventory/Exports/StockQueryExport.php | 167 +++++ app/Modules/Inventory/Routes/web.php | 7 + .../Inventory/Services/InventoryService.php | 323 +++++++++- .../js/Components/ui/searchable-select.tsx | 2 +- resources/js/Layouts/AuthenticatedLayout.tsx | 7 + resources/js/Pages/Dashboard.tsx | 398 ++++++------ .../js/Pages/Inventory/StockQuery/Index.tsx | 582 ++++++++++++++++++ resources/js/Pages/Warehouse/AddInventory.tsx | 22 +- 12 files changed, 1401 insertions(+), 198 deletions(-) create mode 100644 app/Modules/Inventory/Controllers/StockQueryController.php create mode 100644 app/Modules/Inventory/Exports/StockQueryExport.php create mode 100644 resources/js/Pages/Inventory/StockQuery/Index.tsx diff --git a/app/Modules/Core/Controllers/DashboardController.php b/app/Modules/Core/Controllers/DashboardController.php index 65febf2..24a5629 100644 --- a/app/Modules/Core/Controllers/DashboardController.php +++ b/app/Modules/Core/Controllers/DashboardController.php @@ -32,20 +32,16 @@ class DashboardController extends Controller } $invStats = $this->inventoryService->getDashboardStats(); - $procStats = $this->procurementService->getDashboardStats(); - - $stats = [ - 'productsCount' => $invStats['productsCount'], - 'vendorsCount' => $procStats['vendorsCount'], - 'purchaseOrdersCount' => $procStats['purchaseOrdersCount'], - 'warehousesCount' => $invStats['warehousesCount'], - 'totalInventoryValue' => $invStats['totalInventoryQuantity'], // 原本前端命名是 totalInventoryValue 但實作是 Quantity,暫且保留欄位名以不破壞前端 - 'pendingOrdersCount' => $procStats['pendingOrdersCount'], - 'lowStockCount' => $invStats['lowStockCount'], - ]; return Inertia::render('Dashboard', [ - 'stats' => $stats, + 'stats' => [ + 'totalItems' => $invStats['productsCount'], + 'lowStockCount' => $invStats['lowStockCount'], + 'negativeCount' => $invStats['negativeCount'] ?? 0, + 'expiringCount' => $invStats['expiringCount'] ?? 0, + ], + 'abnormalItems' => $invStats['abnormalItems'] ?? [], ]); } } + diff --git a/app/Modules/Inventory/Contracts/InventoryServiceInterface.php b/app/Modules/Inventory/Contracts/InventoryServiceInterface.php index 30ae0de..f71053d 100644 --- a/app/Modules/Inventory/Contracts/InventoryServiceInterface.php +++ b/app/Modules/Inventory/Contracts/InventoryServiceInterface.php @@ -116,6 +116,15 @@ interface InventoryServiceInterface */ public function findInventoryByBatch(int $warehouseId, int $productId, ?string $batchNumber); + /** + * 取得即時庫存查詢資料(含統計卡片 + 分頁明細)。 + * + * @param array $filters 篩選條件 + * @param int $perPage 每頁筆數 + * @return array + */ + public function getStockQueryData(array $filters = [], int $perPage = 10): array; + /** * Get statistics for the dashboard. * diff --git a/app/Modules/Inventory/Controllers/InventoryController.php b/app/Modules/Inventory/Controllers/InventoryController.php index aefc5ce..8acc81b 100644 --- a/app/Modules/Inventory/Controllers/InventoryController.php +++ b/app/Modules/Inventory/Controllers/InventoryController.php @@ -295,7 +295,8 @@ class InventoryController extends Controller 'originCountry' => $inventory->origin_country, 'expiryDate' => $inventory->expiry_date ? $inventory->expiry_date->format('Y-m-d') : null, 'quantity' => (float) $inventory->quantity, - 'unitCost' => (float) $inventory->unit_cost, // 新增 + 'unitCost' => (float) $inventory->unit_cost, + 'location' => $inventory->location, ]; }); diff --git a/app/Modules/Inventory/Controllers/StockQueryController.php b/app/Modules/Inventory/Controllers/StockQueryController.php new file mode 100644 index 0000000..b797b8e --- /dev/null +++ b/app/Modules/Inventory/Controllers/StockQueryController.php @@ -0,0 +1,59 @@ +inventoryService = $inventoryService; + } + + /** + * 即時庫存查詢頁面 + */ + public function index(Request $request) + { + $filters = $request->only(['warehouse_id', 'category_id', 'search', 'status', 'sort_by', 'sort_order', 'per_page']); + $perPage = (int) ($filters['per_page'] ?? 10); + + $result = $this->inventoryService->getStockQueryData($filters, $perPage); + + return Inertia::render('Inventory/StockQuery/Index', [ + 'filters' => $filters, + 'summary' => $result['summary'], + 'inventories' => [ + 'data' => $result['data'], + 'total' => $result['pagination']['total'], + 'per_page' => $result['pagination']['per_page'], + 'current_page' => $result['pagination']['current_page'], + 'last_page' => $result['pagination']['last_page'], + 'links' => $result['pagination']['links'], + ], + 'warehouses' => Warehouse::select('id', 'name')->orderBy('name')->get(), + 'categories' => Category::select('id', 'name')->orderBy('name')->get(), + ]); + } + + /** + * Excel 匯出 + */ + public function export(Request $request) + { + $filters = $request->only(['warehouse_id', 'category_id', 'search', 'status']); + + return \Maatwebsite\Excel\Facades\Excel::download( + new \App\Modules\Inventory\Exports\StockQueryExport($filters), + '即時庫存查詢_' . now()->format('Ymd_His') . '.xlsx' + ); + } +} diff --git a/app/Modules/Inventory/Exports/StockQueryExport.php b/app/Modules/Inventory/Exports/StockQueryExport.php new file mode 100644 index 0000000..9da737d --- /dev/null +++ b/app/Modules/Inventory/Exports/StockQueryExport.php @@ -0,0 +1,167 @@ +filters = $filters; + } + + public function collection() + { + $today = now()->toDateString(); + $expiryThreshold = now()->addDays(30)->toDateString(); + + $query = Inventory::query() + ->join('products', 'inventories.product_id', '=', 'products.id') + ->join('warehouses', 'inventories.warehouse_id', '=', 'warehouses.id') + ->leftJoin('categories', 'products.category_id', '=', 'categories.id') + ->leftJoin('warehouse_product_safety_stocks as ss', function ($join) { + $join->on('inventories.warehouse_id', '=', 'ss.warehouse_id') + ->on('inventories.product_id', '=', 'ss.product_id'); + }) + ->whereNull('inventories.deleted_at') + ->select([ + 'inventories.id', + 'inventories.quantity', + 'inventories.batch_number', + 'inventories.expiry_date', + 'inventories.quality_status', + 'products.code as product_code', + 'products.name as product_name', + 'categories.name as category_name', + 'warehouses.name as warehouse_name', + 'ss.safety_stock', + ]); + + // 篩選 + if (!empty($this->filters['warehouse_id'])) { + $query->where('inventories.warehouse_id', $this->filters['warehouse_id']); + } + if (!empty($this->filters['category_id'])) { + $query->where('products.category_id', $this->filters['category_id']); + } + if (!empty($this->filters['search'])) { + $search = $this->filters['search']; + $query->where(function ($q) use ($search) { + $q->where('products.code', 'like', "%{$search}%") + ->orWhere('products.name', 'like', "%{$search}%"); + }); + } + if (!empty($this->filters['status'])) { + switch ($this->filters['status']) { + case 'low_stock': + $query->whereNotNull('ss.safety_stock') + ->whereRaw('inventories.quantity <= ss.safety_stock') + ->where('inventories.quantity', '>=', 0); + break; + case 'negative': + $query->where('inventories.quantity', '<', 0); + break; + case 'expiring': + $query->whereNotNull('inventories.expiry_date') + ->where('inventories.expiry_date', '>', $today) + ->where('inventories.expiry_date', '<=', $expiryThreshold); + break; + case 'expired': + $query->whereNotNull('inventories.expiry_date') + ->where('inventories.expiry_date', '<=', $today); + break; + case 'abnormal': + $query->where(function ($q) use ($today, $expiryThreshold) { + $q->where('inventories.quantity', '<', 0) + ->orWhere(function ($q2) { + $q2->whereNotNull('ss.safety_stock') + ->whereRaw('inventories.quantity <= ss.safety_stock'); + }) + ->orWhere(function ($q2) use ($expiryThreshold) { + $q2->whereNotNull('inventories.expiry_date') + ->where('inventories.expiry_date', '<=', $expiryThreshold); + }); + }); + break; + } + } + + return $query->orderBy('products.code', 'asc')->get(); + } + + public function headings(): array + { + return [ + '商品代碼', + '商品名稱', + '分類', + '倉庫', + '批號', + '數量', + '安全庫存', + '到期日', + '品質狀態', + '狀態', + ]; + } + + public function map($row): array + { + $today = now()->toDateString(); + $expiryThreshold = now()->addDays(30)->toDateString(); + + $statuses = []; + if ($row->quantity < 0) { + $statuses[] = '負庫存'; + } + if ($row->safety_stock !== null && $row->quantity <= $row->safety_stock && $row->quantity >= 0) { + $statuses[] = '低庫存'; + } + if ($row->expiry_date) { + if ($row->expiry_date <= $today) { + $statuses[] = '已過期'; + } elseif ($row->expiry_date <= $expiryThreshold) { + $statuses[] = '即將過期'; + } + } + if (empty($statuses)) { + $statuses[] = '正常'; + } + + $qualityLabels = [ + 'normal' => '正常', + 'inspecting' => '檢驗中', + 'rejected' => '不合格', + ]; + + return [ + $row->product_code, + $row->product_name, + $row->category_name ?? '-', + $row->warehouse_name, + $row->batch_number ?? '-', + $row->quantity, + $row->safety_stock ?? '-', + $row->expiry_date ?? '-', + $qualityLabels[$row->quality_status] ?? $row->quality_status ?? '-', + implode('、', $statuses), + ]; + } + + public function styles(Worksheet $sheet): array + { + return [ + 1 => ['font' => ['bold' => true]], + ]; + } +} diff --git a/app/Modules/Inventory/Routes/web.php b/app/Modules/Inventory/Routes/web.php index c091dc4..f5381ab 100644 --- a/app/Modules/Inventory/Routes/web.php +++ b/app/Modules/Inventory/Routes/web.php @@ -11,8 +11,15 @@ use App\Modules\Inventory\Controllers\TransferOrderController; use App\Modules\Inventory\Controllers\CountDocController; use App\Modules\Inventory\Controllers\AdjustDocController; +use App\Modules\Inventory\Controllers\StockQueryController; + Route::middleware('auth')->group(function () { + // 即時庫存查詢 + Route::middleware('permission:inventory.view')->group(function () { + Route::get('/inventory/stock-query', [StockQueryController::class, 'index'])->name('inventory.stock-query.index'); + Route::get('/inventory/stock-query/export', [StockQueryController::class, 'export'])->name('inventory.stock-query.export'); + }); // 類別管理 (用於商品對話框) - 需要商品權限 Route::middleware('permission:products.view')->group(function () { Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index'); diff --git a/app/Modules/Inventory/Services/InventoryService.php b/app/Modules/Inventory/Services/InventoryService.php index 3b57abb..451bf60 100644 --- a/app/Modules/Inventory/Services/InventoryService.php +++ b/app/Modules/Inventory/Services/InventoryService.php @@ -229,9 +229,137 @@ class InventoryService implements InventoryServiceInterface ->first(); } - public function getDashboardStats(): array + /** + * 即時庫存查詢:統計卡片 + 分頁明細 + */ + public function getStockQueryData(array $filters = [], int $perPage = 10): array { - // 庫存總表 join 安全庫存表,計算低庫存 + $today = now()->toDateString(); + $expiryThreshold = now()->addDays(30)->toDateString(); + + // 基礎查詢 + $query = Inventory::query() + ->join('products', 'inventories.product_id', '=', 'products.id') + ->join('warehouses', 'inventories.warehouse_id', '=', 'warehouses.id') + ->leftJoin('categories', 'products.category_id', '=', 'categories.id') + ->leftJoin('warehouse_product_safety_stocks as ss', function ($join) { + $join->on('inventories.warehouse_id', '=', 'ss.warehouse_id') + ->on('inventories.product_id', '=', 'ss.product_id'); + }) + ->whereNull('inventories.deleted_at') + ->select([ + 'inventories.id', + 'inventories.warehouse_id', + 'inventories.product_id', + 'inventories.quantity', + 'inventories.batch_number', + 'inventories.expiry_date', + 'inventories.location', + 'inventories.quality_status', + 'inventories.arrival_date', + 'products.code as product_code', + 'products.name as product_name', + 'categories.name as category_name', + 'warehouses.name as warehouse_name', + 'ss.safety_stock', + ]); + + // 篩選:倉庫 + if (!empty($filters['warehouse_id'])) { + $query->where('inventories.warehouse_id', $filters['warehouse_id']); + } + + // 篩選:分類 + if (!empty($filters['category_id'])) { + $query->where('products.category_id', $filters['category_id']); + } + + // 篩選:關鍵字(商品代碼或名稱) + if (!empty($filters['search'])) { + $search = $filters['search']; + $query->where(function ($q) use ($search) { + $q->where('products.code', 'like', "%{$search}%") + ->orWhere('products.name', 'like', "%{$search}%"); + }); + } + + // 篩選:狀態 (改為對齊聚合統計的判斷標準) + if (!empty($filters['status'])) { + switch ($filters['status']) { + case 'low_stock': + $query->whereIn(DB::raw('(inventories.warehouse_id, inventories.product_id)'), function ($sub) { + $sub->select('i2.warehouse_id', 'i2.product_id') + ->from('inventories as i2') + ->join('warehouse_product_safety_stocks as ss2', function ($join) { + $join->on('i2.warehouse_id', '=', 'ss2.warehouse_id') + ->on('i2.product_id', '=', 'ss2.product_id'); + }) + ->whereNull('i2.deleted_at') + ->groupBy('i2.warehouse_id', 'i2.product_id', 'ss2.safety_stock') + ->havingRaw('SUM(i2.quantity) <= ss2.safety_stock'); + }); + break; + case 'negative': + $query->whereIn(DB::raw('(inventories.warehouse_id, inventories.product_id)'), function ($sub) { + $sub->select('i2.warehouse_id', 'i2.product_id') + ->from('inventories as i2') + ->whereNull('i2.deleted_at') + ->groupBy('i2.warehouse_id', 'i2.product_id') + ->havingRaw('SUM(i2.quantity) < 0'); + }); + break; + case 'expiring': + $query->whereNotNull('inventories.expiry_date') + ->where('inventories.expiry_date', '>', $today) + ->where('inventories.expiry_date', '<=', $expiryThreshold); + break; + case 'expired': + $query->whereNotNull('inventories.expiry_date') + ->where('inventories.expiry_date', '<=', $today); + break; + case 'abnormal': + // 只要該「倉庫-品項」對應的總庫存有低庫存、負庫存,或該批次已過期/即將過期 + $query->where(function ($q) use ($today, $expiryThreshold) { + // 1. 低庫存或負庫存 (依聚合判斷) + $q->whereIn(DB::raw('(inventories.warehouse_id, inventories.product_id)'), function ($sub) { + $sub->select('i3.warehouse_id', 'i3.product_id') + ->from('inventories as i3') + ->leftJoin('warehouse_product_safety_stocks as ss3', function ($join) { + $join->on('i3.warehouse_id', '=', 'ss3.warehouse_id') + ->on('i3.product_id', '=', 'ss3.product_id'); + }) + ->whereNull('i3.deleted_at') + ->groupBy('i3.warehouse_id', 'i3.product_id', 'ss3.safety_stock') + ->havingRaw('SUM(i3.quantity) < 0 OR (ss3.safety_stock IS NOT NULL AND SUM(i3.quantity) <= ss3.safety_stock)'); + }) + // 2. 或該批次效期異常 + ->orWhere(function ($q_batch) use ($expiryThreshold) { + $q_batch->whereNotNull('inventories.expiry_date') + ->where('inventories.expiry_date', '<=', $expiryThreshold); + }); + }); + break; + } + } + + // 排序 + $sortBy = $filters['sort_by'] ?? 'products.code'; + $sortOrder = $filters['sort_order'] ?? 'asc'; + $allowedSorts = ['products.code', 'products.name', 'warehouses.name', 'inventories.quantity', 'inventories.expiry_date']; + if (in_array($sortBy, $allowedSorts)) { + $query->orderBy($sortBy, $sortOrder); + } else { + $query->orderBy('products.code', 'asc'); + } + + // 統計卡片(無篩選條件下的全域統計) + // 1. 庫存品項數:在庫的「倉庫-商品」對總數 + $totalItems = DB::table('inventories') + ->whereNull('deleted_at') + ->distinct() + ->count(DB::raw('CONCAT(warehouse_id, "-", product_id)')); + + // 2. 低庫存:以「倉庫-商品」聚合後的總量與安全庫存比較 (品項計數) $lowStockCount = DB::table('warehouse_product_safety_stocks as ss') ->join(DB::raw('(SELECT warehouse_id, product_id, SUM(quantity) as total_qty FROM inventories WHERE deleted_at IS NULL GROUP BY warehouse_id, product_id) as inv'), function ($join) { @@ -241,11 +369,200 @@ class InventoryService implements InventoryServiceInterface ->whereRaw('inv.total_qty <= ss.safety_stock') ->count(); + // 3. 負庫存:只要該「倉庫-商品」的總庫存為負數 (品項計數) + $negativeCount = DB::table(DB::raw('(SELECT warehouse_id, product_id, SUM(quantity) as total_qty FROM inventories WHERE deleted_at IS NULL GROUP BY warehouse_id, product_id) as inv')) + ->where('total_qty', '<', 0) + ->count(); + + // 4. 即將過期:有任一批次效期符合的「品項」總數 + $expiringCount = DB::table('inventories') + ->whereNull('deleted_at') + ->whereNotNull('expiry_date') + ->where('expiry_date', '<=', $expiryThreshold) + ->distinct() + ->count(DB::raw('CONCAT(warehouse_id, "-", product_id)')); + + // 分頁 + $paginated = $query->paginate($perPage)->withQueryString(); + + // 為每筆紀錄附加最後入庫/出庫時間 + 狀態 + $items = collect($paginated->items())->map(function ($item) use ($today, $expiryThreshold) { + $lastIn = \App\Modules\Inventory\Models\InventoryTransaction::where('inventory_id', $item->id) + ->where('type', '入庫') + ->orderByDesc('actual_time') + ->value('actual_time'); + + $lastOut = \App\Modules\Inventory\Models\InventoryTransaction::where('inventory_id', $item->id) + ->where('type', '出庫') + ->orderByDesc('actual_time') + ->value('actual_time'); + + // 計算狀態 (明細表格依然呈現該批次的狀態) + $statuses = []; + if ($item->quantity < 0) { + $statuses[] = 'negative'; + } + if ($item->safety_stock !== null && $item->quantity <= $item->safety_stock && $item->quantity >= 0) { + $statuses[] = 'low_stock'; + } + if ($item->expiry_date) { + if ($item->expiry_date <= $today) { + $statuses[] = 'expired'; + } elseif ($item->expiry_date <= $expiryThreshold) { + $statuses[] = 'expiring'; + } + } + if (empty($statuses)) { + $statuses[] = 'normal'; + } + + return [ + 'id' => $item->id, + 'product_code' => $item->product_code, + 'product_name' => $item->product_name, + 'category_name' => $item->category_name, + 'warehouse_name' => $item->warehouse_name, + 'batch_number' => $item->batch_number, + 'quantity' => $item->quantity, + 'safety_stock' => $item->safety_stock, + 'expiry_date' => $item->expiry_date ? \Carbon\Carbon::parse($item->expiry_date)->toDateString() : null, + 'location' => $item->location, + 'quality_status' => $item->quality_status, + 'last_inbound' => $lastIn ? \Carbon\Carbon::parse($lastIn)->toDateString() : null, + 'last_outbound' => $lastOut ? \Carbon\Carbon::parse($lastOut)->toDateString() : null, + 'statuses' => $statuses, + ]; + }); + return [ - 'productsCount' => Product::count(), + 'summary' => [ + 'totalItems' => $totalItems, + 'lowStockCount' => $lowStockCount, + 'negativeCount' => $negativeCount, + 'expiringCount' => $expiringCount, + ], + 'data' => $items->toArray(), + 'pagination' => [ + 'total' => $paginated->total(), + 'per_page' => $paginated->perPage(), + 'current_page' => $paginated->currentPage(), + 'last_page' => $paginated->lastPage(), + 'links' => $paginated->linkCollection()->toArray(), + ], + ]; + } + + public function getDashboardStats(): array + { + $today = now()->toDateString(); + $expiryThreshold = now()->addDays(30)->toDateString(); + + // 1. 庫存品項數 (Unique Product-Warehouse) + $totalItems = DB::table('inventories') + ->whereNull('deleted_at') + ->distinct() + ->count(DB::raw('CONCAT(warehouse_id, "-", product_id)')); + + // 2. 低庫存 (品項計數) + $lowStockCount = DB::table('warehouse_product_safety_stocks as ss') + ->join(DB::raw('(SELECT warehouse_id, product_id, SUM(quantity) as total_qty FROM inventories WHERE deleted_at IS NULL GROUP BY warehouse_id, product_id) as inv'), + function ($join) { + $join->on('ss.warehouse_id', '=', 'inv.warehouse_id') + ->on('ss.product_id', '=', 'inv.product_id'); + }) + ->whereRaw('inv.total_qty <= ss.safety_stock') + ->count(); + + // 3. 負庫存 (品項計數) + $negativeCount = DB::table(DB::raw('(SELECT warehouse_id, product_id, SUM(quantity) as total_qty FROM inventories WHERE deleted_at IS NULL GROUP BY warehouse_id, product_id) as inv')) + ->where('total_qty', '<', 0) + ->count(); + + // 4. 即將過期 (品項計數) + $expiringCount = DB::table('inventories') + ->whereNull('deleted_at') + ->whereNotNull('expiry_date') + ->where('expiry_date', '<=', $expiryThreshold) + ->distinct() + ->count(DB::raw('CONCAT(warehouse_id, "-", product_id)')); + + // 異常庫存前 10 筆 (明細面依然以個別批次為主,供快速跳轉) + $abnormalItems = Inventory::query() + ->join('products', 'inventories.product_id', '=', 'products.id') + ->join('warehouses', 'inventories.warehouse_id', '=', 'warehouses.id') + ->leftJoin('warehouse_product_safety_stocks as ss', function ($join) { + $join->on('inventories.warehouse_id', '=', 'ss.warehouse_id') + ->on('inventories.product_id', '=', 'ss.product_id'); + }) + ->whereNull('inventories.deleted_at') + ->where(function ($q) use ($today, $expiryThreshold) { + // 1. 屬於低庫存或負庫存品項的批次 + $q->whereIn(DB::raw('(inventories.warehouse_id, inventories.product_id)'), function ($sub) { + $sub->select('i3.warehouse_id', 'i3.product_id') + ->from('inventories as i3') + ->leftJoin('warehouse_product_safety_stocks as ss3', function ($join) { + $join->on('i3.warehouse_id', '=', 'ss3.warehouse_id') + ->on('i3.product_id', '=', 'ss3.product_id'); + }) + ->whereNull('i3.deleted_at') + ->groupBy('i3.warehouse_id', 'i3.product_id', 'ss3.safety_stock') + ->havingRaw('SUM(i3.quantity) < 0 OR (ss3.safety_stock IS NOT NULL AND SUM(i3.quantity) <= ss3.safety_stock)'); + }) + // 2. 或單一批次效期異常 + ->orWhere(function ($q2) use ($expiryThreshold) { + $q2->whereNotNull('inventories.expiry_date') + ->where('inventories.expiry_date', '<=', $expiryThreshold); + }); + }) + ->select([ + 'inventories.id', + 'inventories.quantity', + 'inventories.expiry_date', + 'products.code as product_code', + 'products.name as product_name', + 'warehouses.name as warehouse_name', + 'ss.safety_stock', + ]) + ->orderBy('inventories.id', 'desc') + ->limit(10) + ->get() + ->map(function ($item) use ($today, $expiryThreshold) { + $statuses = []; + if ($item->quantity < 0) { + $statuses[] = 'negative'; + } + if ($item->safety_stock !== null && $item->quantity <= $item->safety_stock && $item->quantity >= 0) { + $statuses[] = 'low_stock'; + } + if ($item->expiry_date) { + if ($item->expiry_date <= $today) { + $statuses[] = 'expired'; + } elseif ($item->expiry_date <= $expiryThreshold) { + $statuses[] = 'expiring'; + } + } + return [ + 'id' => $item->id, + 'product_code' => $item->product_code, + 'product_name' => $item->product_name, + 'warehouse_name' => $item->warehouse_name, + 'quantity' => $item->quantity, + 'safety_stock' => $item->safety_stock, + 'expiry_date' => $item->expiry_date, + 'statuses' => $statuses, + ]; + }) + ->toArray(); + + return [ + 'productsCount' => $totalItems, 'warehousesCount' => Warehouse::count(), 'lowStockCount' => $lowStockCount, + 'negativeCount' => $negativeCount, + 'expiringCount' => $expiringCount, 'totalInventoryQuantity' => Inventory::sum('quantity'), + 'abnormalItems' => $abnormalItems, ]; } } + diff --git a/resources/js/Components/ui/searchable-select.tsx b/resources/js/Components/ui/searchable-select.tsx index 1ac8362..8e411b9 100644 --- a/resources/js/Components/ui/searchable-select.tsx +++ b/resources/js/Components/ui/searchable-select.tsx @@ -104,7 +104,7 @@ export function SearchableSelect({ {options.map((option) => ( { onValueChange(option.value); setOpen(false); diff --git a/resources/js/Layouts/AuthenticatedLayout.tsx b/resources/js/Layouts/AuthenticatedLayout.tsx index 8203b3e..579caa0 100644 --- a/resources/js/Layouts/AuthenticatedLayout.tsx +++ b/resources/js/Layouts/AuthenticatedLayout.tsx @@ -88,6 +88,13 @@ export default function AuthenticatedLayout({ icon: , permission: ["products.view", "warehouses.view", "inventory.view"], // 滿足任一即可看到此群組 children: [ + { + id: "stock-query", + label: "即時庫存查詢", + icon: , + route: "/inventory/stock-query", + permission: "inventory.view", + }, { id: "product-management", label: "商品資料管理", diff --git a/resources/js/Pages/Dashboard.tsx b/resources/js/Pages/Dashboard.tsx index baab9f3..5ba6253 100644 --- a/resources/js/Pages/Dashboard.tsx +++ b/resources/js/Pages/Dashboard.tsx @@ -1,211 +1,259 @@ -import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; -import { Link, Head, usePage } from '@inertiajs/react'; -import { PageProps } from '@/types/global'; -import { cn } from "@/lib/utils"; +import { Head, Link } from "@inertiajs/react"; +import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; import { Package, - Users, - ShoppingCart, - Warehouse as WarehouseIcon, AlertTriangle, + MinusCircle, Clock, - TrendingUp, - ChevronRight -} from 'lucide-react'; + ArrowRight, + LayoutDashboard, +} from "lucide-react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/Components/ui/table"; +import { Badge } from "@/Components/ui/badge"; +import { Button } from "@/Components/ui/button"; -interface Stats { - productsCount: number; - vendorsCount: number; - purchaseOrdersCount: number; - warehousesCount: number; - totalInventoryValue: number; - pendingOrdersCount: number; - lowStockCount: number; +interface AbnormalItem { + id: number; + product_code: string; + product_name: string; + warehouse_name: string; + quantity: number; + safety_stock: number | null; + expiry_date: string | null; + statuses: string[]; } interface Props { - stats: Stats; + stats: { + totalItems: number; + lowStockCount: number; + negativeCount: number; + expiringCount: number; + }; + abnormalItems: AbnormalItem[]; } -export default function Dashboard({ stats }: Props) { - const { branding } = usePage().props; - const cardData = [ - { - label: '商品總數', - value: stats.productsCount, - icon: , - description: '目前系統中的商品種類', - color: 'bg-primary-main/10', - }, - { - label: '合作廠商', - value: stats.vendorsCount, - icon: , - description: '已建立資料的供應商', - color: 'bg-blue-50', - }, - { - label: '採購單據', - value: stats.purchaseOrdersCount, - icon: , - description: '歷年累計採購單數量', - color: 'bg-purple-50', - }, - { - label: '倉庫站點', - value: stats.warehousesCount, - icon: , - description: '目前營運中的倉庫環境', - color: 'bg-orange-50', - }, - ]; +// 狀態 Badge 映射 +const statusConfig: Record = { + negative: { + label: "負庫存", + className: "bg-red-100 text-red-800 border-red-200", + }, + low_stock: { + label: "低庫存", + className: "bg-amber-100 text-amber-800 border-amber-200", + }, + expiring: { + label: "即將過期", + className: "bg-yellow-100 text-yellow-800 border-yellow-200", + }, + expired: { + label: "已過期", + className: "bg-red-100 text-red-800 border-red-200", + }, +}; - const alertData = [ +export default function Dashboard({ stats, abnormalItems }: Props) { + const cards = [ { - label: '待處理採購單', - value: stats.pendingOrdersCount, - icon: , - status: stats.pendingOrdersCount > 0 ? 'warning' : 'normal', + label: "庫存品項數", + value: stats.totalItems, + icon: , + color: "text-primary-main", + bgColor: "bg-primary-lightest", + borderColor: "border-primary-light", + href: "/inventory/stock-query", }, { - label: '低庫存警示', + label: "低庫存", value: stats.lowStockCount, - icon: , - status: stats.lowStockCount > 0 ? 'error' : 'normal', + icon: , + color: "text-amber-600", + bgColor: "bg-amber-50", + borderColor: "border-amber-200", + href: "/inventory/stock-query?status=low_stock", + alert: stats.lowStockCount > 0, + }, + { + label: "負庫存", + value: stats.negativeCount, + icon: , + color: "text-red-600", + bgColor: "bg-red-50", + borderColor: "border-red-200", + href: "/inventory/stock-query?status=negative", + alert: stats.negativeCount > 0, + }, + { + label: "即將過期", + value: stats.expiringCount, + icon: , + color: "text-yellow-600", + bgColor: "bg-yellow-50", + borderColor: "border-yellow-200", + href: "/inventory/stock-query?status=expiring", + alert: stats.expiringCount > 0, }, ]; return ( - - + + -
-
+
+ {/* 頁面標題 */} +

- - 系統總覽 + + 庫存總覽

-

歡迎回來,這是您的 {branding?.short_name || 'Star'} ERP 營運數據概況。

+

+ 即時掌握庫存狀態,異常情況一目了然 +

- {/* 主要數據卡片 */} -
- {cardData.map((card, index) => ( -
-
-
- {card.icon} + {/* 統計卡片 */} +
+ {cards.map((card) => ( + +
+ {card.alert && ( + + )} +
+
+ {card.icon} +
+ + {card.label} +
- - 統計中 - -
-
-

{card.label}

-
- {card.value} +
+ {card.value.toLocaleString()}
-

{card.description}

-
+ ))}
-
- {/* 警示與通知 */} -
-

- - 即時動態 + {/* 異常庫存清單 */} +
+
+

+ + 異常庫存清單

-
- {alertData.map((alert, index) => ( -
-
-
- {alert.icon} -
-
-

{alert.label}

-

需立即查看

-
-
-
- - {alert.value} - - -
-
- ))} -
- -
-

系統提示

-

- 目前系統運行正常。如有任何問題,請聯絡開發團隊獲取支援。 -

-
+ + +
- {/* 快速捷徑 */} -
-

快速操作

-
- -
-
-

商品管理

-

查看並編輯所有商品資料與單位換算。

-
-
- 即刻前往 -
-
- - -
-
-

採購單管理

-

處理進貨、追蹤採購進度與管理單據。

-
-
- 即刻前往 -
-
- - -
-
-

廠商管理

-

管理供應商聯絡資訊與供貨清單。

-
-
- 即刻前往 -
-
- - -
-
-

倉庫與庫存

-

管理入庫、庫存水位與基礎設施。

-
-
- 即刻前往 -
-
- -
-
+ + + + + # + + 商品代碼 + 商品名稱 + 倉庫 + + 數量 + + + 狀態 + + + + + {abnormalItems.length === 0 ? ( + + + 🎉 目前沒有異常庫存,一切正常! + + + ) : ( + abnormalItems.map((item, index) => ( + + + {index + 1} + + + {item.product_code} + + + {item.product_name} + + + {item.warehouse_name} + + + {item.quantity} + + +
+ {item.statuses.map( + (status) => { + const config = + statusConfig[ + status + ]; + if (!config) + return null; + return ( + + {config.label} + + ); + } + )} +
+
+
+ )) + )} +
+

diff --git a/resources/js/Pages/Inventory/StockQuery/Index.tsx b/resources/js/Pages/Inventory/StockQuery/Index.tsx new file mode 100644 index 0000000..3d609af --- /dev/null +++ b/resources/js/Pages/Inventory/StockQuery/Index.tsx @@ -0,0 +1,582 @@ +import { useState } from "react"; +import { Head, router } from "@inertiajs/react"; +import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; +import { + Search, + Package, + AlertTriangle, + MinusCircle, + Clock, + Download, + ArrowUpDown, + ArrowUp, + ArrowDown, +} from "lucide-react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/Components/ui/table"; +import { Badge } from "@/Components/ui/badge"; +import { Button } from "@/Components/ui/button"; +import { Input } from "@/Components/ui/input"; +import { SearchableSelect } from "@/Components/ui/searchable-select"; +import Pagination from "@/Components/shared/Pagination"; + +interface InventoryItem { + id: number; + product_code: string; + product_name: string; + category_name: string | null; + warehouse_name: string; + batch_number: string | null; + quantity: number; + safety_stock: number | null; + expiry_date: string | null; + quality_status: string | null; + last_inbound: string | null; + last_outbound: string | null; + statuses: string[]; +} + +interface PaginationLink { + url: string | null; + label: string; + active: boolean; +} + +interface Props { + filters: { + warehouse_id?: string; + category_id?: string; + search?: string; + status?: string; + sort_by?: string; + sort_order?: string; + per_page?: string; + }; + summary: { + totalItems: number; + lowStockCount: number; + negativeCount: number; + expiringCount: number; + }; + inventories: { + data: InventoryItem[]; + total: number; + per_page: number; + current_page: number; + last_page: number; + links: PaginationLink[]; + }; + warehouses: { id: number; name: string }[]; + categories: { id: number; name: string }[]; +} + +// 狀態 Badge +const statusConfig: Record< + string, + { label: string; className: string } +> = { + normal: { + label: "正常", + className: "bg-green-100 text-green-800 border-green-200", + }, + negative: { + label: "負庫存", + className: "bg-red-100 text-red-800 border-red-200", + }, + low_stock: { + label: "低庫存", + className: "bg-amber-100 text-amber-800 border-amber-200", + }, + expiring: { + label: "即將過期", + className: "bg-yellow-100 text-yellow-800 border-yellow-200", + }, + expired: { + label: "已過期", + className: "bg-red-100 text-red-800 border-red-200", + }, +}; + +// 狀態篩選選項 +const statusOptions = [ + { label: "全部狀態", value: "" }, + { label: "低庫存", value: "low_stock" }, + { label: "負庫存", value: "negative" }, + { label: "即將過期", value: "expiring" }, + { label: "已過期", value: "expired" }, + { label: "所有異常", value: "abnormal" }, +]; + +export default function StockQueryIndex({ + filters, + summary, + inventories, + warehouses, + categories, +}: Props) { + const [search, setSearch] = useState(filters.search || ""); + const [perPage, setPerPage] = useState( + filters.per_page || "10" + ); + + // 執行篩選 + const applyFilters = (newFilters: Record) => { + const merged = { ...filters, ...newFilters, page: undefined }; + // 移除空值 + const cleaned: Record = {}; + Object.entries(merged).forEach(([key, value]) => { + if (value !== undefined && value !== "" && value !== null) { + cleaned[key] = String(value); + } + }); + router.get(route("inventory.stock-query.index"), cleaned, { + preserveState: true, + replace: true, + }); + }; + + // 搜尋 + const handleSearch = () => { + applyFilters({ search: search || undefined }); + }; + + const handleSearchKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleSearch(); + } + }; + + // 排序 + const handleSort = (field: string) => { + let newSortBy: string | undefined = field; + let newSortOrder: string | undefined = "asc"; + + if (filters.sort_by === field) { + if (filters.sort_order === "asc") { + newSortOrder = "desc"; + } else { + newSortBy = undefined; + newSortOrder = undefined; + } + } + + applyFilters({ sort_by: newSortBy, sort_order: newSortOrder }); + }; + + // 排序圖標 + const SortIcon = ({ field }: { field: string }) => { + if (filters.sort_by !== field) { + return ( + + ); + } + if (filters.sort_order === "asc") { + return ; + } + return ; + }; + + // 每頁筆數變更 + const handlePerPageChange = (value: string) => { + setPerPage(value); + router.get( + route("inventory.stock-query.index"), + { ...filters, per_page: value, page: undefined }, + { preserveState: false, replace: true, preserveScroll: true } + ); + }; + + // 匯出 + const handleExport = () => { + const params = new URLSearchParams(); + if (filters.warehouse_id) + params.append("warehouse_id", filters.warehouse_id); + if (filters.category_id) + params.append("category_id", filters.category_id); + if (filters.search) params.append("search", filters.search); + if (filters.status) params.append("status", filters.status); + window.location.href = + route("inventory.stock-query.export") + "?" + params.toString(); + }; + + // 計算序號起始值 + const startIndex = + (inventories.current_page - 1) * inventories.per_page + 1; + + // 統計卡片 + const cards = [ + { + label: "庫存品項", + value: summary.totalItems, + icon: , + color: "text-primary-main", + bgColor: "bg-primary-lightest", + borderColor: "border-primary-light", + status: "", + }, + { + label: "低庫存", + value: summary.lowStockCount, + icon: , + color: "text-amber-600", + bgColor: "bg-amber-50", + borderColor: "border-amber-200", + status: "low_stock", + alert: summary.lowStockCount > 0, + }, + { + label: "負庫存", + value: summary.negativeCount, + icon: , + color: "text-red-600", + bgColor: "bg-red-50", + borderColor: "border-red-200", + status: "negative", + alert: summary.negativeCount > 0, + }, + { + label: "即將過期", + value: summary.expiringCount, + icon: , + color: "text-yellow-600", + bgColor: "bg-yellow-50", + borderColor: "border-yellow-200", + status: "expiring", + alert: summary.expiringCount > 0, + }, + ]; + + return ( + + + +
+ {/* 頁面標題 */} +
+
+

+ + 即時庫存查詢 +

+

+ 跨倉庫即時庫存查詢,含批號追蹤與到期管理 +

+
+ +
+ + {/* 統計卡片 */} +
+ {cards.map((card) => ( +
+ applyFilters({ + status: card.status || undefined, + }) + } + className={`relative rounded-xl border ${card.borderColor} ${card.bgColor} p-4 transition-all hover:shadow-md hover:-translate-y-0.5 cursor-pointer ${filters.status === card.status + ? "ring-2 ring-primary-main ring-offset-1" + : "" + }`} + > + {card.alert && ( + + )} +
+
{card.icon}
+ + {card.label} + +
+
+ {card.value.toLocaleString()} +
+
+ ))} +
+ + {/* 篩選列 */} +
+
+ + applyFilters({ + warehouse_id: v || undefined, + }) + } + options={[ + { label: "全部倉庫", value: "" }, + ...warehouses.map((w) => ({ + label: w.name, + value: String(w.id), + })), + ]} + className="w-[160px] h-9" + placeholder="選擇倉庫" + /> + + applyFilters({ + category_id: v || undefined, + }) + } + options={[ + { label: "全部分類", value: "" }, + ...categories.map((c) => ({ + label: c.name, + value: String(c.id), + })), + ]} + className="w-[160px] h-9" + placeholder="選擇分類" + /> + + applyFilters({ status: v || undefined }) + } + options={statusOptions} + className="w-[140px] h-9" + showSearch={false} + placeholder="篩選狀態" + /> +
+ setSearch(e.target.value)} + onKeyDown={handleSearchKeyDown} + placeholder="搜尋商品代碼或名稱..." + className="h-9" + /> + +
+
+
+ + {/* 庫存明細表格 */} +
+ + + + + # + + + + + + + + 分類 + + + + 批號 + 儲位/貨道 + + + + 安全庫存 + + + + + 狀態 + + 最後入庫 + 最後出庫 + + + + {inventories.data.length === 0 ? ( + + + 無符合條件的資料 + + + ) : ( + inventories.data.map((item: InventoryItem, index) => ( + + + {startIndex + index} + + + {item.product_code} + + + {item.product_name} + + + {item.category_name || "—"} + + + {item.warehouse_name} + + + {item.batch_number || "—"} + + + {item.location || "—"} + + + {item.quantity} + + + {item.safety_stock !== null + ? item.safety_stock + : "—"} + + + {item.expiry_date || "—"} + + +
+ {item.statuses.map( + (status) => { + const config = + statusConfig[ + status + ]; + if (!config) + return null; + return ( + + {config.label} + + ); + } + )} +
+
+ + {item.last_inbound || "—"} + + + {item.last_outbound || "—"} + +
+ )) + )} +
+
+
+ + {/* 分頁 */} +
+
+
+ 每頁顯示 + + +
+ + 共 {inventories.total} 筆紀錄 + +
+ +
+
+
+ ); +} diff --git a/resources/js/Pages/Warehouse/AddInventory.tsx b/resources/js/Pages/Warehouse/AddInventory.tsx index c348cff..40d21ff 100644 --- a/resources/js/Pages/Warehouse/AddInventory.tsx +++ b/resources/js/Pages/Warehouse/AddInventory.tsx @@ -43,6 +43,7 @@ interface Batch { expiryDate: string | null; quantity: number; isDeleted?: boolean; + location?: string; } interface Props { @@ -322,7 +323,8 @@ export default function AddInventoryPage({ warehouse, products }: Props) { inventoryId: item.inventoryId, originCountry: item.originCountry, expiryDate: item.expiryDate, - unit_cost: item.unit_cost + unit_cost: item.unit_cost, + location: item.location, }; }) }, { @@ -575,17 +577,25 @@ export default function AddInventoryPage({ warehouse, products }: Props) { batchMode: 'existing', inventoryId: value, originCountry: selectedBatch?.originCountry, - expiryDate: selectedBatch?.expiryDate || undefined + expiryDate: selectedBatch?.expiryDate || undefined, + location: selectedBatch?.location || item.location, }); } }} options={[ { label: "📦 不使用批號 (自動累加)", value: "no_batch" }, { label: "+ 建立新批號", value: "new_batch" }, - ...(batchesCache[item.productId]?.batches || []).map(b => ({ - label: `${b.batchNumber === 'NO-BATCH' ? '(無批號紀錄)' : b.batchNumber} - 庫存: ${b.quantity}`, - value: b.inventoryId - })) + ...(batchesCache[item.productId]?.batches || []).map(b => { + const isNoBatch = b.batchNumber === 'NO-BATCH'; + const showLocation = isNoBatch || warehouse.type === 'vending'; + const locationInfo = (showLocation && b.location) ? ` [${b.location}]` : ''; + const batchLabel = isNoBatch ? '(無批號紀錄)' : b.batchNumber; + + return { + label: `${batchLabel}${locationInfo} - 庫存: ${b.quantity}`, + value: b.inventoryId + }; + }) ]} placeholder="選擇或新增批號" className="border-gray-300"