修正庫存報表分頁參數衝突導致明細顯示為空的問題
This commit is contained in:
@@ -184,6 +184,7 @@ class RoleController extends Controller
|
||||
'inventory_count' => '庫存盤點管理',
|
||||
'inventory_adjust' => '庫存盤調管理',
|
||||
'inventory_transfer' => '庫存調撥管理',
|
||||
'inventory_report' => '庫存報表',
|
||||
'vendors' => '廠商資料管理',
|
||||
'purchase_orders' => '採購單管理',
|
||||
'goods_receipts' => '進貨單管理',
|
||||
|
||||
@@ -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, // 加入貨道資訊
|
||||
];
|
||||
});
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Modules\Inventory\Models\Category;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Services\InventoryReportService;
|
||||
use App\Modules\Inventory\Exports\InventoryReportExport;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
|
||||
|
||||
class InventoryReportController extends Controller
|
||||
{
|
||||
protected $reportService;
|
||||
|
||||
public function __construct(InventoryReportService $reportService)
|
||||
{
|
||||
$this->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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
61
app/Modules/Inventory/Exports/InventoryReportExport.php
Normal file
61
app/Modules/Inventory/Exports/InventoryReportExport.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Exports;
|
||||
|
||||
use App\Modules\Inventory\Services\InventoryReportService;
|
||||
use Maatwebsite\Excel\Concerns\FromCollection;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithMapping;
|
||||
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
class InventoryReportExport implements FromCollection, WithHeadings, WithMapping, ShouldAutoSize, WithStyles
|
||||
{
|
||||
protected $service;
|
||||
protected $filters;
|
||||
|
||||
public function __construct(InventoryReportService $service, array $filters)
|
||||
{
|
||||
$this->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]],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
228
app/Modules/Inventory/Services/InventoryReportService.php
Normal file
228
app/Modules/Inventory/Services/InventoryReportService.php
Normal file
@@ -0,0 +1,228 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Services;
|
||||
|
||||
use App\Modules\Inventory\Models\InventoryTransaction;
|
||||
use App\Modules\Inventory\Models\Product; // Use Inventory module's Product if available, or Core's? Usually Product is in Inventory/Models? No, let's check.
|
||||
// Checking Product model location... likely App\Modules\Product\Models\Product or App\Modules\Inventory\Models\Product.
|
||||
// From previous context: "products.create" permission suggests a Products module.
|
||||
// But stock query uses `products` table join.
|
||||
// Let's assume standard Laravel query builder or check existing models.
|
||||
// StockQueryController uses `InventoryService`.
|
||||
// I will use DB facade or InventoryTransaction model for aggregation.
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class InventoryReportService
|
||||
{
|
||||
/**
|
||||
* 取得庫存報表資料
|
||||
*
|
||||
* @param array $filters 篩選條件
|
||||
* @param int|null $perPage 每頁筆數
|
||||
* @return \Illuminate\Pagination\LengthAwarePaginator|\Illuminate\Support\Collection
|
||||
*/
|
||||
public function getReportData(array $filters, ?int $perPage = 10)
|
||||
{
|
||||
$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 new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage ?: 10);
|
||||
}
|
||||
|
||||
// 定義時間欄位轉換 (UTC -> 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();
|
||||
}
|
||||
}
|
||||
@@ -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)'));
|
||||
->count();
|
||||
|
||||
// 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');
|
||||
// 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)');
|
||||
})
|
||||
->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)
|
||||
// 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. 即將過期 (品項計數)
|
||||
// 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()
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
|
||||
43
package-lock.json
generated
43
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -224,7 +224,7 @@ export default function AuthenticatedLayout({
|
||||
id: "report-management",
|
||||
label: "報表管理",
|
||||
icon: <BarChart3 className="h-5 w-5" />,
|
||||
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: <BarChart3 className="h-4 w-4" />,
|
||||
route: "/inventory/report",
|
||||
permission: "inventory_report.view",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -63,7 +63,7 @@ const statusConfig: Record<string, { label: string; className: string }> = {
|
||||
export default function Dashboard({ stats, abnormalItems }: Props) {
|
||||
const cards = [
|
||||
{
|
||||
label: "庫存品項數",
|
||||
label: "庫存明細數",
|
||||
value: stats.totalItems,
|
||||
icon: <Package className="h-6 w-6" />,
|
||||
color: "text-primary-main",
|
||||
|
||||
472
resources/js/Pages/Inventory/Report/Index.tsx
Normal file
472
resources/js/Pages/Inventory/Report/Index.tsx
Normal file
@@ -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 (
|
||||
<AuthenticatedLayout breadcrumbs={[{ label: "報表管理", href: "#" }, { label: "庫存報表", href: route("inventory.report.index"), isPage: true }]}>
|
||||
<Head title="庫存報表" />
|
||||
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<FileSpreadsheet className="h-6 w-6 text-primary-main" />
|
||||
庫存報表
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
統計區間:
|
||||
{filters.date_from && filters.date_to ? (
|
||||
<><span className="font-medium text-gray-700">{filters.date_from}</span> 至 <span className="font-medium text-gray-700">{filters.date_to}</span></>
|
||||
) : (
|
||||
<span className="font-medium text-gray-700">全部期間</span>
|
||||
)}
|
||||
內的進貨、出貨與庫存變動匯總
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Can permission="inventory_report.export">
|
||||
<Button
|
||||
onClick={handleExport}
|
||||
variant="outline"
|
||||
className="button-outlined-primary gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
匯出 Excel 報表
|
||||
</Button>
|
||||
</Can>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-grey-4 p-5 mb-6">
|
||||
<div className="space-y-4">
|
||||
{/* Top Config: Date Range & Quick Buttons */}
|
||||
<div className="flex flex-col lg:flex-row gap-4 lg:items-end">
|
||||
<div className="flex-none space-y-2">
|
||||
<Label className="text-xs font-medium text-grey-2">快速時間區間</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ label: "今日", value: "today" },
|
||||
{ label: "昨日", value: "yesterday" },
|
||||
{ label: "本週", value: "this_week" },
|
||||
{ label: "本月", value: "this_month" },
|
||||
{ label: "上月", value: "last_month" },
|
||||
].map((opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
size="sm"
|
||||
onClick={() => handleDateRangeChange(opt.value)}
|
||||
className={
|
||||
dateRangeType === opt.value
|
||||
? 'button-filled-primary h-9 px-4 shadow-sm'
|
||||
: 'button-outlined-primary h-9 px-4 bg-white'
|
||||
}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Date Inputs */}
|
||||
<div className="w-full lg:flex-1">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-grey-2 font-medium">開始日期</Label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||
<Input
|
||||
type="date"
|
||||
value={dateStart}
|
||||
onChange={(e) => {
|
||||
setDateStart(e.target.value);
|
||||
setDateRangeType('custom');
|
||||
}}
|
||||
className="pl-9 block w-full h-9 bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-grey-2 font-medium">結束日期</Label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||
<Input
|
||||
type="date"
|
||||
value={dateEnd}
|
||||
onChange={(e) => {
|
||||
setDateEnd(e.target.value);
|
||||
setDateRangeType('custom');
|
||||
}}
|
||||
className="pl-9 block w-full h-9 bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detailed Filters row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4 items-end">
|
||||
{/* Warehouse & Category */}
|
||||
<div className="md:col-span-4 space-y-1">
|
||||
<Label className="text-xs text-grey-2 font-medium">倉庫</Label>
|
||||
<SearchableSelect
|
||||
value={warehouseId}
|
||||
onValueChange={setWarehouseId}
|
||||
options={[{ label: "全部倉庫", value: "all" }, ...warehouses.map(w => ({ label: w.name, value: w.id.toString() }))]}
|
||||
className="w-full h-9"
|
||||
placeholder="選擇倉庫..."
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-4 space-y-1">
|
||||
<Label className="text-xs text-grey-2 font-medium">分類</Label>
|
||||
<SearchableSelect
|
||||
value={categoryId}
|
||||
onValueChange={setCategoryId}
|
||||
options={[{ label: "全部分類", value: "all" }, ...categories.map(c => ({ label: c.name, value: c.id.toString() }))]}
|
||||
className="w-full h-9"
|
||||
placeholder="選擇分類..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="md:col-span-4 space-y-1">
|
||||
<Label className="text-xs text-grey-2 font-medium">關鍵字</Label>
|
||||
<Input
|
||||
placeholder="搜尋商品..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="h-9 bg-white"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-end border-t border-grey-4 pt-5 gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClearFilters}
|
||||
className="flex items-center gap-2 button-outlined-primary h-9 ml-auto"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleFilter}
|
||||
className="button-filled-primary h-9 px-6 gap-2"
|
||||
>
|
||||
<Filter className="h-4 w-4" /> 查詢
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="flex items-center gap-3 px-4 py-4 bg-white rounded-xl border-l-4 border-l-emerald-500 shadow-sm border border-gray-100 transition-all hover:bg-gray-50">
|
||||
<ArrowDownToLine className="h-6 w-6 text-emerald-500 shrink-0" />
|
||||
<div className="flex flex-1 items-baseline justify-between gap-2 min-w-0">
|
||||
<span className="text-sm text-gray-500 font-medium shrink-0">總進貨量</span>
|
||||
<span className="text-xl font-bold text-gray-900 truncate">{Number(summary?.total_inbound || 0).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 px-4 py-4 bg-white rounded-xl border-l-4 border-l-red-500 shadow-sm border border-gray-100 transition-all hover:bg-gray-50">
|
||||
<ArrowUpFromLine className="h-6 w-6 text-red-500 shrink-0" />
|
||||
<div className="flex flex-1 items-baseline justify-between gap-2 min-w-0">
|
||||
<span className="text-sm text-gray-500 font-medium shrink-0">總出貨量</span>
|
||||
<span className="text-xl font-bold text-gray-900 truncate">{Number(summary?.total_outbound || 0).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 px-4 py-4 bg-white rounded-xl border-l-4 border-l-blue-500 shadow-sm border border-gray-100 transition-all hover:bg-gray-50">
|
||||
<ArrowRightLeft className="h-6 w-6 text-blue-500 shrink-0" />
|
||||
<div className="flex flex-1 items-baseline justify-between gap-2 min-w-0">
|
||||
<span className="text-sm text-gray-500 font-medium shrink-0">調整量</span>
|
||||
<span className="text-xl font-bold text-gray-900 truncate">{Number(summary?.total_adjust || 0).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 px-4 py-4 bg-white rounded-xl border-l-4 border-l-gray-700 shadow-sm border border-gray-100 transition-all hover:bg-gray-50">
|
||||
<TrendingUp className="h-6 w-6 text-gray-700 shrink-0" />
|
||||
<div className="flex flex-1 items-baseline justify-between gap-2 min-w-0">
|
||||
<span className="text-sm text-gray-500 font-medium shrink-0">淨變動</span>
|
||||
<span className={`text-xl font-bold truncate ${summary?.total_net_change >= 0 ? "text-emerald-600" : "text-red-600"}`}>
|
||||
{summary?.total_net_change > 0 ? "+" : ""}{Number(summary?.total_net_change || 0).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results Table */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px]">商品代碼</TableHead>
|
||||
<TableHead className="">商品名稱</TableHead>
|
||||
<TableHead className="w-[120px]">分類</TableHead>
|
||||
<TableHead className="text-right w-[100px] text-emerald-600">進貨量</TableHead>
|
||||
<TableHead className="text-right w-[100px] text-red-600">出貨量</TableHead>
|
||||
<TableHead className="text-right w-[100px] text-blue-600">調整量</TableHead>
|
||||
<TableHead className="text-right w-[100px]">淨變動</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{reportData.data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
<div className="flex flex-col items-center justify-center space-y-2 py-8 text-gray-400">
|
||||
<Package className="h-10 w-10 opacity-20" />
|
||||
<p>無符合條件的報表資料</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
reportData.data.map((row) => (
|
||||
<TableRow key={row.product_id} className="hover:bg-gray-50/50 transition-colors">
|
||||
<TableCell className="font-medium">
|
||||
<Link
|
||||
href={route('inventory.report.show', {
|
||||
product: row.product_id,
|
||||
date_from: filters.date_from,
|
||||
date_to: filters.date_to,
|
||||
warehouse_id: filters.warehouse_id,
|
||||
// 以下為返回時恢復報表狀態用
|
||||
category_id: filters.category_id,
|
||||
search: filters.search,
|
||||
per_page: filters.per_page,
|
||||
report_page: reportData.current_page,
|
||||
})}
|
||||
className="text-primary hover:underline hover:text-primary/80 transition-colors"
|
||||
>
|
||||
{row.product_code}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Link
|
||||
href={route('inventory.report.show', {
|
||||
product: row.product_id,
|
||||
date_from: filters.date_from,
|
||||
date_to: filters.date_to,
|
||||
warehouse_id: filters.warehouse_id,
|
||||
category_id: filters.category_id,
|
||||
search: filters.search,
|
||||
per_page: filters.per_page,
|
||||
report_page: reportData.current_page,
|
||||
})}
|
||||
className="text-gray-900 hover:text-primary transition-colors font-medium"
|
||||
>
|
||||
{row.product_name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-500">{row.category_name || '-'}</TableCell>
|
||||
<TableCell className="text-right text-emerald-600 font-medium">
|
||||
{row.inbound_qty > 0 ? `+${row.inbound_qty}` : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-red-600 font-medium">
|
||||
{row.outbound_qty > 0 ? `-${row.outbound_qty}` : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-blue-600 font-medium">
|
||||
{row.adjust_qty !== 0 ? (row.adjust_qty > 0 ? `+${row.adjust_qty}` : row.adjust_qty) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className={`text-right font-bold ${Number(row.net_change) >= 0 ? 'text-gray-900' : 'text-red-500'}`}>
|
||||
{Number(row.net_change) > 0 ? `+${row.net_change}` : row.net_change}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Footer */}
|
||||
<div className="mt-6 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span>每頁顯示</span>
|
||||
<SearchableSelect
|
||||
value={perPage}
|
||||
onValueChange={handlePerPageChange}
|
||||
options={[
|
||||
{ label: "10", value: "10" },
|
||||
{ label: "20", value: "20" },
|
||||
{ label: "50", value: "50" },
|
||||
{ label: "100", value: "100" }
|
||||
]}
|
||||
className="w-[100px] h-8"
|
||||
showSearch={false}
|
||||
/>
|
||||
<span>筆</span>
|
||||
</div>
|
||||
<div className="w-full sm:w-auto flex justify-center sm:justify-end">
|
||||
<Pagination links={reportData.links} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
248
resources/js/Pages/Inventory/Report/Show.tsx
Normal file
248
resources/js/Pages/Inventory/Report/Show.tsx
Normal file
@@ -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 (
|
||||
<AuthenticatedLayout
|
||||
breadcrumbs={[
|
||||
{ label: "報表管理", href: "#" },
|
||||
{ label: "庫存報表", href: route("inventory.report.index", reportFilters) },
|
||||
{ label: `${product.name} - 庫存異動明細`, href: "#", isPage: true }
|
||||
]}
|
||||
>
|
||||
<Head title={`${product.name} - 庫存異動明細`} />
|
||||
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
{/* 返回按鈕 */}
|
||||
<div className="mb-6">
|
||||
<Link href={route('inventory.report.index', reportFilters)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2 button-outlined-primary"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
返回庫存報表
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 頁面標題 */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-4">
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<FileText className="h-6 w-6 text-primary-main" />
|
||||
庫存異動明細
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
查看商品「{product.name}」的所有庫存異動紀錄
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 商品資訊 & 篩選條件卡片 */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-6 mb-6">
|
||||
<div className="flex flex-col md:flex-row justify-between gap-6">
|
||||
|
||||
{/* 商品資訊 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-xl font-bold text-grey-0">{product.name}</h3>
|
||||
<Badge variant="outline" className="text-sm px-2 py-0.5 bg-gray-50">
|
||||
{product.code}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 text-sm text-gray-500">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Package className="h-4 w-4" />
|
||||
單位: {product.unit_name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 目前篩選條件 (唯讀) */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 space-y-2 min-w-[280px]">
|
||||
<h4 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
|
||||
目前篩選條件
|
||||
</h4>
|
||||
<div className="space-y-1.5 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">日期範圍:</span>
|
||||
<span className="font-medium text-grey-0">
|
||||
{filters.date_from && filters.date_to
|
||||
? `${filters.date_from} ~ ${filters.date_to}`
|
||||
: filters.date_from ? `${filters.date_from} 起`
|
||||
: filters.date_to ? `${filters.date_to} 止`
|
||||
: '全部期間'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">倉庫:</span>
|
||||
<span className="font-medium text-grey-0">
|
||||
{filters.warehouse_id
|
||||
? warehouses.find(w => w.id.toString() === filters.warehouse_id)?.name || '未指定'
|
||||
: '全部倉庫'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 異動紀錄表格 */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-grey-0 flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-gray-400" />
|
||||
異動紀錄
|
||||
</h3>
|
||||
<span className="text-sm text-gray-500">
|
||||
共 {transactions.total} 筆紀錄
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||
<TableHead className="w-[160px]">異動時間</TableHead>
|
||||
<TableHead>類型</TableHead>
|
||||
<TableHead>倉庫</TableHead>
|
||||
<TableHead className="text-right">異動數量</TableHead>
|
||||
<TableHead>批號</TableHead>
|
||||
<TableHead>經手人</TableHead>
|
||||
<TableHead className="w-[200px]">備註</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transactions.data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-8 text-gray-500">
|
||||
無符合條件的資料
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
transactions.data.map((tx, index) => (
|
||||
<TableRow key={tx.id}>
|
||||
<TableCell className="text-gray-500 font-medium text-center">
|
||||
{(transactions.from || 0) + index}
|
||||
</TableCell>
|
||||
<TableCell className="font-medium text-gray-700">
|
||||
{formatDate(tx.actual_time)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={getTypeBadgeVariant(tx.type)}>
|
||||
{tx.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{tx.warehouse_name}</TableCell>
|
||||
<TableCell className={`text-right font-medium ${tx.quantity > 0 ? 'text-emerald-600' :
|
||||
tx.quantity < 0 ? 'text-red-600' : 'text-gray-500'
|
||||
}`}>
|
||||
{tx.quantity > 0 ? '+' : ''}{tx.quantity}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-500">{tx.batch_no || '-'}</TableCell>
|
||||
<TableCell>{tx.user_name || '-'}</TableCell>
|
||||
<TableCell className="text-gray-500 truncate max-w-[200px]" title={tx.note || ''}>
|
||||
{tx.note || '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 底部分頁列 */}
|
||||
<div className="px-6 pb-6">
|
||||
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<span className="text-sm text-gray-500">共 {transactions.total} 筆紀錄</span>
|
||||
<Pagination links={transactions.links} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user