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,包含倉儲與商品模組相關詞彙。
510 lines
20 KiB
PHP
510 lines
20 KiB
PHP
<?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'));
|
||
}
|
||
}
|