Files
star-erp/app/Modules/Inventory/Controllers/InventoryController.php
sky121113 9b0e3b4f6f
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 1m5s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped
refactor(modular): 完成第三與第四階段深層掃描與 Model 清理
2026-01-27 09:09:55 +08:00

531 lines
22 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;
use App\Modules\Core\Contracts\CoreServiceInterface;
class InventoryController extends Controller
{
protected $coreService;
public function __construct(CoreServiceInterface $coreService)
{
$this->coreService = $coreService;
}
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::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');
$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,
'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)
{
// ... (unchanged) ...
$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)
{
// ... (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',
'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) {
// ... (略,傳遞 unit_cost 交給 Service 處理) ...
// 這裡需要修改呼叫 Service 的地方或直接更新邏輯
// 為求快速,我將在此更新邏輯
$inventory = null;
if ($item['batchMode'] === 'existing') {
// 模式 A選擇現有批號 (包含已刪除的也要能找回來累加)
$inventory = Inventory::withTrashed()->findOrFail($item['inventoryId']);
if ($inventory->trashed()) {
$inventory->restore();
}
// 更新成本 (若有傳入)
if (isset($item['unit_cost'])) {
$inventory->unit_cost = $item['unit_cost'];
}
} else {
// 模式 B建立新批號
$originCountry = $item['originCountry'] ?? 'TW';
$product = Product::find($item['productId']);
$batchNumber = Inventory::generateBatchNumber(
$product->code ?? 'UNK',
$originCountry,
$validated['inboundDate']
);
// 檢查是否存在
$inventory = $warehouse->inventories()->withTrashed()->firstOrNew(
[
'product_id' => $item['productId'],
'batch_number' => $batchNumber
],
[
'quantity' => 0,
'unit_cost' => $item['unit_cost'] ?? 0, // 新增
'total_value' => 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->total_value = $inventory->quantity * $inventory->unit_cost;
$inventory->save();
// 寫入異動紀錄
$inventory->transactions()->create([
'type' => '手動入庫',
'quantity' => $item['quantity'],
'unit_cost' => $inventory->unit_cost, // 記錄成本
'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', '庫存記錄已儲存成功');
});
}
// ... (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, // 新增
];
});
// 計算下一個流水號
$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 ? $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)
{
// ... (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) {
$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;
}
// 更新成本 (若有傳)
if (isset($validated['unit_cost'])) {
$inventory->unit_cost = $validated['unit_cost'];
}
// 更新庫存
$inventory->quantity = $newQty;
// 更新總值
$inventory->total_value = $inventory->quantity * $inventory->unit_cost;
$inventory->save();
// 異動類型映射
$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,
'unit_cost' => $inventory->unit_cost, // 記錄
'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)
{
// ... (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) {
// ... (略) ...
}
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) {
$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 ? $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,
'unit_cost' => (float) $inventory->unit_cost,
'total_value' => (float) $inventory->total_value,
],
'transactions' => $transactions
]);
}
return redirect()->back()->with('error', '未提供查詢參數');
}
}