feat(生產/庫存): 實作生產管理模組與批號追溯功能
This commit is contained in:
@@ -39,8 +39,8 @@ class InventoryController extends Controller
|
||||
'quantity' => (float) $inv->quantity,
|
||||
'safetyStock' => $inv->safety_stock !== null ? (float) $inv->safety_stock : null,
|
||||
'status' => '正常', // 前端會根據 quantity 與 safetyStock 重算,但後端亦可提供基礎狀態
|
||||
'batchNumber' => 'BATCH-' . $inv->id, // DB 無批號,暫時模擬,某些 UI 可能還會用到
|
||||
'expiryDate' => '2099-12-31', // DB 無效期,暫時模擬
|
||||
'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,
|
||||
];
|
||||
@@ -98,15 +98,39 @@ 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.*.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
|
||||
$product = \App\Models\Product::find($item['productId']);
|
||||
if ($product) {
|
||||
$batchNumber = \App\Models\Inventory::generateBatchNumber(
|
||||
$product->code ?? 'UNK',
|
||||
'TW', // 預設來源
|
||||
$validated['inboundDate']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 取得或建立庫存紀錄 (加入批號判斷)
|
||||
$inventory = $warehouse->inventories()->firstOrNew(
|
||||
['product_id' => $item['productId']],
|
||||
['quantity' => 0, 'safety_stock' => null]
|
||||
[
|
||||
'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;
|
||||
|
||||
196
app/Http/Controllers/ProductionOrderController.php
Normal file
196
app/Http/Controllers/ProductionOrderController.php
Normal file
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Inventory;
|
||||
use App\Models\Product;
|
||||
use App\Models\ProductionOrder;
|
||||
use App\Models\ProductionOrderItem;
|
||||
use App\Models\Unit;
|
||||
use App\Models\Warehouse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ProductionOrderController extends Controller
|
||||
{
|
||||
/**
|
||||
* 生產工單列表
|
||||
*/
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$query = ProductionOrder::with(['product', 'warehouse', 'user']);
|
||||
|
||||
// 搜尋
|
||||
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}%"));
|
||||
});
|
||||
}
|
||||
|
||||
// 狀態篩選
|
||||
if ($request->filled('status') && $request->status !== 'all') {
|
||||
$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);
|
||||
|
||||
// 分頁
|
||||
$perPage = $request->input('per_page', 10);
|
||||
$productionOrders = $query->paginate($perPage)->withQueryString();
|
||||
|
||||
return Inertia::render('Production/Index', [
|
||||
'productionOrders' => $productionOrders,
|
||||
'filters' => $request->only(['search', 'status', 'per_page', 'sort_field', 'sort_direction']),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增生產單表單
|
||||
*/
|
||||
public function create(): Response
|
||||
{
|
||||
return Inertia::render('Production/Create', [
|
||||
'products' => Product::with(['baseUnit'])->get(),
|
||||
'warehouses' => Warehouse::all(),
|
||||
'units' => Unit::all(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 儲存生產單(含自動扣料與成品入庫)
|
||||
*/
|
||||
public function store(Request $request)
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'product_id' => 'required|exists:products,id',
|
||||
'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',
|
||||
'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',
|
||||
], [
|
||||
'product_id.required' => '請選擇成品商品',
|
||||
'warehouse_id.required' => '請選擇入庫倉庫',
|
||||
'output_quantity.required' => '請輸入生產數量',
|
||||
'output_batch_number.required' => '請輸入成品批號',
|
||||
'production_date.required' => '請選擇生產日期',
|
||||
'items.required' => '請至少新增一項原物料',
|
||||
'items.min' => '請至少新增一項原物料',
|
||||
]);
|
||||
|
||||
DB::transaction(function () use ($validated, $request) {
|
||||
// 1. 建立生產工單
|
||||
$productionOrder = ProductionOrder::create([
|
||||
'code' => ProductionOrder::generateCode(),
|
||||
'product_id' => $validated['product_id'],
|
||||
'warehouse_id' => $validated['warehouse_id'],
|
||||
'output_quantity' => $validated['output_quantity'],
|
||||
'output_batch_number' => $validated['output_batch_number'],
|
||||
'output_box_count' => $validated['output_box_count'] ?? null,
|
||||
'production_date' => $validated['production_date'],
|
||||
'expiry_date' => $validated['expiry_date'] ?? null,
|
||||
'user_id' => auth()->id(),
|
||||
'status' => 'completed',
|
||||
'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,
|
||||
]);
|
||||
|
||||
// 扣減原物料庫存
|
||||
$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',
|
||||
]);
|
||||
});
|
||||
|
||||
return redirect()->route('production-orders.index')
|
||||
->with('success', '生產單已建立,原物料已扣減,成品已入庫');
|
||||
}
|
||||
|
||||
/**
|
||||
* 檢視生產單詳情(含追溯資訊)
|
||||
*/
|
||||
public function show(ProductionOrder $productionOrder): Response
|
||||
{
|
||||
$productionOrder->load([
|
||||
'product.baseUnit',
|
||||
'warehouse',
|
||||
'user',
|
||||
'items.inventory.product',
|
||||
'items.inventory.sourcePurchaseOrder.vendor',
|
||||
'items.unit',
|
||||
]);
|
||||
|
||||
return Inertia::render('Production/Show', [
|
||||
'productionOrder' => $productionOrder,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取得倉庫內可用庫存(供 BOM 選擇)
|
||||
*/
|
||||
public function getWarehouseInventories(Warehouse $warehouse)
|
||||
{
|
||||
$inventories = Inventory::with(['product.baseUnit'])
|
||||
->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,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json($inventories);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,20 @@ class Inventory extends Model
|
||||
'quantity',
|
||||
'safety_stock',
|
||||
'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',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -89,4 +103,35 @@ class Inventory extends Model
|
||||
$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}-";
|
||||
|
||||
$lastBatch = static::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;
|
||||
}
|
||||
}
|
||||
|
||||
120
app/Models/ProductionOrder.php
Normal file
120
app/Models/ProductionOrder.php
Normal file
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Spatie\Activitylog\Traits\LogsActivity;
|
||||
use Spatie\Activitylog\LogOptions;
|
||||
|
||||
class ProductionOrder extends Model
|
||||
{
|
||||
use HasFactory, LogsActivity;
|
||||
|
||||
protected $fillable = [
|
||||
'code',
|
||||
'product_id',
|
||||
'output_batch_number',
|
||||
'output_box_count',
|
||||
'output_quantity',
|
||||
'warehouse_id',
|
||||
'production_date',
|
||||
'expiry_date',
|
||||
'user_id',
|
||||
'status',
|
||||
'remark',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'production_date' => 'date:Y-m-d',
|
||||
'expiry_date' => 'date:Y-m-d',
|
||||
'output_quantity' => 'decimal:2',
|
||||
];
|
||||
|
||||
/**
|
||||
* 成品商品
|
||||
*/
|
||||
public function product(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Product::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 入庫倉庫
|
||||
*/
|
||||
public function warehouse(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Warehouse::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 操作人員
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生產工單明細 (BOM)
|
||||
*/
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(ProductionOrderItem::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'] ?? [];
|
||||
|
||||
// 快照關鍵名稱
|
||||
$snapshot['production_code'] = $this->code;
|
||||
$snapshot['product_name'] = $this->product ? $this->product->name : null;
|
||||
$snapshot['warehouse_name'] = $this->warehouse ? $this->warehouse->name : null;
|
||||
$snapshot['user_name'] = $this->user ? $this->user->name : null;
|
||||
|
||||
$properties['attributes'] = $attributes;
|
||||
$properties['snapshot'] = $snapshot;
|
||||
$activity->properties = $properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* 產生生產單號
|
||||
*/
|
||||
public static function generateCode(): string
|
||||
{
|
||||
$date = now()->format('Ymd');
|
||||
$prefix = "PRO-{$date}-";
|
||||
|
||||
$lastOrder = static::where('code', 'like', "{$prefix}%")
|
||||
->orderByDesc('code')
|
||||
->first();
|
||||
|
||||
if ($lastOrder) {
|
||||
$lastNumber = (int) substr($lastOrder->code, -3);
|
||||
$nextNumber = str_pad($lastNumber + 1, 3, '0', STR_PAD_LEFT);
|
||||
} else {
|
||||
$nextNumber = '001';
|
||||
}
|
||||
|
||||
return $prefix . $nextNumber;
|
||||
}
|
||||
}
|
||||
47
app/Models/ProductionOrderItem.php
Normal file
47
app/Models/ProductionOrderItem.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ProductionOrderItem extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'production_order_id',
|
||||
'inventory_id',
|
||||
'quantity_used',
|
||||
'unit_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'quantity_used' => 'decimal:4',
|
||||
];
|
||||
|
||||
/**
|
||||
* 所屬生產工單
|
||||
*/
|
||||
public function productionOrder(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(ProductionOrder::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用的庫存紀錄
|
||||
*/
|
||||
public function inventory(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Inventory::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 單位
|
||||
*/
|
||||
public function unit(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Unit::class);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user