Files
star-erp/app/Modules/Inventory/Controllers/InventoryController.php

529 lines
23 KiB
PHP
Raw Permalink 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\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');
}
}