修正庫存報表分頁參數衝突導致明細顯示為空的問題
This commit is contained in:
@@ -184,6 +184,7 @@ class RoleController extends Controller
|
|||||||
'inventory_count' => '庫存盤點管理',
|
'inventory_count' => '庫存盤點管理',
|
||||||
'inventory_adjust' => '庫存盤調管理',
|
'inventory_adjust' => '庫存盤調管理',
|
||||||
'inventory_transfer' => '庫存調撥管理',
|
'inventory_transfer' => '庫存調撥管理',
|
||||||
|
'inventory_report' => '庫存報表',
|
||||||
'vendors' => '廠商資料管理',
|
'vendors' => '廠商資料管理',
|
||||||
'purchase_orders' => '採購單管理',
|
'purchase_orders' => '採購單管理',
|
||||||
'goods_receipts' => '進貨單管理',
|
'goods_receipts' => '進貨單管理',
|
||||||
|
|||||||
@@ -106,8 +106,8 @@ class InventoryController extends Controller
|
|||||||
'batchNumber' => $inv->batch_number ?? 'BATCH-' . $inv->id,
|
'batchNumber' => $inv->batch_number ?? 'BATCH-' . $inv->id,
|
||||||
'location' => $inv->location,
|
'location' => $inv->location,
|
||||||
'expiryDate' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
|
'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,
|
'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 ? $inv->lastOutgoingTransaction->actual_time->format('Y-m-d') : $inv->lastOutgoingTransaction->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(),
|
})->values(),
|
||||||
];
|
];
|
||||||
@@ -360,7 +360,7 @@ class InventoryController extends Controller
|
|||||||
'balanceAfter' => (float) $tx->balance_after,
|
'balanceAfter' => (float) $tx->balance_after,
|
||||||
'reason' => $tx->reason,
|
'reason' => $tx->reason,
|
||||||
'userName' => $user ? $user->name : '系統', // 手動對應
|
'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, // 顯示該商品在倉庫的累計結餘
|
'balanceAfter' => (float) $balanceAfter, // 顯示該商品在倉庫的累計結餘
|
||||||
'reason' => $tx->reason,
|
'reason' => $tx->reason,
|
||||||
'userName' => $user ? $user->name : '系統',
|
'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 ?? '-', // 補上批號資訊
|
'batchNumber' => $tx->inventory?->batch_number ?? '-', // 補上批號資訊
|
||||||
'slot' => $tx->inventory?->location, // 加入貨道資訊
|
'slot' => $tx->inventory?->location, // 加入貨道資訊
|
||||||
];
|
];
|
||||||
@@ -597,7 +597,7 @@ class InventoryController extends Controller
|
|||||||
'balanceAfter' => (float) $tx->balance_after,
|
'balanceAfter' => (float) $tx->balance_after,
|
||||||
'reason' => $tx->reason,
|
'reason' => $tx->reason,
|
||||||
'userName' => $user ? $user->name : '系統', // 手動對應
|
'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, // 加入貨道資訊
|
'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 = [
|
protected $casts = [
|
||||||
'actual_time' => 'datetime',
|
// actual_time 不做時區轉換,保留原始字串格式(台北時間)
|
||||||
|
// 原因:資料庫儲存的是台北時間,但 MySQL 時區為 UTC
|
||||||
|
// 若使用 datetime cast,Laravel 會誤當作 UTC 再轉回台北時間,造成偏移
|
||||||
'unit_cost' => 'decimal:4',
|
'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\CountDocController;
|
||||||
use App\Modules\Inventory\Controllers\AdjustDocController;
|
use App\Modules\Inventory\Controllers\AdjustDocController;
|
||||||
|
|
||||||
|
use App\Modules\Inventory\Controllers\InventoryReportController;
|
||||||
|
|
||||||
use App\Modules\Inventory\Controllers\StockQueryController;
|
use App\Modules\Inventory\Controllers\StockQueryController;
|
||||||
|
|
||||||
Route::middleware('auth')->group(function () {
|
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', [StockQueryController::class, 'index'])->name('inventory.stock-query.index');
|
||||||
Route::get('/inventory/stock-query/export', [StockQueryController::class, 'export'])->name('inventory.stock-query.export');
|
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::middleware('permission:products.view')->group(function () {
|
||||||
Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index');
|
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();
|
->count();
|
||||||
|
|
||||||
// 4. 即將過期明細數
|
// 4. 即將過期明細數 (必須排除已過期)
|
||||||
$expiringCount = DB::table('inventories')
|
$expiringCount = DB::table('inventories')
|
||||||
->whereNull('deleted_at')
|
->whereNull('deleted_at')
|
||||||
->whereNotNull('expiry_date')
|
->whereNotNull('expiry_date')
|
||||||
|
->where('expiry_date', '>', $today)
|
||||||
->where('expiry_date', '<=', $expiryThreshold)
|
->where('expiry_date', '<=', $expiryThreshold)
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
@@ -467,34 +468,46 @@ class InventoryService implements InventoryServiceInterface
|
|||||||
$today = now()->toDateString();
|
$today = now()->toDateString();
|
||||||
$expiryThreshold = now()->addDays(30)->toDateString();
|
$expiryThreshold = now()->addDays(30)->toDateString();
|
||||||
|
|
||||||
// 1. 庫存品項數 (Unique Product-Warehouse)
|
// 1. 庫存品項數 (明細總數)
|
||||||
$totalItems = DB::table('inventories')
|
$totalItems = DB::table('inventories')
|
||||||
->whereNull('deleted_at')
|
->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();
|
->count();
|
||||||
|
|
||||||
// 3. 負庫存 (品項計數)
|
// 2. 低庫存 (明細計數:只要該明細所屬的「倉庫+商品」總量低於安全庫存,則所有相關明細都計入)
|
||||||
$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'))
|
$lowStockCount = DB::table('inventories as i')
|
||||||
->where('total_qty', '<', 0)
|
->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();
|
->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')
|
$expiringCount = DB::table('inventories')
|
||||||
->whereNull('deleted_at')
|
->whereNull('deleted_at')
|
||||||
->whereNotNull('expiry_date')
|
->whereNotNull('expiry_date')
|
||||||
|
->where('expiry_date', '>', $today) // 確保不過期 (getStockQueryData 沒加這個但這裡加上以防與 expired 混淆? 不,stock query 是 > today && <= threshold)
|
||||||
->where('expiry_date', '<=', $expiryThreshold)
|
->where('expiry_date', '<=', $expiryThreshold)
|
||||||
->distinct()
|
->count();
|
||||||
->count(DB::raw('CONCAT(warehouse_id, "-", product_id)'));
|
|
||||||
|
|
||||||
// 異常庫存前 10 筆 (明細面依然以個別批次為主,供快速跳轉)
|
// 異常庫存前 10 筆 (明細面依然以個別批次為主,供快速跳轉)
|
||||||
$abnormalItems = Inventory::query()
|
$abnormalItems = Inventory::query()
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ class PermissionSeeder extends Seeder
|
|||||||
'inventory_transfer.edit' => '編輯',
|
'inventory_transfer.edit' => '編輯',
|
||||||
'inventory_transfer.delete' => '刪除',
|
'inventory_transfer.delete' => '刪除',
|
||||||
|
|
||||||
|
// 庫存報表
|
||||||
|
'inventory_report.view' => '檢視',
|
||||||
|
'inventory_report.export' => '匯出',
|
||||||
|
|
||||||
// 進貨單管理
|
// 進貨單管理
|
||||||
'goods_receipts.view' => '檢視',
|
'goods_receipts.view' => '檢視',
|
||||||
'goods_receipts.create' => '建立',
|
'goods_receipts.create' => '建立',
|
||||||
@@ -153,6 +157,7 @@ class PermissionSeeder extends Seeder
|
|||||||
'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete',
|
'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_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete',
|
||||||
'inventory_transfer.view', 'inventory_transfer.create', 'inventory_transfer.edit', 'inventory_transfer.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',
|
||||||
'delivery_notes.view', 'delivery_notes.create', 'delivery_notes.edit', 'delivery_notes.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',
|
'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_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete',
|
||||||
'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.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_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',
|
'goods_receipts.view', 'goods_receipts.create', 'goods_receipts.edit', 'goods_receipts.delete',
|
||||||
'production_orders.view', 'production_orders.create', 'production_orders.edit',
|
'production_orders.view', 'production_orders.create', 'production_orders.edit',
|
||||||
'warehouses.view', 'warehouses.create', 'warehouses.edit',
|
'warehouses.view', 'warehouses.create', 'warehouses.edit',
|
||||||
@@ -197,6 +204,7 @@ class PermissionSeeder extends Seeder
|
|||||||
'vendors.view',
|
'vendors.view',
|
||||||
'warehouses.view',
|
'warehouses.view',
|
||||||
'utility_fees.view',
|
'utility_fees.view',
|
||||||
|
'inventory_report.view',
|
||||||
'accounting.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-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@types/lodash": "^4.17.21",
|
"@types/lodash": "^4.17.21",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
@@ -78,6 +79,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
|
||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@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": {
|
"node_modules/@radix-ui/react-tooltip": {
|
||||||
"version": "1.2.8",
|
"version": "1.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz",
|
||||||
@@ -2647,6 +2679,7 @@
|
|||||||
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
|
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.16.0"
|
"undici-types": "~7.16.0"
|
||||||
}
|
}
|
||||||
@@ -2657,6 +2690,7 @@
|
|||||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -2667,6 +2701,7 @@
|
|||||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
@@ -2774,6 +2809,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -2986,7 +3022,8 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/date-fns": {
|
"node_modules/date-fns": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
@@ -3865,6 +3902,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -3926,6 +3964,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -3938,6 +3977,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -4430,6 +4470,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
||||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.27.0",
|
"esbuild": "^0.27.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@types/lodash": "^4.17.21",
|
"@types/lodash": "^4.17.21",
|
||||||
"@vitejs/plugin-react": "^5.1.2",
|
"@vitejs/plugin-react": "^5.1.2",
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ export default function AuthenticatedLayout({
|
|||||||
id: "report-management",
|
id: "report-management",
|
||||||
label: "報表管理",
|
label: "報表管理",
|
||||||
icon: <BarChart3 className="h-5 w-5" />,
|
icon: <BarChart3 className="h-5 w-5" />,
|
||||||
permission: "accounting.view",
|
permission: ["accounting.view", "inventory_report.view"],
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
id: "accounting-report",
|
id: "accounting-report",
|
||||||
@@ -233,6 +233,13 @@ export default function AuthenticatedLayout({
|
|||||||
route: "/accounting-report",
|
route: "/accounting-report",
|
||||||
permission: "accounting.view",
|
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) {
|
export default function Dashboard({ stats, abnormalItems }: Props) {
|
||||||
const cards = [
|
const cards = [
|
||||||
{
|
{
|
||||||
label: "庫存品項數",
|
label: "庫存明細數",
|
||||||
value: stats.totalItems,
|
value: stats.totalItems,
|
||||||
icon: <Package className="h-6 w-6" />,
|
icon: <Package className="h-6 w-6" />,
|
||||||
color: "text-primary-main",
|
color: "text-primary-main",
|
||||||
@@ -218,8 +218,8 @@ export default function Dashboard({ stats, abnormalItems }: Props) {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
className={`text-right font-medium ${item.quantity < 0
|
className={`text-right font-medium ${item.quantity < 0
|
||||||
? "text-red-600"
|
? "text-red-600"
|
||||||
: ""
|
: ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{item.quantity}
|
{item.quantity}
|
||||||
|
|||||||
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