feat(production): 優化生產單 BOM 原物料選取邏輯,支援商品 -> 倉庫 -> 批號連動與 API 分佈查詢
This commit is contained in:
@@ -36,7 +36,7 @@ class InventoryAdjustDoc extends Model
|
|||||||
static::creating(function ($model) {
|
static::creating(function ($model) {
|
||||||
if (empty($model->doc_no)) {
|
if (empty($model->doc_no)) {
|
||||||
$today = date('Ymd');
|
$today = date('Ymd');
|
||||||
$prefix = 'ADJ' . $today;
|
$prefix = 'ADJ-' . $today . '-';
|
||||||
|
|
||||||
$lastDoc = static::where('doc_no', 'like', $prefix . '%')
|
$lastDoc = static::where('doc_no', 'like', $prefix . '%')
|
||||||
->orderBy('doc_no', 'desc')
|
->orderBy('doc_no', 'desc')
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ class InventoryCountDoc extends Model
|
|||||||
static::creating(function ($model) {
|
static::creating(function ($model) {
|
||||||
if (empty($model->doc_no)) {
|
if (empty($model->doc_no)) {
|
||||||
$today = date('Ymd');
|
$today = date('Ymd');
|
||||||
$prefix = 'CNT' . $today;
|
$prefix = 'CNT-' . $today . '-';
|
||||||
|
|
||||||
// 查詢當天編號最大的單據
|
// 查詢當天編號最大的單據
|
||||||
$lastDoc = static::where('doc_no', 'like', $prefix . '%')
|
$lastDoc = static::where('doc_no', 'like', $prefix . '%')
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class InventoryTransferOrder extends Model
|
|||||||
static::creating(function ($model) {
|
static::creating(function ($model) {
|
||||||
if (empty($model->doc_no)) {
|
if (empty($model->doc_no)) {
|
||||||
$today = date('Ymd');
|
$today = date('Ymd');
|
||||||
$prefix = 'TRF' . $today;
|
$prefix = 'TRF-' . $today . '-';
|
||||||
|
|
||||||
$lastDoc = static::where('doc_no', 'like', $prefix . '%')
|
$lastDoc = static::where('doc_no', 'like', $prefix . '%')
|
||||||
->orderBy('doc_no', 'desc')
|
->orderBy('doc_no', 'desc')
|
||||||
|
|||||||
@@ -90,8 +90,8 @@ class GoodsReceiptService
|
|||||||
|
|
||||||
private function generateCode(string $date)
|
private function generateCode(string $date)
|
||||||
{
|
{
|
||||||
// Format: GR + YYYYMMDD + NNN
|
// Format: GR-YYYYMMDD-NN
|
||||||
$prefix = 'GR' . date('Ymd', strtotime($date));
|
$prefix = 'GR-' . date('Ymd', strtotime($date)) . '-';
|
||||||
|
|
||||||
$last = GoodsReceipt::where('code', 'like', $prefix . '%')
|
$last = GoodsReceipt::where('code', 'like', $prefix . '%')
|
||||||
->orderBy('id', 'desc')
|
->orderBy('id', 'desc')
|
||||||
@@ -99,11 +99,11 @@ class GoodsReceiptService
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($last) {
|
if ($last) {
|
||||||
$seq = intval(substr($last->code, -3)) + 1;
|
$seq = intval(substr($last->code, -2)) + 1;
|
||||||
} else {
|
} else {
|
||||||
$seq = 1;
|
$seq = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $prefix . str_pad($seq, 3, '0', STR_PAD_LEFT);
|
return $prefix . str_pad($seq, 2, '0', STR_PAD_LEFT);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,20 +187,20 @@ class PurchaseOrderController extends Controller
|
|||||||
try {
|
try {
|
||||||
DB::beginTransaction();
|
DB::beginTransaction();
|
||||||
|
|
||||||
// 生成單號:POYYYYMMDD001
|
// 生成單號:PO-YYYYMMDD-01
|
||||||
$today = now()->format('Ymd');
|
$today = now()->format('Ymd');
|
||||||
$prefix = 'PO' . $today;
|
$prefix = 'PO-' . $today . '-';
|
||||||
$lastOrder = PurchaseOrder::where('code', 'like', $prefix . '%')
|
$lastOrder = PurchaseOrder::where('code', 'like', $prefix . '%')
|
||||||
->lockForUpdate() // 鎖定以避免並發衝突
|
->lockForUpdate() // 鎖定以避免並發衝突
|
||||||
->orderBy('code', 'desc')
|
->orderBy('code', 'desc')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($lastOrder) {
|
if ($lastOrder) {
|
||||||
// 取得最後 3 碼序號並加 1
|
// 取得最後 2 碼序號並加 1
|
||||||
$lastSequence = intval(substr($lastOrder->code, -3));
|
$lastSequence = intval(substr($lastOrder->code, -2));
|
||||||
$sequence = str_pad($lastSequence + 1, 3, '0', STR_PAD_LEFT);
|
$sequence = str_pad($lastSequence + 1, 2, '0', STR_PAD_LEFT);
|
||||||
} else {
|
} else {
|
||||||
$sequence = '001';
|
$sequence = '01';
|
||||||
}
|
}
|
||||||
$code = $prefix . $sequence;
|
$code = $prefix . $sequence;
|
||||||
|
|
||||||
|
|||||||
@@ -269,6 +269,33 @@ class ProductionOrderController extends Controller
|
|||||||
return response()->json($data);
|
return response()->json($data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取得商品在各倉庫的庫存分佈
|
||||||
|
*/
|
||||||
|
public function getProductWarehouses($productId)
|
||||||
|
{
|
||||||
|
$inventories = \App\Modules\Inventory\Models\Inventory::with(['warehouse', 'product.baseUnit'])
|
||||||
|
->where('product_id', $productId)
|
||||||
|
->where('quantity', '>', 0)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$data = $inventories->map(function ($inv) {
|
||||||
|
return [
|
||||||
|
'id' => $inv->id, // Inventory ID
|
||||||
|
'warehouse_id' => $inv->warehouse_id,
|
||||||
|
'warehouse_name' => $inv->warehouse->name ?? '未知倉庫',
|
||||||
|
'batch_number' => $inv->batch_number,
|
||||||
|
'quantity' => $inv->quantity,
|
||||||
|
'expiry_date' => $inv->expiry_date ? $inv->expiry_date->format('Y-m-d') : null,
|
||||||
|
'unit_name' => $inv->product->baseUnit->name ?? '',
|
||||||
|
'base_unit_id' => $inv->product->base_unit_id ?? null,
|
||||||
|
'conversion_rate' => $inv->product->conversion_rate ?? 1,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json($data);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 編輯生產單
|
* 編輯生產單
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ Route::middleware('auth')->group(function () {
|
|||||||
->middleware('permission:production_orders.create')
|
->middleware('permission:production_orders.create')
|
||||||
->name('api.production.warehouses.inventories');
|
->name('api.production.warehouses.inventories');
|
||||||
|
|
||||||
|
Route::get('/api/production/products/{product}/inventories', [ProductionOrderController::class, 'getProductWarehouses'])
|
||||||
|
->middleware('permission:production_orders.create')
|
||||||
|
->name('api.production.products.inventories');
|
||||||
|
|
||||||
Route::get('/api/production/recipes/latest-by-product/{productId}', [RecipeController::class, 'getLatestByProduct'])
|
Route::get('/api/production/recipes/latest-by-product/{productId}', [RecipeController::class, 'getLatestByProduct'])
|
||||||
->name('api.production.recipes.latest-by-product');
|
->name('api.production.recipes.latest-by-product');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
// 刪除 purchase_orders.publish 權限
|
||||||
|
\Spatie\Permission\Models\Permission::where('name', 'purchase_orders.publish')->delete();
|
||||||
|
|
||||||
|
// 重置權限快取
|
||||||
|
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// 恢復權限(如果需要回滾)
|
||||||
|
\Spatie\Permission\Models\Permission::firstOrCreate(['name' => 'purchase_orders.publish']);
|
||||||
|
|
||||||
|
// 重新分配給 admin (簡單恢復,可能無法完全還原所有角色配置)
|
||||||
|
$admin = \Spatie\Permission\Models\Role::where('name', 'admin')->first();
|
||||||
|
if ($admin) {
|
||||||
|
$admin->givePermissionTo('purchase_orders.publish');
|
||||||
|
}
|
||||||
|
|
||||||
|
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -30,7 +30,7 @@ class PermissionSeeder extends Seeder
|
|||||||
'purchase_orders.create',
|
'purchase_orders.create',
|
||||||
'purchase_orders.edit',
|
'purchase_orders.edit',
|
||||||
'purchase_orders.delete',
|
'purchase_orders.delete',
|
||||||
'purchase_orders.publish',
|
|
||||||
|
|
||||||
// 庫存管理
|
// 庫存管理
|
||||||
'inventory.view',
|
'inventory.view',
|
||||||
@@ -132,7 +132,7 @@ class PermissionSeeder extends Seeder
|
|||||||
$admin->givePermissionTo([
|
$admin->givePermissionTo([
|
||||||
'products.view', 'products.create', 'products.edit', 'products.delete',
|
'products.view', 'products.create', 'products.edit', 'products.delete',
|
||||||
'purchase_orders.view', 'purchase_orders.create', 'purchase_orders.edit',
|
'purchase_orders.view', 'purchase_orders.create', 'purchase_orders.edit',
|
||||||
'purchase_orders.delete', 'purchase_orders.publish',
|
'purchase_orders.delete',
|
||||||
'inventory.view', 'inventory.view_cost', 'inventory.delete',
|
'inventory.view', 'inventory.view_cost', 'inventory.delete',
|
||||||
'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete',
|
'inventory_count.view', 'inventory_count.create', 'inventory_count.edit', 'inventory_count.delete',
|
||||||
'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete',
|
'inventory_adjust.view', 'inventory_adjust.create', 'inventory_adjust.edit', 'inventory_adjust.delete',
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default function PermissionSelector({ groupedPermissions, selectedPermiss
|
|||||||
'create': '新增',
|
'create': '新增',
|
||||||
'edit': '編輯',
|
'edit': '編輯',
|
||||||
'delete': '刪除',
|
'delete': '刪除',
|
||||||
'publish': '發布',
|
|
||||||
'adjust': '調整',
|
'adjust': '調整',
|
||||||
'transfer': '調撥',
|
'transfer': '調撥',
|
||||||
'count': '盤點',
|
'count': '盤點',
|
||||||
|
|||||||
@@ -542,6 +542,7 @@ export default function Show({ doc }: { auth: any, doc: AdjDoc }) {
|
|||||||
<div className="flex justify-end pr-2">
|
<div className="flex justify-end pr-2">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
|
step="0.01"
|
||||||
className="text-right h-9 w-32 font-medium"
|
className="text-right h-9 w-32 font-medium"
|
||||||
value={item.adjust_qty}
|
value={item.adjust_qty}
|
||||||
onChange={e => updateItem(index, 'adjust_qty', e.target.value)}
|
onChange={e => updateItem(index, 'adjust_qty', e.target.value)}
|
||||||
|
|||||||
@@ -265,14 +265,14 @@ export default function Show({ doc }: any) {
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm font-mono">{item.batch_number || '-'}</TableCell>
|
<TableCell className="text-sm font-mono">{item.batch_number || '-'}</TableCell>
|
||||||
<TableCell className="text-right font-medium">{item.system_qty.toFixed(0)}</TableCell>
|
<TableCell className="text-right font-medium">{Number(item.system_qty)}</TableCell>
|
||||||
<TableCell className="text-right px-1 py-3">
|
<TableCell className="text-right px-1 py-3">
|
||||||
{isReadOnly ? (
|
{isReadOnly ? (
|
||||||
<span className="font-semibold mr-2">{item.counted_qty}</span>
|
<span className="font-semibold mr-2">{item.counted_qty}</span>
|
||||||
) : (
|
) : (
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
step="1"
|
step="0.01"
|
||||||
value={formItem.counted_qty ?? ''}
|
value={formItem.counted_qty ?? ''}
|
||||||
onChange={(e) => updateItem(index, 'counted_qty', e.target.value)}
|
onChange={(e) => updateItem(index, 'counted_qty', e.target.value)}
|
||||||
onWheel={(e: any) => e.target.blur()}
|
onWheel={(e: any) => e.target.blur()}
|
||||||
@@ -290,7 +290,7 @@ export default function Show({ doc }: any) {
|
|||||||
: 'text-red-600'
|
: 'text-red-600'
|
||||||
}`}>
|
}`}>
|
||||||
{formItem.counted_qty !== '' && formItem.counted_qty !== null
|
{formItem.counted_qty !== '' && formItem.counted_qty !== null
|
||||||
? diff.toFixed(0)
|
? Number(diff.toFixed(2))
|
||||||
: '-'}
|
: '-'}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -314,7 +314,7 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<Link href={route('goods-receipts.index')}>
|
<Link href={route('goods-receipts.index')}>
|
||||||
<Button variant="outline" className="gap-2 mb-4 w-fit">
|
<Button variant="outline" type="button" className="gap-2 mb-4 w-fit button-outlined-primary">
|
||||||
<ArrowLeft className="h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
返回進貨單
|
返回進貨單
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ interface InventoryOption {
|
|||||||
product_id: number;
|
product_id: number;
|
||||||
product_name: string;
|
product_name: string;
|
||||||
product_code: string;
|
product_code: string;
|
||||||
|
warehouse_id: number;
|
||||||
|
warehouse_name: string;
|
||||||
batch_number: string;
|
batch_number: string;
|
||||||
box_number: string | null;
|
box_number: string | null;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
@@ -84,9 +86,9 @@ interface Props {
|
|||||||
|
|
||||||
export default function ProductionCreate({ products, warehouses }: Props) {
|
export default function ProductionCreate({ products, warehouses }: Props) {
|
||||||
const [selectedWarehouse, setSelectedWarehouse] = useState<string>(""); // 產出倉庫
|
const [selectedWarehouse, setSelectedWarehouse] = useState<string>(""); // 產出倉庫
|
||||||
// 快取對照表:warehouse_id -> inventories
|
// 快取對照表:product_id -> inventories across warehouses
|
||||||
const [inventoryMap, setInventoryMap] = useState<Record<string, InventoryOption[]>>({});
|
const [productInventoryMap, setProductInventoryMap] = useState<Record<string, InventoryOption[]>>({});
|
||||||
const [loadingWarehouses, setLoadingWareStates] = useState<Record<string, boolean>>({});
|
const [loadingProducts, setLoadingProducts] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const [bomItems, setBomItems] = useState<BomItem[]>([]);
|
const [bomItems, setBomItems] = useState<BomItem[]>([]);
|
||||||
|
|
||||||
@@ -107,19 +109,21 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
|||||||
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
|
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
|
||||||
});
|
});
|
||||||
|
|
||||||
// 獲取倉庫資料的輔助函式
|
// 獲取特定商品在各倉庫的庫存分佈
|
||||||
const fetchWarehouseInventory = async (warehouseId: string) => {
|
const fetchProductInventories = async (productId: string) => {
|
||||||
if (!warehouseId || inventoryMap[warehouseId] || loadingWarehouses[warehouseId]) return;
|
if (!productId) return;
|
||||||
|
// 如果已經在載入中,則跳過,但如果已經有資料,還是可以考慮重新抓取以確保最新,這裡保持現狀或增加強制更新參數
|
||||||
|
if (loadingProducts[productId]) return;
|
||||||
|
|
||||||
setLoadingWareStates(prev => ({ ...prev, [warehouseId]: true }));
|
setLoadingProducts(prev => ({ ...prev, [productId]: true }));
|
||||||
try {
|
try {
|
||||||
const res = await fetch(route('api.production.warehouses.inventories', warehouseId));
|
const res = await fetch(route('api.production.products.inventories', productId));
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setInventoryMap(prev => ({ ...prev, [warehouseId]: data }));
|
setProductInventoryMap(prev => ({ ...prev, [productId]: data }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingWareStates(prev => ({ ...prev, [warehouseId]: false }));
|
setLoadingProducts(prev => ({ ...prev, [productId]: false }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -151,33 +155,9 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
|||||||
const updated = [...bomItems];
|
const updated = [...bomItems];
|
||||||
const item = { ...updated[index], [field]: value };
|
const item = { ...updated[index], [field]: value };
|
||||||
|
|
||||||
// 0. 當選擇來源倉庫變更時
|
// 1. 當選擇商品變更時 -> 載入庫存分佈並重置後續欄位
|
||||||
if (field === 'ui_warehouse_id') {
|
|
||||||
// 重置後續欄位
|
|
||||||
item.ui_product_id = "";
|
|
||||||
item.inventory_id = "";
|
|
||||||
item.quantity_used = "";
|
|
||||||
item.unit_id = "";
|
|
||||||
item.ui_input_quantity = "";
|
|
||||||
item.ui_selected_unit = "base";
|
|
||||||
delete item.ui_product_name;
|
|
||||||
delete item.ui_batch_number;
|
|
||||||
delete item.ui_available_qty;
|
|
||||||
delete item.ui_expiry_date;
|
|
||||||
delete item.ui_conversion_rate;
|
|
||||||
delete item.ui_base_unit_name;
|
|
||||||
delete item.ui_large_unit_name;
|
|
||||||
delete item.ui_base_unit_id;
|
|
||||||
delete item.ui_large_unit_id;
|
|
||||||
|
|
||||||
// 觸發載入資料
|
|
||||||
if (value) {
|
|
||||||
fetchWarehouseInventory(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. 當選擇商品變更時 -> 清空批號與相關資訊
|
|
||||||
if (field === 'ui_product_id') {
|
if (field === 'ui_product_id') {
|
||||||
|
item.ui_warehouse_id = "";
|
||||||
item.inventory_id = "";
|
item.inventory_id = "";
|
||||||
item.quantity_used = "";
|
item.quantity_used = "";
|
||||||
item.unit_id = "";
|
item.unit_id = "";
|
||||||
@@ -193,24 +173,43 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
|||||||
delete item.ui_large_unit_name;
|
delete item.ui_large_unit_name;
|
||||||
delete item.ui_base_unit_id;
|
delete item.ui_base_unit_id;
|
||||||
delete item.ui_large_unit_id;
|
delete item.ui_large_unit_id;
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
const prod = products.find(p => String(p.id) === value);
|
||||||
|
if (prod) {
|
||||||
|
item.ui_product_name = prod.name;
|
||||||
|
item.ui_base_unit_name = prod.base_unit?.name || '';
|
||||||
|
}
|
||||||
|
fetchProductInventories(value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 當選擇批號 (Inventory ID) 變更時 -> 載入該批號資訊
|
// 2. 當選擇來源倉庫變更時 -> 重置批號與相關資訊
|
||||||
|
if (field === 'ui_warehouse_id') {
|
||||||
|
item.inventory_id = "";
|
||||||
|
item.quantity_used = "";
|
||||||
|
item.unit_id = "";
|
||||||
|
item.ui_input_quantity = "";
|
||||||
|
item.ui_selected_unit = "base";
|
||||||
|
// 清除某些 cache
|
||||||
|
delete item.ui_batch_number;
|
||||||
|
delete item.ui_available_qty;
|
||||||
|
delete item.ui_expiry_date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 當選擇批號 (Inventory ID) 變更時 -> 載入該批號資訊
|
||||||
if (field === 'inventory_id' && value) {
|
if (field === 'inventory_id' && value) {
|
||||||
const currentOptions = inventoryMap[item.ui_warehouse_id] || [];
|
const currentOptions = productInventoryMap[item.ui_product_id] || [];
|
||||||
const inv = currentOptions.find(i => String(i.id) === value);
|
const inv = currentOptions.find(i => String(i.id) === value);
|
||||||
if (inv) {
|
if (inv) {
|
||||||
item.ui_product_id = String(inv.product_id); // 確保商品也被選中 (雖通常是先選商品)
|
item.ui_warehouse_id = String(inv.warehouse_id);
|
||||||
item.ui_product_name = inv.product_name;
|
|
||||||
item.ui_batch_number = inv.batch_number;
|
item.ui_batch_number = inv.batch_number;
|
||||||
item.ui_available_qty = inv.quantity;
|
item.ui_available_qty = inv.quantity;
|
||||||
item.ui_expiry_date = inv.expiry_date || '';
|
item.ui_expiry_date = inv.expiry_date || '';
|
||||||
|
|
||||||
// 單位與轉換率
|
// 單位與轉換率
|
||||||
item.ui_base_unit_name = inv.base_unit_name || inv.unit_name || '';
|
item.ui_base_unit_name = inv.unit_name || '';
|
||||||
item.ui_large_unit_name = inv.large_unit_name || '';
|
|
||||||
item.ui_base_unit_id = inv.base_unit_id;
|
item.ui_base_unit_id = inv.base_unit_id;
|
||||||
item.ui_large_unit_id = inv.large_unit_id;
|
|
||||||
item.ui_conversion_rate = inv.conversion_rate || 1;
|
item.ui_conversion_rate = inv.conversion_rate || 1;
|
||||||
|
|
||||||
// 預設單位
|
// 預設單位
|
||||||
@@ -219,16 +218,13 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 計算最終數量 (Base Quantity)
|
// 4. 計算最終數量 (Base Quantity)
|
||||||
// 當 輸入數量 或 選擇單位 變更時
|
|
||||||
if (field === 'ui_input_quantity' || field === 'ui_selected_unit' || field === 'inventory_id') {
|
if (field === 'ui_input_quantity' || field === 'ui_selected_unit' || field === 'inventory_id') {
|
||||||
const inputQty = parseFloat(item.ui_input_quantity || '0');
|
const inputQty = parseFloat(item.ui_input_quantity || '0');
|
||||||
const rate = item.ui_conversion_rate || 1;
|
const rate = item.ui_conversion_rate || 1;
|
||||||
|
|
||||||
if (item.ui_selected_unit === 'large') {
|
if (item.ui_selected_unit === 'large') {
|
||||||
item.quantity_used = String(inputQty * rate);
|
item.quantity_used = String(inputQty * rate);
|
||||||
// 注意:後端需要的是 Base Unit ID? 這裡我們都送 Base Unit ID,因為 quantity_used 是 Base Unit
|
|
||||||
// 但為了保留 User 的選擇,我們可能可以在 remark 註記? 目前先從簡
|
|
||||||
item.unit_id = String(item.ui_base_unit_id || '');
|
item.unit_id = String(item.ui_base_unit_id || '');
|
||||||
} else {
|
} else {
|
||||||
item.quantity_used = String(inputQty);
|
item.quantity_used = String(inputQty);
|
||||||
@@ -256,17 +252,21 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
|||||||
const yieldQty = parseFloat(recipe.yield_quantity || "0") || 1;
|
const yieldQty = parseFloat(recipe.yield_quantity || "0") || 1;
|
||||||
// 自動帶入配方標準產量
|
// 自動帶入配方標準產量
|
||||||
setData('output_quantity', String(yieldQty));
|
setData('output_quantity', String(yieldQty));
|
||||||
const ratio = 1;
|
|
||||||
|
|
||||||
const newBomItems: BomItem[] = recipe.items.map((item: any) => {
|
const newBomItems: BomItem[] = recipe.items.map((item: any) => {
|
||||||
const baseQty = parseFloat(item.quantity || "0");
|
const baseQty = parseFloat(item.quantity || "0");
|
||||||
const calculatedQty = (baseQty * ratio).toFixed(4); // 保持精度
|
const calculatedQty = baseQty; // 保持精度
|
||||||
|
|
||||||
|
// 若有配方商品,預先載入庫存分佈
|
||||||
|
if (item.product_id) {
|
||||||
|
fetchProductInventories(String(item.product_id));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
inventory_id: "",
|
inventory_id: "",
|
||||||
quantity_used: String(calculatedQty),
|
quantity_used: String(calculatedQty),
|
||||||
unit_id: String(item.unit_id),
|
unit_id: String(item.unit_id),
|
||||||
ui_warehouse_id: selectedWarehouse || "", // 自動帶入目前選擇的倉庫
|
ui_warehouse_id: "",
|
||||||
ui_product_id: String(item.product_id),
|
ui_product_id: String(item.product_id),
|
||||||
ui_product_name: item.product_name,
|
ui_product_name: item.product_name,
|
||||||
ui_batch_number: "",
|
ui_batch_number: "",
|
||||||
@@ -280,11 +280,6 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
|||||||
});
|
});
|
||||||
setBomItems(newBomItems);
|
setBomItems(newBomItems);
|
||||||
|
|
||||||
// 若有選倉庫,預先載入庫存資料以供選擇
|
|
||||||
if (selectedWarehouse) {
|
|
||||||
fetchWarehouseInventory(selectedWarehouse);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success(`已自動載入配方: ${recipe.name}`, {
|
toast.success(`已自動載入配方: ${recipe.name}`, {
|
||||||
description: `標準產量: ${yieldQty} 份`
|
description: `標準產量: ${yieldQty} 份`
|
||||||
});
|
});
|
||||||
@@ -607,8 +602,8 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-gray-50/50">
|
<TableRow className="bg-gray-50/50">
|
||||||
<TableHead className="w-[20%]">來源倉庫 <span className="text-red-500">*</span></TableHead>
|
|
||||||
<TableHead className="w-[20%]">商品 <span className="text-red-500">*</span></TableHead>
|
<TableHead className="w-[20%]">商品 <span className="text-red-500">*</span></TableHead>
|
||||||
|
<TableHead className="w-[20%]">來源倉庫 <span className="text-red-500">*</span></TableHead>
|
||||||
<TableHead className="w-[25%]">批號 <span className="text-red-500">*</span></TableHead>
|
<TableHead className="w-[25%]">批號 <span className="text-red-500">*</span></TableHead>
|
||||||
<TableHead className="w-[15%]">數量 <span className="text-red-500">*</span></TableHead>
|
<TableHead className="w-[15%]">數量 <span className="text-red-500">*</span></TableHead>
|
||||||
<TableHead className="w-[15%]">單位</TableHead>
|
<TableHead className="w-[15%]">單位</TableHead>
|
||||||
@@ -617,61 +612,72 @@ export default function ProductionCreate({ products, warehouses }: Props) {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{bomItems.map((item, index) => {
|
{bomItems.map((item, index) => {
|
||||||
// 取得此列已載入的 Inventory Options
|
// 1. 商品選項
|
||||||
const currentOptions = inventoryMap[item.ui_warehouse_id] || [];
|
const productOptions = products.map(p => ({
|
||||||
|
label: `${p.name} (${p.code})`,
|
||||||
|
value: String(p.id)
|
||||||
|
}));
|
||||||
|
|
||||||
// 過濾商品
|
// 2. 來源倉庫選項 (根據商品库庫存過濾)
|
||||||
const uniqueProductOptions = Array.from(new Map(
|
const currentInventories = productInventoryMap[item.ui_product_id] || [];
|
||||||
currentOptions.map(inv => [inv.product_id, { label: inv.product_name, value: String(inv.product_id) }])
|
const filteredWarehouseOptions = Array.from(new Map(
|
||||||
|
currentInventories.map((inv: InventoryOption) => [inv.warehouse_id, { label: inv.warehouse_name, value: String(inv.warehouse_id) }])
|
||||||
).values());
|
).values());
|
||||||
|
|
||||||
// 過濾批號
|
// 如果篩選後沒有倉庫(即該商品無庫存),則顯示所有倉庫以供選取(或顯示無庫存提示)
|
||||||
const batchOptions = currentOptions
|
const uniqueWarehouseOptions = filteredWarehouseOptions.length > 0
|
||||||
.filter(inv => String(inv.product_id) === item.ui_product_id)
|
? filteredWarehouseOptions
|
||||||
.map(inv => ({
|
: (item.ui_product_id && !loadingProducts[item.ui_product_id] ? [] : []);
|
||||||
|
|
||||||
|
// 3. 批號選項 (根據商品與倉庫過濾)
|
||||||
|
const batchOptions = currentInventories
|
||||||
|
.filter((inv: InventoryOption) => String(inv.warehouse_id) === item.ui_warehouse_id)
|
||||||
|
.map((inv: InventoryOption) => ({
|
||||||
label: `${inv.batch_number} / ${inv.expiry_date || '無效期'} / ${inv.quantity}`,
|
label: `${inv.batch_number} / ${inv.expiry_date || '無效期'} / ${inv.quantity}`,
|
||||||
value: String(inv.id)
|
value: String(inv.id)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={index}>
|
<TableRow key={index}>
|
||||||
{/* 0. 選擇來源倉庫 */}
|
|
||||||
<TableCell className="align-top">
|
|
||||||
<SearchableSelect
|
|
||||||
value={item.ui_warehouse_id}
|
|
||||||
onValueChange={(v) => updateBomItem(index, 'ui_warehouse_id', v)}
|
|
||||||
options={warehouses.map(w => ({ label: w.name, value: String(w.id) }))}
|
|
||||||
placeholder="選擇倉庫"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
{/* 1. 選擇商品 */}
|
{/* 1. 選擇商品 */}
|
||||||
<TableCell className="align-top">
|
<TableCell className="align-top">
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
value={item.ui_product_id}
|
value={item.ui_product_id}
|
||||||
onValueChange={(v) => updateBomItem(index, 'ui_product_id', v)}
|
onValueChange={(v) => updateBomItem(index, 'ui_product_id', v)}
|
||||||
options={uniqueProductOptions}
|
options={productOptions}
|
||||||
placeholder="選擇商品"
|
placeholder="選擇商品"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={!item.ui_warehouse_id}
|
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* 2. 選擇批號 */}
|
{/* 2. 選擇來源倉庫 */}
|
||||||
|
<TableCell className="align-top">
|
||||||
|
<SearchableSelect
|
||||||
|
value={item.ui_warehouse_id}
|
||||||
|
onValueChange={(v) => updateBomItem(index, 'ui_warehouse_id', v)}
|
||||||
|
options={uniqueWarehouseOptions as any}
|
||||||
|
placeholder={item.ui_product_id
|
||||||
|
? (loadingProducts[item.ui_product_id]
|
||||||
|
? "載入庫存中..."
|
||||||
|
: (uniqueWarehouseOptions.length === 0 ? "該商品目前無庫存" : "選擇倉庫"))
|
||||||
|
: "請先選商品"}
|
||||||
|
className="w-full"
|
||||||
|
disabled={!item.ui_product_id || (loadingProducts[item.ui_product_id])}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* 3. 選擇批號 */}
|
||||||
<TableCell className="align-top">
|
<TableCell className="align-top">
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
value={item.inventory_id}
|
value={item.inventory_id}
|
||||||
onValueChange={(v) => updateBomItem(index, 'inventory_id', v)}
|
onValueChange={(v) => updateBomItem(index, 'inventory_id', v)}
|
||||||
options={batchOptions}
|
options={batchOptions as any}
|
||||||
placeholder={item.ui_product_id ? "選擇批號" : "請先選商品"}
|
placeholder={item.ui_warehouse_id ? "選擇批號" : "請先選倉庫"}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={!item.ui_product_id}
|
disabled={!item.ui_warehouse_id}
|
||||||
/>
|
/>
|
||||||
{item.inventory_id && (() => {
|
{item.inventory_id && (() => {
|
||||||
const selectedInv = currentOptions.find(i => String(i.id) === item.inventory_id);
|
const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id);
|
||||||
if (selectedInv) return (
|
if (selectedInv) return (
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
有效日期: {selectedInv.expiry_date || '無'} | 庫存: {selectedInv.quantity}
|
有效日期: {selectedInv.expiry_date || '無'} | 庫存: {selectedInv.quantity}
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ interface InventoryOption {
|
|||||||
product_id: number;
|
product_id: number;
|
||||||
product_name: string;
|
product_name: string;
|
||||||
product_code: string;
|
product_code: string;
|
||||||
|
warehouse_id: number;
|
||||||
|
warehouse_name: string;
|
||||||
batch_number: string;
|
batch_number: string;
|
||||||
box_number: string | null;
|
box_number: string | null;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
@@ -73,6 +75,7 @@ interface BomItem {
|
|||||||
ui_large_unit_name?: string;
|
ui_large_unit_name?: string;
|
||||||
ui_base_unit_id?: number;
|
ui_base_unit_id?: number;
|
||||||
ui_large_unit_id?: number;
|
ui_large_unit_id?: number;
|
||||||
|
ui_product_code?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProductionOrderItem {
|
interface ProductionOrderItem {
|
||||||
@@ -136,23 +139,24 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
|||||||
productionOrder.warehouse_id ? String(productionOrder.warehouse_id) : ""
|
productionOrder.warehouse_id ? String(productionOrder.warehouse_id) : ""
|
||||||
); // 產出倉庫
|
); // 產出倉庫
|
||||||
|
|
||||||
// 快取對照表:warehouse_id -> inventories
|
// 快取對照表:product_id -> inventories
|
||||||
const [inventoryMap, setInventoryMap] = useState<Record<string, InventoryOption[]>>({});
|
const [productInventoryMap, setProductInventoryMap] = useState<Record<string, InventoryOption[]>>({});
|
||||||
const [loadingWarehouses, setLoadingWareStates] = useState<Record<string, boolean>>({});
|
const [loadingProducts, setLoadingProducts] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
// 獲取倉庫資料的輔助函式
|
// 獲取商品所有倉庫庫存的分佈
|
||||||
const fetchWarehouseInventory = async (warehouseId: string) => {
|
const fetchProductInventories = async (productId: string) => {
|
||||||
if (!warehouseId || inventoryMap[warehouseId] || loadingWarehouses[warehouseId]) return;
|
if (!productId) return;
|
||||||
|
if (loadingProducts[productId]) return;
|
||||||
|
|
||||||
setLoadingWareStates(prev => ({ ...prev, [warehouseId]: true }));
|
setLoadingProducts(prev => ({ ...prev, [productId]: true }));
|
||||||
try {
|
try {
|
||||||
const res = await fetch(route('api.production.warehouses.inventories', warehouseId));
|
const res = await fetch(route('api.production.products.inventories', productId));
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setInventoryMap(prev => ({ ...prev, [warehouseId]: data }));
|
setProductInventoryMap(prev => ({ ...prev, [productId]: data }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingWareStates(prev => ({ ...prev, [warehouseId]: false }));
|
setLoadingProducts(prev => ({ ...prev, [productId]: false }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -188,25 +192,25 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
|||||||
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
|
items: [] as { inventory_id: number; quantity_used: number; unit_id: number | null }[],
|
||||||
});
|
});
|
||||||
|
|
||||||
// 初始化載入既有 BOM 的來源倉庫資料
|
// 初始化載入既有 BOM 的商品庫存資料
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initialBomItems.forEach(item => {
|
initialBomItems.forEach(item => {
|
||||||
if (item.ui_warehouse_id) {
|
if (item.ui_product_id) {
|
||||||
fetchWarehouseInventory(item.ui_warehouse_id);
|
fetchProductInventories(item.ui_product_id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 當 inventoryOptions (Map) 載入後,更新現有 BOM items 的詳細資訊 (如單位、轉換率)
|
// 當 inventoryOptions 載入後,更新現有 BOM items 的詳細資訊
|
||||||
// 監聽 inventoryMap 變更
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBomItems(prevItems => prevItems.map(item => {
|
setBomItems(prevItems => prevItems.map(item => {
|
||||||
if (item.ui_warehouse_id && inventoryMap[item.ui_warehouse_id] && item.inventory_id && !item.ui_conversion_rate) {
|
const currentOptions = productInventoryMap[item.ui_product_id] || [];
|
||||||
const inv = inventoryMap[item.ui_warehouse_id].find(i => String(i.id) === item.inventory_id);
|
if (currentOptions.length > 0 && item.inventory_id && !item.ui_conversion_rate) {
|
||||||
|
const inv = currentOptions.find(i => String(i.id) === item.inventory_id);
|
||||||
if (inv) {
|
if (inv) {
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
ui_product_id: String(inv.product_id),
|
ui_warehouse_id: String(inv.warehouse_id), // 重要:還原倉庫 ID
|
||||||
ui_product_name: inv.product_name,
|
ui_product_name: inv.product_name,
|
||||||
ui_batch_number: inv.batch_number,
|
ui_batch_number: inv.batch_number,
|
||||||
ui_available_qty: inv.quantity,
|
ui_available_qty: inv.quantity,
|
||||||
@@ -221,7 +225,7 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
|||||||
}
|
}
|
||||||
return item;
|
return item;
|
||||||
}));
|
}));
|
||||||
}, [inventoryMap]);
|
}, [productInventoryMap]);
|
||||||
|
|
||||||
// 同步 warehouse_id 到 form data
|
// 同步 warehouse_id 到 form data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -251,53 +255,40 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
|||||||
const updated = [...bomItems];
|
const updated = [...bomItems];
|
||||||
const item = { ...updated[index], [field]: value };
|
const item = { ...updated[index], [field]: value };
|
||||||
|
|
||||||
// 0. 當選擇來源倉庫變更時
|
// 0. 當選擇商品變更時 (第一層)
|
||||||
if (field === 'ui_warehouse_id') {
|
if (field === 'ui_product_id') {
|
||||||
item.ui_product_id = "";
|
item.ui_warehouse_id = "";
|
||||||
item.inventory_id = "";
|
item.inventory_id = "";
|
||||||
item.quantity_used = "";
|
item.quantity_used = "";
|
||||||
item.unit_id = "";
|
item.unit_id = "";
|
||||||
item.ui_input_quantity = "";
|
item.ui_input_quantity = "";
|
||||||
item.ui_selected_unit = "base";
|
item.ui_selected_unit = "base";
|
||||||
delete item.ui_product_name;
|
// 保留基本資訊
|
||||||
delete item.ui_batch_number;
|
|
||||||
delete item.ui_available_qty;
|
|
||||||
delete item.ui_expiry_date;
|
|
||||||
delete item.ui_conversion_rate;
|
|
||||||
delete item.ui_base_unit_name;
|
|
||||||
delete item.ui_large_unit_name;
|
|
||||||
delete item.ui_base_unit_id;
|
|
||||||
delete item.ui_large_unit_id;
|
|
||||||
|
|
||||||
if (value) {
|
if (value) {
|
||||||
fetchWarehouseInventory(value);
|
const prod = products.find(p => String(p.id) === value);
|
||||||
|
if (prod) {
|
||||||
|
item.ui_product_name = prod.name;
|
||||||
|
item.ui_base_unit_name = prod.base_unit?.name || '';
|
||||||
|
}
|
||||||
|
fetchProductInventories(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 當選擇商品變更時 -> 清空批號與相關資訊
|
// 1. 當選擇來源倉庫變更時 (第二層)
|
||||||
if (field === 'ui_product_id') {
|
if (field === 'ui_warehouse_id') {
|
||||||
item.inventory_id = "";
|
item.inventory_id = "";
|
||||||
item.quantity_used = "";
|
item.quantity_used = "";
|
||||||
item.unit_id = "";
|
item.unit_id = "";
|
||||||
item.ui_input_quantity = "";
|
item.ui_input_quantity = "";
|
||||||
item.ui_selected_unit = "base";
|
item.ui_selected_unit = "base";
|
||||||
delete item.ui_product_name;
|
|
||||||
delete item.ui_batch_number;
|
|
||||||
delete item.ui_available_qty;
|
|
||||||
delete item.ui_expiry_date;
|
|
||||||
delete item.ui_conversion_rate;
|
|
||||||
delete item.ui_base_unit_name;
|
|
||||||
delete item.ui_large_unit_name;
|
|
||||||
delete item.ui_base_unit_id;
|
|
||||||
delete item.ui_large_unit_id;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 當選擇批號變更時
|
// 2. 當選擇批號 (Inventory) 變更時 (第三層)
|
||||||
if (field === 'inventory_id' && value) {
|
if (field === 'inventory_id' && value) {
|
||||||
const currentOptions = inventoryMap[item.ui_warehouse_id] || [];
|
const currentOptions = productInventoryMap[item.ui_product_id] || [];
|
||||||
const inv = currentOptions.find(i => String(i.id) === value);
|
const inv = currentOptions.find(i => String(i.id) === value);
|
||||||
if (inv) {
|
if (inv) {
|
||||||
item.ui_product_id = String(inv.product_id);
|
item.ui_warehouse_id = String(inv.warehouse_id);
|
||||||
item.ui_product_name = inv.product_name;
|
item.ui_product_name = inv.product_name;
|
||||||
item.ui_batch_number = inv.batch_number;
|
item.ui_batch_number = inv.batch_number;
|
||||||
item.ui_available_qty = inv.quantity;
|
item.ui_available_qty = inv.quantity;
|
||||||
@@ -583,8 +574,8 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-gray-50/50">
|
<TableRow className="bg-gray-50/50">
|
||||||
<TableHead className="w-[20%]">來源倉庫 <span className="text-red-500">*</span></TableHead>
|
|
||||||
<TableHead className="w-[20%]">商品 <span className="text-red-500">*</span></TableHead>
|
<TableHead className="w-[20%]">商品 <span className="text-red-500">*</span></TableHead>
|
||||||
|
<TableHead className="w-[20%]">來源倉庫 <span className="text-red-500">*</span></TableHead>
|
||||||
<TableHead className="w-[25%]">批號 <span className="text-red-500">*</span></TableHead>
|
<TableHead className="w-[25%]">批號 <span className="text-red-500">*</span></TableHead>
|
||||||
<TableHead className="w-[15%]">數量 <span className="text-red-500">*</span></TableHead>
|
<TableHead className="w-[15%]">數量 <span className="text-red-500">*</span></TableHead>
|
||||||
<TableHead className="w-[15%]">單位</TableHead>
|
<TableHead className="w-[15%]">單位</TableHead>
|
||||||
@@ -593,19 +584,31 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{bomItems.map((item, index) => {
|
{bomItems.map((item, index) => {
|
||||||
// 取得此列已載入的 Inventory Options
|
// 1. 商品選項
|
||||||
const currentOptions = inventoryMap[item.ui_warehouse_id] || [];
|
const productOptions = products.map(p => ({
|
||||||
|
label: `${p.name} (${p.code})`,
|
||||||
|
value: String(p.id)
|
||||||
|
}));
|
||||||
|
|
||||||
const uniqueProductOptions = Array.from(new Map(
|
// 2. 來源倉庫選項 (根據商品库庫存過濾)
|
||||||
currentOptions.map(inv => [inv.product_id, { label: inv.product_name, value: String(inv.product_id) }])
|
const currentInventories = productInventoryMap[item.ui_product_id] || [];
|
||||||
|
const filteredWarehouseOptions = Array.from(new Map(
|
||||||
|
currentInventories.map((inv: InventoryOption) => [inv.warehouse_id, { label: inv.warehouse_name, value: String(inv.warehouse_id) }])
|
||||||
).values());
|
).values());
|
||||||
|
|
||||||
// 在獲取前初始狀態的備案
|
const uniqueWarehouseOptions = filteredWarehouseOptions.length > 0
|
||||||
const displayProductOptions = uniqueProductOptions.length > 0 ? uniqueProductOptions : (item.ui_product_name ? [{ label: item.ui_product_name, value: item.ui_product_id }] : []);
|
? filteredWarehouseOptions
|
||||||
|
: (item.ui_product_id && !loadingProducts[item.ui_product_id] ? [] : []);
|
||||||
|
|
||||||
const batchOptions = currentOptions
|
// 備案 (初始載入時)
|
||||||
.filter(inv => String(inv.product_id) === item.ui_product_id)
|
const displayWarehouseOptions = uniqueWarehouseOptions.length > 0
|
||||||
.map(inv => ({
|
? uniqueWarehouseOptions
|
||||||
|
: (item.ui_warehouse_id ? [{ label: "載入中...", value: item.ui_warehouse_id }] : []);
|
||||||
|
|
||||||
|
// 3. 批號選項 (根據商品與倉庫過濾)
|
||||||
|
const batchOptions = currentInventories
|
||||||
|
.filter((inv: InventoryOption) => String(inv.warehouse_id) === item.ui_warehouse_id)
|
||||||
|
.map((inv: InventoryOption) => ({
|
||||||
label: `${inv.batch_number} / ${inv.expiry_date || '無效期'} / ${inv.quantity}`,
|
label: `${inv.batch_number} / ${inv.expiry_date || '無效期'} / ${inv.quantity}`,
|
||||||
value: String(inv.id)
|
value: String(inv.id)
|
||||||
}));
|
}));
|
||||||
@@ -614,44 +617,47 @@ export default function ProductionEdit({ productionOrder, products, warehouses }
|
|||||||
const displayBatchOptions = batchOptions.length > 0 ? batchOptions : (item.inventory_id && item.ui_batch_number ? [{ label: item.ui_batch_number, value: item.inventory_id }] : []);
|
const displayBatchOptions = batchOptions.length > 0 ? batchOptions : (item.inventory_id && item.ui_batch_number ? [{ label: item.ui_batch_number, value: item.inventory_id }] : []);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={index}>
|
<TableRow key={index}>
|
||||||
{/* 0. 選擇來源倉庫 */}
|
|
||||||
<TableCell className="align-top">
|
|
||||||
<SearchableSelect
|
|
||||||
value={item.ui_warehouse_id}
|
|
||||||
onValueChange={(v) => updateBomItem(index, 'ui_warehouse_id', v)}
|
|
||||||
options={warehouses.map(w => ({ label: w.name, value: String(w.id) }))}
|
|
||||||
placeholder="選擇倉庫"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
{/* 1. 選擇商品 */}
|
{/* 1. 選擇商品 */}
|
||||||
<TableCell className="align-top">
|
<TableCell className="align-top">
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
value={item.ui_product_id}
|
value={item.ui_product_id}
|
||||||
onValueChange={(v) => updateBomItem(index, 'ui_product_id', v)}
|
onValueChange={(v) => updateBomItem(index, 'ui_product_id', v)}
|
||||||
options={displayProductOptions}
|
options={productOptions}
|
||||||
placeholder="選擇商品"
|
placeholder="選擇商品"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={!item.ui_warehouse_id}
|
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
{/* 2. 選擇批號 */}
|
{/* 2. 選擇來源倉庫 */}
|
||||||
|
<TableCell className="align-top">
|
||||||
|
<SearchableSelect
|
||||||
|
value={item.ui_warehouse_id}
|
||||||
|
onValueChange={(v) => updateBomItem(index, 'ui_warehouse_id', v)}
|
||||||
|
options={displayWarehouseOptions as any}
|
||||||
|
placeholder={item.ui_product_id
|
||||||
|
? (loadingProducts[item.ui_product_id]
|
||||||
|
? "載入庫存中..."
|
||||||
|
: (uniqueWarehouseOptions.length === 0 ? "該商品目前無庫存" : "選擇倉庫"))
|
||||||
|
: "請先選商品"}
|
||||||
|
className="w-full"
|
||||||
|
disabled={!item.ui_product_id || (loadingProducts[item.ui_product_id])}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
{/* 3. 選擇批號 */}
|
||||||
<TableCell className="align-top">
|
<TableCell className="align-top">
|
||||||
<SearchableSelect
|
<SearchableSelect
|
||||||
value={item.inventory_id}
|
value={item.inventory_id}
|
||||||
onValueChange={(v) => updateBomItem(index, 'inventory_id', v)}
|
onValueChange={(v) => updateBomItem(index, 'inventory_id', v)}
|
||||||
options={displayBatchOptions}
|
options={displayBatchOptions as any}
|
||||||
placeholder={item.ui_product_id ? "選擇批號" : "請先選商品"}
|
placeholder={item.ui_warehouse_id ? "選擇批號" : "請先選倉庫"}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={!item.ui_product_id}
|
disabled={!item.ui_warehouse_id}
|
||||||
/>
|
/>
|
||||||
{item.inventory_id && (() => {
|
{item.inventory_id && (() => {
|
||||||
const selectedInv = currentOptions.find(i => String(i.id) === item.inventory_id);
|
const selectedInv = currentInventories.find((i: InventoryOption) => String(i.id) === item.inventory_id);
|
||||||
if (selectedInv) return (
|
if (selectedInv) return (
|
||||||
<div className="text-xs text-gray-500 mt-1">
|
<div className="text-xs text-gray-500 mt-1">
|
||||||
有效日期: {selectedInv.expiry_date || '無'} | 庫存: {selectedInv.quantity}
|
有效日期: {selectedInv.expiry_date || '無'} | 庫存: {selectedInv.quantity}
|
||||||
|
|||||||
@@ -2,12 +2,11 @@
|
|||||||
* 新增配方頁面
|
* 新增配方頁面
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { BookOpen, Plus, Trash2, ArrowLeft, Save } from 'lucide-react';
|
import { BookOpen, Plus, Trash2, ArrowLeft, Save } from 'lucide-react';
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||||
import { Head, router, useForm, Link } from "@inertiajs/react";
|
import { Head, useForm, Link } from "@inertiajs/react";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { getBreadcrumbs } from "@/utils/breadcrumb";
|
import { getBreadcrumbs } from "@/utils/breadcrumb";
|
||||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||||
import { Input } from "@/Components/ui/input";
|
import { Input } from "@/Components/ui/input";
|
||||||
@@ -36,6 +35,7 @@ interface RecipeItem {
|
|||||||
// UI Helpers
|
// UI Helpers
|
||||||
ui_product_name?: string;
|
ui_product_name?: string;
|
||||||
ui_product_code?: string;
|
ui_product_code?: string;
|
||||||
|
ui_unit_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -91,9 +91,11 @@ export default function RecipeCreate({ products, units }: Props) {
|
|||||||
if (product) {
|
if (product) {
|
||||||
newItems[index].ui_product_name = product.name;
|
newItems[index].ui_product_name = product.name;
|
||||||
newItems[index].ui_product_code = product.code;
|
newItems[index].ui_product_code = product.code;
|
||||||
// Default to base unit
|
// Default to base unit and fix it
|
||||||
if (product.base_unit_id) {
|
if (product.base_unit_id) {
|
||||||
newItems[index].unit_id = String(product.base_unit_id);
|
newItems[index].unit_id = String(product.base_unit_id);
|
||||||
|
const unit = units.find(u => u.id === product.base_unit_id);
|
||||||
|
newItems[index].ui_unit_name = unit?.name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,14 +105,7 @@ export default function RecipeCreate({ products, units }: Props) {
|
|||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
post(route('recipes.store'), {
|
post(route('recipes.store'));
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("配方已建立");
|
|
||||||
},
|
|
||||||
onError: (errors) => {
|
|
||||||
toast.error("儲存失敗,請檢查欄位");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -275,17 +270,10 @@ export default function RecipeCreate({ products, units }: Props) {
|
|||||||
placeholder="數量"
|
placeholder="數量"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="align-top">
|
<TableCell className="align-middle">
|
||||||
<SearchableSelect
|
<div className="text-sm text-gray-700 bg-gray-50 px-3 py-2 rounded-md border border-gray-100 min-h-[38px] flex items-center">
|
||||||
value={item.unit_id}
|
{item.ui_unit_name || (units.find(u => String(u.id) === item.unit_id)?.name) || '-'}
|
||||||
onValueChange={(v) => updateItem(index, 'unit_id', v)}
|
</div>
|
||||||
options={units.map(u => ({
|
|
||||||
label: u.name,
|
|
||||||
value: String(u.id)
|
|
||||||
}))}
|
|
||||||
placeholder="單位"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="align-top">
|
<TableCell className="align-top">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -2,12 +2,11 @@
|
|||||||
* 編輯配方頁面
|
* 編輯配方頁面
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { BookOpen, Plus, Trash2, ArrowLeft, Save } from 'lucide-react';
|
import { BookOpen, Plus, Trash2, ArrowLeft, Save } from 'lucide-react';
|
||||||
import { Button } from "@/Components/ui/button";
|
import { Button } from "@/Components/ui/button";
|
||||||
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
import AuthenticatedLayout from "@/Layouts/AuthenticatedLayout";
|
||||||
import { Head, router, useForm, Link } from "@inertiajs/react";
|
import { Head, useForm, Link } from "@inertiajs/react";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { getBreadcrumbs } from "@/utils/breadcrumb";
|
import { getBreadcrumbs } from "@/utils/breadcrumb";
|
||||||
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
import { SearchableSelect } from "@/Components/ui/searchable-select";
|
||||||
import { Input } from "@/Components/ui/input";
|
import { Input } from "@/Components/ui/input";
|
||||||
@@ -59,6 +58,7 @@ interface RecipeItemForm {
|
|||||||
// UI Helpers
|
// UI Helpers
|
||||||
ui_product_name?: string;
|
ui_product_name?: string;
|
||||||
ui_product_code?: string;
|
ui_product_code?: string;
|
||||||
|
ui_unit_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -80,7 +80,8 @@ export default function RecipeEdit({ recipe, products, units }: Props) {
|
|||||||
unit_id: String(item.unit_id),
|
unit_id: String(item.unit_id),
|
||||||
remark: item.remark || "",
|
remark: item.remark || "",
|
||||||
ui_product_name: item.product?.name,
|
ui_product_name: item.product?.name,
|
||||||
ui_product_code: item.product?.code
|
ui_product_code: item.product?.code,
|
||||||
|
ui_unit_name: item.unit?.name
|
||||||
})) as RecipeItemForm[],
|
})) as RecipeItemForm[],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -118,6 +119,8 @@ export default function RecipeEdit({ recipe, products, units }: Props) {
|
|||||||
// Default to base unit if not set
|
// Default to base unit if not set
|
||||||
if (product.base_unit_id) {
|
if (product.base_unit_id) {
|
||||||
newItems[index].unit_id = String(product.base_unit_id);
|
newItems[index].unit_id = String(product.base_unit_id);
|
||||||
|
const unit = units.find(u => u.id === product.base_unit_id);
|
||||||
|
newItems[index].ui_unit_name = unit?.name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,14 +130,7 @@ export default function RecipeEdit({ recipe, products, units }: Props) {
|
|||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
put(route('recipes.update', recipe.id), {
|
put(route('recipes.update', recipe.id));
|
||||||
onSuccess: () => {
|
|
||||||
toast.success("配方已更新");
|
|
||||||
},
|
|
||||||
onError: (errors) => {
|
|
||||||
toast.error("儲存失敗,請檢查欄位");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -299,17 +295,10 @@ export default function RecipeEdit({ recipe, products, units }: Props) {
|
|||||||
placeholder="數量"
|
placeholder="數量"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="align-top">
|
<TableCell className="align-middle">
|
||||||
<SearchableSelect
|
<div className="text-sm text-gray-700 bg-gray-50 px-3 py-2 rounded-md border border-gray-100 min-h-[38px] flex items-center">
|
||||||
value={item.unit_id}
|
{item.ui_unit_name || (units.find(u => String(u.id) === item.unit_id)?.name) || '-'}
|
||||||
onValueChange={(v) => updateItem(index, 'unit_id', v)}
|
</div>
|
||||||
options={units.map(u => ({
|
|
||||||
label: u.name,
|
|
||||||
value: String(u.id)
|
|
||||||
}))}
|
|
||||||
placeholder="單位"
|
|
||||||
className="w-full"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="align-top">
|
<TableCell className="align-top">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
Reference in New Issue
Block a user