feat: 修正庫存與撥補單邏輯並整合文件
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 53s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped

1. 修復倉庫統計數據加總與樣式。
2. 修正可用庫存計算邏輯(排除不可銷售倉庫)。
3. 撥補單商品列表加入批號與效期顯示。
4. 修正撥補單儲存邏輯以支援精確批號轉移。
5. 整合 FEATURES.md 至 README.md。
This commit is contained in:
2026-01-26 14:59:24 +08:00
parent b0848a6bb8
commit 106de4e945
81 changed files with 4118 additions and 1023 deletions

View File

@@ -4,11 +4,10 @@ namespace App\Modules\Production\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Inventory\Models\Product;
use App\Modules\Production\Models\ProductionOrder;
use App\Modules\Production\Models\ProductionOrderItem;
use App\Modules\Inventory\Models\Unit;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use App\Modules\Core\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
@@ -16,20 +15,31 @@ use Inertia\Response;
class ProductionOrderController extends Controller
{
protected $inventoryService;
public function __construct(InventoryServiceInterface $inventoryService)
{
$this->inventoryService = $inventoryService;
}
/**
* 生產工單列表
*/
public function index(Request $request): Response
{
$query = ProductionOrder::with(['product', 'warehouse', 'user']);
// 不再使用 with(),避免跨模組 Eager Loading
$query = ProductionOrder::query();
// 搜尋
// 搜尋 (此處 orWhereHas 暫時保留,因 Laravel query builder 仍可作用於資料表層級,
// 但實務上若模組完全隔離,應考慮搜尋引擎或 ID 預選)
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('code', 'like', "%{$search}%")
->orWhere('output_batch_number', 'like', "%{$search}%")
->orWhereHas('product', fn($pq) => $pq->where('name', 'like', "%{$search}%"));
->orWhere('output_batch_number', 'like', "%{$search}%");
// 若要搜尋產品名稱,現在需先從 Inventory 查出 IDs
$productIds = \App\Modules\Inventory\Models\Product::where('name', 'like', "%{$search}%")->pluck('id');
$q->orWhereIn('product_id', $productIds);
});
}
@@ -38,19 +48,29 @@ class ProductionOrderController extends Controller
$query->where('status', $request->status);
}
// 排
$sortField = $request->input('sort_field', 'created_at');
$sortDirection = $request->input('sort_direction', 'desc');
$allowedSorts = ['id', 'code', 'production_date', 'output_quantity', 'created_at'];
if (!in_array($sortField, $allowedSorts)) {
$sortField = 'created_at';
}
$query->orderBy($sortField, $sortDirection);
// 排除軟刪除
$query->orderBy($request->input('sort_field', 'created_at'), $request->input('sort_direction', 'desc'));
// 分頁
$perPage = $request->input('per_page', 10);
$productionOrders = $query->paginate($perPage)->withQueryString();
// --- 手動資料水和 (Manual Hydration) ---
$productIds = $productionOrders->pluck('product_id')->unique()->filter()->toArray();
$warehouseIds = $productionOrders->pluck('warehouse_id')->unique()->filter()->toArray();
$userIds = $productionOrders->pluck('user_id')->unique()->filter()->toArray();
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
$warehouses = $this->inventoryService->getAllWarehouses()->whereIn('id', $warehouseIds)->keyBy('id');
$users = User::whereIn('id', $userIds)->get()->keyBy('id'); // Core 模組暫由 Model 直接獲取
$productionOrders->getCollection()->transform(function ($order) use ($products, $warehouses, $users) {
$order->product = $products->get($order->product_id);
$order->warehouse = $warehouses->get($order->warehouse_id);
$order->user = $users->get($order->user_id);
return $order;
});
return Inertia::render('Production/Index', [
'productionOrders' => $productionOrders,
'filters' => $request->only(['search', 'status', 'per_page', 'sort_field', 'sort_direction']),
@@ -63,9 +83,9 @@ class ProductionOrderController extends Controller
public function create(): Response
{
return Inertia::render('Production/Create', [
'products' => Product::with(['baseUnit'])->get(),
'warehouses' => Warehouse::all(),
'units' => Unit::all(),
'products' => $this->inventoryService->getAllProducts(),
'warehouses' => $this->inventoryService->getAllWarehouses(),
'units' => $this->inventoryService->getUnits(),
]);
}
@@ -74,56 +94,26 @@ class ProductionOrderController extends Controller
*/
public function store(Request $request)
{
$status = $request->input('status', 'draft'); // 預設為草稿
$status = $request->input('status', 'draft');
// 共用驗證規則
$baseRules = [
'product_id' => 'required|exists:products,id',
'product_id' => 'required',
'output_batch_number' => 'required|string|max:50',
'status' => 'nullable|in:draft,completed',
];
// 完成模式需要完整驗證
$completedRules = [
'warehouse_id' => 'required|exists:warehouses,id',
'warehouse_id' => 'required',
'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.*.inventory_id' => 'required',
'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) : $baseRules;
$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' => '請至少新增一項原物料',
]);
$validated = $request->validate($rules);
DB::transaction(function () use ($validated, $request, $status) {
// 1. 建立生產工單
@@ -133,20 +123,22 @@ class ProductionOrderController extends Controller
'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,
'output_box_count' => $request->output_box_count,
'production_date' => $validated['production_date'] ?? now()->toDateString(),
'expiry_date' => $validated['expiry_date'] ?? null,
'expiry_date' => $request->expiry_date,
'user_id' => auth()->id(),
'status' => $status,
'remark' => $validated['remark'] ?? null,
'remark' => $request->remark,
]);
// 2. 建立明細 (草稿與完成模式皆需儲存)
if (!empty($validated['items'])) {
foreach ($validated['items'] as $item) {
if (empty($item['inventory_id'])) continue;
activity()
->performedOn($productionOrder)
->causedBy(auth()->user())
->log('created');
// 建立明細
// 2. 處理明細
if (!empty($request->items)) {
foreach ($request->items as $item) {
ProductionOrderItem::create([
'production_order_id' => $productionOrder->id,
'inventory_id' => $item['inventory_id'],
@@ -154,52 +146,71 @@ class ProductionOrderController extends Controller
'unit_id' => $item['unit_id'] ?? null,
]);
// 若為完成模式,則扣減原物料庫存
if ($status === 'completed') {
$inventory = Inventory::findOrFail($item['inventory_id']);
$inventory->decrement('quantity', $item['quantity_used']);
$this->inventoryService->decreaseInventoryQuantity(
$item['inventory_id'],
$item['quantity_used'],
"生產單 #{$productionOrder->code} 耗料",
ProductionOrder::class,
$productionOrder->id
);
}
}
}
// 3. 若為完成模式,執行成品入庫
// 3. 成品入庫
if ($status === 'completed') {
$product = Product::findOrFail($validated['product_id']);
Inventory::create([
$this->inventoryService->createInventoryRecord([
'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', // 生產預設為本地
'box_number' => $request->output_box_count,
'arrival_date' => $validated['production_date'],
'expiry_date' => $validated['expiry_date'] ?? null,
'quality_status' => 'normal',
'expiry_date' => $request->expiry_date,
'reason' => "生產單 #{$productionOrder->code} 成品入庫",
'reference_type' => ProductionOrder::class,
'reference_id' => $productionOrder->id,
]);
activity()
->performedOn($productionOrder)
->causedBy(auth()->user())
->log('completed');
}
});
$message = $status === 'completed'
? '生產單已建立,原物料已扣減,成品已入庫'
: '生產單草稿已儲存';
return redirect()->route('production-orders.index')
->with('success', $message);
->with('success', $status === 'completed' ? '生產單已完成並入庫' : '草稿已儲存');
}
/**
* 檢視生產單詳情(含追溯資訊)
* 檢視生產單詳情
*/
public function show(ProductionOrder $productionOrder): Response
{
$productionOrder->load([
'product.baseUnit',
'warehouse',
'user',
'items.inventory.product',
'items.inventory.sourcePurchaseOrder.vendor',
'items.unit',
]);
// 手動水和主表資料
$productionOrder->product = $this->inventoryService->getProduct($productionOrder->product_id);
if ($productionOrder->product) {
$productionOrder->product->base_unit = $this->inventoryService->getUnits()->where('id', $productionOrder->product->base_unit_id)->first();
}
$productionOrder->warehouse = $this->inventoryService->getWarehouse($productionOrder->warehouse_id);
$productionOrder->user = User::find($productionOrder->user_id);
// 手動水和明細資料
$items = $productionOrder->items;
$inventoryIds = $items->pluck('inventory_id')->unique()->filter()->toArray();
$inventories = $this->inventoryService->getInventoriesByIds(
$inventoryIds,
['product.baseUnit', 'sourcePurchaseOrder.vendor']
)->keyBy('id');
$units = $this->inventoryService->getUnits()->keyBy('id');
foreach ($items as $item) {
$item->inventory = $inventories->get($item->inventory_id);
$item->unit = $units->get($item->unit_id);
}
return Inertia::render('Production/Show', [
'productionOrder' => $productionOrder,
@@ -207,57 +218,67 @@ class ProductionOrderController extends Controller
}
/**
* 取得倉庫內可用庫存(供 BOM 選擇)
* 取得倉庫內可用庫存
*/
public function getWarehouseInventories(Warehouse $warehouse)
public function getWarehouseInventories($warehouseId)
{
$inventories = Inventory::with(['product.baseUnit', 'product.largeUnit'])
->where('warehouse_id', $warehouse->id)
->where('quantity', '>', 0)
->where('quality_status', 'normal')
->orderBy('arrival_date', 'asc') // FIFO舊的排前面
->get()
->map(function ($inv) {
return [
'id' => $inv->id,
'product_id' => $inv->product_id,
'product_name' => $inv->product->name,
'product_code' => $inv->product->code,
'batch_number' => $inv->batch_number,
'box_number' => $inv->box_number,
'quantity' => $inv->quantity,
'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,
];
});
$inventories = $this->inventoryService->getInventoriesByWarehouse($warehouseId);
$data = $inventories->map(function ($inv) {
return [
'id' => $inv->id,
'product_id' => $inv->product_id,
'product_name' => $inv->product->name ?? '未知商品',
'product_code' => $inv->product->code ?? '',
'batch_number' => $inv->batch_number,
'box_number' => $inv->box_number,
'quantity' => $inv->quantity,
'arrival_date' => $inv->arrival_date ? $inv->arrival_date->format('Y-m-d') : null,
'expiry_date' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
'unit_name' => $inv->product->baseUnit->name ?? '',
'base_unit_id' => $inv->product->base_unit_id ?? null,
'large_unit_id' => $inv->product->large_unit_id ?? null,
'conversion_rate' => $inv->product->conversion_rate ?? 1,
];
});
return response()->json($inventories);
return response()->json($data);
}
/**
* 編輯生產單(僅限草稿狀態)
* 編輯生產單
*/
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']);
// 基本水和
$productionOrder->product = $this->inventoryService->getProduct($productionOrder->product_id);
$productionOrder->warehouse = $this->inventoryService->getWarehouse($productionOrder->warehouse_id);
// 手動水和明細資料
$items = $productionOrder->items;
$inventoryIds = $items->pluck('inventory_id')->unique()->filter()->toArray();
$inventories = $this->inventoryService->getInventoriesByIds(
$inventoryIds,
['product.baseUnit']
)->keyBy('id');
$units = $this->inventoryService->getUnits()->keyBy('id');
foreach ($items as $item) {
$item->inventory = $inventories->get($item->inventory_id);
$item->unit = $units->get($item->unit_id);
}
return Inertia::render('Production/Edit', [
'productionOrder' => $productionOrder,
'products' => Product::with(['baseUnit'])->get(),
'warehouses' => Warehouse::all(),
'units' => Unit::all(),
'products' => $this->inventoryService->getAllProducts(),
'warehouses' => $this->inventoryService->getAllWarehouses(),
'units' => $this->inventoryService->getUnits(),
]);
}
@@ -266,85 +287,60 @@ class ProductionOrderController extends Controller
*/
public function update(Request $request, ProductionOrder $productionOrder)
{
// 只有草稿可以編輯
if ($productionOrder->status !== 'draft') {
return redirect()->route('production-orders.show', $productionOrder->id)
->with('error', '只有草稿狀態的生產單可以編輯');
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',
'status' => 'required|in:draft,completed',
'remark' => 'nullable|string',
];
// 完成模式需要完整驗證
// 完工時的嚴格驗證規則
$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',
'expiry_date' => 'nullable|date',
'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',
];
// 若狀態切換為 completed需合併驗證規則
$rules = $status === 'completed' ? array_merge($baseRules, $completedRules) : $baseRules;
$validated = $request->validate($rules);
$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) {
// 更新生產工單基本資料
DB::transaction(function () use ($validated, $request, $status, $productionOrder) {
$productionOrder->update([
'product_id' => $validated['product_id'],
'warehouse_id' => $validated['warehouse_id'] ?? null,
'warehouse_id' => $validated['warehouse_id'] ?? $productionOrder->warehouse_id,
'output_quantity' => $validated['output_quantity'] ?? 0,
'output_batch_number' => $validated['output_batch_number'],
'output_box_count' => $validated['output_box_count'] ?? null,
'output_box_count' => $request->output_box_count,
'production_date' => $validated['production_date'] ?? now()->toDateString(),
'expiry_date' => $validated['expiry_date'] ?? null,
'expiry_date' => $request->expiry_date,
'status' => $status,
'remark' => $validated['remark'] ?? null,
'remark' => $request->remark,
]);
// 刪除舊的明細
activity()
->performedOn($productionOrder)
->causedBy(auth()->user())
->log('updated');
// 重新建立明細
$productionOrder->items()->delete();
// 重新建立明細 (草稿與完成模式皆需儲存)
if (!empty($validated['items'])) {
foreach ($validated['items'] as $item) {
if (empty($item['inventory_id'])) continue;
if (!empty($request->items)) {
foreach ($request->items as $item) {
ProductionOrderItem::create([
'production_order_id' => $productionOrder->id,
'inventory_id' => $item['inventory_id'],
@@ -352,35 +348,63 @@ class ProductionOrderController extends Controller
'unit_id' => $item['unit_id'] ?? null,
]);
// 若為完成模式,則扣減原物料庫存
if ($status === 'completed') {
$inventory = Inventory::findOrFail($item['inventory_id']);
$inventory->decrement('quantity', $item['quantity_used']);
$this->inventoryService->decreaseInventoryQuantity(
$item['inventory_id'],
$item['quantity_used'],
"生產單 #{$productionOrder->code} 耗料",
ProductionOrder::class,
$productionOrder->id
);
}
}
}
// 若為完成模式,執行成品入庫
if ($status === 'completed') {
Inventory::create([
$this->inventoryService->createInventoryRecord([
'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',
'box_number' => $request->output_box_count,
'arrival_date' => $validated['production_date'],
'expiry_date' => $validated['expiry_date'] ?? null,
'quality_status' => 'normal',
'expiry_date' => $request->expiry_date,
'reason' => "生產單 #{$productionOrder->code} 成品入庫",
'reference_type' => ProductionOrder::class,
'reference_id' => $productionOrder->id,
]);
activity()
->performedOn($productionOrder)
->causedBy(auth()->user())
->log('completed');
}
});
$message = $status === 'completed'
? '生產單已完成,原物料已扣減,成品已入庫'
: '生產單草稿已更新';
return redirect()->route('production-orders.index')
->with('success', $message);
->with('success', '生產單已更新');
}
/**
* 刪除生產單
*/
public function destroy(ProductionOrder $productionOrder)
{
if ($productionOrder->status === 'completed') {
return redirect()->back()->with('error', '已完工的生產單無法刪除');
}
DB::transaction(function () use ($productionOrder) {
// 紀錄刪除動作 (需在刪除前或使用軟刪除)
activity()
->performedOn($productionOrder)
->causedBy(auth()->user())
->log('deleted');
$productionOrder->items()->delete();
$productionOrder->delete();
});
return redirect()->route('production-orders.index')->with('success', '生產單已刪除');
}
}

View File

@@ -0,0 +1,191 @@
<?php
namespace App\Modules\Production\Controllers;
use App\Http\Controllers\Controller;
use App\Modules\Production\Models\Recipe;
use App\Modules\Production\Models\RecipeItem;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
class RecipeController extends Controller
{
protected $inventoryService;
public function __construct(InventoryServiceInterface $inventoryService)
{
$this->inventoryService = $inventoryService;
}
/**
* 配方列表
*/
public function index(Request $request): Response
{
$query = Recipe::query();
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('code', 'like', "%{$search}%")
->orWhere('name', 'like', "%{$search}%");
$productIds = $this->inventoryService->getProductsByName($search)->pluck('id');
$q->orWhereIn('product_id', $productIds);
});
}
$query->orderBy($request->input('sort_field', 'created_at'), $request->input('sort_direction', 'desc'));
$recipes = $query->paginate($request->input('per_page', 10))->withQueryString();
// Manual Hydration
$productIds = $recipes->pluck('product_id')->unique()->filter()->toArray();
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
$recipes->getCollection()->transform(function ($recipe) use ($products) {
$recipe->product = $products->get($recipe->product_id);
return $recipe;
});
return Inertia::render('Production/Recipe/Index', [
'recipes' => $recipes,
'filters' => $request->only(['search', 'per_page', 'sort_field', 'sort_direction']),
]);
}
/**
* 新增配方表單
*/
public function create(): Response
{
return Inertia::render('Production/Recipe/Create', [
'products' => $this->inventoryService->getAllProducts(),
'units' => $this->inventoryService->getUnits(),
]);
}
/**
* 儲存配方
*/
public function store(Request $request)
{
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
'code' => 'required|string|max:50|unique:recipes,code',
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'yield_quantity' => 'required|numeric|min:0.01',
'items' => 'required|array|min:1',
'items.*.product_id' => 'required|exists:products,id',
'items.*.quantity' => 'required|numeric|min:0.0001',
'items.*.unit_id' => 'nullable|exists:units,id',
'items.*.remark' => 'nullable|string',
]);
DB::transaction(function () use ($validated) {
$recipe = Recipe::create([
'product_id' => $validated['product_id'],
'code' => $validated['code'],
'name' => $validated['name'],
'description' => $validated['description'],
'yield_quantity' => $validated['yield_quantity'],
'is_active' => true,
]);
foreach ($validated['items'] as $item) {
RecipeItem::create([
'recipe_id' => $recipe->id,
'product_id' => $item['product_id'],
'quantity' => $item['quantity'],
'unit_id' => $item['unit_id'],
'remark' => $item['remark'],
]);
}
});
return redirect()->route('recipes.index')->with('success', '配方已建立');
}
/**
* 編輯配方表單
*/
public function edit(Recipe $recipe): Response
{
// Hydrate Product
$recipe->product = $this->inventoryService->getProduct($recipe->product_id);
// Load items with details
$items = $recipe->items;
$productIds = $items->pluck('product_id')->unique()->toArray();
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
$units = $this->inventoryService->getUnits()->keyBy('id');
foreach ($items as $item) {
$item->product = $products->get($item->product_id);
$item->unit = $units->get($item->unit_id);
}
return Inertia::render('Production/Recipe/Edit', [
'recipe' => $recipe,
'products' => $this->inventoryService->getAllProducts(),
'units' => $this->inventoryService->getUnits(),
]);
}
/**
* 更新配方
*/
public function update(Request $request, Recipe $recipe)
{
$validated = $request->validate([
'product_id' => 'required|exists:products,id',
'code' => 'required|string|max:50|unique:recipes,code,' . $recipe->id,
'name' => 'required|string|max:255',
'description' => 'nullable|string',
'yield_quantity' => 'required|numeric|min:0.01',
'items' => 'required|array|min:1',
'items.*.product_id' => 'required|exists:products,id',
'items.*.quantity' => 'required|numeric|min:0.0001',
'items.*.unit_id' => 'nullable|exists:units,id',
'items.*.remark' => 'nullable|string',
]);
DB::transaction(function () use ($validated, $recipe) {
$recipe->update([
'product_id' => $validated['product_id'],
'code' => $validated['code'],
'name' => $validated['name'],
'description' => $validated['description'],
'yield_quantity' => $validated['yield_quantity'],
]);
// Sync items (Delete all and recreate)
$recipe->items()->delete();
foreach ($validated['items'] as $item) {
RecipeItem::create([
'recipe_id' => $recipe->id,
'product_id' => $item['product_id'],
'quantity' => $item['quantity'],
'unit_id' => $item['unit_id'],
'remark' => $item['remark'],
]);
}
});
return redirect()->route('recipes.index')->with('success', '配方已更新');
}
/**
* 刪除配方
*/
public function destroy(Recipe $recipe)
{
$recipe->delete();
return redirect()->back()->with('success', '配方已刪除');
}
}

View File

@@ -4,14 +4,12 @@ namespace App\Modules\Production\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use App\Modules\Inventory\Models\Product;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Core\Models\User;
use Spatie\Activitylog\Traits\LogsActivity;
use Spatie\Activitylog\LogOptions;
class ProductionOrder extends Model
{
/** @use HasFactory<\Database\Factories\ProductionOrderFactory> */
use HasFactory;
use HasFactory, LogsActivity;
protected $fillable = [
'code',
@@ -27,6 +25,38 @@ class ProductionOrder extends Model
'remark',
];
protected $casts = [
'production_date' => 'date',
'expiry_date' => 'date',
'output_quantity' => 'decimal:2',
];
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly([
'code',
'status',
'output_quantity',
'output_batch_number',
'production_date',
'remark'
])
->logOnlyDirty()
->dontSubmitEmptyLogs()
->setDescriptionForEvent(fn(string $eventName) => "生產工單已{$this->getEventDescription($eventName)}");
}
protected function getEventDescription($eventName): string
{
return match ($eventName) {
'created' => '建立',
'updated' => '更新',
'deleted' => '刪除',
default => $eventName,
};
}
public static function generateCode()
{
$prefix = 'PO' . now()->format('Ymd');
@@ -40,27 +70,28 @@ class ProductionOrder extends Model
return $prefix . $sequence;
}
protected $casts = [
'order_date' => 'date',
'start_date' => 'datetime',
'completion_date' => 'datetime',
'quantity' => 'decimal:2',
'produced_quantity' => 'decimal:2',
];
public function product(): \Illuminate\Database\Eloquent\Relations\BelongsTo
/**
* @deprecated 使用 InventoryServiceInterface 獲取產品資訊
*/
public function product()
{
return $this->belongsTo(Product::class);
return null;
}
public function warehouse(): \Illuminate\Database\Eloquent\Relations\BelongsTo
/**
* @deprecated 使用 InventoryServiceInterface 獲取倉庫資訊
*/
public function warehouse()
{
return $this->belongsTo(Warehouse::class);
return null;
}
public function user(): \Illuminate\Database\Eloquent\Relations\BelongsTo
/**
* @deprecated 使用 CoreServiceInterface 獲取使用者資訊
*/
public function user()
{
return $this->belongsTo(User::class);
return null;
}
public function items(): \Illuminate\Database\Eloquent\Relations\HasMany

View File

@@ -22,14 +22,20 @@ class ProductionOrderItem extends Model
'quantity_used' => 'decimal:4',
];
/**
* @deprecated 使用 InventoryServiceInterface 獲取庫存資訊
*/
public function inventory()
{
return $this->belongsTo(\App\Modules\Inventory\Models\Inventory::class);
return null;
}
/**
* @deprecated
*/
public function unit()
{
return $this->belongsTo(\App\Modules\Inventory\Models\Unit::class);
return null;
}
public function productionOrder(): \Illuminate\Database\Eloquent\Relations\BelongsTo
@@ -37,8 +43,11 @@ class ProductionOrderItem extends Model
return $this->belongsTo(ProductionOrder::class);
}
public function product(): \Illuminate\Database\Eloquent\Relations\BelongsTo
/**
* @deprecated 使用 InventoryServiceInterface 獲取產品資訊
*/
public function product()
{
return $this->belongsTo(Product::class);
return null;
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Modules\Production\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Recipe extends Model
{
use HasFactory, SoftDeletes;
protected $fillable = [
'product_id',
'code',
'name',
'description',
'yield_quantity',
'is_active',
];
protected $casts = [
'yield_quantity' => 'decimal:2',
'is_active' => 'boolean',
];
public function items()
{
return $this->hasMany(RecipeItem::class);
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Modules\Production\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class RecipeItem extends Model
{
use HasFactory;
protected $fillable = [
'recipe_id',
'product_id',
'quantity',
'unit_id',
'remark',
];
protected $casts = [
'quantity' => 'decimal:4',
];
public function recipe()
{
return $this->belongsTo(Recipe::class);
}
}

View File

@@ -2,8 +2,12 @@
use Illuminate\Support\Facades\Route;
use App\Modules\Production\Controllers\ProductionOrderController;
use App\Modules\Production\Controllers\RecipeController;
Route::middleware('auth')->group(function () {
// 配方管理
Route::resource('recipes', RecipeController::class);
// 生產管理
Route::middleware('permission:production_orders.view')->group(function () {
Route::get('/production-orders', [ProductionOrderController::class, 'index'])->name('production-orders.index');