star-cloud/app/Http/Controllers/Admin/WarehouseController.php
sky121113 9bc6450fac [FEAT] 遷移機台系統設定至機台管理模組並優化 UI
1. 修正客戶管理頁面 H1 標題與麵包屑導覽路徑。
2. 在機台編輯頁面實作分頁功能(基本、營運、機台系統設定)。
3. 實作極簡奢華風的機台系統設定 UI,按功能模組(電子發票、刷卡、支付、購物車等)進行卡片式分組。
4. 將系統設定資料從 Company JSON 遷移至 Machine 資料表獨立欄位,並更新關聯控制器與模型邏輯。
5. 清理 CompanyController 與視圖中的冗餘設定邏輯。
6. 完成現有資料遷移,確保機台正確繼承客戶層級之設定。
7. 優化倉儲管理「現有庫存」矩陣視圖與導覽功能。
2026-04-24 08:30:25 +08:00

767 lines
29 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()
->with(['company'])
->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(),
];
$companies = collect();
if (Auth::user()->isSystemAdmin()) {
$companies = \App\Models\System\Company::active()->orderBy('name')->get();
}
return view('admin.warehouses.index', compact('warehouses', 'stats', 'companies'));
}
public function store(Request $request)
{
$validated = $request->validate([
'company_id' => Auth::user()->isSystemAdmin() ? 'nullable|exists:companies,id' : 'nullable',
'name' => 'required|string|max:255',
'type' => 'required|in:main,branch',
'address' => 'nullable|string|max:500',
], [], [
'name' => __('Warehouse Name'),
'type' => __('Warehouse Type'),
'company_id' => __('Company Name'),
]);
if (!Auth::user()->isSystemAdmin()) {
$validated['company_id'] = Auth::user()->company_id;
}
Warehouse::create($validated);
if ($request->ajax() || $request->wantsJson()) {
return response()->json([
'success' => true,
'message' => __('Warehouse created successfully'),
]);
}
return redirect()->route('admin.warehouses.index')
->with('success', __('Warehouse created successfully'));
}
public function update(Request $request, Warehouse $warehouse)
{
$rules = [
'name' => 'required|string|max:255',
'type' => 'required|in:main,branch',
'address' => 'nullable|string|max:500',
'is_active' => 'required|boolean',
];
if (Auth::user()->isSystemAdmin()) {
$rules['company_id'] = 'nullable|exists:companies,id';
}
$validated = $request->validate($rules, [], [
'name' => __('Warehouse Name'),
'type' => __('Warehouse Type'),
'company_id' => __('Company Name'),
]);
$warehouse->update($validated);
if ($request->ajax() || $request->wantsJson()) {
return response()->json([
'success' => true,
'message' => __('Warehouse updated successfully'),
]);
}
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') {
$inventory_warehouses = Warehouse::active()->orderBy('name')->get(['id', 'name', 'type']);
$query = Product::with(['stocks' => function($q) {
$q->select('id', 'product_id', 'warehouse_id', 'quantity', 'safety_stock');
}, 'translations']);
if ($request->filled('warehouse_id')) {
$query->whereHas('stocks', fn($q) => $q->where('warehouse_id', $request->input('warehouse_id')));
}
if ($search = $request->input('search')) {
$query->where('name', 'like', "%{$search}%");
}
$data['inventory_warehouses'] = $inventory_warehouses;
$data['products'] = $query->orderBy('name')
->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($request->input('per_page', 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($request->input('per_page', 15))->withQueryString();
}
if ($request->ajax()) {
return response()->json([
'success' => true,
'tab' => $tab,
'html' => view('admin.warehouses.partials.tab-' . $tab, $data)->render()
]);
}
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);
}
});
if ($request->ajax()) {
return response()->json([
'success' => true,
'message' => __('Stock-in order created')
]);
}
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);
}
});
if ($request->ajax()) {
return response()->json([
'success' => true,
'message' => __('Transfer order created')
]);
}
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);
}
});
if ($request->ajax()) {
return response()->json([
'success' => true,
'message' => __('Replenishment order created')
]);
}
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'));
}
/**
* AJAX取得來源庫存倉庫或機台
*/
public function getStockAjax(Request $request)
{
$warehouseId = $request->input('warehouse_id');
$machineId = $request->input('machine_id');
try {
if ($warehouseId) {
// Warehouse uses TenantScoped, findOrFail will respect it
$warehouse = Warehouse::findOrFail($warehouseId);
$stocks = WarehouseStock::with('product:id,name,image_url')
->where('warehouse_id', $warehouse->id)
->where('quantity', '>', 0)
->get();
$data = $stocks->map(fn($s) => [
'id' => $s->product_id,
'name' => $s->product?->name ?? 'Unknown',
'quantity' => $s->quantity,
'image' => $s->product?->image_url
]);
} elseif ($machineId) {
// Machine uses TenantScoped and machine_access scope
$machine = Machine::findOrFail($machineId);
$slots = MachineSlot::with('product:id,name,image_url')
->where('machine_id', $machine->id)
->where('stock', '>', 0)
->get()
->groupBy('product_id');
$data = $slots->map(function($group) {
$p = $group->first()->product;
return [
'id' => $group->first()->product_id,
'name' => $p?->name ?? 'Unknown',
'quantity' => $group->sum('stock'),
'image' => $p?->image_url
];
})->values();
} else {
return response()->json(['success' => false, 'message' => 'No source provided']);
}
} catch (\Exception $e) {
return response()->json(['success' => false, 'message' => __('Unauthorized or not found')]);
}
return response()->json(['success' => true, 'data' => $data]);
}
/**
* AJAX取得倉庫商品庫存詳情 (用於滑出面板)
*/
public function warehouseStocks(Warehouse $warehouse, Request $request)
{
$query = $warehouse->stocks()->with('product:id,name,image_url');
if ($search = $request->input('search')) {
$query->whereHas('product', fn($q) => $q->where('name', 'like', "%{$search}%"));
}
$stocks = $query->get();
return response()->json([
'success' => true,
'warehouse' => [
'id' => $warehouse->id,
'name' => $warehouse->name,
'type' => $warehouse->type,
],
'stocks' => $stocks->map(fn($s) => [
'id' => $s->id,
'product_id' => $s->product_id,
'product_name' => $s->product?->name ?? 'Unknown',
'image_url' => $s->product?->image_url,
'quantity' => (int)$s->quantity,
'safety_stock' => (int)$s->safety_stock,
])
]);
}
/**
* AJAX取得進貨單詳情
*/
public function stockInOrderDetails(StockInOrder $order)
{
$order->load(['warehouse', 'creator', 'items.product']);
return response()->json([
'success' => true,
'order' => [
'id' => $order->id,
'order_no' => $order->order_no,
'status' => $order->status,
'warehouse_name' => $order->warehouse?->name,
'creator_name' => $order->creator?->name,
'created_at' => $order->created_at?->format('Y-m-d H:i:s'),
'completed_at' => $order->completed_at?->format('Y-m-d H:i:s'),
'note' => $order->note,
],
'items' => $order->items->map(fn($item) => [
'product_name' => $item->product?->name ?? 'Unknown',
'image_url' => $item->product?->image_url,
'quantity' => $item->quantity,
])
]);
}
/**
* Get replenishment order details for AJAX.
*/
public function replenishmentOrderDetails(Request $request, $id)
{
$order = \App\Models\WarehouseReplenishment::with(['machine', 'warehouse', 'creator'])
->where('id', $id)
->firstOrFail();
$items = \App\Models\WarehouseReplenishmentItem::with('product')
->where('replenishment_id', $id)
->get()
->map(function($item) {
return [
'product_id' => $item->product_id,
'product_name' => $item->product?->name ?? 'Unknown',
'image_url' => $item->product?->image_url,
'quantity' => $item->quantity,
'slot_no' => $item->slot_no,
];
});
return response()->json([
'success' => true,
'order' => [
'id' => $order->id,
'order_no' => $order->order_no,
'status' => $order->status,
'machine_name' => $order->machine?->name,
'warehouse_name' => $order->warehouse?->name,
'creator_name' => $order->creator?->name,
'note' => $order->note,
'created_at' => $order->created_at->format('Y-m-d H:i:s'),
'completed_at' => $order->completed_at?->format('Y-m-d H:i:s'),
],
'items' => $items
]);
}
/**
* Get transfer order details for AJAX.
*/
public function transferOrderDetails(Request $request, $id)
{
$order = \App\Models\WarehouseTransfer::with(['fromWarehouse', 'toWarehouse', 'fromMachine', 'creator'])
->where('id', $id)
->firstOrFail();
$items = \App\Models\WarehouseTransferItem::with('product')
->where('transfer_id', $id)
->get()
->map(function($item) {
return [
'product_id' => $item->product_id,
'product_name' => $item->product?->name ?? 'Unknown',
'image_url' => $item->product?->image_url,
'quantity' => $item->quantity,
];
});
return response()->json([
'success' => true,
'order' => [
'id' => $order->id,
'order_no' => $order->order_no,
'status' => $order->status,
'type' => $order->type,
'from_name' => $order->type === 'warehouse_to_warehouse' ? $order->fromWarehouse?->name : $order->fromMachine?->name,
'to_warehouse_name' => $order->toWarehouse?->name,
'creator_name' => $order->creator?->name,
'note' => $order->note,
'created_at' => $order->created_at->format('Y-m-d H:i:s'),
'completed_at' => $order->completed_at?->format('Y-m-d H:i:s'),
],
'items' => $items
]);
}
}