star-cloud/app/Http/Controllers/Admin/WarehouseController.php
sky121113 315c226560 [FEAT] 倉儲管理系統模組實作與全站 UI/UX 標準化優化
1. 實作倉儲管理系統 (Warehouse Management):包含總覽、庫存、調撥、機台庫存與補貨模組。
2. 建立倉儲相關資料結構:新增 warehouses, warehouse_stocks, stock_in_orders, stock_movements, transfer_orders, replenishment_orders 等資料表。
3. 優化商品管理 (Product Management) 介面:調整商品與類別管理的分頁、搜尋與編輯邏輯,並導入極簡奢華風 UI。
4. 增強遠端機台管理 (Remote Machine Management):優化機台清單、庫存監控與遠端出貨介面,提升載入效能。
5. 更新全站導覽選單 (Sidebar):整合倉儲管理入口,並修復側邊欄語法錯誤。
6. 標準化分頁組件 (Pagination):套用 luxury 奢華風分頁樣式。
7. 補齊多語系語系檔 (i18n):更新 zh_TW.json,包含倉儲與商品模組相關詞彙。
2026-04-23 11:51:08 +08:00

510 lines
20 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\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Machine\Machine;
use App\Models\Machine\MachineSlot;
use App\Models\Product\Product;
use App\Models\Warehouse\ReplenishmentOrder;
use App\Models\Warehouse\ReplenishmentOrderItem;
use App\Models\Warehouse\StockInOrder;
use App\Models\Warehouse\StockInOrderItem;
use App\Models\Warehouse\StockMovement;
use App\Models\Warehouse\TransferOrder;
use App\Models\Warehouse\TransferOrderItem;
use App\Models\Warehouse\Warehouse;
use App\Models\Warehouse\WarehouseStock;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
class WarehouseController extends Controller
{
// ─── 模組 1倉庫總覽 ───
public function index(Request $request)
{
$query = Warehouse::query()
->withCount('stocks as products_count')
->withSum('stocks as total_stock', 'quantity');
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('address', 'like', "%{$search}%");
});
}
if ($request->filled('type')) {
$query->where('type', $request->input('type'));
}
$warehouses = $query->orderBy('type')->orderBy('name')
->paginate($request->input('per_page', 10))
->withQueryString();
$stats = [
'total' => Warehouse::count(),
'main' => Warehouse::main()->count(),
'branch' => Warehouse::branch()->count(),
'low_stock' => WarehouseStock::lowStock()
->whereHas('warehouse', fn($q) => $q)->count(),
];
return view('admin.warehouses.index', compact('warehouses', 'stats'));
}
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'type' => 'required|in:main,branch',
'address' => 'nullable|string|max:500',
]);
$validated['company_id'] = Auth::user()->company_id;
Warehouse::create($validated);
return redirect()->route('admin.warehouses.index')
->with('success', __('Warehouse created successfully'));
}
public function update(Request $request, Warehouse $warehouse)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'type' => 'required|in:main,branch',
'address' => 'nullable|string|max:500',
'is_active' => 'required|boolean',
]);
$warehouse->update($validated);
return redirect()->route('admin.warehouses.index')
->with('success', __('Warehouse updated successfully'));
}
public function destroy(Warehouse $warehouse)
{
if ($warehouse->stocks()->where('quantity', '>', 0)->exists()) {
return redirect()->route('admin.warehouses.index')
->with('error', __('Cannot delete warehouse with existing stock'));
}
$warehouse->delete();
return redirect()->route('admin.warehouses.index')
->with('success', __('Warehouse deleted successfully'));
}
public function toggleStatus(Warehouse $warehouse)
{
$warehouse->update(['is_active' => !$warehouse->is_active]);
$status = $warehouse->is_active ? __('enabled') : __('disabled');
return redirect()->route('admin.warehouses.index')
->with('success', __('Warehouse :status successfully', ['status' => $status]));
}
// ─── 模組 2庫存管理 ───
public function inventory(Request $request)
{
$tab = $request->input('tab', 'stock');
$warehouses = Warehouse::active()->orderBy('name')->get();
$data = compact('warehouses', 'tab');
if ($tab === 'stock') {
$query = WarehouseStock::with(['warehouse:id,name,type', 'product:id,name,image_url'])
->whereHas('warehouse', fn($q) => $q);
if ($request->filled('warehouse_id')) {
$query->where('warehouse_id', $request->input('warehouse_id'));
}
if ($search = $request->input('search')) {
$query->whereHas('product', fn($q) => $q->where('name', 'like', "%{$search}%"));
}
$data['stocks'] = $query->orderBy('warehouse_id')
->paginate($request->input('per_page', 15))
->withQueryString();
} elseif ($tab === 'stock-in') {
$query = StockInOrder::with(['warehouse:id,name', 'creator:id,name'])
->latest();
if ($request->filled('status')) {
$query->where('status', $request->input('status'));
}
$data['orders'] = $query->paginate(10)->withQueryString();
$data['products'] = Product::orderBy('name')->get(['id', 'name', 'image_url']);
} elseif ($tab === 'movements') {
$query = StockMovement::with(['warehouse:id,name', 'product:id,name', 'creator:id,name'])
->orderBy('created_at', 'desc');
if ($request->filled('warehouse_id')) {
$query->where('warehouse_id', $request->input('warehouse_id'));
}
if ($request->filled('type')) {
$query->where('type', $request->input('type'));
}
$data['movements'] = $query->paginate(15)->withQueryString();
}
return view('admin.warehouses.inventory', $data);
}
/**
* 建立入庫單
*/
public function storeStockIn(Request $request)
{
$validated = $request->validate([
'warehouse_id' => 'required|exists:warehouses,id',
'note' => 'nullable|string|max:500',
'items' => 'required|array|min:1',
'items.*.product_id' => 'required|exists:products,id',
'items.*.quantity' => 'required|integer|min:1',
]);
DB::transaction(function () use ($validated) {
$order = StockInOrder::create([
'company_id' => Auth::user()->company_id,
'warehouse_id' => $validated['warehouse_id'],
'order_no' => 'SI-' . now()->format('Ymd') . '-' . str_pad(StockInOrder::whereDate('created_at', today())->count() + 1, 4, '0', STR_PAD_LEFT),
'status' => 'draft',
'note' => $validated['note'] ?? null,
'created_by' => Auth::id(),
]);
foreach ($validated['items'] as $item) {
$order->items()->create($item);
}
});
return redirect()->route('admin.warehouses.inventory', ['tab' => 'stock-in'])
->with('success', __('Stock-in order created'));
}
/**
* 確認入庫單(草稿 → 完成,同時增加庫存)
*/
public function confirmStockIn(StockInOrder $stockInOrder)
{
if ($stockInOrder->status !== 'draft') {
return back()->with('error', __('Order already processed'));
}
DB::transaction(function () use ($stockInOrder) {
$stockInOrder->update([
'status' => 'completed',
'completed_at' => now(),
]);
foreach ($stockInOrder->items as $item) {
// 更新或建立倉庫庫存
$stock = WarehouseStock::firstOrCreate(
['warehouse_id' => $stockInOrder->warehouse_id, 'product_id' => $item->product_id],
['quantity' => 0, 'safety_stock' => 0]
);
$before = $stock->quantity;
$stock->increment('quantity', $item->quantity);
// 記錄異動
StockMovement::create([
'company_id' => $stockInOrder->company_id,
'warehouse_id' => $stockInOrder->warehouse_id,
'product_id' => $item->product_id,
'type' => 'in',
'quantity' => $item->quantity,
'before_qty' => $before,
'after_qty' => $before + $item->quantity,
'reference_type' => StockInOrder::class,
'reference_id' => $stockInOrder->id,
'note' => __('Stock-in order') . ' #' . $stockInOrder->order_no,
'created_by' => Auth::id(),
'created_at' => now(),
]);
}
});
return back()->with('success', __('Stock-in order confirmed and inventory updated'));
}
// ─── 模組 3調撥單 ───
public function transfers(Request $request)
{
$query = TransferOrder::with(['fromWarehouse:id,name', 'toWarehouse:id,name', 'fromMachine:id,name,serial_no', 'creator:id,name', 'items'])
->latest();
if ($request->filled('type')) {
$query->where('type', $request->input('type'));
}
if ($request->filled('status')) {
$query->where('status', $request->input('status'));
}
$orders = $query->paginate(10)->withQueryString();
$warehouses = Warehouse::active()->orderBy('name')->get(['id', 'name', 'type']);
$machines = Machine::orderBy('name')->get(['id', 'name', 'serial_no']);
$products = Product::orderBy('name')->get(['id', 'name', 'image_url']);
return view('admin.warehouses.transfers', compact('orders', 'warehouses', 'machines', 'products'));
}
/**
* 建立調撥單W2W 或 M2W
*/
public function storeTransfer(Request $request)
{
$validated = $request->validate([
'type' => 'required|in:warehouse_to_warehouse,machine_to_warehouse',
'from_warehouse_id' => 'required_if:type,warehouse_to_warehouse|nullable|exists:warehouses,id',
'from_machine_id' => 'required_if:type,machine_to_warehouse|nullable|exists:machines,id',
'to_warehouse_id' => 'required|exists:warehouses,id',
'note' => 'nullable|string|max:500',
'items' => 'required|array|min:1',
'items.*.product_id' => 'required|exists:products,id',
'items.*.quantity' => 'required|integer|min:1',
]);
DB::transaction(function () use ($validated) {
$order = TransferOrder::create([
'company_id' => Auth::user()->company_id,
'order_no' => 'TR-' . now()->format('Ymd') . '-' . str_pad(TransferOrder::whereDate('created_at', today())->count() + 1, 4, '0', STR_PAD_LEFT),
'type' => $validated['type'],
'from_warehouse_id' => $validated['from_warehouse_id'] ?? null,
'from_machine_id' => $validated['from_machine_id'] ?? null,
'to_warehouse_id' => $validated['to_warehouse_id'],
'status' => TransferOrder::STATUS_DRAFT,
'note' => $validated['note'] ?? null,
'created_by' => Auth::id(),
]);
foreach ($validated['items'] as $item) {
$order->items()->create($item);
}
});
return redirect()->route('admin.warehouses.transfers')
->with('success', __('Transfer order created'));
}
/**
* 確認調撥(扣除來源庫存 + 增加目標庫存)
*/
public function confirmTransfer(TransferOrder $transferOrder)
{
if ($transferOrder->status !== TransferOrder::STATUS_DRAFT) {
return back()->with('error', __('Order already processed'));
}
DB::transaction(function () use ($transferOrder) {
$transferOrder->loadMissing('items');
foreach ($transferOrder->items as $item) {
// 扣除來源庫存(僅 W2W
if ($transferOrder->type === TransferOrder::TYPE_W2W && $transferOrder->from_warehouse_id) {
$fromStock = WarehouseStock::where('warehouse_id', $transferOrder->from_warehouse_id)
->where('product_id', $item->product_id)->first();
if (!$fromStock || $fromStock->quantity < $item->quantity) {
throw new \Exception(__('Insufficient stock for transfer'));
}
$beforeFrom = $fromStock->quantity;
$fromStock->decrement('quantity', $item->quantity);
StockMovement::create([
'company_id' => $transferOrder->company_id,
'warehouse_id' => $transferOrder->from_warehouse_id,
'product_id' => $item->product_id,
'type' => 'transfer_out',
'quantity' => $item->quantity,
'before_qty' => $beforeFrom,
'after_qty' => $beforeFrom - $item->quantity,
'reference_type' => TransferOrder::class,
'reference_id' => $transferOrder->id,
'note' => __('Transfer out') . ' #' . $transferOrder->order_no,
'created_by' => Auth::id(),
'created_at' => now(),
]);
}
// 增加目標倉庫庫存
$toStock = WarehouseStock::firstOrCreate(
['warehouse_id' => $transferOrder->to_warehouse_id, 'product_id' => $item->product_id],
['quantity' => 0, 'safety_stock' => 0]
);
$beforeTo = $toStock->quantity;
$toStock->increment('quantity', $item->quantity);
StockMovement::create([
'company_id' => $transferOrder->company_id,
'warehouse_id' => $transferOrder->to_warehouse_id,
'product_id' => $item->product_id,
'type' => 'transfer_in',
'quantity' => $item->quantity,
'before_qty' => $beforeTo,
'after_qty' => $beforeTo + $item->quantity,
'reference_type' => TransferOrder::class,
'reference_id' => $transferOrder->id,
'note' => __('Transfer in') . ' #' . $transferOrder->order_no,
'created_by' => Auth::id(),
'created_at' => now(),
]);
}
$transferOrder->update([
'status' => TransferOrder::STATUS_COMPLETED,
'completed_at' => now(),
]);
});
return back()->with('success', __('Transfer completed'));
}
// ─── 模組 4機台庫存總覽 ───
public function machineInventory(Request $request)
{
$query = Machine::with(['slots' => fn($q) => $q->with('product:id,name,image_url')->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')])
->withCount('slots as total_slots')
->withSum('slots as total_stock', 'stock');
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('serial_no', 'like', "%{$search}%");
});
}
$machines = $query->orderBy('name')
->paginate($request->input('per_page', 10))
->withQueryString();
return view('admin.warehouses.machine-inventory', compact('machines'));
}
/**
* AJAX取得單台機台貨道詳情
*/
public function machineSlots(Machine $machine)
{
$slots = $machine->slots()
->with('product:id,name,image_url')
->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')
->get();
return response()->json(['success' => true, 'slots' => $slots]);
}
// ─── 模組 5機台補貨 ───
public function replenishments(Request $request)
{
$query = ReplenishmentOrder::with(['warehouse:id,name', 'machine:id,name,serial_no', 'assignee:id,name', 'creator:id,name'])
->withCount('items')
->latest();
if ($request->filled('status')) {
$query->where('status', $request->input('status'));
}
$orders = $query->paginate(10)->withQueryString();
$warehouses = Warehouse::active()->orderBy('name')->get(['id', 'name']);
$machines = Machine::orderBy('name')->get(['id', 'name', 'serial_no']);
return view('admin.warehouses.replenishments', compact('orders', 'warehouses', 'machines'));
}
/**
* 建立補貨單
*/
public function storeReplenishment(Request $request)
{
$validated = $request->validate([
'warehouse_id' => 'required|exists:warehouses,id',
'machine_id' => 'required|exists:machines,id',
'note' => 'nullable|string|max:500',
'items' => 'required|array|min:1',
'items.*.product_id' => 'required|exists:products,id',
'items.*.slot_no' => 'required|integer',
'items.*.quantity' => 'required|integer|min:1',
]);
DB::transaction(function () use ($validated) {
$order = ReplenishmentOrder::create([
'company_id' => Auth::user()->company_id,
'order_no' => 'RP-' . now()->format('Ymd') . '-' . str_pad(ReplenishmentOrder::whereDate('created_at', today())->count() + 1, 4, '0', STR_PAD_LEFT),
'warehouse_id' => $validated['warehouse_id'],
'machine_id' => $validated['machine_id'],
'status' => ReplenishmentOrder::STATUS_PENDING,
'note' => $validated['note'] ?? null,
'created_by' => Auth::id(),
]);
foreach ($validated['items'] as $item) {
$order->items()->create($item);
}
});
return redirect()->route('admin.warehouses.replenishments')
->with('success', __('Replenishment order created'));
}
/**
* 確認補貨完成(扣倉庫庫存 + 增加機台貨道庫存)
*/
public function confirmReplenishment(ReplenishmentOrder $replenishmentOrder)
{
if ($replenishmentOrder->status === ReplenishmentOrder::STATUS_COMPLETED) {
return back()->with('error', __('Order already completed'));
}
DB::transaction(function () use ($replenishmentOrder) {
$replenishmentOrder->loadMissing('items');
foreach ($replenishmentOrder->items as $item) {
// 扣除倉庫庫存
$stock = WarehouseStock::where('warehouse_id', $replenishmentOrder->warehouse_id)
->where('product_id', $item->product_id)->first();
if ($stock && $stock->quantity >= $item->quantity) {
$before = $stock->quantity;
$stock->decrement('quantity', $item->quantity);
StockMovement::create([
'company_id' => $replenishmentOrder->company_id,
'warehouse_id' => $replenishmentOrder->warehouse_id,
'product_id' => $item->product_id,
'type' => 'out',
'quantity' => $item->quantity,
'before_qty' => $before,
'after_qty' => $before - $item->quantity,
'reference_type' => ReplenishmentOrder::class,
'reference_id' => $replenishmentOrder->id,
'note' => __('Replenishment') . ' #' . $replenishmentOrder->order_no,
'created_by' => Auth::id(),
'created_at' => now(),
]);
}
// 增加機台貨道庫存
MachineSlot::where('machine_id', $replenishmentOrder->machine_id)
->where('slot_no', $item->slot_no)
->increment('stock', $item->quantity);
}
$replenishmentOrder->update([
'status' => ReplenishmentOrder::STATUS_COMPLETED,
'completed_at' => now(),
]);
});
return back()->with('success', __('Replenishment completed'));
}
}