生產工單BOM以及批號完善
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 57s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped

This commit is contained in:
2026-01-22 15:39:35 +08:00
parent 1ae21febb5
commit 1d134c9ad8
31 changed files with 2684 additions and 694 deletions

View File

@@ -7,8 +7,10 @@ use App\Models\Vendor;
use App\Models\PurchaseOrder;
use App\Models\Warehouse;
use App\Models\Inventory;
use App\Models\WarehouseProductSafetyStock;
use Inertia\Inertia;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class DashboardController extends Controller
{
@@ -21,15 +23,25 @@ class DashboardController extends Controller
return redirect()->route('landlord.dashboard');
}
// 計算低庫存數量:各商品在各倉庫的總量 < 安全庫存
$lowStockCount = DB::table('warehouse_product_safety_stocks as ss')
->join(DB::raw('(SELECT warehouse_id, product_id, SUM(quantity) as total_qty FROM inventories WHERE deleted_at IS NULL GROUP BY warehouse_id, product_id) as inv'),
function ($join) {
$join->on('ss.warehouse_id', '=', 'inv.warehouse_id')
->on('ss.product_id', '=', 'inv.product_id');
})
->whereRaw('inv.total_qty <= ss.safety_stock')
->count();
$stats = [
'productsCount' => Product::count(),
'vendorsCount' => Vendor::count(),
'purchaseOrdersCount' => PurchaseOrder::count(),
'warehousesCount' => Warehouse::count(),
'totalInventoryValue' => Inventory::join('products', 'inventories.product_id', '=', 'products.id')
->sum('inventories.quantity'), // Simplified, maybe just sum quantities for now
->sum('inventories.quantity'),
'pendingOrdersCount' => PurchaseOrder::where('status', 'pending')->count(),
'lowStockCount' => Inventory::whereColumn('quantity', '<=', 'safety_stock')->count(),
'lowStockCount' => $lowStockCount,
];
return Inertia::render('Dashboard', [

View File

@@ -3,6 +3,7 @@
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use App\Models\WarehouseProductSafetyStock;
class InventoryController extends Controller
{
@@ -19,49 +20,82 @@ class InventoryController extends Controller
// 1. 準備 availableProducts
$availableProducts = $allProducts->map(function ($product) {
return [
'id' => (string) $product->id, // Frontend expects string
'id' => (string) $product->id,
'name' => $product->name,
'type' => $product->category?->name ?? '其他', // 暫時用 Category Name 當 Type
'type' => $product->category?->name ?? '其他',
];
});
// 2. 準備 inventories (模擬批號)
// 2. 準備 inventories
// 資料庫結構為 (warehouse_id, product_id) 唯一,故為扁平列表
$inventories = $warehouse->inventories->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' => $inv->safety_stock !== null ? (float) $inv->safety_stock : null,
'status' => '正常', // 前端會根據 quantity 與 safetyStock 重算,但後端亦可提供基礎狀態
'batchNumber' => $inv->batch_number ?? 'BATCH-' . $inv->id, // 優先使用 DB 批號,若無則 fallback
'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,
];
});
// 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 = '低於';
}
}
// 3. 準備 safetyStockSettings
$safetyStockSettings = $warehouse->inventories->filter(function($inv) {
return !is_null($inv->safety_stock);
})->map(function ($inv) {
return [
'id' => 'ss-' . $inv->id,
'warehouseId' => (string) $inv->warehouse_id,
'productId' => (string) $inv->product_id,
'productName' => $inv->product?->name ?? '未知商品',
'productType' => $inv->product?->category?->name ?? '其他',
'safetyStock' => (float) $inv->safety_stock,
'createdAt' => $inv->created_at->toIso8601String(),
'updatedAt' => $inv->updated_at->toIso8601String(),
'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,
@@ -73,10 +107,14 @@ class InventoryController extends Controller
public function create(\App\Models\Warehouse $warehouse)
{
// 取得所有商品供前端選單使用
$products = \App\Models\Product::with(['baseUnit', 'largeUnit'])->select('id', 'name', 'base_unit_id', 'large_unit_id', 'conversion_rate')->get()->map(function ($product) {
$products = \App\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,
@@ -98,45 +136,55 @@ class InventoryController extends Controller
'items' => 'required|array|min:1',
'items.*.productId' => 'required|exists:products,id',
'items.*.quantity' => 'required|numeric|min:0.01',
'items.*.batchNumber' => 'nullable|string',
'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) {
$batchNumber = $item['batchNumber'] ?? null;
// 如果未提供批號,且系統設定需要批號,則自動產生 (這裡先保留彈性,若無則為 null 或預設)
if (empty($batchNumber)) {
// 嘗試自動產生:需要 product_code, country, date
$inventory = null;
if ($item['batchMode'] === 'existing') {
// 模式 A選擇現有批號 (包含已刪除的也要能找回來累加)
$inventory = \App\Models\Inventory::withTrashed()->findOrFail($item['inventoryId']);
if ($inventory->trashed()) {
$inventory->restore();
}
} else {
// 模式 B建立新批號
$originCountry = $item['originCountry'] ?? 'TW';
$product = \App\Models\Product::find($item['productId']);
if ($product) {
$batchNumber = \App\Models\Inventory::generateBatchNumber(
$product->code ?? 'UNK',
'TW', // 預設來源
$validated['inboundDate']
);
$batchNumber = \App\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();
}
}
// 取得或建立庫存紀錄 (加入批號判斷)
$inventory = $warehouse->inventories()->firstOrNew(
[
'product_id' => $item['productId'],
'batch_number' => $batchNumber
],
[
'quantity' => 0,
'safety_stock' => null,
'arrival_date' => $validated['inboundDate'],
'expiry_date' => $item['expiryDate'] ?? null,
'origin_country' => 'TW', // 預設
]
);
$currentQty = $inventory->quantity;
$newQty = $currentQty + $item['quantity'];
// 更新庫存並儲存 (新紀錄: Created, 舊紀錄: Updated)
$inventory->quantity = $newQty;
$inventory->save();
@@ -157,7 +205,46 @@ class InventoryController extends Controller
});
}
public function edit(\App\Models\Warehouse $warehouse, $inventoryId)
/**
* API: 取得商品在特定倉庫的所有批號,並回傳當前日期/產地下的一個流水號
*/
public function getBatches(\App\Models\Warehouse $warehouse, $productId, \Illuminate\Http\Request $request)
{
$originCountry = $request->query('originCountry', 'TW');
$arrivalDate = $request->query('arrivalDate', now()->format('Y-m-d'));
$batches = \App\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\Models\Product::find($productId);
$nextSequence = '01';
if ($product) {
$batchNumber = \App\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\Models\Warehouse $warehouse, $inventoryId)
{
// 取得庫存紀錄,包含商品資訊與異動紀錄 (含經手人)
// 這裡如果是 Mock 的 ID (mock-inv-1),需要特殊處理
@@ -176,8 +263,8 @@ class InventoryController extends Controller
'productId' => (string) $inventory->product_id,
'productName' => $inventory->product?->name ?? '未知商品',
'quantity' => (float) $inventory->quantity,
'batchNumber' => 'BATCH-' . $inventory->id, // Mock
'expiryDate' => '2099-12-31', // Mock
'batchNumber' => $inventory->batch_number ?? '-',
'expiryDate' => $inventory->expiry_date ?? null,
'lastInboundDate' => $inventory->updated_at->format('Y-m-d'),
'lastOutboundDate' => null,
];
@@ -234,19 +321,21 @@ class InventoryController extends Controller
]);
return \Illuminate\Support\Facades\DB::transaction(function () use ($validated, $inventory) {
$currentQty = $inventory->quantity;
$newQty = $validated['quantity'];
$currentQty = (float) $inventory->quantity;
$newQty = (float) $validated['quantity'];
// 判斷操作模式
if (isset($validated['operation'])) {
$changeQty = 0;
// 判斷是否來自調整彈窗 (包含 operation 參數)
$isAdjustment = isset($validated['operation']);
$changeQty = 0;
if ($isAdjustment) {
switch ($validated['operation']) {
case 'add':
$changeQty = $validated['quantity'];
$changeQty = (float) $validated['quantity'];
$newQty = $currentQty + $changeQty;
break;
case 'subtract':
$changeQty = -$validated['quantity'];
$changeQty = -(float) $validated['quantity'];
$newQty = $currentQty + $changeQty;
break;
case 'set':
@@ -262,8 +351,9 @@ class InventoryController extends Controller
$inventory->update(['quantity' => $newQty]);
// 異動類型映射
$type = $validated['type'] ?? 'adjustment';
$type = $validated['type'] ?? ($isAdjustment ? 'manual_adjustment' : 'adjustment');
$typeMapping = [
'manual_adjustment' => '手動調整庫存',
'adjustment' => '盤點調整',
'purchase_in' => '採購進貨',
'sales_out' => '銷售出庫',
@@ -274,22 +364,26 @@ class InventoryController extends Controller
];
$chineseType = $typeMapping[$type] ?? $type;
// 如果是編輯頁面來的,可能沒有 type設為 "盤點調整" 或 "手動編輯"
if (!isset($validated['type'])) {
// 如果是編輯頁面來的,且沒傳 type設為手動編輯
if (!$isAdjustment && !isset($validated['type'])) {
$chineseType = '手動編輯';
}
// 整理原因
$reason = $validated['reason'] ?? ($isAdjustment ? '手動庫存調整' : '編輯頁面更新');
if (isset($validated['notes'])) {
$reason .= ' - ' . $validated['notes'];
}
// 寫入異動紀錄
// 如果數量沒變,是否要寫紀錄?通常編輯頁面按儲存可能只改了其他欄位(如果有)
// 但因為我們目前只存 quantity如果 quantity 沒變,可以不寫異動,或者寫一筆 0 的異動代表更新屬性
if (abs($changeQty) > 0.0001) {
$inventory->transactions()->create([
'type' => $chineseType,
'quantity' => $changeQty,
'balance_before' => $currentQty,
'balance_after' => $newQty,
'reason' => ($validated['reason'] ?? '編輯頁面更新') . ($validated['notes'] ?? ''),
'actual_time' => now(), // 手動調整設定為當下
'reason' => $reason,
'actual_time' => now(),
'user_id' => auth()->id(),
]);
}
@@ -303,8 +397,13 @@ class InventoryController extends Controller
{
$inventory = \App\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,
@@ -322,33 +421,117 @@ class InventoryController extends Controller
->with('success', '庫存品項已刪除');
}
public function history(\App\Models\Warehouse $warehouse, $inventoryId)
public function history(Request $request, \App\Models\Warehouse $warehouse)
{
$inventory = \App\Models\Inventory::with(['product', 'transactions' => function($query) {
$query->orderBy('actual_time', 'desc')->orderBy('id', 'desc');
}, 'transactions.user'])->findOrFail($inventoryId);
$inventoryId = $request->query('inventoryId');
$productId = $request->query('productId');
$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'),
];
});
if ($productId) {
// 商品層級查詢
$inventories = \App\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();
return \Inertia\Inertia::render('Warehouse/InventoryHistory', [
'warehouse' => $warehouse,
'inventory' => [
'id' => (string) $inventory->id,
'productName' => $inventory->product?->name ?? '未知商品',
'productCode' => $inventory->product?->code ?? 'N/A',
'quantity' => (float) $inventory->quantity,
],
'transactions' => $transactions
]);
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\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', '未提供查詢參數');
}
}

View File

@@ -73,11 +73,19 @@ class ProductionOrderController extends Controller
*/
public function store(Request $request)
{
$validated = $request->validate([
$status = $request->input('status', 'draft'); // 預設為草稿
// 共用驗證規則
$baseRules = [
'product_id' => 'required|exists:products,id',
'output_batch_number' => 'required|string|max:50',
'status' => 'nullable|in:draft,completed',
];
// 完成模式需要完整驗證
$completedRules = [
'warehouse_id' => 'required|exists:warehouses,id',
'output_quantity' => 'required|numeric|min:0.01',
'output_batch_number' => 'required|string|max:50',
'output_box_count' => 'nullable|string|max:10',
'production_date' => 'required|date',
'expiry_date' => 'nullable|date|after_or_equal:production_date',
@@ -86,64 +94,96 @@ class ProductionOrderController extends Controller
'items.*.inventory_id' => 'required|exists:inventories,id',
'items.*.quantity_used' => 'required|numeric|min:0.0001',
'items.*.unit_id' => 'nullable|exists:units,id',
], [
];
// 草稿模式的寬鬆規則
$draftRules = [
'warehouse_id' => 'nullable|exists:warehouses,id',
'output_quantity' => 'nullable|numeric|min:0',
'output_box_count' => 'nullable|string|max:10',
'production_date' => 'nullable|date',
'expiry_date' => 'nullable|date',
'remark' => 'nullable|string',
'items' => 'nullable|array',
'items.*.inventory_id' => 'nullable|exists:inventories,id',
'items.*.quantity_used' => 'nullable|numeric|min:0',
'items.*.unit_id' => 'nullable|exists:units,id',
];
$rules = $status === 'completed'
? array_merge($baseRules, $completedRules)
: array_merge($baseRules, $draftRules);
$validated = $request->validate($rules, [
'product_id.required' => '請選擇成品商品',
'output_batch_number.required' => '請輸入成品批號',
'warehouse_id.required' => '請選擇入庫倉庫',
'output_quantity.required' => '請輸入生產數量',
'output_batch_number.required' => '請輸入成品批號',
'production_date.required' => '請選擇生產日期',
'items.required' => '請至少新增一項原物料',
'items.min' => '請至少新增一項原物料',
]);
DB::transaction(function () use ($validated, $request) {
DB::transaction(function () use ($validated, $request, $status) {
// 1. 建立生產工單
$productionOrder = ProductionOrder::create([
'code' => ProductionOrder::generateCode(),
'product_id' => $validated['product_id'],
'warehouse_id' => $validated['warehouse_id'],
'output_quantity' => $validated['output_quantity'],
'warehouse_id' => $validated['warehouse_id'] ?? null,
'output_quantity' => $validated['output_quantity'] ?? 0,
'output_batch_number' => $validated['output_batch_number'],
'output_box_count' => $validated['output_box_count'] ?? null,
'production_date' => $validated['production_date'],
'production_date' => $validated['production_date'] ?? now()->toDateString(),
'expiry_date' => $validated['expiry_date'] ?? null,
'user_id' => auth()->id(),
'status' => 'completed',
'status' => $status,
'remark' => $validated['remark'] ?? null,
]);
// 2. 建立明細並扣減原物料庫存
foreach ($validated['items'] as $item) {
// 建立明細
ProductionOrderItem::create([
'production_order_id' => $productionOrder->id,
'inventory_id' => $item['inventory_id'],
'quantity_used' => $item['quantity_used'],
'unit_id' => $item['unit_id'] ?? null,
]);
// 2. 建立明細 (草稿與完成模式皆需儲存)
if (!empty($validated['items'])) {
foreach ($validated['items'] as $item) {
if (empty($item['inventory_id'])) continue;
// 扣減原物料庫存
$inventory = Inventory::findOrFail($item['inventory_id']);
$inventory->decrement('quantity', $item['quantity_used']);
// 建立明細
ProductionOrderItem::create([
'production_order_id' => $productionOrder->id,
'inventory_id' => $item['inventory_id'],
'quantity_used' => $item['quantity_used'] ?? 0,
'unit_id' => $item['unit_id'] ?? null,
]);
// 若為完成模式,則扣減原物料庫存
if ($status === 'completed') {
$inventory = Inventory::findOrFail($item['inventory_id']);
$inventory->decrement('quantity', $item['quantity_used']);
}
}
}
// 3. 成品入庫:在目標倉庫建立新的庫存紀錄
$product = Product::findOrFail($validated['product_id']);
Inventory::create([
'warehouse_id' => $validated['warehouse_id'],
'product_id' => $validated['product_id'],
'quantity' => $validated['output_quantity'],
'batch_number' => $validated['output_batch_number'],
'box_number' => $validated['output_box_count'],
'origin_country' => 'TW', // 生產預設為本地
'arrival_date' => $validated['production_date'],
'expiry_date' => $validated['expiry_date'] ?? null,
'quality_status' => 'normal',
]);
// 3. 若為完成模式,執行成品入庫
if ($status === 'completed') {
$product = Product::findOrFail($validated['product_id']);
Inventory::create([
'warehouse_id' => $validated['warehouse_id'],
'product_id' => $validated['product_id'],
'quantity' => $validated['output_quantity'],
'batch_number' => $validated['output_batch_number'],
'box_number' => $validated['output_box_count'],
'origin_country' => 'TW', // 生產預設為本地
'arrival_date' => $validated['production_date'],
'expiry_date' => $validated['expiry_date'] ?? null,
'quality_status' => 'normal',
]);
}
});
$message = $status === 'completed'
? '生產單已建立,原物料已扣減,成品已入庫'
: '生產單草稿已儲存';
return redirect()->route('production-orders.index')
->with('success', '生產單已建立,原物料已扣減,成品已入庫');
->with('success', $message);
}
/**
@@ -170,7 +210,7 @@ class ProductionOrderController extends Controller
*/
public function getWarehouseInventories(Warehouse $warehouse)
{
$inventories = Inventory::with(['product.baseUnit'])
$inventories = Inventory::with(['product.baseUnit', 'product.largeUnit'])
->where('warehouse_id', $warehouse->id)
->where('quantity', '>', 0)
->where('quality_status', 'normal')
@@ -188,9 +228,158 @@ class ProductionOrderController extends Controller
'arrival_date' => $inv->arrival_date?->format('Y-m-d'),
'expiry_date' => $inv->expiry_date?->format('Y-m-d'),
'unit_name' => $inv->product->baseUnit?->name,
'base_unit_id' => $inv->product->base_unit_id,
'base_unit_name' => $inv->product->baseUnit?->name,
'large_unit_id' => $inv->product->large_unit_id,
'large_unit_name' => $inv->product->largeUnit?->name,
'conversion_rate' => $inv->product->conversion_rate,
];
});
return response()->json($inventories);
}
/**
* 編輯生產單(僅限草稿狀態)
*/
public function edit(ProductionOrder $productionOrder): Response
{
// 只有草稿可以編輯
if ($productionOrder->status !== 'draft') {
return redirect()->route('production-orders.show', $productionOrder->id)
->with('error', '只有草稿狀態的生產單可以編輯');
}
$productionOrder->load(['product', 'warehouse', 'items.inventory.product', 'items.unit']);
return Inertia::render('Production/Edit', [
'productionOrder' => $productionOrder,
'products' => Product::with(['baseUnit'])->get(),
'warehouses' => Warehouse::all(),
'units' => Unit::all(),
]);
}
/**
* 更新生產單
*/
public function update(Request $request, ProductionOrder $productionOrder)
{
// 只有草稿可以編輯
if ($productionOrder->status !== 'draft') {
return redirect()->route('production-orders.show', $productionOrder->id)
->with('error', '只有草稿狀態的生產單可以編輯');
}
$status = $request->input('status', 'draft');
// 共用驗證規則
$baseRules = [
'product_id' => 'required|exists:products,id',
'output_batch_number' => 'required|string|max:50',
'status' => 'nullable|in:draft,completed',
];
// 完成模式需要完整驗證
$completedRules = [
'warehouse_id' => 'required|exists:warehouses,id',
'output_quantity' => 'required|numeric|min:0.01',
'output_box_count' => 'nullable|string|max:10',
'production_date' => 'required|date',
'expiry_date' => 'nullable|date|after_or_equal:production_date',
'remark' => 'nullable|string',
'items' => 'required|array|min:1',
'items.*.inventory_id' => 'required|exists:inventories,id',
'items.*.quantity_used' => 'required|numeric|min:0.0001',
'items.*.unit_id' => 'nullable|exists:units,id',
];
// 草稿模式的寬鬆規則
$draftRules = [
'warehouse_id' => 'nullable|exists:warehouses,id',
'output_quantity' => 'nullable|numeric|min:0',
'output_box_count' => 'nullable|string|max:10',
'production_date' => 'nullable|date',
'expiry_date' => 'nullable|date',
'remark' => 'nullable|string',
'items' => 'nullable|array',
'items.*.inventory_id' => 'nullable|exists:inventories,id',
'items.*.quantity_used' => 'nullable|numeric|min:0',
'items.*.unit_id' => 'nullable|exists:units,id',
];
$rules = $status === 'completed'
? array_merge($baseRules, $completedRules)
: array_merge($baseRules, $draftRules);
$validated = $request->validate($rules, [
'product_id.required' => '請選擇成品商品',
'output_batch_number.required' => '請輸入成品批號',
'warehouse_id.required' => '請選擇入庫倉庫',
'output_quantity.required' => '請輸入生產數量',
'production_date.required' => '請選擇生產日期',
'items.required' => '請至少新增一項原物料',
'items.min' => '請至少新增一項原物料',
]);
DB::transaction(function () use ($validated, $status, $productionOrder) {
// 更新生產工單基本資料
$productionOrder->update([
'product_id' => $validated['product_id'],
'warehouse_id' => $validated['warehouse_id'] ?? null,
'output_quantity' => $validated['output_quantity'] ?? 0,
'output_batch_number' => $validated['output_batch_number'],
'output_box_count' => $validated['output_box_count'] ?? null,
'production_date' => $validated['production_date'] ?? now()->toDateString(),
'expiry_date' => $validated['expiry_date'] ?? null,
'status' => $status,
'remark' => $validated['remark'] ?? null,
]);
// 刪除舊的明細
$productionOrder->items()->delete();
// 重新建立明細 (草稿與完成模式皆需儲存)
if (!empty($validated['items'])) {
foreach ($validated['items'] as $item) {
if (empty($item['inventory_id'])) continue;
ProductionOrderItem::create([
'production_order_id' => $productionOrder->id,
'inventory_id' => $item['inventory_id'],
'quantity_used' => $item['quantity_used'] ?? 0,
'unit_id' => $item['unit_id'] ?? null,
]);
// 若為完成模式,則扣減原物料庫存
if ($status === 'completed') {
$inventory = Inventory::findOrFail($item['inventory_id']);
$inventory->decrement('quantity', $item['quantity_used']);
}
}
}
// 若為完成模式,執行成品入庫
if ($status === 'completed') {
Inventory::create([
'warehouse_id' => $validated['warehouse_id'],
'product_id' => $validated['product_id'],
'quantity' => $validated['output_quantity'],
'batch_number' => $validated['output_batch_number'],
'box_number' => $validated['output_box_count'],
'origin_country' => 'TW',
'arrival_date' => $validated['production_date'],
'expiry_date' => $validated['expiry_date'] ?? null,
'quality_status' => 'normal',
]);
}
});
$message = $status === 'completed'
? '生產單已完成,原物料已扣減,成品已入庫'
: '生產單草稿已更新';
return redirect()->route('production-orders.index')
->with('success', $message);
}
}

View File

@@ -3,8 +3,9 @@
namespace App\Http\Controllers;
use App\Models\Warehouse;
use App\Models\Inventory;
use App\Models\WarehouseProductSafetyStock;
use App\Models\Product;
use App\Models\Inventory;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Illuminate\Support\Facades\DB;
@@ -16,8 +17,6 @@ class SafetyStockController extends Controller
*/
public function index(Warehouse $warehouse)
{
$warehouse->load(['inventories.product.category']);
$allProducts = Product::with(['category', 'baseUnit'])->get();
// 準備可選商品列表
@@ -30,32 +29,34 @@ class SafetyStockController extends Controller
];
});
// 準備現有庫存列表 (用於狀態計算)
$inventories = $warehouse->inventories->map(function ($inv) {
return [
'id' => (string) $inv->id,
'productId' => (string) $inv->product_id,
'quantity' => (float) $inv->quantity,
'safetyStock' => (float) $inv->safety_stock,
];
});
// 準備安全庫存設定列表
$safetyStockSettings = $warehouse->inventories->filter(function($inv) {
return !is_null($inv->safety_stock);
})->map(function ($inv) {
return [
'id' => (string) $inv->id,
'warehouseId' => (string) $inv->warehouse_id,
'productId' => (string) $inv->product_id,
'productName' => $inv->product->name,
'productType' => $inv->product->category ? $inv->product->category->name : '其他',
'safetyStock' => (float) $inv->safety_stock,
'unit' => $inv->product->baseUnit?->name ?? '個',
'updatedAt' => $inv->updated_at->toIso8601String(),
];
})->values();
// 準備現有庫存列表 (用於庫存量對比)
$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,
@@ -78,7 +79,7 @@ class SafetyStockController extends Controller
DB::transaction(function () use ($validated, $warehouse) {
foreach ($validated['settings'] as $item) {
Inventory::updateOrCreate(
WarehouseProductSafetyStock::updateOrCreate(
[
'warehouse_id' => $warehouse->id,
'product_id' => $item['productId'],
@@ -96,13 +97,13 @@ class SafetyStockController extends Controller
/**
* 更新單筆安全庫存設定
*/
public function update(Request $request, Warehouse $warehouse, Inventory $inventory)
public function update(Request $request, Warehouse $warehouse, WarehouseProductSafetyStock $safetyStock)
{
$validated = $request->validate([
'safetyStock' => 'required|numeric|min:0',
]);
$inventory->update([
$safetyStock->update([
'safety_stock' => $validated['safetyStock'],
]);
@@ -110,13 +111,11 @@ class SafetyStockController extends Controller
}
/**
* 刪除 (歸零) 安全庫存設定
* 刪除安全庫存設定
*/
public function destroy(Warehouse $warehouse, Inventory $inventory)
public function destroy(Warehouse $warehouse, WarehouseProductSafetyStock $safetyStock)
{
$inventory->update([
'safety_stock' => null,
]);
$safetyStock->delete();
return redirect()->back()->with('success', '安全庫存設定已移除');
}

View File

@@ -46,7 +46,6 @@ class TransferOrderController extends Controller
],
[
'quantity' => 0,
'safety_stock' => null, // 預設為 null (未設定),而非 0
]
);

View File

@@ -23,9 +23,6 @@ class WarehouseController extends Controller
}
$warehouses = $query->withSum('inventories as total_quantity', 'quantity')
->withCount(['inventories as low_stock_count' => function ($query) {
$query->whereColumn('quantity', '<', 'safety_stock');
}])
->orderBy('created_at', 'desc')
->paginate(10)
->withQueryString();

View File

@@ -9,13 +9,13 @@ 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',
'safety_stock',
'location',
// 批號追溯欄位
'batch_number',
@@ -121,7 +121,9 @@ class Inventory extends Model
$dateFormatted = date('Ymd', strtotime($arrivalDate));
$prefix = "{$productCode}-{$originCountry}-{$dateFormatted}-";
$lastBatch = static::where('batch_number', 'like', "{$prefix}%")
// 加入 withTrashed() 確保流水號不會撞到已刪除的紀錄
$lastBatch = static::withTrashed()
->where('batch_number', 'like', "{$prefix}%")
->orderByDesc('batch_number')
->first();

View File

@@ -0,0 +1,41 @@
<?php
namespace App\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);
}
}