diff --git a/app/Modules/Core/Controllers/RoleController.php b/app/Modules/Core/Controllers/RoleController.php index 309280b..fe55716 100644 --- a/app/Modules/Core/Controllers/RoleController.php +++ b/app/Modules/Core/Controllers/RoleController.php @@ -184,6 +184,7 @@ class RoleController extends Controller 'inventory_count' => '庫存盤點管理', 'inventory_adjust' => '庫存盤調管理', 'inventory_transfer' => '庫存調撥管理', + 'inventory_report' => '庫存報表', 'vendors' => '廠商資料管理', 'purchase_orders' => '採購單管理', 'goods_receipts' => '進貨單管理', diff --git a/app/Modules/Inventory/Controllers/InventoryController.php b/app/Modules/Inventory/Controllers/InventoryController.php index 8acc81b..b5e090b 100644 --- a/app/Modules/Inventory/Controllers/InventoryController.php +++ b/app/Modules/Inventory/Controllers/InventoryController.php @@ -106,8 +106,8 @@ class InventoryController extends Controller 'batchNumber' => $inv->batch_number ?? 'BATCH-' . $inv->id, 'location' => $inv->location, 'expiryDate' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null, - 'lastInboundDate' => $inv->lastIncomingTransaction ? ($inv->lastIncomingTransaction->actual_time ? $inv->lastIncomingTransaction->actual_time->format('Y-m-d') : $inv->lastIncomingTransaction->created_at->format('Y-m-d')) : null, - 'lastOutboundDate' => $inv->lastOutgoingTransaction ? ($inv->lastOutgoingTransaction->actual_time ? $inv->lastOutgoingTransaction->actual_time->format('Y-m-d') : $inv->lastOutgoingTransaction->created_at->format('Y-m-d')) : null, + 'lastInboundDate' => $inv->lastIncomingTransaction ? ($inv->lastIncomingTransaction->actual_time ? substr($inv->lastIncomingTransaction->actual_time, 0, 10) : $inv->lastIncomingTransaction->created_at->format('Y-m-d')) : null, + 'lastOutboundDate' => $inv->lastOutgoingTransaction ? ($inv->lastOutgoingTransaction->actual_time ? substr($inv->lastOutgoingTransaction->actual_time, 0, 10) : $inv->lastOutgoingTransaction->created_at->format('Y-m-d')) : null, ]; })->values(), ]; @@ -360,7 +360,7 @@ class InventoryController extends Controller 'balanceAfter' => (float) $tx->balance_after, 'reason' => $tx->reason, 'userName' => $user ? $user->name : '系統', // 手動對應 - 'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'), + 'actualTime' => $tx->actual_time ? substr($tx->actual_time, 0, 16) : $tx->created_at->format('Y-m-d H:i'), ]; }); @@ -554,7 +554,7 @@ class InventoryController extends Controller 'balanceAfter' => (float) $balanceAfter, // 顯示該商品在倉庫的累計結餘 'reason' => $tx->reason, 'userName' => $user ? $user->name : '系統', - 'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'), + 'actualTime' => $tx->actual_time ? substr($tx->actual_time, 0, 16) : $tx->created_at->format('Y-m-d H:i'), 'batchNumber' => $tx->inventory?->batch_number ?? '-', // 補上批號資訊 'slot' => $tx->inventory?->location, // 加入貨道資訊 ]; @@ -597,7 +597,7 @@ class InventoryController extends Controller 'balanceAfter' => (float) $tx->balance_after, 'reason' => $tx->reason, 'userName' => $user ? $user->name : '系統', // 手動對應 - 'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'), + 'actualTime' => $tx->actual_time ? substr($tx->actual_time, 0, 16) : $tx->created_at->format('Y-m-d H:i'), 'slot' => $inventory->location, // 加入貨道資訊 ]; }); diff --git a/app/Modules/Inventory/Controllers/InventoryReportController.php b/app/Modules/Inventory/Controllers/InventoryReportController.php new file mode 100644 index 0000000..4359257 --- /dev/null +++ b/app/Modules/Inventory/Controllers/InventoryReportController.php @@ -0,0 +1,86 @@ +reportService = $reportService; + } + + public function index(Request $request) + { + $filters = $request->only([ + 'date_from', 'date_to', 'warehouse_id', 'category_id', 'search', 'per_page' + ]); + + $reportData = $this->reportService->getReportData($filters, $request->input('per_page', 10)); + $summary = $this->reportService->getSummary($filters); + + return Inertia::render('Inventory/Report/Index', [ + 'reportData' => $reportData, + 'summary' => $summary, + 'warehouses' => Warehouse::select('id', 'name')->get(), + 'categories' => Category::select('id', 'name')->get(), + 'filters' => $filters, + ]); + } + + public function export(Request $request) + { + $filters = $request->only([ + 'period', 'date_from', 'date_to', 'warehouse_id', 'category_id', 'search' + ]); + + return Excel::download(new InventoryReportExport($this->reportService, $filters), 'inventory_report_' . date('YmdHis') . '.xlsx'); + } + + public function show(Request $request, $productId) + { + // 明細頁面自身使用的篩選條件 + $filters = $request->only([ + 'date_from', 'date_to', 'warehouse_id' + ]); + + // 報表頁面的完整篩選狀態(用於返回時恢復) + $reportFilters = $request->only([ + 'date_from', 'date_to', 'warehouse_id', + 'category_id', 'search', 'per_page' + ]); + // 將傳入的 report_page 轉回 page 以便 Link 元件正確生成回報表頁的連結 + if ($request->has('report_page')) { + $reportFilters['page'] = $request->input('report_page'); + } + + // 取得商品資訊 (用於顯示標題,含基本單位) + $product = \App\Modules\Inventory\Models\Product::with('baseUnit')->findOrFail($productId); + + $transactions = $this->reportService->getProductDetails($productId, $filters, 20); + + return Inertia::render('Inventory/Report/Show', [ + 'product' => [ + 'id' => $product->id, + 'code' => $product->code, + 'name' => $product->name, + 'unit_name' => $product->baseUnit?->name ?? '-', + ], + 'transactions' => $transactions, + 'filters' => $filters, + 'reportFilters' => $reportFilters, + 'warehouses' => Warehouse::select('id', 'name')->get(), + ]); + } +} diff --git a/app/Modules/Inventory/Exports/InventoryReportExport.php b/app/Modules/Inventory/Exports/InventoryReportExport.php new file mode 100644 index 0000000..2154fe6 --- /dev/null +++ b/app/Modules/Inventory/Exports/InventoryReportExport.php @@ -0,0 +1,61 @@ +service = $service; + $this->filters = $filters; + } + + public function collection() + { + return $this->service->getReportData($this->filters, null); // perPage = null to get all + } + + public function headings(): array + { + return [ + '商品代碼', + '商品名稱', + '分類', + '進貨量', + '出貨量', + '調整量', + '淨變動', + ]; + } + + public function map($row): array + { + return [ + $row->product_code, + $row->product_name, + $row->category_name ?? '-', + $row->inbound_qty, + $row->outbound_qty, + $row->adjust_qty, + $row->net_change, + ]; + } + + public function styles(Worksheet $sheet) + { + return [ + 1 => ['font' => ['bold' => true]], + ]; + } +} diff --git a/app/Modules/Inventory/Models/InventoryTransaction.php b/app/Modules/Inventory/Models/InventoryTransaction.php index 7cfcf73..0f2f25a 100644 --- a/app/Modules/Inventory/Models/InventoryTransaction.php +++ b/app/Modules/Inventory/Models/InventoryTransaction.php @@ -26,7 +26,9 @@ class InventoryTransaction extends Model ]; protected $casts = [ - 'actual_time' => 'datetime', + // actual_time 不做時區轉換,保留原始字串格式(台北時間) + // 原因:資料庫儲存的是台北時間,但 MySQL 時區為 UTC + // 若使用 datetime cast,Laravel 會誤當作 UTC 再轉回台北時間,造成偏移 'unit_cost' => 'decimal:4', ]; diff --git a/app/Modules/Inventory/Routes/web.php b/app/Modules/Inventory/Routes/web.php index f5381ab..32f7d23 100644 --- a/app/Modules/Inventory/Routes/web.php +++ b/app/Modules/Inventory/Routes/web.php @@ -11,6 +11,8 @@ use App\Modules\Inventory\Controllers\TransferOrderController; use App\Modules\Inventory\Controllers\CountDocController; use App\Modules\Inventory\Controllers\AdjustDocController; +use App\Modules\Inventory\Controllers\InventoryReportController; + use App\Modules\Inventory\Controllers\StockQueryController; Route::middleware('auth')->group(function () { @@ -20,6 +22,16 @@ Route::middleware('auth')->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:inventory_report.view')->group(function () { + Route::get('/inventory/report', [InventoryReportController::class, 'index'])->name('inventory.report.index'); + Route::get('/inventory/report/export', [InventoryReportController::class, 'export']) + ->middleware('permission:inventory_report.export') + ->name('inventory.report.export'); + Route::get('/inventory/report/{product}', [InventoryReportController::class, 'show'])->name('inventory.report.show'); + }); + // 類別管理 (用於商品對話框) - 需要商品權限 Route::middleware('permission:products.view')->group(function () { Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index'); diff --git a/app/Modules/Inventory/Services/InventoryReportService.php b/app/Modules/Inventory/Services/InventoryReportService.php new file mode 100644 index 0000000..5b682b6 --- /dev/null +++ b/app/Modules/Inventory/Services/InventoryReportService.php @@ -0,0 +1,228 @@ + Asia/Taipei) + // 日期欄位:Laravel 時區已設為 Asia/Taipei,直接使用 actual_time + $timeColumn = "inventory_transactions.actual_time"; + + // 建立查詢 + // 我們需要針對每個 品項 在選定區間內 進行彙總 + // 來源:inventory_transactions -> inventory -> product + + $query = InventoryTransaction::query() + ->select([ + 'products.code as product_code', + 'products.name as product_name', + 'categories.name as category_name', + 'products.id as product_id', + // 進貨量:type 為 入庫, 手動入庫 (排除 調撥入庫) + DB::raw("SUM(CASE WHEN inventory_transactions.type IN ('入庫', '手動入庫') AND inventory_transactions.quantity > 0 THEN inventory_transactions.quantity ELSE 0 END) as inbound_qty"), + // 出貨量:type 為 出庫 (排除 調撥出庫) (取絕對值) + DB::raw("ABS(SUM(CASE WHEN inventory_transactions.type IN ('出庫') AND inventory_transactions.quantity < 0 THEN inventory_transactions.quantity ELSE 0 END)) as outbound_qty"), + // 調整量:type 為 庫存調整, 手動編輯 + DB::raw("SUM(CASE WHEN inventory_transactions.type IN ('庫存調整', '手動編輯') THEN inventory_transactions.quantity ELSE 0 END) as adjust_qty"), + // 調撥淨額 (隱藏欄位,但包含在 net_change) + // 淨變動:總和 (包含所有類型:進貨、出貨、調整、調撥) + DB::raw("SUM(inventory_transactions.quantity) as net_change"), + ]) + ->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id') + ->join('products', 'inventories.product_id', '=', 'products.id') + ->leftJoin('categories', 'products.category_id', '=', 'categories.id'); + + // 日期篩選:資料庫儲存的是台北時間,直接用字串比對 + if ($dateFrom && $dateTo) { + $query->whereRaw("$timeColumn >= ? AND $timeColumn <= ?", [ + $dateFrom . ' 00:00:00', + $dateTo . ' 23:59:59' + ]); + } elseif ($dateFrom) { + $query->whereRaw("$timeColumn >= ?", [$dateFrom . ' 00:00:00']); + } elseif ($dateTo) { + $query->whereRaw("$timeColumn <= ?", [$dateTo . ' 23:59:59']); + } + + // 應用篩選 + if ($warehouseId) { + $query->where('inventories.warehouse_id', $warehouseId); + } + + if ($categoryId) { + $query->where('products.category_id', $categoryId); + } + + if ($search) { + $query->where(function ($q) use ($search) { + $q->where('products.name', 'like', "%{$search}%") + ->orWhere('products.code', 'like', "%{$search}%"); + }); + } + + // 分組與排序 + $query->groupBy([ + 'products.id', + 'products.code', + 'products.name', + 'categories.name' + ]); + + $query->orderBy('products.code', 'asc'); + + + if ($perPage) { + return $query->paginate($perPage)->withQueryString(); + } + + return $query->get(); + } + + /** + * 取得報表統計數據 (不分頁,針對篩選條件的全量統計) + */ + public function getSummary(array $filters) + { + $dateFrom = $filters['date_from'] ?? null; + $dateTo = $filters['date_to'] ?? null; + $warehouseId = $filters['warehouse_id'] ?? null; + $categoryId = $filters['category_id'] ?? null; + $search = $filters['search'] ?? null; + + // 若無任何篩選條件,直接回傳零值 + if (!$dateFrom && !$dateTo && !$warehouseId && !$categoryId && !$search) { + return (object)[ + 'total_inbound' => 0, + 'total_outbound' => 0, + 'total_adjust' => 0, + 'total_net_change' => 0, + ]; + } + // 日期欄位:Laravel 時區已設為 Asia/Taipei,直接使用 actual_time + $timeColumn = "inventory_transactions.actual_time"; + + $query = InventoryTransaction::query() + ->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id') + ->join('products', 'inventories.product_id', '=', 'products.id') + ->leftJoin('categories', 'products.category_id', '=', 'categories.id'); + + // 日期篩選:資料庫儲存的是台北時間,直接用字串比對 + if ($dateFrom && $dateTo) { + $query->whereRaw("$timeColumn >= ? AND $timeColumn <= ?", [ + $dateFrom . ' 00:00:00', + $dateTo . ' 23:59:59' + ]); + } elseif ($dateFrom) { + $query->whereRaw("$timeColumn >= ?", [$dateFrom . ' 00:00:00']); + } elseif ($dateTo) { + $query->whereRaw("$timeColumn <= ?", [$dateTo . ' 23:59:59']); + } + + if ($warehouseId) { + $query->where('inventories.warehouse_id', $warehouseId); + } + + if ($categoryId) { + $query->where('products.category_id', $categoryId); + } + + if ($search) { + $query->where(function ($q) use ($search) { + $q->where('products.name', 'like', "%{$search}%") + ->orWhere('products.code', 'like', "%{$search}%"); + }); + } + + // 直接聚合所有符合條件的交易 + return $query->select([ + DB::raw("SUM(CASE WHEN inventory_transactions.type IN ('入庫', '手動入庫') AND inventory_transactions.quantity > 0 THEN inventory_transactions.quantity ELSE 0 END) as total_inbound"), + DB::raw("ABS(SUM(CASE WHEN inventory_transactions.type IN ('出庫') AND inventory_transactions.quantity < 0 THEN inventory_transactions.quantity ELSE 0 END)) as total_outbound"), + DB::raw("SUM(CASE WHEN inventory_transactions.type IN ('庫存調整', '手動編輯') THEN inventory_transactions.quantity ELSE 0 END) as total_adjust"), + DB::raw("SUM(inventory_transactions.quantity) as total_net_change"), + ])->first(); + } + + /** + * 取得特定商品的庫存異動明細 + */ + public function getProductDetails($productId, array $filters, ?int $perPage = 20) + { + $dateFrom = $filters['date_from'] ?? null; + $dateTo = $filters['date_to'] ?? null; + $warehouseId = $filters['warehouse_id'] ?? null; + // 日期欄位:Laravel 時區已設為 Asia/Taipei,直接使用 actual_time + $timeColumn = "inventory_transactions.actual_time"; + + $query = InventoryTransaction::query() + ->select([ + 'inventory_transactions.*', + 'inventories.warehouse_id', + 'inventories.batch_number as batch_no', + 'warehouses.name as warehouse_name', + 'users.name as user_name', + 'products.code as product_code', + 'products.name as product_name', + 'units.name as unit_name' + ]) + ->join('inventories', 'inventory_transactions.inventory_id', '=', 'inventories.id') + ->join('products', 'inventories.product_id', '=', 'products.id') + ->leftJoin('units', 'products.base_unit_id', '=', 'units.id') + ->leftJoin('warehouses', 'inventories.warehouse_id', '=', 'warehouses.id') + ->leftJoin('users', 'inventory_transactions.user_id', '=', 'users.id') + ->where('products.id', $productId); + + // 日期篩選:資料庫儲存的是台北時間,直接用字串比對 + if ($dateFrom && $dateTo) { + $query->whereRaw("$timeColumn >= ? AND $timeColumn <= ?", [ + $dateFrom . ' 00:00:00', + $dateTo . ' 23:59:59' + ]); + } elseif ($dateFrom) { + $query->whereRaw("$timeColumn >= ?", [$dateFrom . ' 00:00:00']); + } elseif ($dateTo) { + $query->whereRaw("$timeColumn <= ?", [$dateTo . ' 23:59:59']); + } + + if ($warehouseId) { + $query->where('inventories.warehouse_id', $warehouseId); + } + + // 排序:最新的在最上面 + $query->orderBy('inventory_transactions.actual_time', 'desc') + ->orderBy('inventory_transactions.id', 'desc'); + + return $query->paginate($perPage)->withQueryString(); + } +} diff --git a/app/Modules/Inventory/Services/InventoryService.php b/app/Modules/Inventory/Services/InventoryService.php index 29b952b..c1d562e 100644 --- a/app/Modules/Inventory/Services/InventoryService.php +++ b/app/Modules/Inventory/Services/InventoryService.php @@ -385,10 +385,11 @@ class InventoryService implements InventoryServiceInterface }) ->count(); - // 4. 即將過期明細數 + // 4. 即將過期明細數 (必須排除已過期) $expiringCount = DB::table('inventories') ->whereNull('deleted_at') ->whereNotNull('expiry_date') + ->where('expiry_date', '>', $today) ->where('expiry_date', '<=', $expiryThreshold) ->count(); @@ -467,34 +468,46 @@ class InventoryService implements InventoryServiceInterface $today = now()->toDateString(); $expiryThreshold = now()->addDays(30)->toDateString(); - // 1. 庫存品項數 (Unique Product-Warehouse) + // 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) { - $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) + // 2. 低庫存 (明細計數:只要該明細所屬的「倉庫+商品」總量低於安全庫存,則所有相關明細都計入) + $lowStockCount = DB::table('inventories as i') + ->join('warehouse_product_safety_stocks as ss', function ($join) { + $join->on('i.warehouse_id', '=', 'ss.warehouse_id') + ->on('i.product_id', '=', 'ss.product_id'); + }) + ->whereNull('i.deleted_at') + ->whereIn(DB::raw('(i.warehouse_id, i.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) <= (SELECT safety_stock FROM warehouse_product_safety_stocks WHERE warehouse_id = i2.warehouse_id AND product_id = i2.product_id LIMIT 1)'); + }) ->count(); - // 4. 即將過期 (品項計數) + // 3. 負庫存 (明細計數) + $negativeCount = DB::table('inventories as i') + ->whereNull('i.deleted_at') + ->whereIn(DB::raw('(i.warehouse_id, i.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'); + }) + ->count(); + + // 4. 即將過期 (明細計數) $expiringCount = DB::table('inventories') ->whereNull('deleted_at') ->whereNotNull('expiry_date') + ->where('expiry_date', '>', $today) // 確保不過期 (getStockQueryData 沒加這個但這裡加上以防與 expired 混淆? 不,stock query 是 > today && <= threshold) ->where('expiry_date', '<=', $expiryThreshold) - ->distinct() - ->count(DB::raw('CONCAT(warehouse_id, "-", product_id)')); + ->count(); // 異常庫存前 10 筆 (明細面依然以個別批次為主,供快速跳轉) $abnormalItems = Inventory::query() diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php index 45ea670..c161f66 100644 --- a/database/seeders/PermissionSeeder.php +++ b/database/seeders/PermissionSeeder.php @@ -56,6 +56,10 @@ class PermissionSeeder extends Seeder 'inventory_transfer.edit' => '編輯', 'inventory_transfer.delete' => '刪除', + // 庫存報表 + 'inventory_report.view' => '檢視', + 'inventory_report.export' => '匯出', + // 進貨單管理 'goods_receipts.view' => '檢視', 'goods_receipts.create' => '建立', @@ -153,6 +157,7 @@ class PermissionSeeder extends Seeder 'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete', 'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete', 'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.delete', + 'inventory_report.view', 'inventory_report.export', 'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete', 'delivery_notes.view', 'delivery_notes.create', 'delivery_notes.edit', 'delivery_notes.delete', 'production_orders.view', 'production_orders.create', 'production_orders.edit', 'production_orders.delete', @@ -174,6 +179,8 @@ class PermissionSeeder extends Seeder 'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete', 'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete', 'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.delete', + 'inventory_report.view', 'inventory_report.export', + 'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete', 'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete', 'production_orders.view', 'production_orders.create', 'production_orders.edit', 'warehouses.view', 'warehouses.create', 'warehouses.edit', @@ -197,6 +204,7 @@ class PermissionSeeder extends Seeder 'vendors.view', 'warehouses.view', 'utility_fees.view', + 'inventory_report.view', 'accounting.view', ]); diff --git a/package-lock.json b/package-lock.json index ac5615d..8653fa5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@types/lodash": "^4.17.21", "@vitejs/plugin-react": "^5.1.2", @@ -78,6 +79,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1798,6 +1800,36 @@ } } }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-tooltip": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", @@ -2647,6 +2679,7 @@ "integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2657,6 +2690,7 @@ "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2667,6 +2701,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2774,6 +2809,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2986,7 +3022,8 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/date-fns": { "version": "4.1.0", @@ -3865,6 +3902,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3926,6 +3964,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -3938,6 +3977,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -4430,6 +4470,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz", "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/package.json b/package.json index b26d1ae..b829cb6 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@types/lodash": "^4.17.21", "@vitejs/plugin-react": "^5.1.2", diff --git a/resources/js/Layouts/AuthenticatedLayout.tsx b/resources/js/Layouts/AuthenticatedLayout.tsx index 579caa0..7f63942 100644 --- a/resources/js/Layouts/AuthenticatedLayout.tsx +++ b/resources/js/Layouts/AuthenticatedLayout.tsx @@ -224,7 +224,7 @@ export default function AuthenticatedLayout({ id: "report-management", label: "報表管理", icon: , - permission: "accounting.view", + permission: ["accounting.view", "inventory_report.view"], children: [ { id: "accounting-report", @@ -233,6 +233,13 @@ export default function AuthenticatedLayout({ route: "/accounting-report", permission: "accounting.view", }, + { + id: "inventory-report", + label: "庫存報表", + icon: , + route: "/inventory/report", + permission: "inventory_report.view", + }, ], }, { diff --git a/resources/js/Pages/Dashboard.tsx b/resources/js/Pages/Dashboard.tsx index 5ba6253..8a6c16f 100644 --- a/resources/js/Pages/Dashboard.tsx +++ b/resources/js/Pages/Dashboard.tsx @@ -63,7 +63,7 @@ const statusConfig: Record = { export default function Dashboard({ stats, abnormalItems }: Props) { const cards = [ { - label: "庫存品項數", + label: "庫存明細數", value: stats.totalItems, icon: , color: "text-primary-main", @@ -218,8 +218,8 @@ export default function Dashboard({ stats, abnormalItems }: Props) { {item.quantity} diff --git a/resources/js/Pages/Inventory/Report/Index.tsx b/resources/js/Pages/Inventory/Report/Index.tsx new file mode 100644 index 0000000..fe3eb57 --- /dev/null +++ b/resources/js/Pages/Inventory/Report/Index.tsx @@ -0,0 +1,472 @@ +import { useState, useCallback } from "react"; +import { Button } from "@/Components/ui/button"; +import { Input } from "@/Components/ui/input"; +import { Label } from "@/Components/ui/label"; +import { + Download, + Calendar, + Filter, + Package, + RotateCcw, + FileSpreadsheet, + ArrowUpFromLine, + ArrowDownToLine, + ArrowRightLeft, + TrendingUp +} from 'lucide-react'; +import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; +import { Head, Link, router } from "@inertiajs/react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/Components/ui/table"; +import { getDateRange } from "@/utils/format"; +import Pagination from "@/Components/shared/Pagination"; +import { SearchableSelect } from "@/Components/ui/searchable-select"; +import { Can } from "@/Components/Permission/Can"; +import { PageProps } from "@/types/global"; + +interface ReportData { + product_code: string; + product_name: string; + category_name: string; + product_id: number; + inbound_qty: number; + outbound_qty: number; + adjust_qty: number; + net_change: number; +} + +interface SummaryData { + total_inbound: number; + total_outbound: number; + total_adjust: number; + total_net_change: number; +} + +interface InventoryReportProps extends PageProps { + reportData: { + data: ReportData[]; + links: any[]; + total: number; + from: number; + to: number; + current_page: number; + }; + summary: SummaryData; + warehouses: { id: number; name: string }[]; + categories: { id: number; name: string }[]; + filters: { + date_from: string; + date_to: string; + warehouse_id: string; + category_id: string; + search: string; + per_page?: number; + }; +} + +export default function InventoryReportIndex({ reportData, summary, warehouses, categories, filters }: InventoryReportProps) { + const [dateStart, setDateStart] = useState(filters.date_from || ""); + const [dateEnd, setDateEnd] = useState(filters.date_to || ""); + const [warehouseId, setWarehouseId] = useState(filters.warehouse_id || "all"); + const [categoryId, setCategoryId] = useState(filters.category_id || "all"); + const [search, setSearch] = useState(filters.search || ""); + const [perPage, setPerPage] = useState(filters.per_page?.toString() || "10"); + + // Determine initial range type based on date pairs + const getInitialRangeType = () => { + const { start: todayS, end: todayE } = getDateRange('today'); + const { start: yestS, end: yestE } = getDateRange('yesterday'); + const { start: weekS, end: weekE } = getDateRange('this_week'); + const { start: monthS, end: monthE } = getDateRange('this_month'); + const { start: lastMS, end: lastME } = getDateRange('last_month'); + + const fS = filters.date_from || ""; + const fE = filters.date_to || ""; + + if (fS === todayS && fE === todayE) return "today"; + if (fS === yestS && fE === yestE) return "yesterday"; + if (fS === weekS && fE === weekE) return "this_week"; + if (fS === monthS && fE === monthE) return "this_month"; + if (fS === lastMS && fE === lastME) return "last_month"; + + return "custom"; + }; + + const [dateRangeType, setDateRangeType] = useState(getInitialRangeType()); + + const handleDateRangeChange = (type: string) => { + setDateRangeType(type); + if (type === "custom") return; + + const { start, end } = getDateRange(type); + setDateStart(start); + setDateEnd(end); + }; + + const handleFilter = useCallback(() => { + router.get( + route("inventory.report.index"), + { + date_from: dateStart, + date_to: dateEnd, + warehouse_id: warehouseId === "all" ? "" : warehouseId, + category_id: categoryId === "all" ? "" : categoryId, + search: search, + per_page: perPage, + }, + { preserveState: true } + ); + }, [dateStart, dateEnd, warehouseId, categoryId, search, perPage]); + + const handlePerPageChange = (value: string) => { + setPerPage(value); + router.get( + route("inventory.report.index"), + { + date_from: dateStart, + date_to: dateEnd, + warehouse_id: warehouseId === "all" ? "" : warehouseId, + category_id: categoryId === "all" ? "" : categoryId, + search: search, + per_page: value, + }, + { preserveState: true } + ); + }; + + const handleClearFilters = () => { + // Service defaults: -7 days to today. + // Let's just clear params and let backend decide or set explicitly. + // Or simply reset to "daily" and "all" + setWarehouseId("all"); + setCategoryId("all"); + setSearch(""); + setDateStart(""); // Will trigger service default + setDateEnd(""); + setDateRangeType("custom"); + setPerPage("10"); + router.get(route("inventory.report.index")); + }; + + const handleExport = () => { + const query: any = { + date_from: dateStart, + date_to: dateEnd, + warehouse_id: warehouseId === "all" ? "" : warehouseId, + category_id: categoryId === "all" ? "" : categoryId, + search: search, + }; + window.location.href = route("inventory.report.export", query); + }; + + return ( + + + +
+ {/* Header */} +
+
+

