star-cloud/app/Http/Controllers/Admin/WarehouseController.php
sky121113 201663741d [FEAT] 帳號與角色多層級隔離(主/子帳號樹狀 + 角色權限/機台授權子集把關)
1. 主帳號不變量:storeAccount 的 is_admin 改補位(每家公司恰一主帳號)、destroyAccount 禁刪最後主帳號、roles 新增 is_company_admin 旗標保護公司管理員角色不被刪除
2. 帳號樹狀層級:users 新增 parent_id/level(主帳號 level 0、子帳號最多兩層),新增 visibleTo/canManageAccount,帳號清單與管理端點(accounts/update/destroy/toggle、機台授權選帳號、補貨指派)一律套層級可見範圍與越權防護
3. 角色指派子集:storeAccount/updateAccount 指派角色與帳號 Modal 下拉一律限制為「權限為操作者子集」,杜絕權限提升
4. 機台授權子集:MachinePermissionController 僅允許在操作者本身可授權機台範圍內增刪,保留範圍外既有授權,並修正 machine_access 全域 scope 對讀寫的干擾
5. 角色層級隔離:roles 新增 created_by,子帳號只看/只管/只能指派自己建立的角色(主帳號看全公司),edit/update/destroy 加 canBeManagedBy 守衛
6. 補強:MachineSetting 機台權限人員清單補租戶隔離、修正角色下拉 N+1、三語系新增提示字串
7. 測試:新增 AccountHierarchyTest/AccountSubsetGuardTest/RoleHierarchyTest,Admin 套件 55 測試 0 回歸

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 17:03:43 +08:00

