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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* 新增批號追溯相關欄位至 inventories 資料表。
|
||||
* 批號格式:{商品代號}-{來源國家}-{入庫日期}-{批次流水號}
|
||||
* 完整格式(含箱號):{商品代號}-{來源國家}-{入庫日期}-{批次流水號}-{箱號}
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// Step 1: 新增批號相關欄位
|
||||
Schema::table('inventories', function (Blueprint $table) {
|
||||
// 批號組成:{商品代號}-{來源國家}-{入庫日期}-{批次流水號}
|
||||
$table->string('batch_number', 50)->nullable()->after('location')
|
||||
->comment('批號 (格式: AB-VN-20260119-01)');
|
||||
$table->string('box_number', 10)->nullable()->after('batch_number')
|
||||
->comment('箱號 (如: 01, 02)');
|
||||
|
||||
// 批號解析欄位(方便查詢與排序)
|
||||
$table->string('origin_country', 10)->nullable()->after('box_number')
|
||||
->comment('來源國家代碼 (如: VN, TW)');
|
||||
$table->date('arrival_date')->nullable()->after('origin_country')
|
||||
->comment('入庫日期');
|
||||
$table->date('expiry_date')->nullable()->after('arrival_date')
|
||||
->comment('效期');
|
||||
|
||||
// 來源追溯
|
||||
$table->foreignId('source_purchase_order_id')->nullable()->after('expiry_date')
|
||||
->constrained('purchase_orders')->nullOnDelete()
|
||||
->comment('來源採購單');
|
||||
|
||||
// 品質狀態
|
||||
$table->enum('quality_status', ['normal', 'frozen', 'rejected'])
|
||||
->default('normal')->after('source_purchase_order_id')
|
||||
->comment('品質狀態:正常/凍結/退貨');
|
||||
$table->text('quality_remark')->nullable()->after('quality_status')
|
||||
->comment('品質異常備註');
|
||||
});
|
||||
|
||||
// Step 2: 為現有資料設定預設批號 (LEGACY-{id})
|
||||
DB::statement("UPDATE inventories SET batch_number = CONCAT('LEGACY-', id) WHERE batch_number IS NULL");
|
||||
|
||||
// Step 3: 將 batch_number 改為必填
|
||||
Schema::table('inventories', function (Blueprint $table) {
|
||||
$table->string('batch_number', 50)->nullable(false)->change();
|
||||
});
|
||||
|
||||
// Step 4: 新增批號相關索引 (不刪除舊索引,因為有外鍵依賴)
|
||||
// 舊的 warehouse_product_unique 保留,新增更精確的批號索引
|
||||
Schema::table('inventories', function (Blueprint $table) {
|
||||
$table->index(['warehouse_id', 'product_id', 'batch_number'], 'inventories_batch_lookup');
|
||||
$table->index(['arrival_date'], 'inventories_arrival_date');
|
||||
$table->index(['quality_status'], 'inventories_quality_status');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('inventories', function (Blueprint $table) {
|
||||
// 移除索引
|
||||
$table->dropIndex('inventories_batch_lookup');
|
||||
$table->dropIndex('inventories_arrival_date');
|
||||
$table->dropIndex('inventories_quality_status');
|
||||
|
||||
// 移除新增欄位
|
||||
$table->dropForeign(['source_purchase_order_id']);
|
||||
$table->dropColumn([
|
||||
'batch_number',
|
||||
'box_number',
|
||||
'origin_country',
|
||||
'arrival_date',
|
||||
'expiry_date',
|
||||
'source_purchase_order_id',
|
||||
'quality_status',
|
||||
'quality_remark',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* 生產工單主表,記錄每次生產的成品資訊。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('production_orders', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('code', 50)->unique()->comment('生產單號 (如: PRO-20260121-001)');
|
||||
|
||||
// 成品資訊
|
||||
$table->foreignId('product_id')->constrained()->onDelete('restrict')
|
||||
->comment('成品商品');
|
||||
$table->string('output_batch_number', 50)->comment('成品批號');
|
||||
$table->string('output_box_count', 10)->nullable()->comment('成品箱數');
|
||||
$table->decimal('output_quantity', 10, 2)->comment('生產數量');
|
||||
|
||||
// 入庫倉庫
|
||||
$table->foreignId('warehouse_id')->constrained()->onDelete('restrict')
|
||||
->comment('入庫倉庫');
|
||||
|
||||
// 生產資訊
|
||||
$table->date('production_date')->comment('生產日期');
|
||||
$table->date('expiry_date')->nullable()->comment('成品效期');
|
||||
|
||||
// 操作人員
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete()
|
||||
->comment('操作人員');
|
||||
|
||||
// 狀態與備註
|
||||
$table->enum('status', ['draft', 'completed', 'cancelled'])
|
||||
->default('completed')->comment('狀態:草稿/完成/取消');
|
||||
$table->text('remark')->nullable()->comment('備註');
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
// 索引
|
||||
$table->index(['production_date', 'product_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('production_orders');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* 生產工單明細表 (BOM),記錄使用的原物料。
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('production_order_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
|
||||
// 所屬生產工單
|
||||
$table->foreignId('production_order_id')->constrained()->onDelete('cascade')
|
||||
->comment('所屬生產工單');
|
||||
|
||||
// 使用的庫存(含商品與批號)
|
||||
$table->foreignId('inventory_id')->constrained()->onDelete('restrict')
|
||||
->comment('使用的庫存紀錄 (含 product, batch)');
|
||||
|
||||
// 使用量
|
||||
$table->decimal('quantity_used', 10, 4)->comment('使用量');
|
||||
$table->foreignId('unit_id')->nullable()->constrained('units')->nullOnDelete()
|
||||
->comment('單位');
|
||||
|
||||
$table->timestamps();
|
||||
|
||||
// 索引
|
||||
$table->index(['production_order_id', 'inventory_id']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('production_order_items');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*
|
||||
* 新增生產管理權限
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$guard = 'web';
|
||||
|
||||
// 建立生產管理權限
|
||||
$permissions = [
|
||||
'production_orders.view' => '檢視生產工單',
|
||||
'production_orders.create' => '建立生產工單',
|
||||
'production_orders.edit' => '編輯生產工單',
|
||||
'production_orders.delete' => '刪除生產工單',
|
||||
];
|
||||
|
||||
foreach ($permissions as $name => $description) {
|
||||
Permission::firstOrCreate(
|
||||
['name' => $name, 'guard_name' => $guard],
|
||||
['name' => $name, 'guard_name' => $guard]
|
||||
);
|
||||
}
|
||||
|
||||
// 授予 super-admin 所有新權限
|
||||
$superAdmin = Role::where('name', 'super-admin')->first();
|
||||
if ($superAdmin) {
|
||||
$superAdmin->givePermissionTo(array_keys($permissions));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
$permissions = [
|
||||
'production_orders.view',
|
||||
'production_orders.create',
|
||||
'production_orders.edit',
|
||||
'production_orders.delete',
|
||||
];
|
||||
|
||||
foreach ($permissions as $name) {
|
||||
Permission::where('name', $name)->delete();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -129,6 +129,21 @@ export default function AuthenticatedLayout({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "production-management",
|
||||
label: "生產管理",
|
||||
icon: <Boxes className="h-5 w-5" />,
|
||||
permission: "production_orders.view",
|
||||
children: [
|
||||
{
|
||||
id: "production-order-list",
|
||||
label: "生產工單",
|
||||
icon: <Package className="h-4 w-4" />,
|
||||
route: "/production-orders",
|
||||
permission: "production_orders.view",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "finance-management",
|
||||
label: "財務管理",
|
||||
|
||||
442
resources/js/Pages/Production/Create.tsx
Normal file
442
resources/js/Pages/Production/Create.tsx
Normal file
@@ -0,0 +1,442 @@
|
||||
/**
|
||||
* 建立生產工單頁面
|
||||
* 動態 BOM 表單:選擇倉庫 → 選擇原物料 → 選擇批號 → 輸入用量
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Factory, Plus, Trash2, ArrowLeft, Save, AlertTriangle, Calendar } from 'lucide-react';
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, router, useForm } from "@inertiajs/react";
|
||||
import { getBreadcrumbs } from "@/utils/breadcrumb";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Textarea } from "@/Components/ui/textarea";
|
||||
|
||||
interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
base_unit?: { id: number; name: string } | null;
|
||||
}
|
||||
|
||||
interface Warehouse {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Unit {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface InventoryOption {
|
||||
id: number;
|
||||
product_id: number;
|
||||
product_name: string;
|
||||
product_code: string;
|
||||
batch_number: string;
|
||||
box_number: string | null;
|
||||
quantity: number;
|
||||
arrival_date: string | null;
|
||||
expiry_date: string | null;
|
||||
unit_name: string | null;
|
||||
}
|
||||
|
||||
interface BomItem {
|
||||
inventory_id: string;
|
||||
quantity_used: string;
|
||||
unit_id: string;
|
||||
// 顯示用
|
||||
product_name?: string;
|
||||
batch_number?: string;
|
||||
available_qty?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
products: Product[];
|
||||
warehouses: Warehouse[];
|
||||
units: Unit[];
|
||||
}
|
||||
|
||||
export default function ProductionCreate({ products, warehouses, units }: Props) {
|
||||
const [selectedWarehouse, setSelectedWarehouse] = useState<string>("");
|
||||
const [inventoryOptions, setInventoryOptions] = useState<InventoryOption[]>([]);
|
||||
const [isLoadingInventory, setIsLoadingInventory] = useState(false);
|
||||
const [bomItems, setBomItems] = useState<BomItem[]>([]);
|
||||
|
||||
const { data, setData, processing, errors } = useForm({
|
||||
product_id: "",
|
||||
warehouse_id: "",
|
||||
output_quantity: "",
|
||||
output_batch_number: "",
|
||||
output_box_count: "",
|
||||
production_date: new Date().toISOString().split('T')[0],
|
||||
expiry_date: "",
|
||||
remark: "",
|
||||
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
|
||||
});
|
||||
|
||||
// 當選擇倉庫時,載入該倉庫的可用庫存
|
||||
useEffect(() => {
|
||||
if (selectedWarehouse) {
|
||||
setIsLoadingInventory(true);
|
||||
fetch(route('api.production.warehouses.inventories', selectedWarehouse))
|
||||
.then(res => res.json())
|
||||
.then((inventories: InventoryOption[]) => {
|
||||
setInventoryOptions(inventories);
|
||||
setIsLoadingInventory(false);
|
||||
})
|
||||
.catch(() => setIsLoadingInventory(false));
|
||||
} else {
|
||||
setInventoryOptions([]);
|
||||
}
|
||||
}, [selectedWarehouse]);
|
||||
|
||||
// 同步 warehouse_id 到 form data
|
||||
useEffect(() => {
|
||||
setData('warehouse_id', selectedWarehouse);
|
||||
}, [selectedWarehouse]);
|
||||
|
||||
// 新增 BOM 項目
|
||||
const addBomItem = () => {
|
||||
setBomItems([...bomItems, {
|
||||
inventory_id: "",
|
||||
quantity_used: "",
|
||||
unit_id: "",
|
||||
}]);
|
||||
};
|
||||
|
||||
// 移除 BOM 項目
|
||||
const removeBomItem = (index: number) => {
|
||||
setBomItems(bomItems.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// 更新 BOM 項目
|
||||
const updateBomItem = (index: number, field: keyof BomItem, value: string) => {
|
||||
const updated = [...bomItems];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
|
||||
// 如果選擇了庫存,自動填入顯示資訊
|
||||
if (field === 'inventory_id' && value) {
|
||||
const inv = inventoryOptions.find(i => String(i.id) === value);
|
||||
if (inv) {
|
||||
updated[index].product_name = inv.product_name;
|
||||
updated[index].batch_number = inv.batch_number;
|
||||
updated[index].available_qty = inv.quantity;
|
||||
}
|
||||
}
|
||||
|
||||
setBomItems(updated);
|
||||
};
|
||||
|
||||
// 產生成品批號建議
|
||||
const generateBatchNumber = () => {
|
||||
if (!data.product_id) return;
|
||||
const product = products.find(p => String(p.id) === data.product_id);
|
||||
if (!product) return;
|
||||
|
||||
const date = data.production_date.replace(/-/g, '');
|
||||
const suggested = `${product.code}-TW-${date}-01`;
|
||||
setData('output_batch_number', suggested);
|
||||
};
|
||||
|
||||
// 提交表單
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// 轉換 BOM items 格式
|
||||
const formattedItems = bomItems
|
||||
.filter(item => item.inventory_id && item.quantity_used)
|
||||
.map(item => ({
|
||||
inventory_id: parseInt(item.inventory_id),
|
||||
quantity_used: parseFloat(item.quantity_used),
|
||||
unit_id: item.unit_id ? parseInt(item.unit_id) : null,
|
||||
}));
|
||||
|
||||
// 使用 router.post 提交完整資料
|
||||
router.post(route('production-orders.store'), {
|
||||
...data,
|
||||
items: formattedItems,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersCreate")}>
|
||||
<Head title="建立生產單" />
|
||||
<div className="container mx-auto p-6 max-w-4xl">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => router.get(route('production-orders.index'))}
|
||||
className="p-2"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<Factory className="h-6 w-6 text-primary-main" />
|
||||
建立生產單
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
記錄生產使用的原物料與產出成品
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* 成品資訊 */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">成品資訊</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2">成品商品 *</Label>
|
||||
<SearchableSelect
|
||||
value={data.product_id}
|
||||
onValueChange={(v) => setData('product_id', v)}
|
||||
options={products.map(p => ({
|
||||
label: `${p.name} (${p.code})`,
|
||||
value: String(p.id),
|
||||
}))}
|
||||
placeholder="選擇成品"
|
||||
className="w-full h-9"
|
||||
/>
|
||||
{errors.product_id && <p className="text-red-500 text-xs mt-1">{errors.product_id}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2">生產數量 *</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={data.output_quantity}
|
||||
onChange={(e) => setData('output_quantity', e.target.value)}
|
||||
placeholder="例如: 50"
|
||||
className="h-9"
|
||||
/>
|
||||
{errors.output_quantity && <p className="text-red-500 text-xs mt-1">{errors.output_quantity}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2">成品批號 *</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={data.output_batch_number}
|
||||
onChange={(e) => setData('output_batch_number', e.target.value)}
|
||||
placeholder="例如: AB-TW-20260121-01"
|
||||
className="h-9 font-mono"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={generateBatchNumber}
|
||||
disabled={!data.product_id}
|
||||
className="h-9 button-outlined-primary shrink-0"
|
||||
>
|
||||
自動產生
|
||||
</Button>
|
||||
</div>
|
||||
{errors.output_batch_number && <p className="text-red-500 text-xs mt-1">{errors.output_batch_number}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2">箱數(選填)</Label>
|
||||
<Input
|
||||
value={data.output_box_count}
|
||||
onChange={(e) => setData('output_box_count', e.target.value)}
|
||||
placeholder="例如: 10"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2">生產日期 *</Label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||
<Input
|
||||
type="date"
|
||||
value={data.production_date}
|
||||
onChange={(e) => setData('production_date', e.target.value)}
|
||||
className="h-9 pl-9"
|
||||
/>
|
||||
</div>
|
||||
{errors.production_date && <p className="text-red-500 text-xs mt-1">{errors.production_date}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2">成品效期(選填)</Label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-2.5 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||
<Input
|
||||
type="date"
|
||||
value={data.expiry_date}
|
||||
onChange={(e) => setData('expiry_date', e.target.value)}
|
||||
className="h-9 pl-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2">入庫倉庫 *</Label>
|
||||
<SearchableSelect
|
||||
value={selectedWarehouse}
|
||||
onValueChange={setSelectedWarehouse}
|
||||
options={warehouses.map(w => ({
|
||||
label: w.name,
|
||||
value: String(w.id),
|
||||
}))}
|
||||
placeholder="選擇倉庫"
|
||||
className="w-full h-9"
|
||||
/>
|
||||
{errors.warehouse_id && <p className="text-red-500 text-xs mt-1">{errors.warehouse_id}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2">備註</Label>
|
||||
<Textarea
|
||||
value={data.remark}
|
||||
onChange={(e) => setData('remark', e.target.value)}
|
||||
placeholder="生產備註..."
|
||||
rows={2}
|
||||
className="resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* BOM 原物料明細 */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">原物料使用明細 (BOM)</h2>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addBomItem}
|
||||
disabled={!selectedWarehouse}
|
||||
className="gap-2 button-filled-primary text-white"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
新增原物料
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!selectedWarehouse && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<AlertTriangle className="h-8 w-8 mx-auto mb-2 text-yellow-500" />
|
||||
請先選擇「入庫倉庫」以取得可用原物料清單
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedWarehouse && isLoadingInventory && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
載入中...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedWarehouse && !isLoadingInventory && bomItems.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Factory className="h-8 w-8 mx-auto mb-2 text-gray-300" />
|
||||
點擊「新增原物料」開始建立 BOM
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bomItems.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{bomItems.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="grid grid-cols-1 md:grid-cols-12 gap-3 items-end p-4 bg-gray-50/50 border border-gray-100 rounded-lg relative group"
|
||||
>
|
||||
<div className="md:col-span-5 space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2">原物料 (批號)</Label>
|
||||
<SearchableSelect
|
||||
value={item.inventory_id}
|
||||
onValueChange={(v) => updateBomItem(index, 'inventory_id', v)}
|
||||
options={inventoryOptions.map(inv => ({
|
||||
label: `${inv.product_name} - ${inv.batch_number} (庫存: ${inv.quantity})`,
|
||||
value: String(inv.id),
|
||||
}))}
|
||||
placeholder="選擇原物料與批號"
|
||||
className="w-full h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-3 space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2">使用量</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={item.quantity_used}
|
||||
onChange={(e) => updateBomItem(index, 'quantity_used', e.target.value)}
|
||||
placeholder="0.00"
|
||||
className="h-9 pr-12"
|
||||
/>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-gray-400 pointer-events-none">
|
||||
單位
|
||||
</div>
|
||||
</div>
|
||||
{item.available_qty && (
|
||||
<p className="text-xs text-gray-400 mt-1">可用庫存: {item.available_qty.toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-3 space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2">備註/單位</Label>
|
||||
<SearchableSelect
|
||||
value={item.unit_id}
|
||||
onValueChange={(v) => updateBomItem(index, 'unit_id', v)}
|
||||
options={units.map(u => ({
|
||||
label: u.name,
|
||||
value: String(u.id),
|
||||
}))}
|
||||
placeholder="選擇單位"
|
||||
className="w-full h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => removeBomItem(index)}
|
||||
className="button-outlined-error h-9 w-full md:w-9 p-0"
|
||||
title="移除此項目"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errors.items && <p className="text-red-500 text-sm mt-2">{errors.items}</p>}
|
||||
</div>
|
||||
|
||||
{/* 提交按鈕 */}
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.get(route('production-orders.index'))}
|
||||
className="h-10 px-6"
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={processing || bomItems.length === 0}
|
||||
className="gap-2 button-filled-primary h-10 px-8"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{processing ? '處理中...' : '建立生產單'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
287
resources/js/Pages/Production/Index.tsx
Normal file
287
resources/js/Pages/Production/Index.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* 生產工單管理主頁面
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Plus, Factory, Search, RotateCcw, Eye } from 'lucide-react';
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, router, Link } from "@inertiajs/react";
|
||||
import Pagination from "@/Components/shared/Pagination";
|
||||
import { getBreadcrumbs } from "@/utils/breadcrumb";
|
||||
import { Can } from "@/Components/Permission/Can";
|
||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
|
||||
|
||||
interface ProductionOrder {
|
||||
id: number;
|
||||
code: string;
|
||||
product: { id: number; name: string; code: string } | null;
|
||||
warehouse: { id: number; name: string } | null;
|
||||
user: { id: number; name: string } | null;
|
||||
output_batch_number: string;
|
||||
output_quantity: number;
|
||||
production_date: string;
|
||||
status: 'draft' | 'completed' | 'cancelled';
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
productionOrders: {
|
||||
data: ProductionOrder[];
|
||||
links: any[];
|
||||
total: number;
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
filters: {
|
||||
search?: string;
|
||||
status?: string;
|
||||
per_page?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
|
||||
draft: { label: "草稿", variant: "secondary" },
|
||||
completed: { label: "已完成", variant: "default" },
|
||||
cancelled: { label: "已取消", variant: "destructive" },
|
||||
};
|
||||
|
||||
export default function ProductionIndex({ productionOrders, filters }: Props) {
|
||||
const [search, setSearch] = useState(filters.search || "");
|
||||
const [status, setStatus] = useState<string>(filters.status || "all");
|
||||
const [perPage, setPerPage] = useState<string>(filters.per_page || "10");
|
||||
|
||||
useEffect(() => {
|
||||
setSearch(filters.search || "");
|
||||
setStatus(filters.status || "all");
|
||||
setPerPage(filters.per_page || "10");
|
||||
}, [filters]);
|
||||
|
||||
const handleFilter = () => {
|
||||
router.get(
|
||||
route('production-orders.index'),
|
||||
{
|
||||
search,
|
||||
status: status === 'all' ? undefined : status,
|
||||
per_page: perPage,
|
||||
},
|
||||
{ preserveState: true, replace: true }
|
||||
);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSearch("");
|
||||
setStatus("all");
|
||||
router.get(route('production-orders.index'));
|
||||
};
|
||||
|
||||
const handlePerPageChange = (value: string) => {
|
||||
setPerPage(value);
|
||||
router.get(
|
||||
route("production-orders.index"),
|
||||
{ ...filters, per_page: value },
|
||||
{ preserveState: false, replace: true, preserveScroll: true }
|
||||
);
|
||||
};
|
||||
|
||||
const handleNavigateToCreate = () => {
|
||||
router.get(route('production-orders.create'));
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrders")}>
|
||||
<Head title="生產工單" />
|
||||
<div className="container mx-auto p-6 max-w-7xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<Factory className="h-6 w-6 text-primary-main" />
|
||||
生產工單
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
記錄生產過程,追蹤原物料使用與成品入庫
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Can permission="production_orders.create">
|
||||
<Button
|
||||
onClick={handleNavigateToCreate}
|
||||
className="gap-2 button-filled-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
建立生產單
|
||||
</Button>
|
||||
</Can>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 篩選區塊 */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 mb-6 overflow-hidden">
|
||||
<div className="p-5">
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-4">
|
||||
<div className="md:col-span-8 space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2">關鍵字搜尋</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="搜尋生產單號、批號、商品名稱..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-10 h-9 block"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleFilter()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-4 space-y-1">
|
||||
<Label className="text-xs font-medium text-grey-2">狀態</Label>
|
||||
<Select value={status} onValueChange={setStatus}>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder="選擇狀態" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部狀態</SelectItem>
|
||||
<SelectItem value="draft">草稿</SelectItem>
|
||||
<SelectItem value="completed">已完成</SelectItem>
|
||||
<SelectItem value="cancelled">已取消</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end px-5 py-4 bg-gray-50/50 border-t border-gray-100 gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
className="button-outlined-primary h-9 gap-2"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
重置
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleFilter}
|
||||
className="button-filled-primary h-9 px-6 gap-2"
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
搜尋
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 生產單列表 */}
|
||||
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[150px]">生產單號</TableHead>
|
||||
<TableHead>成品</TableHead>
|
||||
<TableHead>成品批號</TableHead>
|
||||
<TableHead className="text-right">數量</TableHead>
|
||||
<TableHead>入庫倉庫</TableHead>
|
||||
<TableHead>生產日期</TableHead>
|
||||
<TableHead className="text-center w-[100px]">狀態</TableHead>
|
||||
<TableHead className="text-center w-[100px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{productionOrders.data.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="h-32 text-center text-gray-500">
|
||||
<div className="flex flex-col items-center justify-center gap-2">
|
||||
<Factory className="h-10 w-10 text-gray-300" />
|
||||
<p>尚無生產工單</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
productionOrders.data.map((order) => (
|
||||
<TableRow key={order.id}>
|
||||
<TableCell className="font-medium text-gray-900">
|
||||
{order.code}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-gray-900">{order.product?.name || '-'}</span>
|
||||
<span className="text-gray-400 text-xs">
|
||||
{order.product?.code || '-'}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="bg-gray-100 px-1.5 py-0.5 rounded text-xs font-mono">
|
||||
{order.output_batch_number}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{order.output_quantity.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-600">
|
||||
{order.warehouse?.name || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-600">
|
||||
{order.production_date}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant={statusConfig[order.status]?.variant || "secondary"} className="font-normal capitalize">
|
||||
{statusConfig[order.status]?.label || order.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<div className="flex items-center justify-center">
|
||||
<Link href={route('production-orders.show', order.id)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="button-outlined-primary h-8"
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
檢視
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 分頁 */}
|
||||
<div className="mt-4 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span>每頁顯示</span>
|
||||
<SearchableSelect
|
||||
value={perPage}
|
||||
onValueChange={handlePerPageChange}
|
||||
options={[
|
||||
{ label: "10", value: "10" },
|
||||
{ label: "20", value: "20" },
|
||||
{ label: "50", value: "50" },
|
||||
{ label: "100", value: "100" }
|
||||
]}
|
||||
className="w-[100px] h-8"
|
||||
showSearch={false}
|
||||
/>
|
||||
<span>筆</span>
|
||||
</div>
|
||||
<div className="w-full sm:w-auto flex justify-center sm:justify-end">
|
||||
<Pagination links={productionOrders.links} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
254
resources/js/Pages/Production/Show.tsx
Normal file
254
resources/js/Pages/Production/Show.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* 生產工單詳情頁面
|
||||
* 含追溯資訊:成品批號 → 原物料批號 → 來源採購單
|
||||
*/
|
||||
|
||||
import { Factory, ArrowLeft, Package, Calendar, User, Warehouse, FileText, Link2 } from 'lucide-react';
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||
import { Head, router, Link } from "@inertiajs/react";
|
||||
import { getBreadcrumbs } from "@/utils/breadcrumb";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
|
||||
|
||||
interface ProductionOrderItem {
|
||||
id: number;
|
||||
quantity_used: number;
|
||||
unit?: { id: number; name: string } | null;
|
||||
inventory: {
|
||||
id: number;
|
||||
batch_number: string;
|
||||
box_number: string | null;
|
||||
arrival_date: string | null;
|
||||
origin_country: string | null;
|
||||
product: { id: number; name: string; code: string } | null;
|
||||
source_purchase_order?: {
|
||||
id: number;
|
||||
code: string;
|
||||
vendor?: { id: number; name: string } | null;
|
||||
} | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface ProductionOrder {
|
||||
id: number;
|
||||
code: string;
|
||||
product: { id: number; name: string; code: string; base_unit?: { name: string } | null } | null;
|
||||
warehouse: { id: number; name: string } | null;
|
||||
user: { id: number; name: string } | null;
|
||||
output_batch_number: string;
|
||||
output_box_count: string | null;
|
||||
output_quantity: number;
|
||||
production_date: string;
|
||||
expiry_date: string | null;
|
||||
status: 'draft' | 'completed' | 'cancelled';
|
||||
remark: string | null;
|
||||
created_at: string;
|
||||
items: ProductionOrderItem[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
productionOrder: ProductionOrder;
|
||||
}
|
||||
|
||||
const statusConfig: Record<string, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
|
||||
draft: { label: "草稿", variant: "secondary" },
|
||||
completed: { label: "已完成", variant: "default" },
|
||||
cancelled: { label: "已取消", variant: "destructive" },
|
||||
};
|
||||
|
||||
export default function ProductionShow({ productionOrder }: Props) {
|
||||
return (
|
||||
<AuthenticatedLayout breadcrumbs={getBreadcrumbs("productionOrdersShow")}>
|
||||
<Head title={`生產單 ${productionOrder.code}`} />
|
||||
<div className="container mx-auto p-6 max-w-4xl">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => router.get(route('production-orders.index'))}
|
||||
className="p-2"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
|
||||
<Factory className="h-6 w-6 text-primary-main" />
|
||||
{productionOrder.code}
|
||||
</h1>
|
||||
<p className="text-gray-500 mt-1">
|
||||
生產工單詳情與追溯資訊
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={statusConfig[productionOrder.status]?.variant || "secondary"}>
|
||||
{statusConfig[productionOrder.status]?.label || productionOrder.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 成品資訊 */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Package className="h-5 w-5 text-gray-500" />
|
||||
成品資訊
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-x-8 gap-y-6">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-grey-2">成品商品</p>
|
||||
<p className="font-medium text-grey-0">
|
||||
{productionOrder.product?.name || '-'}
|
||||
<span className="text-gray-400 ml-2 text-sm font-normal">
|
||||
({productionOrder.product?.code || '-'})
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-grey-2">成品批號</p>
|
||||
<p className="font-mono font-medium text-primary-main">
|
||||
{productionOrder.output_batch_number}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-grey-2">生產數量</p>
|
||||
<p className="font-medium text-grey-0">
|
||||
{productionOrder.output_quantity.toLocaleString()}
|
||||
{productionOrder.product?.base_unit?.name && (
|
||||
<span className="text-gray-400 ml-1 font-normal">{productionOrder.product.base_unit.name}</span>
|
||||
)}
|
||||
{productionOrder.output_box_count && (
|
||||
<span className="text-gray-400 ml-2 font-normal">({productionOrder.output_box_count} 箱)</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-grey-2">入庫倉庫</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Warehouse className="h-4 w-4 text-gray-400" />
|
||||
<p className="font-medium text-grey-0">{productionOrder.warehouse?.name || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-grey-2">生產日期</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-gray-400" />
|
||||
<p className="font-medium text-grey-0">{productionOrder.production_date}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-grey-2">成品效期</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4 text-gray-400" />
|
||||
<p className="font-medium text-grey-0">{productionOrder.expiry_date || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-grey-2">操作人員</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="h-4 w-4 text-gray-400" />
|
||||
<p className="font-medium text-grey-0">{productionOrder.user?.name || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{productionOrder.remark && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<div className="flex items-start gap-2">
|
||||
<FileText className="h-4 w-4 text-gray-400 mt-1" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">備註</p>
|
||||
<p className="text-gray-700">{productionOrder.remark}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 原物料使用明細 (BOM) */}
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Link2 className="h-5 w-5 text-gray-500" />
|
||||
原物料使用明細 (BOM) - 追溯資訊
|
||||
</h2>
|
||||
|
||||
{productionOrder.items.length === 0 ? (
|
||||
<p className="text-center text-gray-500 py-8">無原物料記錄</p>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-gray-50">
|
||||
<TableRow>
|
||||
<TableHead className="px-4 py-3 text-left text-xs font-medium text-grey-2">
|
||||
原物料
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3 text-left text-xs font-medium text-grey-2">
|
||||
批號
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3 text-left text-xs font-medium text-grey-2">
|
||||
來源國家
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3 text-left text-xs font-medium text-grey-2">
|
||||
入庫日期
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3 text-left text-xs font-medium text-grey-2">
|
||||
使用量
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3 text-left text-xs font-medium text-grey-2">
|
||||
來源採購單
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{productionOrder.items.map((item) => (
|
||||
<TableRow key={item.id} className="hover:bg-gray-50/50">
|
||||
<TableCell className="px-4 py-4 text-sm">
|
||||
<div className="font-medium text-grey-0">{item.inventory?.product?.name || '-'}</div>
|
||||
<div className="text-gray-400 text-xs">
|
||||
{item.inventory?.product?.code || '-'}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm font-mono text-primary-main">
|
||||
{item.inventory?.batch_number || '-'}
|
||||
{item.inventory?.box_number && (
|
||||
<span className="text-gray-300 ml-1">#{item.inventory.box_number}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-grey-1">
|
||||
{item.inventory?.origin_country || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm text-grey-1">
|
||||
{item.inventory?.arrival_date || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm font-medium text-grey-0">
|
||||
{item.quantity_used.toLocaleString()}
|
||||
{item.unit?.name && (
|
||||
<span className="text-gray-400 ml-1 font-normal text-xs">{item.unit.name}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-4 text-sm">
|
||||
{item.inventory?.source_purchase_order ? (
|
||||
<div className="flex flex-col">
|
||||
<Link
|
||||
href={route('purchase-orders.show', item.inventory.source_purchase_order.id)}
|
||||
className="text-primary-main hover:underline font-medium"
|
||||
>
|
||||
{item.inventory.source_purchase_order.code}
|
||||
</Link>
|
||||
{item.inventory.source_purchase_order.vendor && (
|
||||
<span className="text-[11px] text-gray-400 mt-0.5">
|
||||
{item.inventory.source_purchase_order.vendor.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
@@ -148,7 +148,9 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
||||
|
||||
return {
|
||||
productId: item.productId,
|
||||
quantity: finalQuantity
|
||||
quantity: finalQuantity,
|
||||
batchNumber: item.batchNumber,
|
||||
expiryDate: item.expiryDate
|
||||
};
|
||||
})
|
||||
}, {
|
||||
@@ -307,8 +309,8 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
||||
</TableHead>
|
||||
<TableHead className="w-[100px]">單位</TableHead>
|
||||
<TableHead className="w-[150px]">轉換數量</TableHead>
|
||||
{/* <TableHead className="w-[180px]">效期</TableHead>
|
||||
<TableHead className="w-[220px]">進貨編號</TableHead> */}
|
||||
<TableHead className="w-[180px]">效期</TableHead>
|
||||
<TableHead className="w-[220px]">批號</TableHead>
|
||||
<TableHead className="w-[60px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -395,37 +397,40 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
|
||||
</TableCell>
|
||||
|
||||
{/* 效期 */}
|
||||
{/* <TableCell>
|
||||
<div className="relative">
|
||||
<TableCell>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400 pointer-events-none" />
|
||||
<Input
|
||||
type="date"
|
||||
value={item.expiryDate || ""}
|
||||
onChange={(e) =>
|
||||
handleUpdateItem(item.tempId, {
|
||||
expiryDate: e.target.value,
|
||||
})
|
||||
}
|
||||
className="border-gray-300 pl-9"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* 批號 */}
|
||||
<TableCell>
|
||||
<Input
|
||||
type="date"
|
||||
value={item.expiryDate}
|
||||
value={item.batchNumber || ""}
|
||||
onChange={(e) =>
|
||||
handleUpdateItem(item.tempId, {
|
||||
expiryDate: e.target.value,
|
||||
batchNumber: e.target.value,
|
||||
})
|
||||
}
|
||||
className="border-gray-300"
|
||||
placeholder="系統自動生成"
|
||||
/>
|
||||
</div>
|
||||
</TableCell> */}
|
||||
|
||||
{/* 批號 */}
|
||||
{/* <TableCell>
|
||||
<Input
|
||||
value={item.batchNumber}
|
||||
onChange={(e) =>
|
||||
handleBatchNumberChange(item.tempId, e.target.value)
|
||||
}
|
||||
className="border-gray-300"
|
||||
placeholder="系統自動生成"
|
||||
/>
|
||||
{errors[`item-${index}-batch`] && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors[`item-${index}-batch`]}
|
||||
</p>
|
||||
)}
|
||||
</TableCell> */}
|
||||
{errors[`item-${index}-batch`] && (
|
||||
<p className="text-xs text-red-500 mt-1">
|
||||
{errors[`item-${index}-batch`]}
|
||||
</p>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
{/* 刪除按鈕 */}
|
||||
<TableCell>
|
||||
|
||||
@@ -151,6 +151,8 @@ export interface InboundItem {
|
||||
largeUnit?: string;
|
||||
conversionRate?: number;
|
||||
selectedUnit?: 'base' | 'large';
|
||||
batchNumber?: string;
|
||||
expiryDate?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,6 +22,20 @@ export const BREADCRUMB_MAP: Record<string, BreadcrumbItemType[]> = {
|
||||
{ label: "採購管理" },
|
||||
{ label: "管理採購單", href: "/purchase-orders", isPage: true }
|
||||
],
|
||||
productionOrders: [
|
||||
{ label: "生產管理" },
|
||||
{ label: "生產工單", href: "/production-orders", isPage: true }
|
||||
],
|
||||
productionOrdersCreate: [
|
||||
{ label: "生產管理" },
|
||||
{ label: "生產工單", href: "/production-orders" },
|
||||
{ label: "建立生產單", isPage: true }
|
||||
],
|
||||
productionOrdersShow: [
|
||||
{ label: "生產管理" },
|
||||
{ label: "生產工單", href: "/production-orders" },
|
||||
{ label: "詳情", isPage: true }
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,6 +20,7 @@ use App\Http\Controllers\Admin\ActivityLogController;
|
||||
use App\Http\Controllers\ProfileController;
|
||||
use App\Http\Controllers\UtilityFeeController;
|
||||
use App\Http\Controllers\AccountingReportController;
|
||||
use App\Http\Controllers\ProductionOrderController;
|
||||
use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain;
|
||||
|
||||
// 登入/登出路由
|
||||
@@ -152,6 +153,23 @@ Route::middleware('auth')->group(function () {
|
||||
->name('accounting.export');
|
||||
});
|
||||
|
||||
// 生產管理
|
||||
Route::middleware('permission:production_orders.view')->group(function () {
|
||||
Route::get('/production-orders', [ProductionOrderController::class, 'index'])->name('production-orders.index');
|
||||
|
||||
Route::middleware('permission:production_orders.create')->group(function () {
|
||||
Route::get('/production-orders/create', [ProductionOrderController::class, 'create'])->name('production-orders.create');
|
||||
Route::post('/production-orders', [ProductionOrderController::class, 'store'])->name('production-orders.store');
|
||||
});
|
||||
|
||||
Route::get('/production-orders/{productionOrder}', [ProductionOrderController::class, 'show'])->name('production-orders.show');
|
||||
});
|
||||
|
||||
// 生產管理 API
|
||||
Route::get('/api/production/warehouses/{warehouse}/inventories', [ProductionOrderController::class, 'getWarehouseInventories'])
|
||||
->middleware('permission:production_orders.create')
|
||||
->name('api.production.warehouses.inventories');
|
||||
|
||||
// 系統管理
|
||||
Route::prefix('admin')->group(function () {
|
||||
Route::middleware('permission:roles.view')->group(function () {
|
||||
|
||||
Reference in New Issue
Block a user