529 lines
23 KiB
PHP
529 lines
23 KiB
PHP
<?php
|
||
|
||
namespace App\Modules\Inventory\Controllers;
|
||
|
||
use App\Http\Controllers\Controller;
|
||
|
||
use Illuminate\Http\Request;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Inertia\Inertia;
|
||
use App\Modules\Inventory\Models\Warehouse;
|
||
use App\Modules\Inventory\Models\Product;
|
||
use App\Modules\Inventory\Models\Inventory;
|
||
use App\Modules\Inventory\Models\InventoryTransaction;
|
||
use App\Modules\Inventory\Models\WarehouseProductSafetyStock;
|
||
use App\Modules\Inventory\Imports\InventoryImport;
|
||
use App\Modules\Inventory\Exports\InventoryTemplateExport;
|
||
use Maatwebsite\Excel\Facades\Excel;
|
||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||
|
||
use App\Modules\Core\Contracts\CoreServiceInterface;
|
||
|
||
class InventoryController extends Controller
|
||
{
|
||
protected $coreService;
|
||
protected $inventoryService;
|
||
|
||
public function __construct(
|
||
CoreServiceInterface $coreService,
|
||
\App\Modules\Inventory\Contracts\InventoryServiceInterface $inventoryService
|
||
) {
|
||
$this->coreService = $coreService;
|
||
$this->inventoryService = $inventoryService;
|
||
}
|
||
|
||
public function index(Request $request, Warehouse $warehouse)
|
||
{
|
||
// ... (existing code for index) ...
|
||
$warehouse->load([
|
||
'inventories.product.category',
|
||
'inventories.product.baseUnit',
|
||
'inventories.lastIncomingTransaction',
|
||
'inventories.lastOutgoingTransaction'
|
||
]);
|
||
$allProducts = Product::select('id', 'name', 'code', 'category_id')->with('category:id,name')->get();
|
||
|
||
// 1. 準備 availableProducts
|
||
$availableProducts = $allProducts->map(function ($product) {
|
||
return [
|
||
'id' => (string) $product->id,
|
||
'name' => $product->name,
|
||
'type' => $product->category?->name ?? '其他',
|
||
];
|
||
});
|
||
|
||
// 2. 從新表格讀取安全庫存設定 (商品-倉庫層級)
|
||
$safetyStockMap = WarehouseProductSafetyStock::where('warehouse_id', $warehouse->id)
|
||
->pluck('safety_stock', 'product_id')
|
||
->mapWithKeys(fn($val, $key) => [(string)$key => (float)$val]);
|
||
|
||
$query = $warehouse->inventories()
|
||
->with(['product.baseUnit', 'lastIncomingTransaction', 'lastOutgoingTransaction']);
|
||
|
||
// 加入搜尋過濾
|
||
if ($request->filled('search')) {
|
||
$search = $request->input('search');
|
||
$query->where(function ($q) use ($search) {
|
||
$q->where('batch_number', 'like', "%{$search}%")
|
||
->orWhere(\Illuminate\Support\Facades\DB::raw("CONCAT('BATCH-', inventories.id)"), 'like', "%{$search}%")
|
||
->orWhereHas('product', function ($pq) use ($search) {
|
||
$pq->where('name', 'like', "%{$search}%")
|
||
->orWhere('code', 'like', "%{$search}%");
|
||
});
|
||
});
|
||
}
|
||
|
||
// 加入類型過濾
|
||
if ($request->filled('type') && $request->input('type') !== 'all') {
|
||
$type = $request->input('type');
|
||
$query->whereHas('product.category', function ($cq) use ($type) {
|
||
$cq->where('name', $type);
|
||
});
|
||
}
|
||
|
||
$items = $query->get();
|
||
|
||
// 判斷是否為販賣機並調整分組
|
||
$isVending = $warehouse->type === 'vending';
|
||
|
||
$inventories = $items->groupBy(function ($item) use ($isVending) {
|
||
return $isVending
|
||
? $item->product_id . '-' . ($item->location ?? 'NO-SLOT')
|
||
: $item->product_id;
|
||
})->map(function ($batchItems) use ($safetyStockMap, $isVending) {
|
||
$firstItem = $batchItems->first();
|
||
$product = $firstItem->product;
|
||
$totalQuantity = $batchItems->sum('quantity');
|
||
$totalValue = $batchItems->sum('total_value'); // 計算總價值
|
||
|
||
// 從獨立表格讀取安全庫存
|
||
$safetyStock = $safetyStockMap[(string)$firstItem->product_id] ?? null;
|
||
|
||
// 計算狀態
|
||
$status = '正常';
|
||
if (!is_null($safetyStock)) {
|
||
if ($totalQuantity < $safetyStock) {
|
||
$status = '低於';
|
||
}
|
||
}
|
||
|
||
return [
|
||
'productId' => (string) $firstItem->product_id,
|
||
'productName' => $product?->name ?? '未知商品',
|
||
'productCode' => $product?->code ?? 'N/A',
|
||
'baseUnit' => $product?->baseUnit?->name ?? '個',
|
||
'totalQuantity' => (float) $totalQuantity,
|
||
'totalValue' => (float) $totalValue,
|
||
'safetyStock' => $safetyStock,
|
||
'status' => $status,
|
||
'batches' => $batchItems->map(function ($inv) {
|
||
return [
|
||
'id' => (string) $inv->id,
|
||
'warehouseId' => (string) $inv->warehouse_id,
|
||
'productId' => (string) $inv->product_id,
|
||
'productName' => $inv->product?->name ?? '未知商品',
|
||
'productCode' => $inv->product?->code ?? 'N/A',
|
||
'unit' => $inv->product?->baseUnit?->name ?? '個',
|
||
'quantity' => (float) $inv->quantity,
|
||
'unit_cost' => (float) $inv->unit_cost,
|
||
'total_value' => (float) $inv->total_value,
|
||
'safetyStock' => null, // 批號層級不再有安全庫存
|
||
'status' => '正常',
|
||
'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 ? 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(),
|
||
];
|
||
})->values();
|
||
|
||
// 4. 準備 safetyStockSettings (從新表格讀取)
|
||
$safetyStockSettings = WarehouseProductSafetyStock::where('warehouse_id', $warehouse->id)
|
||
->with(['product.category'])
|
||
->get()
|
||
->map(function ($setting) {
|
||
return [
|
||
'id' => (string) $setting->id,
|
||
'warehouseId' => (string) $setting->warehouse_id,
|
||
'productId' => (string) $setting->product_id,
|
||
'productName' => $setting->product?->name ?? '未知商品',
|
||
'productType' => $setting->product?->category?->name ?? '其他',
|
||
'safetyStock' => (float) $setting->safety_stock,
|
||
'createdAt' => $setting->created_at->toIso8601String(),
|
||
'updatedAt' => $setting->updated_at->toIso8601String(),
|
||
];
|
||
});
|
||
|
||
return Inertia::render('Warehouse/Inventory', [
|
||
'warehouse' => $warehouse,
|
||
'inventories' => $inventories,
|
||
'safetyStockSettings' => $safetyStockSettings,
|
||
'availableProducts' => $availableProducts,
|
||
]);
|
||
}
|
||
|
||
public function create(Warehouse $warehouse)
|
||
{
|
||
// ... (unchanged) ...
|
||
$products = Product::select('id', 'name', 'code', 'barcode', 'base_unit_id', 'large_unit_id', 'conversion_rate', 'cost_price')
|
||
->with(['baseUnit:id,name', 'largeUnit:id,name'])
|
||
->get()
|
||
->map(function ($product) {
|
||
return [
|
||
'id' => (string) $product->id,
|
||
'name' => $product->name,
|
||
'code' => $product->code,
|
||
'barcode' => $product->barcode,
|
||
'baseUnit' => $product->baseUnit?->name ?? '個',
|
||
'largeUnit' => $product->largeUnit?->name, // 可能為 null
|
||
'conversionRate' => (float) $product->conversion_rate,
|
||
'costPrice' => (float) $product->cost_price,
|
||
];
|
||
});
|
||
|
||
return Inertia::render('Warehouse/AddInventory', [
|
||
'warehouse' => $warehouse,
|
||
'products' => $products,
|
||
]);
|
||
}
|
||
|
||
public function store(Request $request, Warehouse $warehouse)
|
||
{
|
||
// ... (unchanged) ...
|
||
$validated = $request->validate([
|
||
'inboundDate' => 'required|date',
|
||
'reason' => 'required|string',
|
||
'notes' => 'nullable|string',
|
||
'items' => 'required|array|min:1',
|
||
'items.*.productId' => 'required|exists:products,id',
|
||
'items.*.quantity' => 'required|numeric|min:0.01',
|
||
'items.*.unit_cost' => 'nullable|numeric|min:0', // 新增成本驗證
|
||
'items.*.batchMode' => 'required|in:existing,new,none',
|
||
'items.*.inventoryId' => 'required_if:items.*.batchMode,existing|nullable|exists:inventories,id',
|
||
'items.*.originCountry' => 'required_if:items.*.batchMode,new|nullable|string|max:2',
|
||
'items.*.expiryDate' => 'nullable|date',
|
||
'items.*.location' => 'nullable|string|max:50',
|
||
]);
|
||
|
||
return DB::transaction(function () use ($validated, $warehouse) {
|
||
// 修正時間精度:使用 Carbon 解析,若含時間則保留並補上秒數,若只有日期則補上當前時間
|
||
$dt = \Illuminate\Support\Carbon::parse($validated['inboundDate']);
|
||
if ($dt->hour === 0 && $dt->minute === 0 && $dt->second === 0) {
|
||
$dt->setTimeFrom(now());
|
||
} else {
|
||
$dt->setSecond(now()->second);
|
||
}
|
||
$inboundDateTime = $dt->toDateTimeString();
|
||
|
||
$this->inventoryService->processIncomingInventory($warehouse, $validated['items'], [
|
||
'inboundDate' => $inboundDateTime,
|
||
'reason' => $validated['reason'],
|
||
'notes' => $validated['notes'] ?? '',
|
||
]);
|
||
|
||
return redirect()->route('warehouses.inventory.index', $warehouse->id)
|
||
->with('success', '庫存記錄已儲存成功');
|
||
});
|
||
}
|
||
|
||
// ... (getBatches unchanged) ...
|
||
public function getBatches(Warehouse $warehouse, $productId, Request $request)
|
||
{
|
||
$originCountry = $request->query('originCountry', 'TW');
|
||
$arrivalDate = $request->query('arrivalDate', now()->format('Y-m-d'));
|
||
|
||
$batches = Inventory::where('warehouse_id', $warehouse->id)
|
||
->where('product_id', $productId)
|
||
->get()
|
||
->map(function ($inventory) {
|
||
return [
|
||
'inventoryId' => (string) $inventory->id,
|
||
'batchNumber' => $inventory->batch_number,
|
||
'originCountry' => $inventory->origin_country,
|
||
'expiryDate' => $inventory->expiry_date ? $inventory->expiry_date->format('Y-m-d') : null,
|
||
'quantity' => (float) $inventory->quantity,
|
||
'unitCost' => (float) $inventory->unit_cost,
|
||
'location' => $inventory->location,
|
||
];
|
||
});
|
||
|
||
// 計算下一個流水號
|
||
$product = Product::find($productId);
|
||
$nextSequence = '01';
|
||
if ($product) {
|
||
$batchNumber = Inventory::generateBatchNumber(
|
||
$product->code ?? 'UNK',
|
||
$originCountry,
|
||
$arrivalDate
|
||
);
|
||
$nextSequence = substr($batchNumber, -2);
|
||
}
|
||
|
||
return response()->json([
|
||
'batches' => $batches,
|
||
'nextSequence' => $nextSequence
|
||
]);
|
||
}
|
||
|
||
|
||
public function edit(Request $request, Warehouse $warehouse, $inventoryId)
|
||
{
|
||
if (str_starts_with($inventoryId, 'mock-inv-')) {
|
||
return redirect()->back()->with('error', '無法編輯範例資料');
|
||
}
|
||
|
||
// 移除 'transactions.user' 預載入
|
||
$inventory = Inventory::with(['product', 'transactions' => function($query) {
|
||
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
|
||
}])->findOrFail($inventoryId);
|
||
|
||
// 手動 Hydrate 使用者資料
|
||
$userIds = $inventory->transactions->pluck('user_id')->filter()->unique()->toArray();
|
||
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
|
||
|
||
// 轉換為前端需要的格式
|
||
$inventoryData = [
|
||
'id' => (string) $inventory->id,
|
||
'warehouseId' => (string) $inventory->warehouse_id,
|
||
'productId' => (string) $inventory->product_id,
|
||
'productName' => $inventory->product?->name ?? '未知商品',
|
||
'quantity' => (float) $inventory->quantity,
|
||
'unit_cost' => (float) $inventory->unit_cost,
|
||
'total_value' => (float) $inventory->total_value,
|
||
'batchNumber' => $inventory->batch_number ?? '-',
|
||
'expiryDate' => $inventory->expiry_date ?? null,
|
||
'lastInboundDate' => $inventory->updated_at->format('Y-m-d'),
|
||
'lastOutboundDate' => null,
|
||
];
|
||
|
||
// 整理異動紀錄
|
||
$transactions = $inventory->transactions->map(function ($tx) use ($users) {
|
||
$user = $tx->user_id ? ($users[$tx->user_id] ?? null) : null;
|
||
return [
|
||
'id' => (string) $tx->id,
|
||
'type' => $tx->type,
|
||
'quantity' => (float) $tx->quantity,
|
||
'unit_cost' => (float) $tx->unit_cost,
|
||
'balanceAfter' => (float) $tx->balance_after,
|
||
'reason' => $tx->reason,
|
||
'userName' => $user ? $user->name : '系統', // 手動對應
|
||
'actualTime' => $tx->actual_time ? substr($tx->actual_time, 0, 16) : $tx->created_at->format('Y-m-d H:i'),
|
||
];
|
||
});
|
||
|
||
return Inertia::render('Warehouse/EditInventory', [
|
||
'warehouse' => $warehouse,
|
||
'inventory' => $inventoryData,
|
||
'transactions' => $transactions,
|
||
]);
|
||
}
|
||
|
||
public function update(Request $request, Warehouse $warehouse, $inventoryId)
|
||
{
|
||
// ... (unchanged) ...
|
||
$inventory = Inventory::find($inventoryId);
|
||
|
||
// 如果找不到 (可能是舊路由傳 product ID)
|
||
if (!$inventory) {
|
||
$inventory = $warehouse->inventories()->where('product_id', $inventoryId)->first();
|
||
}
|
||
|
||
if (!$inventory) {
|
||
return redirect()->back()->with('error', '找不到庫存紀錄');
|
||
}
|
||
|
||
$validated = $request->validate([
|
||
'quantity' => 'required|numeric|min:0',
|
||
// 以下欄位改為 nullable,支援新表單
|
||
'type' => 'nullable|string',
|
||
'operation' => 'nullable|in:add,subtract,set',
|
||
'reason' => 'nullable|string',
|
||
'notes' => 'nullable|string',
|
||
'unit_cost' => 'nullable|numeric|min:0', // 新增成本
|
||
// ...
|
||
'batchNumber' => 'nullable|string',
|
||
'expiryDate' => 'nullable|date',
|
||
'lastInboundDate' => 'nullable|date',
|
||
'lastOutboundDate' => 'nullable|date',
|
||
]);
|
||
|
||
return DB::transaction(function () use ($validated, $inventory) {
|
||
$this->inventoryService->adjustInventory($inventory, $validated);
|
||
|
||
return redirect()->route('warehouses.inventory.index', $inventory->warehouse_id)
|
||
->with('success', '庫存資料已更新');
|
||
});
|
||
}
|
||
|
||
public function destroy(Warehouse $warehouse, $inventoryId)
|
||
{
|
||
// ... (unchanged) ...
|
||
$inventory = Inventory::findOrFail($inventoryId);
|
||
|
||
// 庫存 > 0 不允許刪除 (哪怕是軟刪除)
|
||
if ($inventory->quantity > 0) {
|
||
return redirect()->back()->with('error', '庫存數量大於 0,無法刪除。請先進行出庫或調整。');
|
||
}
|
||
|
||
// 歸零異動 (因為已經限制為 0 才能刪,這段邏輯可以簡化,但為了保險起見,若有微小殘值仍可記錄歸零)
|
||
if (abs($inventory->quantity) > 0.0001) {
|
||
$inventory->transactions()->create([
|
||
'type' => '手動編輯',
|
||
'quantity' => -$inventory->quantity,
|
||
'unit_cost' => $inventory->unit_cost,
|
||
'balance_before' => $inventory->quantity,
|
||
'balance_after' => 0,
|
||
'reason' => '刪除庫存品項',
|
||
'actual_time' => now(),
|
||
'user_id' => auth()->id(),
|
||
]);
|
||
}
|
||
|
||
$inventory->delete();
|
||
|
||
return redirect()->route('warehouses.inventory.index', $warehouse->id)
|
||
->with('success', '庫存品項已刪除');
|
||
}
|
||
|
||
public function history(Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse)
|
||
{
|
||
// ... (前端 history 頁面可能也需要 unit_cost,這裡可補上) ...
|
||
$inventoryId = $request->query('inventoryId');
|
||
$productId = $request->query('productId');
|
||
|
||
if ($productId) {
|
||
$product = Product::findOrFail($productId);
|
||
// 取得該倉庫中該商品的所有批號 ID
|
||
$inventoryIds = Inventory::where('warehouse_id', $warehouse->id)
|
||
->where('product_id', $productId)
|
||
->pluck('id')
|
||
->toArray();
|
||
|
||
$transactionsRaw = InventoryTransaction::whereIn('inventory_id', $inventoryIds)
|
||
->with('inventory') // 需要批號資訊
|
||
->orderBy('actual_time', 'desc')
|
||
->orderBy('id', 'desc')
|
||
->get();
|
||
|
||
// 手動 Hydrate 使用者資料
|
||
$userIds = $transactionsRaw->pluck('user_id')->filter()->unique()->toArray();
|
||
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
|
||
|
||
// 計算商品在該倉庫的總量(不分批號)
|
||
$currentRunningTotal = (float) Inventory::whereIn('id', $inventoryIds)->sum('quantity');
|
||
|
||
$transactions = $transactionsRaw->map(function ($tx) use ($users, &$currentRunningTotal) {
|
||
$user = $tx->user_id ? ($users[$tx->user_id] ?? null) : null;
|
||
$balanceAfter = $currentRunningTotal;
|
||
|
||
// 為下一筆(較舊的)紀錄更新 Running Total
|
||
$currentRunningTotal -= (float) $tx->quantity;
|
||
|
||
return [
|
||
'id' => (string) $tx->id,
|
||
'type' => $tx->type,
|
||
'quantity' => (float) $tx->quantity,
|
||
'unit_cost' => (float) $tx->unit_cost,
|
||
'balanceAfter' => (float) $balanceAfter, // 顯示該商品在倉庫的累計結餘
|
||
'reason' => $tx->reason,
|
||
'userName' => $user ? $user->name : '系統',
|
||
'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, // 加入貨道資訊
|
||
];
|
||
});
|
||
|
||
// 重新計算目前的總量(用於 Header 顯示,確保一致性)
|
||
$totalQuantity = Inventory::whereIn('id', $inventoryIds)->sum('quantity');
|
||
|
||
return Inertia::render('Warehouse/InventoryHistory', [
|
||
'warehouse' => $warehouse,
|
||
'inventory' => [
|
||
'id' => null, // 跨批號查詢沒有單一 ID
|
||
'productName' => $product->name,
|
||
'productCode' => $product->code,
|
||
'batchNumber' => '所有批號',
|
||
'quantity' => (float) $totalQuantity,
|
||
],
|
||
'transactions' => $transactions
|
||
]);
|
||
}
|
||
|
||
if ($inventoryId) {
|
||
// 單一批號查詢
|
||
// 移除 'transactions.user'
|
||
$inventory = Inventory::with(['product', 'transactions' => function($query) {
|
||
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
|
||
}])->findOrFail($inventoryId);
|
||
|
||
// 手動 Hydrate 使用者資料
|
||
$userIds = $inventory->transactions->pluck('user_id')->filter()->unique()->toArray();
|
||
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
|
||
|
||
$transactions = $inventory->transactions->map(function ($tx) use ($users, $inventory) {
|
||
$user = $tx->user_id ? ($users[$tx->user_id] ?? null) : null;
|
||
return [
|
||
'id' => (string) $tx->id,
|
||
'type' => $tx->type,
|
||
'quantity' => (float) $tx->quantity,
|
||
'unit_cost' => (float) $tx->unit_cost,
|
||
'balanceAfter' => (float) $tx->balance_after,
|
||
'reason' => $tx->reason,
|
||
'userName' => $user ? $user->name : '系統', // 手動對應
|
||
'actualTime' => $tx->actual_time ? substr($tx->actual_time, 0, 16) : $tx->created_at->format('Y-m-d H:i'),
|
||
'slot' => $inventory->location, // 加入貨道資訊
|
||
];
|
||
});
|
||
|
||
return Inertia::render('Warehouse/InventoryHistory', [
|
||
'warehouse' => $warehouse,
|
||
'inventory' => [
|
||
'id' => (string) $inventory->id,
|
||
'productName' => $inventory->product?->name ?? '未知商品',
|
||
'productCode' => $inventory->product?->code ?? 'N/A',
|
||
'batchNumber' => $inventory->batch_number ?? '-',
|
||
'quantity' => (float) $inventory->quantity,
|
||
'unit_cost' => (float) $inventory->unit_cost,
|
||
'total_value' => (float) $inventory->total_value,
|
||
],
|
||
'transactions' => $transactions
|
||
]);
|
||
}
|
||
|
||
return redirect()->back()->with('error', '未提供查詢參數');
|
||
}
|
||
|
||
/**
|
||
* 匯入入庫
|
||
*/
|
||
public function import(Request $request, Warehouse $warehouse)
|
||
{
|
||
$request->validate([
|
||
'file' => 'required|mimes:xlsx,xls,csv',
|
||
'inboundDate' => 'required|date',
|
||
'notes' => 'nullable|string',
|
||
]);
|
||
|
||
try {
|
||
Excel::import(
|
||
new InventoryImport($warehouse, $request->inboundDate, $request->notes),
|
||
$request->file('file')
|
||
);
|
||
|
||
return back()->with('success', '庫存資料匯入成功');
|
||
} catch (\Exception $e) {
|
||
return back()->withErrors(['file' => '匯入過程中發生錯誤: ' . $e->getMessage()]);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 下載匯入範本 (.xlsx)
|
||
*/
|
||
public function template()
|
||
{
|
||
return Excel::download(new InventoryTemplateExport, '庫存匯入範本.xlsx');
|
||
}
|
||
}
|