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

This commit is contained in:
2026-01-21 17:19:36 +08:00
parent fc20c6d813
commit 1ae21febb5
17 changed files with 1753 additions and 33 deletions

View File

@@ -39,8 +39,8 @@ class InventoryController extends Controller
'quantity' => (float) $inv->quantity, 'quantity' => (float) $inv->quantity,
'safetyStock' => $inv->safety_stock !== null ? (float) $inv->safety_stock : null, 'safetyStock' => $inv->safety_stock !== null ? (float) $inv->safety_stock : null,
'status' => '正常', // 前端會根據 quantity 與 safetyStock 重算,但後端亦可提供基礎狀態 'status' => '正常', // 前端會根據 quantity 與 safetyStock 重算,但後端亦可提供基礎狀態
'batchNumber' => 'BATCH-' . $inv->id, // DB 批號,暫時模擬,某些 UI 可能還會用到 'batchNumber' => $inv->batch_number ?? 'BATCH-' . $inv->id, // 優先使用 DB 批號,若無則 fallback
'expiryDate' => '2099-12-31', // DB 無效期,暫時模擬 '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, '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, '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' => 'required|array|min:1',
'items.*.productId' => 'required|exists:products,id', 'items.*.productId' => 'required|exists:products,id',
'items.*.quantity' => 'required|numeric|min:0.01', '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) { return \Illuminate\Support\Facades\DB::transaction(function () use ($validated, $warehouse) {
foreach ($validated['items'] as $item) { 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( $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; $currentQty = $inventory->quantity;

View 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);
}
}

View File

@@ -17,6 +17,20 @@ class Inventory extends Model
'quantity', 'quantity',
'safety_stock', 'safety_stock',
'location', '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); $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;
}
} }

View 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;
}
}

View 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);
}
}

View File

@@ -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',
]);
});
}
};

View File

@@ -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');
}
};

View File

@@ -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');
}
};

View File

@@ -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();
}
}
};

View File

@@ -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", id: "finance-management",
label: "財務管理", label: "財務管理",

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -148,7 +148,9 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
return { return {
productId: item.productId, 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>
<TableHead className="w-[100px]"></TableHead> <TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[150px]"></TableHead> <TableHead className="w-[150px]"></TableHead>
{/* <TableHead className="w-[180px]">效期</TableHead> <TableHead className="w-[180px]"></TableHead>
<TableHead className="w-[220px]">進貨編號</TableHead> */} <TableHead className="w-[220px]"></TableHead>
<TableHead className="w-[60px]"></TableHead> <TableHead className="w-[60px]"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -395,37 +397,40 @@ export default function AddInventoryPage({ warehouse, products }: Props) {
</TableCell> </TableCell>
{/* 效期 */} {/* 效期 */}
{/* <TableCell> <TableCell>
<div className="relative"> <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 <Input
type="date" value={item.batchNumber || ""}
value={item.expiryDate}
onChange={(e) => onChange={(e) =>
handleUpdateItem(item.tempId, { handleUpdateItem(item.tempId, {
expiryDate: e.target.value, batchNumber: e.target.value,
}) })
} }
className="border-gray-300" className="border-gray-300"
placeholder="系統自動生成"
/> />
</div> {errors[`item-${index}-batch`] && (
</TableCell> */} <p className="text-xs text-red-500 mt-1">
{errors[`item-${index}-batch`]}
{/* 批號 */} </p>
{/* <TableCell> )}
<Input </TableCell>
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> */}
{/* 刪除按鈕 */} {/* 刪除按鈕 */}
<TableCell> <TableCell>

View File

@@ -151,6 +151,8 @@ export interface InboundItem {
largeUnit?: string; largeUnit?: string;
conversionRate?: number; conversionRate?: number;
selectedUnit?: 'base' | 'large'; selectedUnit?: 'base' | 'large';
batchNumber?: string;
expiryDate?: string;
} }
/** /**

View File

@@ -22,6 +22,20 @@ export const BREADCRUMB_MAP: Record<string, BreadcrumbItemType[]> = {
{ label: "採購管理" }, { label: "採購管理" },
{ label: "管理採購單", href: "/purchase-orders", isPage: true } { 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 }
],
}; };
/** /**

View File

@@ -20,6 +20,7 @@ use App\Http\Controllers\Admin\ActivityLogController;
use App\Http\Controllers\ProfileController; use App\Http\Controllers\ProfileController;
use App\Http\Controllers\UtilityFeeController; use App\Http\Controllers\UtilityFeeController;
use App\Http\Controllers\AccountingReportController; use App\Http\Controllers\AccountingReportController;
use App\Http\Controllers\ProductionOrderController;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain; use Stancl\Tenancy\Middleware\InitializeTenancyByDomainOrSubdomain;
// 登入/登出路由 // 登入/登出路由
@@ -152,6 +153,23 @@ Route::middleware('auth')->group(function () {
->name('accounting.export'); ->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::prefix('admin')->group(function () {
Route::middleware('permission:roles.view')->group(function () { Route::middleware('permission:roles.view')->group(function () {