chore: 完善模組化架構遷移與修復前端顯示錯誤
- 修正所有模組 Controller 的 Model 引用路徑 (App\Modules\...) - 更新 ProductionOrder 與 ProductionOrderItem 模型結構以符合新版邏輯 - 修復 resources/js/utils/format.ts 在處理空值時導致 toLocaleString 崩潰的問題 - 清除全域路徑與 Controller 遷移殘留檔案
This commit is contained in:
56
app/Modules/Inventory/Controllers/CategoryController.php
Normal file
56
app/Modules/Inventory/Controllers/CategoryController.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
use App\Modules\Inventory\Models\Category;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255|unique:categories,name',
|
||||
]);
|
||||
|
||||
Category::create([
|
||||
'name' => $validated['name'],
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
return redirect()->back()->with('success', '分類已建立');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(Request $request, Category $category)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255|unique:categories,name,' . $category->id,
|
||||
]);
|
||||
|
||||
$category->update($validated);
|
||||
|
||||
return redirect()->back()->with('success', '分類已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy(Category $category)
|
||||
{
|
||||
if ($category->products()->count() > 0) {
|
||||
return redirect()->back()->with('error', '該分類下尚有商品,無法刪除');
|
||||
}
|
||||
|
||||
$category->delete();
|
||||
|
||||
return redirect()->back()->with('success', '分類已刪除');
|
||||
}
|
||||
}
|
||||
539
app/Modules/Inventory/Controllers/InventoryController.php
Normal file
539
app/Modules/Inventory/Controllers/InventoryController.php
Normal file
@@ -0,0 +1,539 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use App\Modules\Inventory\Models\WarehouseProductSafetyStock;
|
||||
|
||||
class InventoryController extends Controller
|
||||
{
|
||||
public function index(\Illuminate\Http\Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse)
|
||||
{
|
||||
$warehouse->load([
|
||||
'inventories.product.category',
|
||||
'inventories.product.baseUnit',
|
||||
'inventories.lastIncomingTransaction',
|
||||
'inventories.lastOutgoingTransaction'
|
||||
]);
|
||||
$allProducts = \App\Modules\Inventory\Models\Product::with('category')->get();
|
||||
|
||||
// 1. 準備 availableProducts
|
||||
$availableProducts = $allProducts->map(function ($product) {
|
||||
return [
|
||||
'id' => (string) $product->id,
|
||||
'name' => $product->name,
|
||||
'type' => $product->category?->name ?? '其他',
|
||||
];
|
||||
});
|
||||
|
||||
// 2. 從新表格讀取安全庫存設定 (商品-倉庫層級)
|
||||
$safetyStockMap = WarehouseProductSafetyStock::where('warehouse_id', $warehouse->id)
|
||||
->pluck('safety_stock', 'product_id')
|
||||
->mapWithKeys(fn($val, $key) => [(string)$key => (float)$val]);
|
||||
|
||||
// 3. 準備 inventories (批號分組)
|
||||
$items = $warehouse->inventories()
|
||||
->with(['product.baseUnit', 'lastIncomingTransaction', 'lastOutgoingTransaction'])
|
||||
->get();
|
||||
|
||||
$inventories = $items->groupBy('product_id')->map(function ($batchItems) use ($safetyStockMap) {
|
||||
$firstItem = $batchItems->first();
|
||||
$product = $firstItem->product;
|
||||
$totalQuantity = $batchItems->sum('quantity');
|
||||
// 從獨立表格讀取安全庫存
|
||||
$safetyStock = $safetyStockMap[(string)$firstItem->product_id] ?? null;
|
||||
|
||||
// 計算狀態
|
||||
$status = '正常';
|
||||
if (!is_null($safetyStock)) {
|
||||
if ($totalQuantity < $safetyStock) {
|
||||
$status = '低於';
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'productId' => (string) $firstItem->product_id,
|
||||
'productName' => $product?->name ?? '未知商品',
|
||||
'productCode' => $product?->code ?? 'N/A',
|
||||
'baseUnit' => $product?->baseUnit?->name ?? '個',
|
||||
'totalQuantity' => (float) $totalQuantity,
|
||||
'safetyStock' => $safetyStock,
|
||||
'status' => $status,
|
||||
'batches' => $batchItems->map(function ($inv) {
|
||||
return [
|
||||
'id' => (string) $inv->id,
|
||||
'warehouseId' => (string) $inv->warehouse_id,
|
||||
'productId' => (string) $inv->product_id,
|
||||
'productName' => $inv->product?->name ?? '未知商品',
|
||||
'productCode' => $inv->product?->code ?? 'N/A',
|
||||
'unit' => $inv->product?->baseUnit?->name ?? '個',
|
||||
'quantity' => (float) $inv->quantity,
|
||||
'safetyStock' => null, // 批號層級不再有安全庫存
|
||||
'status' => '正常',
|
||||
'batchNumber' => $inv->batch_number ?? 'BATCH-' . $inv->id,
|
||||
'expiryDate' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
|
||||
'lastInboundDate' => $inv->lastIncomingTransaction ? ($inv->lastIncomingTransaction->actual_time ? $inv->lastIncomingTransaction->actual_time->format('Y-m-d') : $inv->lastIncomingTransaction->created_at->format('Y-m-d')) : null,
|
||||
'lastOutboundDate' => $inv->lastOutgoingTransaction ? ($inv->lastOutgoingTransaction->actual_time ? $inv->lastOutgoingTransaction->actual_time->format('Y-m-d') : $inv->lastOutgoingTransaction->created_at->format('Y-m-d')) : null,
|
||||
];
|
||||
})->values(),
|
||||
];
|
||||
})->values();
|
||||
|
||||
// 4. 準備 safetyStockSettings (從新表格讀取)
|
||||
$safetyStockSettings = WarehouseProductSafetyStock::where('warehouse_id', $warehouse->id)
|
||||
->with(['product.category'])
|
||||
->get()
|
||||
->map(function ($setting) {
|
||||
return [
|
||||
'id' => (string) $setting->id,
|
||||
'warehouseId' => (string) $setting->warehouse_id,
|
||||
'productId' => (string) $setting->product_id,
|
||||
'productName' => $setting->product?->name ?? '未知商品',
|
||||
'productType' => $setting->product?->category?->name ?? '其他',
|
||||
'safetyStock' => (float) $setting->safety_stock,
|
||||
'createdAt' => $setting->created_at->toIso8601String(),
|
||||
'updatedAt' => $setting->updated_at->toIso8601String(),
|
||||
];
|
||||
});
|
||||
|
||||
return \Inertia\Inertia::render('Warehouse/Inventory', [
|
||||
'warehouse' => $warehouse,
|
||||
'inventories' => $inventories,
|
||||
'safetyStockSettings' => $safetyStockSettings,
|
||||
'availableProducts' => $availableProducts,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create(\App\Modules\Inventory\Models\Warehouse $warehouse)
|
||||
{
|
||||
// 取得所有商品供前端選單使用
|
||||
$products = \App\Modules\Inventory\Models\Product::with(['baseUnit', 'largeUnit'])
|
||||
->select('id', 'name', 'code', 'base_unit_id', 'large_unit_id', 'conversion_rate')
|
||||
->get()
|
||||
->map(function ($product) {
|
||||
return [
|
||||
'id' => (string) $product->id,
|
||||
'name' => $product->name,
|
||||
'code' => $product->code,
|
||||
'baseUnit' => $product->baseUnit?->name ?? '個',
|
||||
'largeUnit' => $product->largeUnit?->name, // 可能為 null
|
||||
'conversionRate' => (float) $product->conversion_rate,
|
||||
];
|
||||
});
|
||||
|
||||
return \Inertia\Inertia::render('Warehouse/AddInventory', [
|
||||
'warehouse' => $warehouse,
|
||||
'products' => $products,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(\Illuminate\Http\Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'inboundDate' => 'required|date',
|
||||
'reason' => 'required|string',
|
||||
'notes' => 'nullable|string',
|
||||
'items' => 'required|array|min:1',
|
||||
'items.*.productId' => 'required|exists:products,id',
|
||||
'items.*.quantity' => 'required|numeric|min:0.01',
|
||||
'items.*.batchMode' => 'required|in:existing,new',
|
||||
'items.*.inventoryId' => 'required_if:items.*.batchMode,existing|nullable|exists:inventories,id',
|
||||
'items.*.originCountry' => 'required_if:items.*.batchMode,new|nullable|string|max:2',
|
||||
'items.*.expiryDate' => 'nullable|date',
|
||||
]);
|
||||
|
||||
return \Illuminate\Support\Facades\DB::transaction(function () use ($validated, $warehouse) {
|
||||
foreach ($validated['items'] as $item) {
|
||||
$inventory = null;
|
||||
|
||||
if ($item['batchMode'] === 'existing') {
|
||||
// 模式 A:選擇現有批號 (包含已刪除的也要能找回來累加)
|
||||
$inventory = \App\Modules\Inventory\Models\Inventory::withTrashed()->findOrFail($item['inventoryId']);
|
||||
if ($inventory->trashed()) {
|
||||
$inventory->restore();
|
||||
}
|
||||
} else {
|
||||
// 模式 B:建立新批號
|
||||
$originCountry = $item['originCountry'] ?? 'TW';
|
||||
$product = \App\Modules\Inventory\Models\Product::find($item['productId']);
|
||||
|
||||
$batchNumber = \App\Modules\Inventory\Models\Inventory::generateBatchNumber(
|
||||
$product->code ?? 'UNK',
|
||||
$originCountry,
|
||||
$validated['inboundDate']
|
||||
);
|
||||
|
||||
// 同樣要檢查此批號是否已經存在 (即使模式是 new, 但可能撞到同一天同產地手動建立的)
|
||||
$inventory = $warehouse->inventories()->withTrashed()->firstOrNew(
|
||||
[
|
||||
'product_id' => $item['productId'],
|
||||
'batch_number' => $batchNumber
|
||||
],
|
||||
[
|
||||
'quantity' => 0,
|
||||
'arrival_date' => $validated['inboundDate'],
|
||||
'expiry_date' => $item['expiryDate'] ?? null,
|
||||
'origin_country' => $originCountry,
|
||||
]
|
||||
);
|
||||
|
||||
if ($inventory->trashed()) {
|
||||
$inventory->restore();
|
||||
}
|
||||
}
|
||||
|
||||
$currentQty = $inventory->quantity;
|
||||
$newQty = $currentQty + $item['quantity'];
|
||||
|
||||
$inventory->quantity = $newQty;
|
||||
$inventory->save();
|
||||
|
||||
// 寫入異動紀錄
|
||||
$inventory->transactions()->create([
|
||||
'type' => '手動入庫',
|
||||
'quantity' => $item['quantity'],
|
||||
'balance_before' => $currentQty,
|
||||
'balance_after' => $newQty,
|
||||
'reason' => $validated['reason'] . ($validated['notes'] ? ' - ' . $validated['notes'] : ''),
|
||||
'actual_time' => $validated['inboundDate'],
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('warehouses.inventory.index', $warehouse->id)
|
||||
->with('success', '庫存記錄已儲存成功');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* API: 取得商品在特定倉庫的所有批號,並回傳當前日期/產地下的一個流水號
|
||||
*/
|
||||
public function getBatches(\App\Modules\Inventory\Models\Warehouse $warehouse, $productId, \Illuminate\Http\Request $request)
|
||||
{
|
||||
$originCountry = $request->query('originCountry', 'TW');
|
||||
$arrivalDate = $request->query('arrivalDate', now()->format('Y-m-d'));
|
||||
|
||||
$batches = \App\Modules\Inventory\Models\Inventory::where('warehouse_id', $warehouse->id)
|
||||
->where('product_id', $productId)
|
||||
->get()
|
||||
->map(function ($inventory) {
|
||||
return [
|
||||
'inventoryId' => (string) $inventory->id,
|
||||
'batchNumber' => $inventory->batch_number,
|
||||
'originCountry' => $inventory->origin_country,
|
||||
'expiryDate' => $inventory->expiry_date ? $inventory->expiry_date->format('Y-m-d') : null,
|
||||
'quantity' => (float) $inventory->quantity,
|
||||
];
|
||||
});
|
||||
|
||||
// 計算下一個流水號
|
||||
$product = \App\Modules\Inventory\Models\Product::find($productId);
|
||||
$nextSequence = '01';
|
||||
if ($product) {
|
||||
$batchNumber = \App\Modules\Inventory\Models\Inventory::generateBatchNumber(
|
||||
$product->code ?? 'UNK',
|
||||
$originCountry,
|
||||
$arrivalDate
|
||||
);
|
||||
$nextSequence = substr($batchNumber, -2);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'batches' => $batches,
|
||||
'nextSequence' => $nextSequence
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit(\Illuminate\Http\Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse, $inventoryId)
|
||||
{
|
||||
// 取得庫存紀錄,包含商品資訊與異動紀錄 (含經手人)
|
||||
// 這裡如果是 Mock 的 ID (mock-inv-1),需要特殊處理
|
||||
if (str_starts_with($inventoryId, 'mock-inv-')) {
|
||||
return redirect()->back()->with('error', '無法編輯範例資料');
|
||||
}
|
||||
|
||||
$inventory = \App\Modules\Inventory\Models\Inventory::with(['product', 'transactions' => function($query) {
|
||||
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
|
||||
}, 'transactions.user'])->findOrFail($inventoryId);
|
||||
|
||||
// 轉換為前端需要的格式
|
||||
$inventoryData = [
|
||||
'id' => (string) $inventory->id,
|
||||
'warehouseId' => (string) $inventory->warehouse_id,
|
||||
'productId' => (string) $inventory->product_id,
|
||||
'productName' => $inventory->product?->name ?? '未知商品',
|
||||
'quantity' => (float) $inventory->quantity,
|
||||
'batchNumber' => $inventory->batch_number ?? '-',
|
||||
'expiryDate' => $inventory->expiry_date ?? null,
|
||||
'lastInboundDate' => $inventory->updated_at->format('Y-m-d'),
|
||||
'lastOutboundDate' => null,
|
||||
];
|
||||
|
||||
// 整理異動紀錄
|
||||
$transactions = $inventory->transactions->map(function ($tx) {
|
||||
return [
|
||||
'id' => (string) $tx->id,
|
||||
'type' => $tx->type,
|
||||
'quantity' => (float) $tx->quantity,
|
||||
'balanceAfter' => (float) $tx->balance_after,
|
||||
'reason' => $tx->reason,
|
||||
'userName' => $tx->user ? $tx->user->name : '系統',
|
||||
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
|
||||
];
|
||||
});
|
||||
|
||||
return \Inertia\Inertia::render('Warehouse/EditInventory', [
|
||||
'warehouse' => $warehouse,
|
||||
'inventory' => $inventoryData,
|
||||
'transactions' => $transactions,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(\Illuminate\Http\Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse, $inventoryId)
|
||||
{
|
||||
// 若是 product ID (舊邏輯),先轉為 inventory
|
||||
// 但新路由我們傳的是 inventory ID
|
||||
// 為了相容,我們先判斷 $inventoryId 是 inventory ID
|
||||
|
||||
$inventory = \App\Modules\Inventory\Models\Inventory::find($inventoryId);
|
||||
|
||||
// 如果找不到 (可能是舊路由傳 product ID)
|
||||
if (!$inventory) {
|
||||
$inventory = $warehouse->inventories()->where('product_id', $inventoryId)->first();
|
||||
}
|
||||
|
||||
if (!$inventory) {
|
||||
return redirect()->back()->with('error', '找不到庫存紀錄');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'quantity' => 'required|numeric|min:0',
|
||||
// 以下欄位改為 nullable,支援新表單
|
||||
'type' => 'nullable|string',
|
||||
'operation' => 'nullable|in:add,subtract,set',
|
||||
'reason' => 'nullable|string',
|
||||
'notes' => 'nullable|string',
|
||||
// 新增日期欄位驗證 (雖然暫不儲存到 DB)
|
||||
'batchNumber' => 'nullable|string',
|
||||
'expiryDate' => 'nullable|date',
|
||||
'lastInboundDate' => 'nullable|date',
|
||||
'lastOutboundDate' => 'nullable|date',
|
||||
]);
|
||||
|
||||
return \Illuminate\Support\Facades\DB::transaction(function () use ($validated, $inventory) {
|
||||
$currentQty = (float) $inventory->quantity;
|
||||
$newQty = (float) $validated['quantity'];
|
||||
|
||||
// 判斷是否來自調整彈窗 (包含 operation 參數)
|
||||
$isAdjustment = isset($validated['operation']);
|
||||
$changeQty = 0;
|
||||
|
||||
if ($isAdjustment) {
|
||||
switch ($validated['operation']) {
|
||||
case 'add':
|
||||
$changeQty = (float) $validated['quantity'];
|
||||
$newQty = $currentQty + $changeQty;
|
||||
break;
|
||||
case 'subtract':
|
||||
$changeQty = -(float) $validated['quantity'];
|
||||
$newQty = $currentQty + $changeQty;
|
||||
break;
|
||||
case 'set':
|
||||
$changeQty = $newQty - $currentQty;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// 來自編輯頁面,直接 Set
|
||||
$changeQty = $newQty - $currentQty;
|
||||
}
|
||||
|
||||
// 更新庫存
|
||||
$inventory->update(['quantity' => $newQty]);
|
||||
|
||||
// 異動類型映射
|
||||
$type = $validated['type'] ?? ($isAdjustment ? 'manual_adjustment' : 'adjustment');
|
||||
$typeMapping = [
|
||||
'manual_adjustment' => '手動調整庫存',
|
||||
'adjustment' => '盤點調整',
|
||||
'purchase_in' => '採購進貨',
|
||||
'sales_out' => '銷售出庫',
|
||||
'return_in' => '退貨入庫',
|
||||
'return_out' => '退貨出庫',
|
||||
'transfer_in' => '撥補入庫',
|
||||
'transfer_out' => '撥補出庫',
|
||||
];
|
||||
$chineseType = $typeMapping[$type] ?? $type;
|
||||
|
||||
// 如果是編輯頁面來的,且沒傳 type,設為手動編輯
|
||||
if (!$isAdjustment && !isset($validated['type'])) {
|
||||
$chineseType = '手動編輯';
|
||||
}
|
||||
|
||||
// 整理原因
|
||||
$reason = $validated['reason'] ?? ($isAdjustment ? '手動庫存調整' : '編輯頁面更新');
|
||||
if (isset($validated['notes'])) {
|
||||
$reason .= ' - ' . $validated['notes'];
|
||||
}
|
||||
|
||||
// 寫入異動紀錄
|
||||
if (abs($changeQty) > 0.0001) {
|
||||
$inventory->transactions()->create([
|
||||
'type' => $chineseType,
|
||||
'quantity' => $changeQty,
|
||||
'balance_before' => $currentQty,
|
||||
'balance_after' => $newQty,
|
||||
'reason' => $reason,
|
||||
'actual_time' => now(),
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->route('warehouses.inventory.index', $inventory->warehouse_id)
|
||||
->with('success', '庫存資料已更新');
|
||||
});
|
||||
}
|
||||
|
||||
public function destroy(\App\Modules\Inventory\Models\Warehouse $warehouse, $inventoryId)
|
||||
{
|
||||
$inventory = \App\Modules\Inventory\Models\Inventory::findOrFail($inventoryId);
|
||||
|
||||
// 庫存 > 0 不允許刪除 (哪怕是軟刪除)
|
||||
if ($inventory->quantity > 0) {
|
||||
return redirect()->back()->with('error', '庫存數量大於 0,無法刪除。請先進行出庫或調整。');
|
||||
}
|
||||
|
||||
// 歸零異動 (因為已經限制為 0 才能刪,這段邏輯可以簡化,但為了保險起見,若有微小殘值仍可記錄歸零)
|
||||
if (abs($inventory->quantity) > 0.0001) {
|
||||
$inventory->transactions()->create([
|
||||
'type' => '手動編輯',
|
||||
'quantity' => -$inventory->quantity,
|
||||
'balance_before' => $inventory->quantity,
|
||||
'balance_after' => 0,
|
||||
'reason' => '刪除庫存品項',
|
||||
'actual_time' => now(),
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
|
||||
$inventory->delete();
|
||||
|
||||
return redirect()->route('warehouses.inventory.index', $warehouse->id)
|
||||
->with('success', '庫存品項已刪除');
|
||||
}
|
||||
|
||||
public function history(Request $request, \App\Modules\Inventory\Models\Warehouse $warehouse)
|
||||
{
|
||||
$inventoryId = $request->query('inventoryId');
|
||||
$productId = $request->query('productId');
|
||||
|
||||
if ($productId) {
|
||||
// 商品層級查詢
|
||||
$inventories = \App\Modules\Inventory\Models\Inventory::where('warehouse_id', $warehouse->id)
|
||||
->where('product_id', $productId)
|
||||
->with(['product', 'transactions' => function($query) {
|
||||
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
|
||||
}, 'transactions.user'])
|
||||
->get();
|
||||
|
||||
if ($inventories->isEmpty()) {
|
||||
return redirect()->back()->with('error', '找不到該商品的庫存紀錄');
|
||||
}
|
||||
|
||||
$firstInventory = $inventories->first();
|
||||
$productName = $firstInventory->product?->name ?? '未知商品';
|
||||
$productCode = $firstInventory->product?->code ?? 'N/A';
|
||||
$currentTotalQuantity = $inventories->sum('quantity');
|
||||
|
||||
// 合併所有批號的交易紀錄
|
||||
$allTransactions = collect();
|
||||
foreach ($inventories as $inv) {
|
||||
foreach ($inv->transactions as $tx) {
|
||||
$allTransactions->push([
|
||||
'raw_tx' => $tx,
|
||||
'batchNumber' => $inv->batch_number ?? '-',
|
||||
'sort_time' => $tx->actual_time ?? $tx->created_at,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// 依時間倒序排序 (最新的在前面)
|
||||
$sortedTransactions = $allTransactions->sort(function ($a, $b) {
|
||||
// 先比時間 (Desc)
|
||||
if ($a['sort_time'] != $b['sort_time']) {
|
||||
return $a['sort_time'] > $b['sort_time'] ? -1 : 1;
|
||||
}
|
||||
// 再比 ID (Desc)
|
||||
return $a['raw_tx']->id > $b['raw_tx']->id ? -1 : 1;
|
||||
});
|
||||
|
||||
// 回推計算結餘
|
||||
$runningBalance = $currentTotalQuantity;
|
||||
$transactions = $sortedTransactions->map(function ($item) use (&$runningBalance) {
|
||||
$tx = $item['raw_tx'];
|
||||
|
||||
// 本次異動後的結餘 = 當前推算的結餘
|
||||
$balanceAfter = $runningBalance;
|
||||
|
||||
// 推算前一次的結餘 (減去本次的異動量:如果是入庫+10,前一次就是-10)
|
||||
$runningBalance = $runningBalance - $tx->quantity;
|
||||
|
||||
return [
|
||||
'id' => (string) $tx->id,
|
||||
'type' => $tx->type,
|
||||
'quantity' => (float) $tx->quantity,
|
||||
'balanceAfter' => (float) $balanceAfter, // 使用即時計算的商品總結餘
|
||||
'reason' => $tx->reason,
|
||||
'userName' => $tx->user ? $tx->user->name : '系統',
|
||||
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
|
||||
'batchNumber' => $item['batchNumber'],
|
||||
];
|
||||
})->values();
|
||||
|
||||
return \Inertia\Inertia::render('Warehouse/InventoryHistory', [
|
||||
'warehouse' => $warehouse,
|
||||
'inventory' => [
|
||||
'id' => 'product-' . $productId,
|
||||
'productName' => $productName,
|
||||
'productCode' => $productCode,
|
||||
'quantity' => (float) $currentTotalQuantity,
|
||||
],
|
||||
'transactions' => $transactions
|
||||
]);
|
||||
}
|
||||
|
||||
if ($inventoryId) {
|
||||
// 單一批號查詢
|
||||
$inventory = \App\Modules\Inventory\Models\Inventory::with(['product', 'transactions' => function($query) {
|
||||
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
|
||||
}, 'transactions.user'])->findOrFail($inventoryId);
|
||||
|
||||
$transactions = $inventory->transactions->map(function ($tx) {
|
||||
return [
|
||||
'id' => (string) $tx->id,
|
||||
'type' => $tx->type,
|
||||
'quantity' => (float) $tx->quantity,
|
||||
'balanceAfter' => (float) $tx->balance_after,
|
||||
'reason' => $tx->reason,
|
||||
'userName' => $tx->user ? $tx->user->name : '系統',
|
||||
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
|
||||
];
|
||||
});
|
||||
|
||||
return \Inertia\Inertia::render('Warehouse/InventoryHistory', [
|
||||
'warehouse' => $warehouse,
|
||||
'inventory' => [
|
||||
'id' => (string) $inventory->id,
|
||||
'productName' => $inventory->product?->name ?? '未知商品',
|
||||
'productCode' => $inventory->product?->code ?? 'N/A',
|
||||
'batchNumber' => $inventory->batch_number ?? '-',
|
||||
'quantity' => (float) $inventory->quantity,
|
||||
],
|
||||
'transactions' => $transactions
|
||||
]);
|
||||
}
|
||||
|
||||
return redirect()->back()->with('error', '未提供查詢參數');
|
||||
}
|
||||
}
|
||||
152
app/Modules/Inventory/Controllers/ProductController.php
Normal file
152
app/Modules/Inventory/Controllers/ProductController.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use App\Modules\Inventory\Models\Unit;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ProductController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$query = Product::with(['category', 'baseUnit', 'largeUnit', 'purchaseUnit']);
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('code', 'like', "%{$search}%")
|
||||
->orWhere('brand', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
if ($request->filled('category_id') && $request->category_id !== 'all') {
|
||||
$query->where('category_id', $request->category_id);
|
||||
}
|
||||
|
||||
$perPage = $request->input('per_page', 10);
|
||||
if (!in_array($perPage, [10, 20, 50, 100])) {
|
||||
$perPage = 10;
|
||||
}
|
||||
|
||||
$sortField = $request->input('sort_field', 'id');
|
||||
$sortDirection = $request->input('sort_direction', 'desc');
|
||||
|
||||
// Define allowed sort fields to prevent SQL injection
|
||||
$allowedSorts = ['id', 'code', 'name', 'category_id', 'base_unit_id', 'conversion_rate'];
|
||||
if (!in_array($sortField, $allowedSorts)) {
|
||||
$sortField = 'id';
|
||||
}
|
||||
if (!in_array(strtolower($sortDirection), ['asc', 'desc'])) {
|
||||
$sortDirection = 'desc';
|
||||
}
|
||||
|
||||
// Handle relation sorting (category name) separately if needed, or simple join
|
||||
if ($sortField === 'category_id') {
|
||||
// Join categories for sorting by name? Or just by ID?
|
||||
// Simple approach: sort by ID for now, or join if user wants name sort.
|
||||
// Let's assume standard field sorting first.
|
||||
$query->orderBy('category_id', $sortDirection);
|
||||
} else {
|
||||
$query->orderBy($sortField, $sortDirection);
|
||||
}
|
||||
|
||||
$products = $query->paginate($perPage)->withQueryString();
|
||||
|
||||
$categories = \App\Modules\Inventory\Models\Category::where('is_active', true)->get();
|
||||
|
||||
return Inertia::render('Product/Index', [
|
||||
'products' => $products,
|
||||
'categories' => $categories,
|
||||
'units' => Unit::all(),
|
||||
'filters' => $request->only(['search', 'category_id', 'per_page', 'sort_field', 'sort_direction']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'code' => 'required|string|max:2|unique:products,code',
|
||||
'name' => 'required|string|max:255',
|
||||
'category_id' => 'required|exists:categories,id',
|
||||
'brand' => 'nullable|string|max:255',
|
||||
'specification' => 'nullable|string',
|
||||
|
||||
'base_unit_id' => 'required|exists:units,id',
|
||||
'large_unit_id' => 'nullable|exists:units,id',
|
||||
'conversion_rate' => 'required_with:large_unit_id|nullable|numeric|min:0.0001',
|
||||
'purchase_unit_id' => 'nullable|exists:units,id',
|
||||
], [
|
||||
'code.required' => '商品代號為必填',
|
||||
'code.max' => '商品代號最多 2 碼',
|
||||
'code.unique' => '商品代號已存在',
|
||||
'name.required' => '商品名稱為必填',
|
||||
'category_id.required' => '請選擇分類',
|
||||
'category_id.exists' => '所選分類不存在',
|
||||
'base_unit_id.required' => '基本庫存單位為必填',
|
||||
'base_unit_id.exists' => '所選基本單位不存在',
|
||||
'conversion_rate.required_with' => '填寫大單位時,換算率為必填',
|
||||
'conversion_rate.numeric' => '換算率必須為數字',
|
||||
'conversion_rate.min' => '換算率最小為 0.0001',
|
||||
]);
|
||||
|
||||
$product = Product::create($validated);
|
||||
|
||||
return redirect()->back()->with('success', '商品已建立');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(Request $request, Product $product)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'code' => 'required|string|max:2|unique:products,code,' . $product->id,
|
||||
'name' => 'required|string|max:255',
|
||||
'category_id' => 'required|exists:categories,id',
|
||||
'brand' => 'nullable|string|max:255',
|
||||
'specification' => 'nullable|string',
|
||||
'base_unit_id' => 'required|exists:units,id',
|
||||
'large_unit_id' => 'nullable|exists:units,id',
|
||||
'conversion_rate' => 'required_with:large_unit_id|nullable|numeric|min:0.0001',
|
||||
'purchase_unit_id' => 'nullable|exists:units,id',
|
||||
], [
|
||||
'code.required' => '商品代號為必填',
|
||||
'code.max' => '商品代號最多 2 碼',
|
||||
'code.unique' => '商品代號已存在',
|
||||
'name.required' => '商品名稱為必填',
|
||||
'category_id.required' => '請選擇分類',
|
||||
'category_id.exists' => '所選分類不存在',
|
||||
'base_unit_id.required' => '基本庫存單位為必填',
|
||||
'base_unit_id.exists' => '所選基本單位不存在',
|
||||
'conversion_rate.required_with' => '填寫大單位時,換算率為必填',
|
||||
'conversion_rate.numeric' => '換算率必須為數字',
|
||||
'conversion_rate.min' => '換算率最小為 0.0001',
|
||||
]);
|
||||
|
||||
$product->update($validated);
|
||||
|
||||
return redirect()->back()->with('success', '商品已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy(Product $product)
|
||||
{
|
||||
$product->delete();
|
||||
|
||||
return redirect()->back()->with('success', '商品已刪除');
|
||||
}
|
||||
}
|
||||
124
app/Modules/Inventory/Controllers/SafetyStockController.php
Normal file
124
app/Modules/Inventory/Controllers/SafetyStockController.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Models\WarehouseProductSafetyStock;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class SafetyStockController extends Controller
|
||||
{
|
||||
/**
|
||||
* 顯示安全庫存設定頁面
|
||||
*/
|
||||
public function index(Warehouse $warehouse)
|
||||
{
|
||||
$allProducts = Product::with(['category', 'baseUnit'])->get();
|
||||
|
||||
// 準備可選商品列表
|
||||
$availableProducts = $allProducts->map(function ($product) {
|
||||
return [
|
||||
'id' => (string) $product->id,
|
||||
'name' => $product->name,
|
||||
'type' => $product->category ? $product->category->name : '其他',
|
||||
'unit' => $product->baseUnit?->name ?? '個',
|
||||
];
|
||||
});
|
||||
|
||||
// 準備現有庫存列表 (用於庫存量對比)
|
||||
$inventories = Inventory::where('warehouse_id', $warehouse->id)
|
||||
->select('product_id', DB::raw('SUM(quantity) as total_quantity'))
|
||||
->groupBy('product_id')
|
||||
->get()
|
||||
->map(function ($inv) {
|
||||
return [
|
||||
'productId' => (string) $inv->product_id,
|
||||
'quantity' => (float) $inv->total_quantity,
|
||||
];
|
||||
});
|
||||
|
||||
// 準備安全庫存設定列表 (從新表格讀取)
|
||||
$safetyStockSettings = WarehouseProductSafetyStock::where('warehouse_id', $warehouse->id)
|
||||
->with(['product.category', 'product.baseUnit'])
|
||||
->get()
|
||||
->map(function ($setting) {
|
||||
return [
|
||||
'id' => (string) $setting->id,
|
||||
'warehouseId' => (string) $setting->warehouse_id,
|
||||
'productId' => (string) $setting->product_id,
|
||||
'productName' => $setting->product->name,
|
||||
'productType' => $setting->product->category ? $setting->product->category->name : '其他',
|
||||
'safetyStock' => (float) $setting->safety_stock,
|
||||
'unit' => $setting->product->baseUnit?->name ?? '個',
|
||||
'updatedAt' => $setting->updated_at->toIso8601String(),
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Warehouse/SafetyStockSettings', [
|
||||
'warehouse' => $warehouse,
|
||||
'safetyStockSettings' => $safetyStockSettings,
|
||||
'inventories' => $inventories,
|
||||
'availableProducts' => $availableProducts,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量儲存安全庫存設定
|
||||
*/
|
||||
public function store(Request $request, Warehouse $warehouse)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'settings' => 'required|array|min:1',
|
||||
'settings.*.productId' => 'required|exists:products,id',
|
||||
'settings.*.quantity' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($validated, $warehouse) {
|
||||
foreach ($validated['settings'] as $item) {
|
||||
WarehouseProductSafetyStock::updateOrCreate(
|
||||
[
|
||||
'warehouse_id' => $warehouse->id,
|
||||
'product_id' => $item['productId'],
|
||||
],
|
||||
[
|
||||
'safety_stock' => $item['quantity'],
|
||||
]
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return redirect()->back()->with('success', '安全庫存設定已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新單筆安全庫存設定
|
||||
*/
|
||||
public function update(Request $request, Warehouse $warehouse, WarehouseProductSafetyStock $safetyStock)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'safetyStock' => 'required|numeric|min:0',
|
||||
]);
|
||||
|
||||
$safetyStock->update([
|
||||
'safety_stock' => $validated['safetyStock'],
|
||||
]);
|
||||
|
||||
return redirect()->back()->with('success', '安全庫存已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* 刪除安全庫存設定
|
||||
*/
|
||||
public function destroy(Warehouse $warehouse, WarehouseProductSafetyStock $safetyStock)
|
||||
{
|
||||
$safetyStock->delete();
|
||||
|
||||
return redirect()->back()->with('success', '安全庫存設定已移除');
|
||||
}
|
||||
}
|
||||
122
app/Modules/Inventory/Controllers/TransferOrderController.php
Normal file
122
app/Modules/Inventory/Controllers/TransferOrderController.php
Normal file
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class TransferOrderController extends Controller
|
||||
{
|
||||
/**
|
||||
* 儲存撥補單(建立調撥單並執行庫存轉移)
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'sourceWarehouseId' => 'required|exists:warehouses,id',
|
||||
'targetWarehouseId' => 'required|exists:warehouses,id|different:sourceWarehouseId',
|
||||
'productId' => 'required|exists:products,id',
|
||||
'quantity' => 'required|numeric|min:0.01',
|
||||
'transferDate' => 'required|date',
|
||||
'status' => 'required|in:待處理,處理中,已完成,已取消', // 目前僅支援立即完成或單純記錄
|
||||
'notes' => 'nullable|string',
|
||||
'batchNumber' => 'nullable|string', // 暫時接收,雖然 DB 可能沒存
|
||||
]);
|
||||
|
||||
return DB::transaction(function () use ($validated) {
|
||||
// 1. 檢查來源倉庫庫存
|
||||
$sourceInventory = Inventory::where('warehouse_id', $validated['sourceWarehouseId'])
|
||||
->where('product_id', $validated['productId'])
|
||||
->first();
|
||||
|
||||
if (!$sourceInventory || $sourceInventory->quantity < $validated['quantity']) {
|
||||
throw ValidationException::withMessages([
|
||||
'quantity' => ['來源倉庫庫存不足'],
|
||||
]);
|
||||
}
|
||||
|
||||
// 2. 獲取或建立目標倉庫庫存
|
||||
$targetInventory = Inventory::firstOrCreate(
|
||||
[
|
||||
'warehouse_id' => $validated['targetWarehouseId'],
|
||||
'product_id' => $validated['productId'],
|
||||
],
|
||||
[
|
||||
'quantity' => 0,
|
||||
]
|
||||
);
|
||||
|
||||
$sourceWarehouse = Warehouse::find($validated['sourceWarehouseId']);
|
||||
$targetWarehouse = Warehouse::find($validated['targetWarehouseId']);
|
||||
|
||||
// 3. 執行庫存轉移 (扣除來源)
|
||||
$oldSourceQty = $sourceInventory->quantity;
|
||||
$newSourceQty = $oldSourceQty - $validated['quantity'];
|
||||
|
||||
// 設定活動紀錄原因
|
||||
$sourceInventory->activityLogReason = "撥補出庫 至 {$targetWarehouse->name}";
|
||||
$sourceInventory->update(['quantity' => $newSourceQty]);
|
||||
|
||||
// 記錄來源異動
|
||||
$sourceInventory->transactions()->create([
|
||||
'type' => '撥補出庫',
|
||||
'quantity' => -$validated['quantity'],
|
||||
'balance_before' => $oldSourceQty,
|
||||
'balance_after' => $newSourceQty,
|
||||
'reason' => "撥補至 {$targetWarehouse->name}" . ($validated['notes'] ? " ({$validated['notes']})" : ""),
|
||||
'actual_time' => $validated['transferDate'],
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
// 4. 執行庫存轉移 (增加目標)
|
||||
$oldTargetQty = $targetInventory->quantity;
|
||||
$newTargetQty = $oldTargetQty + $validated['quantity'];
|
||||
|
||||
// 設定活動紀錄原因
|
||||
$targetInventory->activityLogReason = "撥補入庫 來自 {$sourceWarehouse->name}";
|
||||
$targetInventory->update(['quantity' => $newTargetQty]);
|
||||
|
||||
// 記錄目標異動
|
||||
$targetInventory->transactions()->create([
|
||||
'type' => '撥補入庫',
|
||||
'quantity' => $validated['quantity'],
|
||||
'balance_before' => $oldTargetQty,
|
||||
'balance_after' => $newTargetQty,
|
||||
'reason' => "來自 {$sourceWarehouse->name} 的撥補" . ($validated['notes'] ? " ({$validated['notes']})" : ""),
|
||||
'actual_time' => $validated['transferDate'],
|
||||
'user_id' => auth()->id(),
|
||||
]);
|
||||
|
||||
// TODO: 未來若有獨立的 TransferOrder 模型,可在此建立紀錄
|
||||
|
||||
return redirect()->back()->with('success', '撥補單已建立且庫存已轉移');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 獲取特定倉庫的庫存列表 (API)
|
||||
*/
|
||||
public function getWarehouseInventories(Warehouse $warehouse)
|
||||
{
|
||||
$inventories = $warehouse->inventories()
|
||||
->with(['product.baseUnit', 'product.category'])
|
||||
->where('quantity', '>', 0) // 只回傳有庫存的
|
||||
->get()
|
||||
->map(function ($inv) {
|
||||
return [
|
||||
'productId' => (string) $inv->product_id,
|
||||
'productName' => $inv->product->name,
|
||||
'batchNumber' => 'BATCH-' . $inv->id, // 模擬批號
|
||||
'availableQty' => (float) $inv->quantity,
|
||||
'unit' => $inv->product->baseUnit?->name ?? '個',
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json($inventories);
|
||||
}
|
||||
}
|
||||
72
app/Modules/Inventory/Controllers/UnitController.php
Normal file
72
app/Modules/Inventory/Controllers/UnitController.php
Normal file
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
use App\Modules\Inventory\Models\Unit;
|
||||
use App\Modules\Inventory\Models\Product; // Import Product to check for usage
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class UnitController extends Controller
|
||||
{
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255|unique:units,name',
|
||||
'code' => 'nullable|string|max:50',
|
||||
], [
|
||||
'name.required' => '單位名稱為必填項目',
|
||||
'name.unique' => '該單位名稱已存在',
|
||||
'name.max' => '單位名稱不能超過 255 個字元',
|
||||
'code.max' => '單位代碼不能超過 50 個字元',
|
||||
]);
|
||||
|
||||
Unit::create($validated);
|
||||
|
||||
return redirect()->back()->with('success', '單位已建立');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(Request $request, Unit $unit)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:255|unique:units,name,' . $unit->id,
|
||||
'code' => 'nullable|string|max:50',
|
||||
], [
|
||||
'name.required' => '單位名稱為必填項目',
|
||||
'name.unique' => '該單位名稱已存在',
|
||||
'name.max' => '單位名稱不能超過 255 個字元',
|
||||
'code.max' => '單位代碼不能超過 50 個字元',
|
||||
]);
|
||||
|
||||
$unit->update($validated);
|
||||
|
||||
return redirect()->back()->with('success', '單位已更新');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy(Unit $unit)
|
||||
{
|
||||
// Check if unit is used in any product
|
||||
$isUsed = Product::where('base_unit_id', $unit->id)
|
||||
->orWhere('large_unit_id', $unit->id)
|
||||
->orWhere('purchase_unit_id', $unit->id)
|
||||
->exists();
|
||||
|
||||
if ($isUsed) {
|
||||
return redirect()->back()->with('error', '該單位已被商品使用,無法刪除');
|
||||
}
|
||||
|
||||
$unit->delete();
|
||||
|
||||
return redirect()->back()->with('success', '單位已刪除');
|
||||
}
|
||||
}
|
||||
95
app/Modules/Inventory/Controllers/WarehouseController.php
Normal file
95
app/Modules/Inventory/Controllers/WarehouseController.php
Normal file
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Controllers;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
|
||||
use Inertia\Inertia;
|
||||
|
||||
class WarehouseController extends Controller
|
||||
{
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = Warehouse::query();
|
||||
|
||||
if ($request->has('search')) {
|
||||
$search = $request->input('search');
|
||||
$query->where(function ($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('code', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
$warehouses = $query->withSum('inventories as total_quantity', 'quantity')
|
||||
->orderBy('created_at', 'desc')
|
||||
->paginate(10)
|
||||
->withQueryString();
|
||||
|
||||
return Inertia::render('Warehouse/Index', [
|
||||
'warehouses' => $warehouses,
|
||||
'filters' => $request->only(['search']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
]);
|
||||
|
||||
// Auto-generate code
|
||||
$prefix = 'WH';
|
||||
$lastWarehouse = Warehouse::latest('id')->first();
|
||||
$nextId = $lastWarehouse ? $lastWarehouse->id + 1 : 1;
|
||||
$code = $prefix . str_pad($nextId, 3, '0', STR_PAD_LEFT);
|
||||
|
||||
$validated['code'] = $code;
|
||||
|
||||
Warehouse::create($validated);
|
||||
|
||||
return redirect()->back()->with('success', '倉庫已建立');
|
||||
}
|
||||
|
||||
public function update(Request $request, Warehouse $warehouse)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'name' => 'required|string|max:50',
|
||||
'address' => 'nullable|string|max:255',
|
||||
'description' => 'nullable|string',
|
||||
]);
|
||||
|
||||
$warehouse->update($validated);
|
||||
|
||||
return redirect()->back()->with('success', '倉庫資訊已更新');
|
||||
}
|
||||
|
||||
public function destroy(Warehouse $warehouse)
|
||||
{
|
||||
// 檢查是否有相關聯的採購單
|
||||
if ($warehouse->purchaseOrders()->exists()) {
|
||||
return redirect()->back()->with('error', '無法刪除:該倉庫有相關聯的採購單,請先處理採購單。');
|
||||
}
|
||||
|
||||
\Illuminate\Support\Facades\DB::transaction(function () use ($warehouse) {
|
||||
// 刪除庫存異動紀錄 (透過庫存關聯)
|
||||
foreach ($warehouse->inventories as $inventory) {
|
||||
// 刪除該庫存的所有異動紀錄
|
||||
$inventory->transactions()->delete();
|
||||
}
|
||||
|
||||
// 刪除庫存項目
|
||||
$warehouse->inventories()->delete();
|
||||
|
||||
// 刪除倉庫
|
||||
$warehouse->delete();
|
||||
});
|
||||
|
||||
return redirect()->back()->with('success', '倉庫及其庫存與紀錄已刪除');
|
||||
}
|
||||
}
|
||||
40
app/Modules/Inventory/Models/Category.php
Normal file
40
app/Modules/Inventory/Models/Category.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
|
||||
class Category extends Model
|
||||
{
|
||||
use HasFactory, LogsActivity;
|
||||
|
||||
protected $fillable = ['name', 'description'];
|
||||
|
||||
public function products(): HasMany
|
||||
{
|
||||
return $this->hasMany(Product::class);
|
||||
}
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logAll()
|
||||
->logOnlyDirty()
|
||||
->dontSubmitEmptyLogs();
|
||||
}
|
||||
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||
{
|
||||
$properties = $activity->properties;
|
||||
|
||||
$snapshot = $properties['snapshot'] ?? [];
|
||||
$snapshot['name'] = $this->name;
|
||||
$properties['snapshot'] = $snapshot;
|
||||
|
||||
$activity->properties = $properties;
|
||||
}
|
||||
}
|
||||
140
app/Modules/Inventory/Models/Inventory.php
Normal file
140
app/Modules/Inventory/Models/Inventory.php
Normal file
@@ -0,0 +1,140 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Modules\Procurement\Models\PurchaseOrder; // Cross-module dependency
|
||||
|
||||
class Inventory extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\InventoryFactory> */
|
||||
use HasFactory;
|
||||
use \Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use \Spatie\Activitylog\Traits\LogsActivity;
|
||||
|
||||
protected $fillable = [
|
||||
'warehouse_id',
|
||||
'product_id',
|
||||
'quantity',
|
||||
'location',
|
||||
// 批號追溯欄位
|
||||
'batch_number',
|
||||
'box_number',
|
||||
'origin_country',
|
||||
'arrival_date',
|
||||
'expiry_date',
|
||||
'source_purchase_order_id',
|
||||
'quality_status',
|
||||
'quality_remark',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'arrival_date' => 'date:Y-m-d',
|
||||
'expiry_date' => 'date:Y-m-d',
|
||||
];
|
||||
|
||||
/**
|
||||
* Transient property to store the reason for the activity log (e.g., "Replenishment #123").
|
||||
* This is not stored in the database column but used for logging context.
|
||||
* @var string|null
|
||||
*/
|
||||
public $activityLogReason;
|
||||
|
||||
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
|
||||
{
|
||||
return \Spatie\Activitylog\LogOptions::defaults()
|
||||
->logAll()
|
||||
->logOnlyDirty()
|
||||
->dontSubmitEmptyLogs();
|
||||
}
|
||||
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||
{
|
||||
$properties = $activity->properties;
|
||||
$attributes = $properties['attributes'] ?? [];
|
||||
$snapshot = $properties['snapshot'] ?? [];
|
||||
|
||||
// Always snapshot names for context, even if IDs didn't change
|
||||
// $this refers to the Inventory model instance
|
||||
$snapshot['warehouse_name'] = $this->warehouse ? $this->warehouse->name : ($snapshot['warehouse_name'] ?? null);
|
||||
$snapshot['product_name'] = $this->product ? $this->product->name : ($snapshot['product_name'] ?? null);
|
||||
|
||||
// Capture the reason if set
|
||||
if ($this->activityLogReason) {
|
||||
$attributes['_reason'] = $this->activityLogReason;
|
||||
}
|
||||
|
||||
$properties['attributes'] = $attributes;
|
||||
$properties['snapshot'] = $snapshot;
|
||||
$activity->properties = $properties;
|
||||
}
|
||||
|
||||
public function warehouse(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
|
||||
public function product(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
|
||||
public function transactions(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
{
|
||||
return $this->hasMany(InventoryTransaction::class);
|
||||
}
|
||||
|
||||
public function lastOutgoingTransaction()
|
||||
{
|
||||
return $this->hasOne(InventoryTransaction::class)->ofMany([
|
||||
'actual_time' => 'max',
|
||||
'id' => 'max',
|
||||
], function ($query) {
|
||||
$query->where('quantity', '<', 0);
|
||||
});
|
||||
}
|
||||
|
||||
public function lastIncomingTransaction()
|
||||
{
|
||||
return $this->hasOne(InventoryTransaction::class)->ofMany([
|
||||
'actual_time' => 'max',
|
||||
'id' => 'max',
|
||||
], function ($query) {
|
||||
$query->where('quantity', '>', 0);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 來源採購單
|
||||
*/
|
||||
public function sourcePurchaseOrder(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PurchaseOrder::class, 'source_purchase_order_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* 產生批號
|
||||
* 格式:{商品代號}-{來源國家}-{入庫日期}-{批次流水號}
|
||||
*/
|
||||
public static function generateBatchNumber(string $productCode, string $originCountry, string $arrivalDate): string
|
||||
{
|
||||
$dateFormatted = date('Ymd', strtotime($arrivalDate));
|
||||
$prefix = "{$productCode}-{$originCountry}-{$dateFormatted}-";
|
||||
|
||||
// 加入 withTrashed() 確保流水號不會撞到已刪除的紀錄
|
||||
$lastBatch = static::withTrashed()
|
||||
->where('batch_number', 'like', "{$prefix}%")
|
||||
->orderByDesc('batch_number')
|
||||
->first();
|
||||
|
||||
if ($lastBatch) {
|
||||
$lastNumber = (int) substr($lastBatch->batch_number, -2);
|
||||
$nextNumber = str_pad($lastNumber + 1, 2, '0', STR_PAD_LEFT);
|
||||
} else {
|
||||
$nextNumber = '01';
|
||||
}
|
||||
|
||||
return $prefix . $nextNumber;
|
||||
}
|
||||
}
|
||||
45
app/Modules/Inventory/Models/InventoryTransaction.php
Normal file
45
app/Modules/Inventory/Models/InventoryTransaction.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Modules\Core\Models\User; // Cross-module Core dependency
|
||||
|
||||
class InventoryTransaction extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\InventoryTransactionFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'inventory_id',
|
||||
'type',
|
||||
'quantity',
|
||||
'balance_before',
|
||||
'balance_after',
|
||||
'reason',
|
||||
'reference_type',
|
||||
'reference_id',
|
||||
'user_id',
|
||||
'actual_time',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'actual_time' => 'datetime',
|
||||
];
|
||||
|
||||
public function inventory(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Inventory::class);
|
||||
}
|
||||
|
||||
public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function reference(): \Illuminate\Database\Eloquent\Relations\MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
}
|
||||
116
app/Modules/Inventory/Models/Product.php
Normal file
116
app/Modules/Inventory/Models/Product.php
Normal file
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
use App\Modules\Procurement\Models\Vendor; // Cross-module dependency (Procurement)
|
||||
|
||||
class Product extends Model
|
||||
{
|
||||
use HasFactory, LogsActivity, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'name',
|
||||
'category_id',
|
||||
'brand',
|
||||
'specification',
|
||||
'base_unit_id',
|
||||
'large_unit_id',
|
||||
'conversion_rate',
|
||||
'purchase_unit_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'conversion_rate' => 'decimal:4',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the category that owns the product.
|
||||
*/
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class);
|
||||
}
|
||||
|
||||
public function baseUnit(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Unit::class, 'base_unit_id');
|
||||
}
|
||||
|
||||
public function largeUnit(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Unit::class, 'large_unit_id');
|
||||
}
|
||||
|
||||
public function purchaseUnit(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Unit::class, 'purchase_unit_id');
|
||||
}
|
||||
|
||||
public function vendors(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Vendor::class)->withPivot('last_price')->withTimestamps();
|
||||
}
|
||||
|
||||
public function inventories(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
{
|
||||
return $this->hasMany(Inventory::class);
|
||||
}
|
||||
|
||||
public function transactions(): HasMany
|
||||
{
|
||||
return $this->hasMany(InventoryTransaction::class);
|
||||
}
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logAll()
|
||||
->logOnlyDirty()
|
||||
->dontSubmitEmptyLogs();
|
||||
}
|
||||
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||
{
|
||||
$properties = $activity->properties;
|
||||
$attributes = $properties['attributes'] ?? [];
|
||||
$snapshot = $properties['snapshot'] ?? [];
|
||||
|
||||
// Handle Category Name Snapshot
|
||||
if (isset($attributes['category_id'])) {
|
||||
$category = Category::find($attributes['category_id']);
|
||||
$snapshot['category_name'] = $category ? $category->name : null;
|
||||
}
|
||||
|
||||
// Handle Unit Name Snapshots
|
||||
$unitFields = ['base_unit_id', 'large_unit_id', 'purchase_unit_id'];
|
||||
foreach ($unitFields as $field) {
|
||||
if (isset($attributes[$field])) {
|
||||
$unit = Unit::find($attributes[$field]);
|
||||
$nameKey = str_replace('_id', '_name', $field);
|
||||
$snapshot[$nameKey] = $unit ? $unit->name : null;
|
||||
}
|
||||
}
|
||||
|
||||
// Always snapshot self name for context (so logs always show "Cola")
|
||||
$snapshot['name'] = $this->name;
|
||||
|
||||
$properties['attributes'] = $attributes;
|
||||
$properties['snapshot'] = $snapshot;
|
||||
$activity->properties = $properties;
|
||||
}
|
||||
|
||||
public function warehouses(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Warehouse::class, 'inventories')
|
||||
->withPivot(['quantity', 'safety_stock', 'location'])
|
||||
->withTimestamps();
|
||||
}
|
||||
}
|
||||
45
app/Modules/Inventory/Models/Unit.php
Normal file
45
app/Modules/Inventory/Models/Unit.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
|
||||
class Unit extends Model
|
||||
{
|
||||
use HasFactory, LogsActivity;
|
||||
|
||||
protected $fillable = ['name', 'abbreviation'];
|
||||
|
||||
public function productsAsBase(): HasMany
|
||||
{
|
||||
return $this->hasMany(Product::class, 'base_unit_id');
|
||||
}
|
||||
|
||||
public function productsAsLarge(): HasMany
|
||||
{
|
||||
return $this->hasMany(Product::class, 'large_unit_id');
|
||||
}
|
||||
|
||||
public function getActivitylogOptions(): LogOptions
|
||||
{
|
||||
return LogOptions::defaults()
|
||||
->logAll()
|
||||
->logOnlyDirty()
|
||||
->dontSubmitEmptyLogs();
|
||||
}
|
||||
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||
{
|
||||
$properties = $activity->properties;
|
||||
|
||||
$snapshot = $properties['snapshot'] ?? [];
|
||||
$snapshot['name'] = $this->name;
|
||||
$properties['snapshot'] = $snapshot;
|
||||
|
||||
$activity->properties = $properties;
|
||||
}
|
||||
}
|
||||
57
app/Modules/Inventory/Models/Warehouse.php
Normal file
57
app/Modules/Inventory/Models/Warehouse.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use App\Modules\Procurement\Models\PurchaseOrder; // Cross-module dependency (Procurement)
|
||||
|
||||
class Warehouse extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\WarehouseFactory> */
|
||||
use HasFactory;
|
||||
use \Spatie\Activitylog\Traits\LogsActivity;
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'name',
|
||||
'address',
|
||||
'description',
|
||||
];
|
||||
|
||||
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
|
||||
{
|
||||
return \Spatie\Activitylog\LogOptions::defaults()
|
||||
->logAll()
|
||||
->logOnlyDirty()
|
||||
->dontSubmitEmptyLogs();
|
||||
}
|
||||
|
||||
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName)
|
||||
{
|
||||
$properties = $activity->properties;
|
||||
|
||||
$snapshot = $properties['snapshot'] ?? [];
|
||||
$snapshot['name'] = $this->name;
|
||||
$properties['snapshot'] = $snapshot;
|
||||
|
||||
$activity->properties = $properties;
|
||||
}
|
||||
|
||||
public function inventories(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
{
|
||||
return $this->hasMany(Inventory::class);
|
||||
}
|
||||
|
||||
public function purchaseOrders(): \Illuminate\Database\Eloquent\Relations\HasMany
|
||||
{
|
||||
return $this->hasMany(PurchaseOrder::class);
|
||||
}
|
||||
|
||||
public function products(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Product::class, 'inventories')
|
||||
->withPivot(['quantity', 'safety_stock', 'location'])
|
||||
->withTimestamps();
|
||||
}
|
||||
}
|
||||
41
app/Modules/Inventory/Models/WarehouseProductSafetyStock.php
Normal file
41
app/Modules/Inventory/Models/WarehouseProductSafetyStock.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Modules\Inventory\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* 倉庫-商品安全庫存設定
|
||||
* 每個倉庫-商品組合只有一筆安全庫存設定
|
||||
*/
|
||||
class WarehouseProductSafetyStock extends Model
|
||||
{
|
||||
protected $table = 'warehouse_product_safety_stocks';
|
||||
|
||||
protected $fillable = [
|
||||
'warehouse_id',
|
||||
'product_id',
|
||||
'safety_stock',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'safety_stock' => 'decimal:2',
|
||||
];
|
||||
|
||||
/**
|
||||
* 所屬倉庫
|
||||
*/
|
||||
public function warehouse(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 所屬商品
|
||||
*/
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
}
|
||||
80
app/Modules/Inventory/Routes/web.php
Normal file
80
app/Modules/Inventory/Routes/web.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Modules\Inventory\Controllers\CategoryController;
|
||||
use App\Modules\Inventory\Controllers\UnitController;
|
||||
use App\Modules\Inventory\Controllers\ProductController;
|
||||
use App\Modules\Inventory\Controllers\WarehouseController;
|
||||
use App\Modules\Inventory\Controllers\InventoryController;
|
||||
use App\Modules\Inventory\Controllers\SafetyStockController;
|
||||
use App\Modules\Inventory\Controllers\TransferOrderController;
|
||||
|
||||
Route::middleware('auth')->group(function () {
|
||||
|
||||
// 類別管理 (用於商品對話框) - 需要商品權限
|
||||
Route::middleware('permission:products.view')->group(function () {
|
||||
Route::get('/categories', [CategoryController::class, 'index'])->name('categories.index');
|
||||
Route::post('/categories', [CategoryController::class, 'store'])->middleware('permission:products.create')->name('categories.store');
|
||||
Route::put('/categories/{category}', [CategoryController::class, 'update'])->middleware('permission:products.edit')->name('categories.update');
|
||||
Route::delete('/categories/{category}', [CategoryController::class, 'destroy'])->middleware('permission:products.delete')->name('categories.destroy');
|
||||
});
|
||||
|
||||
// 單位管理 - 需要商品權限
|
||||
Route::middleware('permission:products.create|products.edit')->group(function () {
|
||||
Route::post('/units', [UnitController::class, 'store'])->name('units.store');
|
||||
Route::put('/units/{unit}', [UnitController::class, 'update'])->name('units.update');
|
||||
Route::delete('/units/{unit}', [UnitController::class, 'destroy'])->name('units.destroy');
|
||||
});
|
||||
|
||||
// 商品管理
|
||||
Route::middleware('permission:products.view')->group(function () {
|
||||
Route::get('/products', [ProductController::class, 'index'])->name('products.index');
|
||||
Route::post('/products', [ProductController::class, 'store'])->middleware('permission:products.create')->name('products.store');
|
||||
Route::put('/products/{product}', [ProductController::class, 'update'])->middleware('permission:products.edit')->name('products.update');
|
||||
Route::delete('/products/{product}', [ProductController::class, 'destroy'])->middleware('permission:products.delete')->name('products.destroy');
|
||||
});
|
||||
|
||||
// 倉庫管理
|
||||
Route::middleware('permission:warehouses.view')->group(function () {
|
||||
Route::get('/warehouses', [WarehouseController::class, 'index'])->name('warehouses.index');
|
||||
Route::post('/warehouses', [WarehouseController::class, 'store'])->middleware('permission:warehouses.create')->name('warehouses.store');
|
||||
Route::put('/warehouses/{warehouse}', [WarehouseController::class, 'update'])->middleware('permission:warehouses.edit')->name('warehouses.update');
|
||||
Route::delete('/warehouses/{warehouse}', [WarehouseController::class, 'destroy'])->middleware('permission:warehouses.delete')->name('warehouses.destroy');
|
||||
|
||||
// 倉庫庫存管理 - 需要庫存權限
|
||||
Route::middleware('permission:inventory.view')->group(function () {
|
||||
Route::get('/warehouses/{warehouse}/inventory', [InventoryController::class, 'index'])->name('warehouses.inventory.index');
|
||||
Route::get('/warehouses/{warehouse}/inventory-history', [InventoryController::class, 'history'])->name('warehouses.inventory.history');
|
||||
|
||||
Route::middleware('permission:inventory.adjust')->group(function () {
|
||||
Route::get('/warehouses/{warehouse}/inventory/create', [InventoryController::class, 'create'])->name('warehouses.inventory.create');
|
||||
Route::post('/warehouses/{warehouse}/inventory', [InventoryController::class, 'store'])->name('warehouses.inventory.store');
|
||||
Route::get('/warehouses/{warehouse}/inventory/{inventoryId}/edit', [InventoryController::class, 'edit'])->name('warehouses.inventory.edit');
|
||||
Route::put('/warehouses/{warehouse}/inventory/{inventoryId}', [InventoryController::class, 'update'])->name('warehouses.inventory.update');
|
||||
Route::delete('/warehouses/{warehouse}/inventory/{inventoryId}', [InventoryController::class, 'destroy'])->name('warehouses.inventory.destroy');
|
||||
});
|
||||
|
||||
// API: 取得商品在特定倉庫的所有批號
|
||||
Route::get('/api/warehouses/{warehouse}/inventory/batches/{productId}', [InventoryController::class, 'getBatches'])
|
||||
->name('api.warehouses.inventory.batches');
|
||||
});
|
||||
|
||||
// 安全庫存設定
|
||||
Route::middleware('permission:inventory.view')->group(function () {
|
||||
Route::get('/warehouses/{warehouse}/safety-stock', [SafetyStockController::class, 'index'])->name('warehouses.safety-stock.index');
|
||||
Route::middleware('permission:inventory.safety_stock')->group(function () {
|
||||
Route::post('/warehouses/{warehouse}/safety-stock', [SafetyStockController::class, 'store'])->name('warehouses.safety-stock.store');
|
||||
Route::put('/warehouses/{warehouse}/safety-stock/{safetyStock}', [SafetyStockController::class, 'update'])->name('warehouses.safety-stock.update');
|
||||
Route::delete('/warehouses/{warehouse}/safety-stock/{safetyStock}', [SafetyStockController::class, 'destroy'])->name('warehouses.safety-stock.destroy');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// 撥補單 (在庫存調撥時使用)
|
||||
Route::middleware('permission:inventory.transfer')->group(function () {
|
||||
Route::post('/transfer-orders', [TransferOrderController::class, 'store'])->name('transfer-orders.store');
|
||||
});
|
||||
Route::get('/api/warehouses/{warehouse}/inventories', [TransferOrderController::class, 'getWarehouseInventories'])
|
||||
->middleware('permission:inventory.view')
|
||||
->name('api.warehouses.inventories');
|
||||
});
|
||||
Reference in New Issue
Block a user