Files
star-erp/app/Modules/Inventory/Controllers/InventoryController.php
sky121113 106de4e945
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 53s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
feat: 修正庫存與撥補單邏輯並整合文件
1. 修復倉庫統計數據加總與樣式。
2. 修正可用庫存計算邏輯(排除不可銷售倉庫)。
3. 撥補單商品列表加入批號與效期顯示。
4. 修正撥補單儲存邏輯以支援精確批號轉移。
5. 整合 FEATURES.md 至 README.md。
2026-01-26 14:59:24 +08:00

545 lines
23 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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\WarehouseProductSafetyStock;
class InventoryController extends Controller
{
public function index(Request $request, Warehouse $warehouse)
{
$warehouse->load([
'inventories.product.category',
'inventories.product.baseUnit',
'inventories.lastIncomingTransaction',
'inventories.lastOutgoingTransaction'
]);
$allProducts = Product::with('category')->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]);
// 3. 準備 inventories (批號分組)
$items = $warehouse->inventories()
->with(['product.baseUnit', 'lastIncomingTransaction', 'lastOutgoingTransaction'])
->get();
$inventories = $items->groupBy('product_id')->map(function ($batchItems) use ($safetyStockMap) {
$firstItem = $batchItems->first();
$product = $firstItem->product;
$totalQuantity = $batchItems->sum('quantity');
// 從獨立表格讀取安全庫存
$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,
'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,
'safetyStock' => null, // 批號層級不再有安全庫存
'status' => '正常',
'batchNumber' => $inv->batch_number ?? 'BATCH-' . $inv->id,
'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,
];
})->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)
{
// 取得所有商品供前端選單使用
$products = Product::with(['baseUnit', 'largeUnit'])
->select('id', 'name', 'code', 'base_unit_id', 'large_unit_id', 'conversion_rate')
->get()
->map(function ($product) {
return [
'id' => (string) $product->id,
'name' => $product->name,
'code' => $product->code,
'baseUnit' => $product->baseUnit?->name ?? '個',
'largeUnit' => $product->largeUnit?->name, // 可能為 null
'conversionRate' => (float) $product->conversion_rate,
];
});
return Inertia::render('Warehouse/AddInventory', [
'warehouse' => $warehouse,
'products' => $products,
]);
}
public function store(Request $request, Warehouse $warehouse)
{
$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.*.batchMode' => 'required|in:existing,new',
'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',
]);
return DB::transaction(function () use ($validated, $warehouse) {
foreach ($validated['items'] as $item) {
$inventory = null;
if ($item['batchMode'] === 'existing') {
// 模式 A選擇現有批號 (包含已刪除的也要能找回來累加)
$inventory = Inventory::withTrashed()->findOrFail($item['inventoryId']);
if ($inventory->trashed()) {
$inventory->restore();
}
} else {
// 模式 B建立新批號
$originCountry = $item['originCountry'] ?? 'TW';
$product = Product::find($item['productId']);
$batchNumber = Inventory::generateBatchNumber(
$product->code ?? 'UNK',
$originCountry,
$validated['inboundDate']
);
// 同樣要檢查此批號是否已經存在 (即使模式是 new, 但可能撞到同一天同產地手動建立的)
$inventory = $warehouse->inventories()->withTrashed()->firstOrNew(
[
'product_id' => $item['productId'],
'batch_number' => $batchNumber
],
[
'quantity' => 0,
'arrival_date' => $validated['inboundDate'],
'expiry_date' => $item['expiryDate'] ?? null,
'origin_country' => $originCountry,
]
);
if ($inventory->trashed()) {
$inventory->restore();
}
}
$currentQty = $inventory->quantity;
$newQty = $currentQty + $item['quantity'];
$inventory->quantity = $newQty;
$inventory->save();
// 寫入異動紀錄
$inventory->transactions()->create([
'type' => '手動入庫',
'quantity' => $item['quantity'],
'balance_before' => $currentQty,
'balance_after' => $newQty,
'reason' => $validated['reason'] . ($validated['notes'] ? ' - ' . $validated['notes'] : ''),
'actual_time' => $validated['inboundDate'],
'user_id' => auth()->id(),
]);
}
return redirect()->route('warehouses.inventory.index', $warehouse->id)
->with('success', '庫存記錄已儲存成功');
});
}
/**
* API: 取得商品在特定倉庫的所有批號,並回傳當前日期/產地下的一個流水號
*/
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,
];
});
// 計算下一個流水號
$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)
{
// 取得庫存紀錄,包含商品資訊與異動紀錄 (含經手人)
// 這裡如果是 Mock 的 ID (mock-inv-1),需要特殊處理
if (str_starts_with($inventoryId, 'mock-inv-')) {
return redirect()->back()->with('error', '無法編輯範例資料');
}
$inventory = Inventory::with(['product', 'transactions' => function($query) {
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
}, 'transactions.user'])->findOrFail($inventoryId);
// 轉換為前端需要的格式
$inventoryData = [
'id' => (string) $inventory->id,
'warehouseId' => (string) $inventory->warehouse_id,
'productId' => (string) $inventory->product_id,
'productName' => $inventory->product?->name ?? '未知商品',
'quantity' => (float) $inventory->quantity,
'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) {
return [
'id' => (string) $tx->id,
'type' => $tx->type,
'quantity' => (float) $tx->quantity,
'balanceAfter' => (float) $tx->balance_after,
'reason' => $tx->reason,
'userName' => $tx->user ? $tx->user->name : '系統',
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $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)
{
// 若是 product ID (舊邏輯),先轉為 inventory
// 但新路由我們傳的是 inventory ID
// 為了相容,我們先判斷 $inventoryId 是 inventory ID
$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',
// 新增日期欄位驗證 (雖然暫不儲存到 DB)
'batchNumber' => 'nullable|string',
'expiryDate' => 'nullable|date',
'lastInboundDate' => 'nullable|date',
'lastOutboundDate' => 'nullable|date',
]);
return DB::transaction(function () use ($validated, $inventory) {
$currentQty = (float) $inventory->quantity;
$newQty = (float) $validated['quantity'];
// 判斷是否來自調整彈窗 (包含 operation 參數)
$isAdjustment = isset($validated['operation']);
$changeQty = 0;
if ($isAdjustment) {
switch ($validated['operation']) {
case 'add':
$changeQty = (float) $validated['quantity'];
$newQty = $currentQty + $changeQty;
break;
case 'subtract':
$changeQty = -(float) $validated['quantity'];
$newQty = $currentQty + $changeQty;
break;
case 'set':
$changeQty = $newQty - $currentQty;
break;
}
} else {
// 來自編輯頁面,直接 Set
$changeQty = $newQty - $currentQty;
}
// 更新庫存
$inventory->update(['quantity' => $newQty]);
// 異動類型映射
$type = $validated['type'] ?? ($isAdjustment ? 'manual_adjustment' : 'adjustment');
$typeMapping = [
'manual_adjustment' => '手動調整庫存',
'adjustment' => '盤點調整',
'purchase_in' => '採購進貨',
'sales_out' => '銷售出庫',
'return_in' => '退貨入庫',
'return_out' => '退貨出庫',
'transfer_in' => '撥補入庫',
'transfer_out' => '撥補出庫',
];
$chineseType = $typeMapping[$type] ?? $type;
// 如果是編輯頁面來的,且沒傳 type設為手動編輯
if (!$isAdjustment && !isset($validated['type'])) {
$chineseType = '手動編輯';
}
// 整理原因
$reason = $validated['reason'] ?? ($isAdjustment ? '手動庫存調整' : '編輯頁面更新');
if (isset($validated['notes'])) {
$reason .= ' - ' . $validated['notes'];
}
// 寫入異動紀錄
if (abs($changeQty) > 0.0001) {
$inventory->transactions()->create([
'type' => $chineseType,
'quantity' => $changeQty,
'balance_before' => $currentQty,
'balance_after' => $newQty,
'reason' => $reason,
'actual_time' => now(),
'user_id' => auth()->id(),
]);
}
return redirect()->route('warehouses.inventory.index', $inventory->warehouse_id)
->with('success', '庫存資料已更新');
});
}
public function destroy(Warehouse $warehouse, $inventoryId)
{
$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,
'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)
{
$inventoryId = $request->query('inventoryId');
$productId = $request->query('productId');
if ($productId) {
// 商品層級查詢
$inventories = Inventory::where('warehouse_id', $warehouse->id)
->where('product_id', $productId)
->with(['product', 'transactions' => function($query) {
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
}, 'transactions.user'])
->get();
if ($inventories->isEmpty()) {
return redirect()->back()->with('error', '找不到該商品的庫存紀錄');
}
$firstInventory = $inventories->first();
$productName = $firstInventory->product?->name ?? '未知商品';
$productCode = $firstInventory->product?->code ?? 'N/A';
$currentTotalQuantity = $inventories->sum('quantity');
// 合併所有批號的交易紀錄
$allTransactions = collect();
foreach ($inventories as $inv) {
foreach ($inv->transactions as $tx) {
$allTransactions->push([
'raw_tx' => $tx,
'batchNumber' => $inv->batch_number ?? '-',
'sort_time' => $tx->actual_time ?? $tx->created_at,
]);
}
}
// 依時間倒序排序 (最新的在前面)
$sortedTransactions = $allTransactions->sort(function ($a, $b) {
// 先比時間 (Desc)
if ($a['sort_time'] != $b['sort_time']) {
return $a['sort_time'] > $b['sort_time'] ? -1 : 1;
}
// 再比 ID (Desc)
return $a['raw_tx']->id > $b['raw_tx']->id ? -1 : 1;
});
// 回推計算結餘
$runningBalance = $currentTotalQuantity;
$transactions = $sortedTransactions->map(function ($item) use (&$runningBalance) {
$tx = $item['raw_tx'];
// 本次異動後的結餘 = 當前推算的結餘
$balanceAfter = $runningBalance;
// 推算前一次的結餘 (減去本次的異動量:如果是入庫+10前一次就是-10)
$runningBalance = $runningBalance - $tx->quantity;
return [
'id' => (string) $tx->id,
'type' => $tx->type,
'quantity' => (float) $tx->quantity,
'balanceAfter' => (float) $balanceAfter, // 使用即時計算的商品總結餘
'reason' => $tx->reason,
'userName' => $tx->user ? $tx->user->name : '系統',
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
'batchNumber' => $item['batchNumber'],
];
})->values();
return Inertia::render('Warehouse/InventoryHistory', [
'warehouse' => $warehouse,
'inventory' => [
'id' => 'product-' . $productId,
'productName' => $productName,
'productCode' => $productCode,
'quantity' => (float) $currentTotalQuantity,
],
'transactions' => $transactions
]);
}
if ($inventoryId) {
// 單一批號查詢
$inventory = Inventory::with(['product', 'transactions' => function($query) {
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
}, 'transactions.user'])->findOrFail($inventoryId);
$transactions = $inventory->transactions->map(function ($tx) {
return [
'id' => (string) $tx->id,
'type' => $tx->type,
'quantity' => (float) $tx->quantity,
'balanceAfter' => (float) $tx->balance_after,
'reason' => $tx->reason,
'userName' => $tx->user ? $tx->user->name : '系統',
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
];
});
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,
],
'transactions' => $transactions
]);
}
return redirect()->back()->with('error', '未提供查詢參數');
}
}