1545 lines
62 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)
{
$currentUser = Auth::user();
$companyId = trim((string) $request->input('company_id', ''));
$search = $request->input('search');
$type = $request->input('type');
$applyWarehouseFilters = function ($query) use ($currentUser, $companyId, $search, $type) {
if ($currentUser->isSystemAdmin() && $companyId !== '') {
$query->where('company_id', $companyId);
}
if ($search) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('address', 'like', "%{$search}%");
});
}
if ($type) {
$query->where('type', $type);
}
return $query;
};
$query = Warehouse::query()
->with(['company'])
->withCount('stocks as products_count')
->withSum('stocks as total_stock', 'quantity');
$applyWarehouseFilters($query);
$warehouses = $query->orderBy('type')->orderBy('name')
->paginate($request->input('per_page', 10))
->withQueryString();
$statsWarehouseQuery = fn() => $applyWarehouseFilters(Warehouse::query());
$stats = [
'total' => $statsWarehouseQuery()->count(),
'main' => $statsWarehouseQuery()->main()->count(),
'branch' => $statsWarehouseQuery()->branch()->count(),
'low_stock' => WarehouseStock::lowStock()
->whereHas('warehouse', fn($q) => $applyWarehouseFilters($q))
->count(),
];
$companies = collect();
if ($currentUser->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');
$currentUser = Auth::user();
$companyId = trim((string) $request->input('company_id', ''));
$applyCompanyFilter = fn($query) => $query->when(
$currentUser->isSystemAdmin() && $companyId !== '',
fn($q) => $q->where('company_id', $companyId)
);
$warehouses = $applyCompanyFilter(Warehouse::active())
->orderBy('name')
->get();
$companies = $currentUser->isSystemAdmin()
? \App\Models\System\Company::active()->orderBy('name')->get()
: collect();
$isAjax = $request->ajax();
$data = compact('warehouses', 'companies', 'tab');
// 1. Current Stocks (stock)
if (!$isAjax || $tab === 'stock') {
$warehouse_ids = $request->input('warehouse_ids');
$inventory_warehouses = $applyCompanyFilter(Warehouse::active())
->when($warehouse_ids, fn($q) => $q->whereIn('id', (array)$warehouse_ids))
->orderBy('name')
->get(['id', 'name', 'type', 'company_id']);
$query = Product::with(['stocks' => function($q) {
$q->select('id', 'product_id', 'warehouse_id', 'quantity', 'safety_stock');
}, 'translations']);
if ($currentUser->isSystemAdmin() && $companyId !== '') {
$query->where('company_id', $companyId);
}
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 ($currentUser->isSystemAdmin() && $companyId !== '') {
$query->where('company_id', $companyId);
}
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')) {
if ($request->filled('start_date')) {
$query->where('created_at', '>=', \Illuminate\Support\Carbon::parse($request->start_date));
}
if ($request->filled('end_date')) {
$query->where('created_at', '<=', \Illuminate\Support\Carbon::parse($request->end_date)->endOfMinute());
}
} else {
// 隱性限制:預設只抓 6 個月內的單據以保護效能,但 UI 保持空白
$query->where('created_at', '>=', now()->subMonths(6)->startOfDay());
$data['stockInDefaultStart'] = '';
$data['stockInDefaultEnd'] = '';
}
$data['orders'] = $query->paginate($request->input('per_page', 10), ['*'], 'order_page')->withQueryString();
$data['stock_in_products'] = Product::with('translations')
->when($currentUser->isSystemAdmin() && $companyId !== '', fn($q) => $q->where('company_id', $companyId))
->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 ($currentUser->isSystemAdmin() && $companyId !== '') {
$query->where('company_id', $companyId);
}
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')) {
if ($request->filled('start_date')) {
$query->where('created_at', '>=', \Illuminate\Support\Carbon::parse($request->start_date));
}
if ($request->filled('end_date')) {
$query->where('created_at', '<=', \Illuminate\Support\Carbon::parse($request->end_date)->endOfMinute());
}
} else {
// 預設當天
$start = now()->startOfDay();
$end = now()->endOfDay();
$query->whereBetween('created_at', [$start, $end]);
$data['movementDefaultStart'] = $start->format('Y-m-d H:i');
$data['movementDefaultEnd'] = $end->format('Y-m-d H:i');
// 只有在當前分頁是 movements 時才合併到 request
if ($tab === 'movements') {
$request->merge([
'start_date' => $data['movementDefaultStart'],
'end_date' => $data['movementDefaultEnd']
]);
}
}
$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)
{
$currentUser = Auth::user();
$companyId = trim((string) $request->input('company_id', ''));
$isSystemAdmin = $currentUser->isSystemAdmin();
$query = TransferOrder::with(['fromWarehouse:id,name', 'toWarehouse:id,name', 'fromMachine:id,name,serial_no', 'creator:id,name'])
->withCount('items')
->latest();
if ($isSystemAdmin && $companyId !== '') {
$query->where('company_id', $companyId);
}
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')) {
if ($request->filled('start_date')) {
$query->where('created_at', '>=', \Illuminate\Support\Carbon::parse($request->start_date));
}
if ($request->filled('end_date')) {
$query->where('created_at', '<=', \Illuminate\Support\Carbon::parse($request->end_date)->endOfMinute());
}
} else {
// 隱性限制:預設只抓 6 個月內的單據以保護效能,但 UI 保持空白
$query->where('created_at', '>=', now()->subMonths(6)->startOfDay());
}
$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()
]);
}
$companies = $isSystemAdmin
? \App\Models\System\Company::active()->orderBy('name')->get()
: collect();
$applyCompanyFilter = fn($query) => $query->when(
$isSystemAdmin && $companyId !== '',
fn($q) => $q->where('company_id', $companyId)
);
$warehouses = $applyCompanyFilter(Warehouse::active())->orderBy('name')->get(['id', 'name']);
$machines = $applyCompanyFilter(Machine::query())->orderBy('name')->get(['id', 'name', 'serial_no']);
$products = $applyCompanyFilter(Product::with('translations'))->orderBy('name')->get(['id', 'name', 'image_url', 'name_dictionary_key']);
$defaultStart = '';
$defaultEnd = '';
return view('admin.warehouses.transfers', compact('orders', 'warehouses', 'machines', 'products', 'companies', 'defaultStart', 'defaultEnd'));
}
/**
* 建立調撥單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(),
]);
// 預先取得商品資訊以建立快照
$productIds = collect($validated['items'])->pluck('product_id')->unique()->toArray();
$products = \App\Models\Product\Product::whereIn('id', $productIds)->get()->keyBy('id');
foreach ($validated['items'] as $item) {
$product = $products->get($item['product_id']);
if ($product) {
$item['snapshot_product_name'] = $product->name;
$item['snapshot_product_name_key'] = $product->name_dictionary_key;
$item['snapshot_product_barcode'] = $product->barcode;
$item['snapshot_product_image_url'] = $product->image_url;
}
$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)
{
$currentUser = Auth::user();
$companyId = trim((string) $request->input('company_id', ''));
$isSystemAdmin = $currentUser->isSystemAdmin();
$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 ($isSystemAdmin && $companyId !== '') {
$query->where('company_id', $companyId);
}
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()
]);
}
$companies = $isSystemAdmin
? \App\Models\System\Company::active()->orderBy('name')->get()
: collect();
return view('admin.warehouses.machine-inventory', compact('machines', 'companies'));
}
/**
* 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)
{
$currentUser = Auth::user();
$companyId = trim((string) $request->input('company_id', ''));
$isSystemAdmin = $currentUser->isSystemAdmin();
$query = ReplenishmentOrder::with(['warehouse:id,name', 'machine:id,name,serial_no', 'assignee:id,name', 'creator:id,name'])
->withCount('items')
->latest();
if ($isSystemAdmin && $companyId !== '') {
$query->where('company_id', $companyId);
}
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')) {
if ($request->filled('start_date')) {
$query->where('created_at', '>=', \Illuminate\Support\Carbon::parse($request->start_date));
}
if ($request->filled('end_date')) {
$query->where('created_at', '<=', \Illuminate\Support\Carbon::parse($request->end_date)->endOfMinute());
}
} else {
// 隱性限制:預設只抓 6 個月內的單據以保護效能,但 UI 保持空白
$query->where('created_at', '>=', now()->subMonths(6)->startOfDay());
}
$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()
]);
}
$companies = $isSystemAdmin
? \App\Models\System\Company::active()->orderBy('name')->get()
: collect();
$applyCompanyFilter = fn($query) => $query->when(
$isSystemAdmin && $companyId !== '',
fn($q) => $q->where('company_id', $companyId)
);
$warehouses = $applyCompanyFilter(Warehouse::active())->orderBy('name')->get(['id', 'name']);
$machines = $applyCompanyFilter(Machine::query())->orderBy('name')->get(['id', 'name', 'serial_no']);
$products = $applyCompanyFilter(Product::with('translations'))->orderBy('name')->get(['id', 'name', 'image_url', 'name_dictionary_key']);
// 可見範圍:系統管理員看全部;主帳號看同公司全部;子帳號僅看自己直接建立的下層(避免洩漏旁線帳號)。
$userQuery = \App\Models\System\User::where('status', 1)->orderBy('name')->visibleTo($currentUser);
if ($isSystemAdmin && $companyId !== '') {
$userQuery->where('company_id', $companyId);
}
$users = $userQuery->get(['id', 'name']);
$defaultStart = '';
$defaultEnd = '';
return view('admin.warehouses.replenishments', compact('orders', 'warehouses', 'machines', 'products', 'users', 'companies', 'defaultStart', 'defaultEnd'));
}
/**
* 建立補貨單(含 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']),
]);
// 補貨針對機台,單據歸屬以「機台所綁公司」為準(系統管理員可為他公司機台建單)
$machine = Machine::findOrFail($validated['machine_id']);
DB::transaction(function () use ($validated, $machine) {
$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' => $machine->company_id,
'order_no' => $order_no,
'warehouse_id' => $validated['warehouse_id'],
'machine_id' => $machine->id,
'status' => ReplenishmentOrder::STATUS_PENDING,
'note' => $validated['note'] ?? null,
'created_by' => Auth::id(),
]);
// 預先取得商品資訊以建立快照
$productIds = collect($validated['items'])->pluck('product_id')->unique()->toArray();
$products = \App\Models\Product\Product::whereIn('id', $productIds)->get()->keyBy('id');
// 批量寫入明細(遵循 DB Best Practice: 不在迴圈中逐筆 create
$itemsData = [];
foreach ($validated['items'] as $item) {
$product = $products->get($item['product_id']);
$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,
'snapshot_product_name' => $product ? $product->name : null,
'snapshot_product_name_key' => $product ? $product->name_dictionary_key : null,
'snapshot_product_barcode' => $product ? $product->barcode : null,
'snapshot_product_image_url' => $product ? $product->image_url : null,
'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, $machine) {
$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' => $machine->company_id,
'order_no' => $order_no,
'warehouse_id' => $validated['warehouse_id'],
'machine_id' => $machine->id,
'status' => ReplenishmentOrder::STATUS_PENDING,
'note' => $validated['note'] ?? __('Auto Replenishment'),
'created_by' => Auth::id(),
]);
// 預先取得商品資訊以建立快照
$productIds = collect($validated['items'])->pluck('product_id')->unique()->toArray();
$products = \App\Models\Product\Product::whereIn('id', $productIds)->get()->keyBy('id');
$itemsData = [];
foreach ($validated['items'] as $item) {
$product = $products->get($item['product_id']);
$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,
'snapshot_product_name' => $product ? $product->name : null,
'snapshot_product_name_key' => $product ? $product->name_dictionary_key : null,
'snapshot_product_barcode' => $product ? $product->barcode : null,
'snapshot_product_image_url' => $product ? $product->image_url : null,
'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) {
$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(['translations', 'product.translations'])
->where('replenishment_order_id', $id)
->get()
->map(function ($item) {
return [
'product_id' => $item->product_id,
'product_name' => $item->localized_name ?: __('Unknown'),
'image_url' => $item->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(['translations', 'product.translations'])
->where('transfer_order_id', $id)
->get()
->map(function($item) {
return [
'product_id' => $item->product_id,
'product_name' => $item->localized_name ?: __('Unknown'),
'image_url' => $item->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
]);
}
/**
* 列印調撥單
*/
public function printTransfer($id)
{
$order = TransferOrder::with(['fromWarehouse', 'toWarehouse', 'fromMachine', 'creator'])
->findOrFail($id);
// 雙重防護:驗證租戶資料隔離安全
$user = auth()->user();
if ($user && $user->company_id && $order->company_id !== $user->company_id) {
abort(404);
}
$items = \App\Models\Warehouse\TransferOrderItem::with(['product' => fn($q) => $q->with('translations')])
->where('transfer_order_id', $id)
->get();
return view('admin.warehouses.print.transfer', compact('order', 'items'));
}
/**
* 列印機台補貨單
*/
public function printReplenishment($id)
{
$order = ReplenishmentOrder::with(['machine', 'warehouse', 'creator', 'assignee'])
->findOrFail($id);
// 雙重防護:驗證租戶資料隔離安全
$user = auth()->user();
if ($user && $user->company_id && $order->company_id !== $user->company_id) {
abort(404);
}
$items = ReplenishmentOrderItem::with(['product' => fn($q) => $q->with('translations')])
->where('replenishment_order_id', $id)
->get();
return view('admin.warehouses.print.replenishment', compact('order', 'items'));
}
}