star-cloud/app/Http/Controllers/Admin/WarehouseController.php

1358 lines
54 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\Machine\MachineStockMovement;
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();
$isAjax = $request->ajax();
$data = compact('warehouses', 'tab');
// 1. Current Stocks (stock)
if (!$isAjax || $tab === 'stock') {
$warehouse_ids = $request->input('warehouse_ids');
$inventory_warehouses = Warehouse::active()
->when($warehouse_ids, fn($q) => $q->whereIn('id', (array)$warehouse_ids))
->orderBy('name')
->get(['id', 'name', 'type']);
$query = Product::with(['stocks' => function($q) {
$q->select('id', 'product_id', 'warehouse_id', 'quantity', 'safety_stock');
}, 'translations']);
if ($warehouse_ids) {
$query->whereHas('stocks', fn($q) => $q->whereIn('warehouse_id', (array)$warehouse_ids));
}
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), ['*'], 'stock_page')
->withQueryString();
}
// 2. Stock-In Orders (stock-in)
if (!$isAjax || $tab === 'stock-in') {
$query = StockInOrder::with(['warehouse:id,name', 'creator:id,name'])
->withCount('items')
->latest();
if ($request->filled('status')) {
$query->where('status', $request->input('status'));
}
if ($search = $request->input('search_order')) {
$query->where('order_no', 'like', "%{$search}%");
}
if ($request->filled('start_date') && $request->filled('end_date')) {
$query->whereBetween('created_at', [$request->start_date, $request->end_date]);
}
$data['orders'] = $query->paginate($request->input('per_page', 10), ['*'], 'order_page')->withQueryString();
$data['stock_in_products'] = Product::with('translations')->orderBy('name')->get(['id', 'name', 'image_url', 'name_dictionary_key']);
}
// 3. Movement History (movements)
if (!$isAjax || $tab === 'movements') {
$query = StockMovement::with(['warehouse:id,name', 'product.translations', 'creator:id,name'])
->orderBy('created_at', 'desc')
->orderBy('id', 'desc');
if ($request->filled('warehouse_id')) {
$query->where('warehouse_id', $request->input('warehouse_id'));
}
if ($request->filled('type')) {
$query->where('type', $request->input('type'));
}
if ($request->filled('start_date') && $request->filled('end_date')) {
$query->whereBetween('created_at', [$request->start_date, $request->end_date]);
}
$data['movements'] = $query->paginate($request->input('per_page', 15), ['*'], 'movement_page')->withQueryString();
}
if ($isAjax) {
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',
], [
'warehouse_id.required' => __('Please select target warehouse'),
'items.required' => __('Please add at least one item'),
'items.*.product_id.required' => __('Please select product for item :num', ['num' => ':position']),
'items.*.quantity.min' => __('Please enter valid quantity for item :num', ['num' => ':position']),
]);
DB::transaction(function () use ($validated) {
$prefix = 'SI-' . now()->format('Ymd') . '-';
$lastOrder = StockInOrder::withoutGlobalScopes()
->withTrashed()
->where('order_no', 'like', $prefix . '%')
->orderBy('order_no', 'desc')
->first();
$nextNumber = $lastOrder ? (int)substr($lastOrder->order_no, -4) + 1 : 1;
$order_no = $prefix . str_pad($nextNumber, 4, '0', STR_PAD_LEFT);
$order = StockInOrder::create([
'company_id' => Auth::user()->company_id,
'warehouse_id' => $validated['warehouse_id'],
'order_no' => $order_no,
'status' => 'draft',
'note' => $validated['note'] ?? null,
'created_by' => Auth::id(),
]);
foreach ($validated['items'] as $item) {
$order->items()->create($item);
}
});
if ($request->ajax()) {
session()->flash('success', __('Stock-in order created successfully'));
return response()->json([
'success' => true,
'message' => __('Stock-in order created successfully')
]);
}
return redirect()->route('admin.warehouses.inventory', ['tab' => 'stock-in'])
->with('success', __('Stock-in order created successfully'));
}
/**
* 確認入庫單(草稿 → 完成,同時增加庫存)
*/
public function confirmStockIn(StockInOrder $stockInOrder)
{
if ($stockInOrder->status !== StockInOrder::STATUS_DRAFT) {
if (request()->ajax()) {
return response()->json(['success' => false, 'message' => __('Order already processed')], 422);
}
return back()->with('error', __('Order already processed'));
}
DB::transaction(function () use ($stockInOrder) {
$stockInOrder->update([
'status' => StockInOrder::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(),
]);
}
});
if (request()->ajax()) {
return response()->json([
'success' => true,
'message' => __('Stock-in order confirmed and inventory updated')
]);
}
return back()->with('success', __('Stock-in order confirmed and inventory updated'));
}
/**
* 刪除入庫單(僅限草稿)
*/
public function destroyStockIn(StockInOrder $stockInOrder)
{
if ($stockInOrder->status !== StockInOrder::STATUS_DRAFT) {
if (request()->ajax()) {
return response()->json(['success' => false, 'message' => __('Only draft orders can be deleted')], 422);
}
return back()->with('error', __('Only draft orders can be deleted'));
}
$stockInOrder->delete(); // Soft delete
if (request()->ajax()) {
return response()->json([
'success' => true,
'message' => __('Stock-in order deleted successfully')
]);
}
return back()->with('success', __('Stock-in order deleted successfully'));
}
// ─── 模組 3調撥單 ───
public function transfers(Request $request)
{
$query = TransferOrder::with(['fromWarehouse:id,name', 'toWarehouse:id,name', 'fromMachine:id,name,serial_no', 'creator:id,name'])
->withCount('items')
->latest();
if ($request->filled('type')) {
$query->where('type', $request->input('type'));
}
if ($request->filled('status')) {
$query->where('status', $request->input('status'));
}
if ($search = $request->input('search_order')) {
$query->where('order_no', 'like', "%{$search}%");
}
if ($request->filled('start_date') && $request->filled('end_date')) {
$query->whereBetween('created_at', [$request->start_date, $request->end_date]);
}
$orders = $query->paginate($request->input('per_page', 15))->withQueryString();
if ($request->ajax()) {
return response()->json([
'success' => true,
'html' => view('admin.warehouses.partials.table-transfers', compact('orders'))->render()
]);
}
$warehouses = Warehouse::active()->orderBy('name')->get(['id', 'name']);
$machines = Machine::orderBy('name')->get(['id', 'name', 'serial_no']);
$products = Product::with('translations')->orderBy('name')->get(['id', 'name', 'image_url', 'name_dictionary_key']);
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',
], [
'to_warehouse_id.required' => __('Please select target warehouse'),
'from_warehouse_id.required_if' => __('Please select source warehouse'),
'from_machine_id.required_if' => __('Please select source machine'),
'items.required' => __('Please add at least one item'),
'items.*.product_id.required' => __('Please select product for item :num', ['num' => ':position']),
'items.*.quantity.min' => __('Please enter valid quantity for item :num', ['num' => ':position']),
]);
DB::transaction(function () use ($validated) {
$prefix = 'TR-' . now()->format('Ymd') . '-';
$lastOrder = TransferOrder::withoutGlobalScopes()
->withTrashed()
->where('order_no', 'like', $prefix . '%')
->orderBy('order_no', 'desc')
->first();
$nextNumber = $lastOrder ? (int)substr($lastOrder->order_no, -4) + 1 : 1;
$order_no = $prefix . str_pad($nextNumber, 4, '0', STR_PAD_LEFT);
$order = TransferOrder::create([
'company_id' => Auth::user()->company_id,
'order_no' => $order_no,
'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()) {
session()->flash('success', __('Transfer order created successfully'));
return response()->json([
'success' => true,
'message' => __('Transfer order created successfully')
]);
}
return redirect()->route('admin.warehouses.transfers')
->with('success', __('Transfer order created successfully'));
}
/**
* 確認調撥(扣除來源庫存 + 增加目標庫存)
*/
public function confirmTransfer(TransferOrder $transferOrder)
{
if ($transferOrder->status !== TransferOrder::STATUS_DRAFT) {
if (request()->ajax()) {
return response()->json(['success' => false, 'message' => __('Order already processed')], 422);
}
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(),
]);
}
// 增加目標倉庫庫存
$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(),
]);
}
$transferOrder->update([
'status' => TransferOrder::STATUS_COMPLETED,
'completed_at' => now(),
]);
});
if (request()->ajax()) {
return response()->json([
'success' => true,
'message' => __('Transfer order confirmed and inventory updated')
]);
}
return back()->with('success', __('Transfer order confirmed and inventory updated'));
}
/**
* 刪除調撥單(僅限草稿)
*/
public function destroyTransfer(TransferOrder $transferOrder)
{
if ($transferOrder->status !== TransferOrder::STATUS_DRAFT) {
if (request()->ajax()) {
return response()->json(['success' => false, 'message' => __('Only draft orders can be deleted')], 422);
}
return back()->with('error', __('Only draft orders can be deleted'));
}
$transferOrder->delete(); // Soft delete
if (request()->ajax()) {
return response()->json([
'success' => true,
'message' => __('Transfer order deleted successfully')
]);
}
return back()->with('success', __('Transfer order deleted successfully'));
}
// ─── 模組 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')
->withSum('slots as total_capacity', 'max_stock');
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('serial_no', 'like', "%{$search}%");
});
}
// 預設按庫存率由低到高排序 (補貨優先),在庫存率相同時再按名稱排序
$machines = $query->orderByRaw('CASE WHEN total_capacity > 0 THEN (total_stock / total_capacity) ELSE 1 END ASC')
->orderBy('name')
->paginate($request->input('per_page', 10))
->withQueryString();
if ($request->ajax()) {
return response()->json([
'success' => true,
'html' => view('admin.warehouses.partials.list-machines', compact('machines'))->render()
]);
}
return view('admin.warehouses.machine-inventory', compact('machines'));
}
/**
* AJAX取得單台機台貨道詳情
*/
public function machineSlots(Machine $machine)
{
$slots = $machine->slots()
->with(['product:id,name,image_url,barcode,price'])
->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')
->get();
// 取得流水帳中出現過的所有貨道編號 (包含已移除的)
$historicalSlotNos = MachineStockMovement::where('machine_id', $machine->id)
->distinct()
->pluck('slot_no')
->map(fn($val) => (string)$val)
->toArray();
return response()->json([
'success' => true,
'machine' => $machine,
'slots' => $slots,
'historical_slot_nos' => $historicalSlotNos
]);
}
/**
* 機台庫存異動紀錄 (AJAX) — 供 Slide-out Panel 使用
*/
public function machineStockMovements(Request $request, Machine $machine)
{
$query = $machine->stockMovements()
->with(['product:id,name', 'creator:id,name'])
->latest();
if ($request->filled('type')) {
$query->where('type', $request->input('type'));
}
if ($request->filled('slot_no')) {
$query->where('slot_no', $request->input('slot_no'));
}
if ($request->filled('date_from')) {
$query->whereDate('created_at', '>=', $request->input('date_from'));
}
if ($request->filled('date_to')) {
$query->whereDate('created_at', '<=', $request->input('date_to'));
}
$movements = $query->paginate($request->input('per_page', 20))->withQueryString();
return response()->json([
'success' => true,
'movements' => $movements->items(),
'meta' => [
'current_page' => $movements->currentPage(),
'last_page' => $movements->lastPage(),
'total' => $movements->total(),
],
]);
}
// ─── 模組 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'));
}
if ($search = $request->input('search_order')) {
$query->where('order_no', 'like', "%{$search}%");
}
if ($request->filled('start_date') && $request->filled('end_date')) {
$query->whereBetween('created_at', [$request->start_date, $request->end_date]);
}
$orders = $query->paginate($request->input('per_page', 10))->withQueryString();
if ($request->ajax()) {
return response()->json([
'success' => true,
'html' => view('admin.warehouses.partials.table-replenishments', compact('orders'))->render()
]);
}
$warehouses = Warehouse::active()->orderBy('name')->get(['id', 'name']);
$machines = Machine::orderBy('name')->get(['id', 'name', 'serial_no']);
$products = Product::with('translations')->orderBy('name')->get(['id', 'name', 'image_url', 'name_dictionary_key']);
// 同租戶帳號(用於指派人員)
$userQuery = \App\Models\System\User::where('status', 1)->orderBy('name');
if (!Auth::user()->isSystemAdmin()) {
$userQuery->where('company_id', Auth::user()->company_id);
}
$users = $userQuery->get(['id', 'name']);
return view('admin.warehouses.replenishments', compact('orders', 'warehouses', 'machines', 'products', 'users'));
}
/**
* 建立補貨單(含 current_stock/max_stock 快照)
*/
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',
'items.*.current_stock' => 'nullable|integer|min:0',
'items.*.max_stock' => 'nullable|integer|min:0',
], [
'warehouse_id.required' => __('Please select source warehouse'),
'machine_id.required' => __('Please select target machine'),
'items.required' => __('Please add at least one item'),
'items.*.product_id.required' => __('Please select product for item :num', ['num' => ':position']),
'items.*.quantity.min' => __('Please enter valid quantity for item :num', ['num' => ':position']),
]);
DB::transaction(function () use ($validated) {
$prefix = 'RP-' . now()->format('Ymd') . '-';
$lastOrder = ReplenishmentOrder::withoutGlobalScopes()
->withTrashed()
->where('order_no', 'like', $prefix . '%')
->orderBy('order_no', 'desc')
->first();
$nextNumber = $lastOrder ? (int)substr($lastOrder->order_no, -4) + 1 : 1;
$order_no = $prefix . str_pad($nextNumber, 4, '0', STR_PAD_LEFT);
$order = ReplenishmentOrder::create([
'company_id' => Auth::user()->company_id,
'order_no' => $order_no,
'warehouse_id' => $validated['warehouse_id'],
'machine_id' => $validated['machine_id'],
'status' => ReplenishmentOrder::STATUS_PENDING,
'note' => $validated['note'] ?? null,
'created_by' => Auth::id(),
]);
// 批量寫入明細(遵循 DB Best Practice: 不在迴圈中逐筆 create
$itemsData = [];
foreach ($validated['items'] as $item) {
$itemsData[] = [
'replenishment_order_id' => $order->id,
'product_id' => $item['product_id'],
'slot_no' => $item['slot_no'],
'quantity' => $item['quantity'],
'current_stock' => $item['current_stock'] ?? 0,
'max_stock' => $item['max_stock'] ?? 0,
'created_at' => now(),
'updated_at' => now(),
];
}
ReplenishmentOrderItem::insert($itemsData);
});
session()->flash('success', __('Replenishment order created successfully'));
return redirect()->route('admin.warehouses.replenishments');
}
/**
* 一鍵補貨 — 自動讀取機台所有貨道,計算 max_stock - stock
*/
public function autoReplenishment(Request $request)
{
$validated = $request->validate([
'warehouse_id' => 'required|exists:warehouses,id',
'machine_id' => 'required|exists:machines,id',
'note' => 'nullable|string|max:500',
'items' => 'nullable|array',
'items.*.slot_no' => 'required_with:items|integer',
'items.*.product_id' => 'required_with:items|exists:products,id',
'items.*.quantity' => 'required_with:items|integer|min:1',
'items.*.current_stock' => 'nullable|integer|min:0',
'items.*.max_stock' => 'nullable|integer|min:0',
]);
$machine = Machine::findOrFail($validated['machine_id']);
// 如果前端未提供 items首次請求自動計算補貨清單
if (empty($validated['items'])) {
$slots = MachineSlot::with('product:id,name,image_url,name_dictionary_key')
->where('machine_id', $machine->id)
->whereNotNull('product_id')
->whereColumn('stock', '<', 'max_stock')
->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')
->get();
if ($slots->isEmpty()) {
return response()->json([
'success' => false,
'message' => __('All slots are fully stocked')
], 422);
}
// 檢查倉庫庫存
$warehouse = Warehouse::findOrFail($validated['warehouse_id']);
$warehouseStocks = WarehouseStock::where('warehouse_id', $warehouse->id)
->pluck('quantity', 'product_id');
// 按商品彙總需求量
$demandByProduct = [];
foreach ($slots as $slot) {
$pid = $slot->product_id;
$need = $slot->max_stock - $slot->stock;
$demandByProduct[$pid] = ($demandByProduct[$pid] ?? 0) + $need;
}
// 檢查是否有庫存不足的商品
$insufficientProducts = [];
foreach ($demandByProduct as $pid => $totalNeed) {
$available = $warehouseStocks[$pid] ?? 0;
if ($available < $totalNeed) {
$insufficientProducts[$pid] = [
'available' => $available,
'needed' => $totalNeed,
];
}
}
$slotData = $slots->map(fn($s) => [
'slot_no' => $s->slot_no,
'product_id' => $s->product_id,
'product_name' => $s->product?->localized_name ?? 'Unknown',
'image_url' => $s->product?->image_url,
'current_stock' => $s->stock,
'max_stock' => $s->max_stock,
'quantity' => $s->max_stock - $s->stock,
]);
return response()->json([
'success' => true,
'preview' => true,
'machine_name' => $machine->name,
'slots' => $slotData,
'insufficient' => $insufficientProducts,
'warehouse_stocks' => $warehouseStocks,
'has_warning' => !empty($insufficientProducts),
]);
}
// 有 items = 確認建單
DB::transaction(function () use ($validated) {
$prefix = 'RP-' . now()->format('Ymd') . '-';
$lastOrder = ReplenishmentOrder::withoutGlobalScopes()
->withTrashed()
->where('order_no', 'like', $prefix . '%')
->orderBy('order_no', 'desc')
->first();
$nextNumber = $lastOrder ? (int)substr($lastOrder->order_no, -4) + 1 : 1;
$order_no = $prefix . str_pad($nextNumber, 4, '0', STR_PAD_LEFT);
$order = ReplenishmentOrder::create([
'company_id' => Auth::user()->company_id,
'order_no' => $order_no,
'warehouse_id' => $validated['warehouse_id'],
'machine_id' => $validated['machine_id'],
'status' => ReplenishmentOrder::STATUS_PENDING,
'note' => $validated['note'] ?? __('Auto Replenishment'),
'created_by' => Auth::id(),
]);
$itemsData = [];
foreach ($validated['items'] as $item) {
$itemsData[] = [
'replenishment_order_id' => $order->id,
'product_id' => $item['product_id'],
'slot_no' => $item['slot_no'],
'quantity' => $item['quantity'],
'current_stock' => $item['current_stock'] ?? 0,
'max_stock' => $item['max_stock'] ?? 0,
'created_at' => now(),
'updated_at' => now(),
];
}
ReplenishmentOrderItem::insert($itemsData);
});
session()->flash('success', __('Replenishment order created successfully'));
return response()->json([
'success' => true,
'message' => __('Replenishment order created successfully')
]);
}
/**
* 狀態推進 (pending → prepared → delivering → completed)
*/
public function updateReplenishmentStatus(Request $request, ReplenishmentOrder $replenishmentOrder)
{
$validated = $request->validate([
'status' => 'required|in:prepared,delivering,completed',
'assigned_to' => 'nullable|exists:users,id',
]);
$newStatus = $validated['status'];
if (!$replenishmentOrder->canTransitionTo($newStatus)) {
return response()->json([
'success' => false,
'message' => __('Invalid status transition')
], 422);
}
DB::transaction(function () use ($replenishmentOrder, $newStatus, $validated) {
$replenishmentOrder->loadMissing('items');
// 更新指派人員 (如果有傳遞的話)
if (array_key_exists('assigned_to', $validated)) {
$replenishmentOrder->assigned_to = $validated['assigned_to'];
}
// pending → prepared扣減倉庫庫存
if ($newStatus === ReplenishmentOrder::STATUS_PREPARED) {
foreach ($replenishmentOrder->items as $item) {
$stock = WarehouseStock::firstOrCreate(
[
'warehouse_id' => $replenishmentOrder->warehouse_id,
'product_id' => $item->product_id,
],
[
'company_id' => $replenishmentOrder->company_id,
'quantity' => 0,
]
);
$stock->lockForUpdate();
$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 prepare') . ' #' . $replenishmentOrder->order_no,
'created_by' => Auth::id(),
]);
}
}
// delivering → completed增加機台貨道庫存並記錄異動
if ($newStatus === ReplenishmentOrder::STATUS_COMPLETED) {
foreach ($replenishmentOrder->items as $item) {
$slot = MachineSlot::where('machine_id', $replenishmentOrder->machine_id)
->where('slot_no', $item->slot_no)
->lockForUpdate()
->first();
if ($slot) {
$beforeQty = $slot->stock;
$slot->increment('stock', $item->quantity);
$slot->refresh();
MachineStockMovement::create([
'company_id' => $replenishmentOrder->company_id,
'machine_id' => $replenishmentOrder->machine_id,
'product_id' => $item->product_id,
'slot_no' => $item->slot_no,
'type' => MachineStockMovement::TYPE_REPLENISHMENT,
'quantity' => $item->quantity,
'before_qty' => $beforeQty,
'after_qty' => $slot->stock,
'reference_type' => ReplenishmentOrder::class,
'reference_id' => $replenishmentOrder->id,
'note' => '補貨單完成入庫 #' . $replenishmentOrder->order_no,
'created_by' => Auth::id(),
]);
}
}
$replenishmentOrder->completed_at = now();
}
$replenishmentOrder->status = $newStatus;
$replenishmentOrder->save();
});
$statusLabels = [
'prepared' => __('Confirm Prepare'),
'delivering' => __('Start Delivery'),
'completed' => __('Confirm Complete'),
];
$statusMessages = [
'prepared' => __('Order prepared successfully, stock deducted'),
'delivering' => __('Order is now in delivery'),
'completed' => __('Order completed and stock updated'),
];
session()->flash('success', $statusMessages[$newStatus] ?? __('Status updated successfully'));
return response()->json([
'success' => true,
'message' => __('Status updated successfully')
]);
}
/**
* 取消補貨單 — 若已備貨/配送中則退回倉庫庫存
*/
public function cancelReplenishment(ReplenishmentOrder $replenishmentOrder)
{
if (!$replenishmentOrder->canTransitionTo(ReplenishmentOrder::STATUS_CANCELLED)) {
return response()->json([
'success' => false,
'message' => __('Cannot cancel this order')
], 422);
}
DB::transaction(function () use ($replenishmentOrder) {
// 若已備貨(prepared)或配送中(delivering),需退回倉庫庫存
if (in_array($replenishmentOrder->status, [ReplenishmentOrder::STATUS_PREPARED, ReplenishmentOrder::STATUS_DELIVERING])) {
$replenishmentOrder->loadMissing('items');
foreach ($replenishmentOrder->items as $item) {
$stock = WarehouseStock::firstOrCreate(
[
'warehouse_id' => $replenishmentOrder->warehouse_id,
'product_id' => $item->product_id,
],
[
'company_id' => $replenishmentOrder->company_id,
'quantity' => 0,
]
);
$stock->lockForUpdate();
$before = $stock->quantity;
$stock->increment('quantity', $item->quantity);
StockMovement::create([
'company_id' => $replenishmentOrder->company_id,
'warehouse_id' => $replenishmentOrder->warehouse_id,
'product_id' => $item->product_id,
'type' => 'in',
'quantity' => $item->quantity,
'before_qty' => $before,
'after_qty' => $before + $item->quantity,
'reference_type' => ReplenishmentOrder::class,
'reference_id' => $replenishmentOrder->id,
'note' => __('Replenishment cancelled, stock returned') . ' #' . $replenishmentOrder->order_no,
'created_by' => Auth::id(),
]);
}
}
$replenishmentOrder->update([
'status' => ReplenishmentOrder::STATUS_CANCELLED,
]);
});
$msg = __('Order has been cancelled');
if (in_array($replenishmentOrder->getOriginal('status'), [ReplenishmentOrder::STATUS_PREPARED, ReplenishmentOrder::STATUS_DELIVERING])) {
$msg .= ' — ' . __('Stock returned to warehouse');
}
session()->flash('success', $msg);
return response()->json([
'success' => true,
'message' => $msg
]);
}
/**
* 指派補貨人員(僅限同租戶帳號)
*/
public function assignReplenishment(Request $request, ReplenishmentOrder $replenishmentOrder)
{
$validated = $request->validate([
'assigned_to' => 'nullable|exists:users,id',
]);
if ($validated['assigned_to']) {
// 確保指派人員屬於同租戶
$user = \App\Models\System\User::where('id', $validated['assigned_to'])
->where('company_id', Auth::user()->company_id)
->first();
if (!$user) {
return response()->json([
'success' => false,
'message' => __('Invalid personnel selection')
], 422);
}
$replenishmentOrder->assigned_to = $user->id;
} else {
$replenishmentOrder->assigned_to = null;
}
session()->flash('success', __('Personnel assigned successfully'));
return response()->json([
'success' => true,
'message' => __('Personnel assigned successfully')
]);
}
/**
* AJAX取得特定機台的貨道資訊供補貨選擇
*/
public function getMachineSlotsForReplenishment(Machine $machine)
{
$slots = $machine->slots()
->with(['product' => fn($q) => $q->select('id', 'name', 'image_url', 'name_dictionary_key')->with('translations')])
->orderByRaw('CAST(slot_no AS UNSIGNED) ASC')
->get();
return response()->json([
'success' => true,
'machine' => [
'id' => $machine->id,
'name' => $machine->name,
'serial_no' => $machine->serial_no,
],
'slots' => $slots->map(fn($s) => [
'slot_no' => $s->slot_no,
'product_id' => $s->product_id,
'product_name' => $s->product?->localized_name ?? null,
'image_url' => $s->product?->image_url,
'stock' => $s->stock,
'max_stock' => $s->max_stock,
'needs_replenishment' => $s->product_id && $s->stock < $s->max_stock,
]),
]);
}
/**
* AJAX取得補貨單詳情
*/
public function replenishmentOrderDetails(Request $request, $id)
{
$order = ReplenishmentOrder::with(['machine', 'warehouse', 'creator', 'assignee'])
->where('id', $id)
->firstOrFail();
$items = ReplenishmentOrderItem::with('product.translations')
->where('replenishment_order_id', $id)
->get()
->map(function ($item) {
return [
'product_id' => $item->product_id,
'product_name' => $item->product?->localized_name ?? 'Unknown',
'image_url' => $item->product?->image_url,
'quantity' => $item->quantity,
'slot_no' => $item->slot_no,
'current_stock' => $item->current_stock,
'max_stock' => $item->max_stock,
];
});
return response()->json([
'success' => true,
'order' => [
'id' => $order->id,
'order_no' => $order->order_no,
'status' => $order->status,
'machine_name' => $order->machine?->name,
'machine_serial' => $order->machine?->serial_no,
'warehouse_name' => $order->warehouse?->name,
'creator_name' => $order->creator?->name,
'assignee_name' => $order->assignee?->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
]);
}
/**
* 舊版 confirmReplenishment 已重構為 updateReplenishmentStatus
* 保留向下相容
*/
public function confirmReplenishment(ReplenishmentOrder $replenishmentOrder)
{
// 模擬直接完成流程pending → completed
request()->merge(['status' => 'completed']);
return $this->updateReplenishmentStatus(request(), $replenishmentOrder);
}
/**
* 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);
// 獲取公司所有啟動中的商品
$products = Product::where('company_id', $warehouse->company_id)
->active()
->select('id', 'name', 'image_url', 'name_dictionary_key')
->with('translations')
->get();
// 獲取目前的庫存量(用於顯示,不限制選擇)
$stocks = WarehouseStock::where('warehouse_id', $warehouse->id)
->pluck('quantity', 'product_id');
$data = $products->map(fn($p) => [
'id' => $p->id,
'name' => $p->localized_name ?? 'Unknown',
'quantity' => $stocks[$p->id] ?? 0,
'image' => $p->image_url
]);
} elseif ($machineId) {
// Machine uses TenantScoped and machine_access scope
$machine = Machine::findOrFail($machineId);
$slots = MachineSlot::with(['product' => fn($q) => $q->select('id', 'name', 'image_url', 'name_dictionary_key')->with('translations')])
->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?->localized_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' => fn($q) => $q->select('id', 'name', 'image_url', 'name_dictionary_key')->with('translations')]);
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?->localized_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.translations']);
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?->localized_name ?? 'Unknown',
'image_url' => $item->product?->image_url,
'quantity' => $item->quantity,
])
]);
}
/**
* Get transfer order details for AJAX.
*/
public function transferOrderDetails(Request $request, $id)
{
$order = TransferOrder::with(['fromWarehouse', 'toWarehouse', 'fromMachine', 'creator'])
->where('id', $id)
->firstOrFail();
$items = \App\Models\Warehouse\TransferOrderItem::with('product.translations')
->where('transfer_order_id', $id)
->get()
->map(function($item) {
return [
'product_id' => $item->product_id,
'product_name' => $item->product?->localized_name ?? __('Unknown'),
'image_url' => $item->product?->image_url,
'quantity' => (int)$item->quantity,
];
});
return response()->json([
'success' => true,
'order' => [
'id' => $order->id,
'order_no' => $order->order_no,
'type' => $order->type,
'from_name' => $order->type === 'warehouse_to_warehouse'
? ($order->fromWarehouse?->name ?? __('Unknown'))
: ($order->fromMachine?->name ?? __('Unknown')),
'to_name' => $order->toWarehouse?->name ?? __('Unknown'),
'status' => $order->status,
'creator_name' => $order->creator?->name ?? __('System'),
'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
]);
}
}