+ + 庫存報表 +

+

+ 統計區間: + {filters.date_from && filters.date_to ? ( + <>{filters.date_from}{filters.date_to} + ) : ( + 全部期間 + )} + 內的進貨、出貨與庫存變動匯總 +

+
+ + + + +
+ + {/* Filters */} +
+
+ {/* Top Config: Date Range & Quick Buttons */} +
+
+ +
+ {[ + { label: "今日", value: "today" }, + { label: "昨日", value: "yesterday" }, + { label: "本週", value: "this_week" }, + { label: "本月", value: "this_month" }, + { label: "上月", value: "last_month" }, + ].map((opt) => ( + + ))} +
+
+ + {/* Date Inputs */} +
+
+
+ +
+ + { + setDateStart(e.target.value); + setDateRangeType('custom'); + }} + className="pl-9 block w-full h-9 bg-white" + /> +
+
+
+ +
+ + { + setDateEnd(e.target.value); + setDateRangeType('custom'); + }} + className="pl-9 block w-full h-9 bg-white" + /> +
+
+
+
+
+ + {/* Detailed Filters row */} +
+ {/* 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="選擇分類..." + /> +
+ + {/* Search */} +
+ + setSearch(e.target.value)} + className="h-9 bg-white" + onKeyDown={(e) => e.key === 'Enter' && handleFilter()} + /> +
+
+ + {/* Action Buttons */} +
+ + +
+
+
+ + {/* Summary Cards */} +
+
+ +
+ 總進貨量 + {Number(summary?.total_inbound || 0).toLocaleString()} +
+
+ +
+ +
+ 總出貨量 + {Number(summary?.total_outbound || 0).toLocaleString()} +
+
+ +
+ +
+ 調整量 + {Number(summary?.total_adjust || 0).toLocaleString()} +
+
+ +
+ +
+ 淨變動 + = 0 ? "text-emerald-600" : "text-red-600"}`}> + {summary?.total_net_change > 0 ? "+" : ""}{Number(summary?.total_net_change || 0).toLocaleString()} + +
+
+
+ + {/* Results Table */} +
+ + + + 商品代碼 + 商品名稱 + 分類 + 進貨量 + 出貨量 + 調整量 + 淨變動 + + + + {reportData.data.length === 0 ? ( + + +
+ +

無符合條件的報表資料

+
+
+
+ ) : ( + reportData.data.map((row) => ( + + + + {row.product_code} + + + + + {row.product_name} + + + {row.category_name || '-'} + + {row.inbound_qty > 0 ? `+${row.inbound_qty}` : "-"} + + + {row.outbound_qty > 0 ? `-${row.outbound_qty}` : "-"} + + + {row.adjust_qty !== 0 ? (row.adjust_qty > 0 ? `+${row.adjust_qty}` : row.adjust_qty) : "-"} + + = 0 ? 'text-gray-900' : 'text-red-500'}`}> + {Number(row.net_change) > 0 ? `+${row.net_change}` : row.net_change} + + + )) + )} +
+
+
+ + {/* Pagination Footer */} +
+
+ 每頁顯示 + + +
+
+ +
+
+
+
+ ); +} diff --git a/resources/js/Pages/Inventory/Report/Show.tsx b/resources/js/Pages/Inventory/Report/Show.tsx new file mode 100644 index 0000000..a471625 --- /dev/null +++ b/resources/js/Pages/Inventory/Report/Show.tsx @@ -0,0 +1,248 @@ +import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout"; +import { Head, Link } from "@inertiajs/react"; +import { PageProps } from "@/types/global"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/Components/ui/table"; +import { Button } from "@/Components/ui/button"; +import { Badge } from "@/Components/ui/badge"; +import { ArrowLeft, FileText, Package } from "lucide-react"; +import Pagination from "@/Components/shared/Pagination"; +import { formatDate } from "@/utils/format"; + +interface Transaction { + id: number; + inventory_id: number; + type: string; + quantity: number; + unit_cost: number; + total_cost: number; + actual_time: string; + note: string | null; + batch_no: string | null; + user_id: number; + created_at: string; + warehouse_name: string; + user_name: string; +} + +interface ShowProps extends PageProps { + product: { + id: number; + code: string; + name: string; + unit_name: string; + }; + transactions: { + data: Transaction[]; + links: any[]; + total: number; + from: number; + to: number; + current_page: number; + last_page: number; + per_page: number; + }; + filters: { + date_from: string; + date_to: string; + warehouse_id: string; + }; + /** 報表頁面的完整篩選狀態(用於返回時恢復) */ + reportFilters: { + date_from: string; + date_to: string; + warehouse_id: string; + category_id: string; + search: string; + per_page: string; + report_page: string; + }; + warehouses: { id: number; name: string }[]; +} + +export default function InventoryReportShow({ product, transactions, filters, reportFilters, warehouses }: ShowProps) { + + // 類型 Badge 顏色映射 + const getTypeBadgeVariant = (type: string) => { + switch (type) { + case '入庫': + case '手動入庫': + case '調撥入庫': + return "default"; + case '出庫': + case '調撥出庫': + return "destructive"; + default: + return "secondary"; + } + }; + + return ( + + + +
+ {/* 返回按鈕 */} +
+ + + +
+ + {/* 頁面標題 */} +
+
+

+ + 庫存異動明細 +

+

+ 查看商品「{product.name}」的所有庫存異動紀錄 +

+
+
+ + {/* 商品資訊 & 篩選條件卡片 */} +
+
+ + {/* 商品資訊 */} +
+
+

{product.name}

+ + {product.code} + +
+
+ + + 單位: {product.unit_name} + +
+
+ + {/* 目前篩選條件 (唯讀) */} +
+

+ 目前篩選條件 +

+
+
+ 日期範圍: + + {filters.date_from && filters.date_to + ? `${filters.date_from} ~ ${filters.date_to}` + : filters.date_from ? `${filters.date_from} 起` + : filters.date_to ? `${filters.date_to} 止` + : '全部期間'} + +
+
+ 倉庫: + + {filters.warehouse_id + ? warehouses.find(w => w.id.toString() === filters.warehouse_id)?.name || '未指定' + : '全部倉庫'} + +
+
+
+
+
+ + {/* 異動紀錄表格 */} +
+
+
+

+ + 異動紀錄 +

+ + 共 {transactions.total} 筆紀錄 + +
+ + + + + # + 異動時間 + 類型 + 倉庫 + 異動數量 + 批號 + 經手人 + 備註 + + + + {transactions.data.length === 0 ? ( + + + 無符合條件的資料 + + + ) : ( + transactions.data.map((tx, index) => ( + + + {(transactions.from || 0) + index} + + + {formatDate(tx.actual_time)} + + + + {tx.type} + + + {tx.warehouse_name} + 0 ? 'text-emerald-600' : + tx.quantity < 0 ? 'text-red-600' : 'text-gray-500' + }`}> + {tx.quantity > 0 ? '+' : ''}{tx.quantity} + + {tx.batch_no || '-'} + {tx.user_name || '-'} + + {tx.note || '-'} + + + )) + )} + +
+
+ + {/* 底部分頁列 */} +
+
+ 共 {transactions.total} 筆紀錄 + +
+
+
+
+
+ ); +}