修正庫存報表分頁參數衝突導致明細顯示為空的問題

This commit is contained in:
2026-02-10 16:07:31 +08:00
parent 8b950f6529
commit 593ce94734
15 changed files with 1210 additions and 30 deletions

View File

@@ -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, // 加入貨道資訊
];
});

View File

@@ -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(),
]);
}
}

View 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]],
];
}
}

View File

@@ -26,7 +26,9 @@ class InventoryTransaction extends Model
];
protected $casts = [
'actual_time' => 'datetime',
// actual_time 不做時區轉換,保留原始字串格式(台北時間)
// 原因:資料庫儲存的是台北時間,但 MySQL 時區為 UTC
// 若使用 datetime castLaravel 會誤當作 UTC 再轉回台北時間,造成偏移
'unit_cost' => 'decimal:4',
];

View File

@@ -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');

View 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();
}
}

View File

@@ -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()