From f543b98d0f20511705a72b7d0d689cedf219ee05 Mon Sep 17 00:00:00 2001 From: sky121113 Date: Tue, 3 Mar 2026 16:57:28 +0800 Subject: [PATCH] =?UTF-8?q?feat(inventory):=20=E5=AF=A6=E4=BD=9C=E9=80=B2?= =?UTF-8?q?=E8=B2=A8=E5=96=AE=E8=8D=89=E7=A8=BF=E7=B7=A8=E8=BC=AF=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E3=80=81=E5=83=B9=E6=A0=BC=E9=9B=99=E5=90=91=E9=80=A3?= =?UTF-8?q?=E5=8B=95=E8=88=87=E6=89=B9=E8=99=9F=20UI=20=E5=84=AA=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 實作進貨單編輯功能,支援草稿資料預填與 PUT 更新。 2. 修復進貨單儲存時 received_date 與 expiry_date 的日期格式錯誤 (Y-m-d)。 3. 實作非標準進貨類型(雜項、其他)的單價與小計雙向連動邏輯。 4. 優化品項批號 UI 為 SearchableSelect 整合模式,支援不使用批號 (NO-BATCH) 與建立新批號,與倉庫管理頁面風格統一。 --- .../Controllers/GoodsReceiptController.php | 181 ++++++- app/Modules/Inventory/Routes/web.php | 2 + .../Services/GoodsReceiptService.php | 16 +- .../Inventory/GoodsReceiptActions.tsx | 18 +- .../Pages/Inventory/GoodsReceipt/Create.tsx | 453 +++++++++++++----- 5 files changed, 527 insertions(+), 143 deletions(-) diff --git a/app/Modules/Inventory/Controllers/GoodsReceiptController.php b/app/Modules/Inventory/Controllers/GoodsReceiptController.php index 9a8b38a..a7df0ed 100644 --- a/app/Modules/Inventory/Controllers/GoodsReceiptController.php +++ b/app/Modules/Inventory/Controllers/GoodsReceiptController.php @@ -167,7 +167,162 @@ class GoodsReceiptController extends Controller public function store(Request $request) { - $validated = $request->validate([ + $validated = $request->validate($this->getItemValidationRules()); + + try { + $this->goodsReceiptService->store($request->all()); + return redirect()->route('goods-receipts.index')->with('success', '進貨草稿已建立'); + } catch (\Exception $e) { + return back()->with('error', $e->getMessage()); + } + } + + /** + * 編輯進貨單(僅草稿/退回狀態) + */ + public function edit(GoodsReceipt $goodsReceipt) + { + if (!in_array($goodsReceipt->status, [GoodsReceipt::STATUS_DRAFT, 'rejected'])) { + return redirect()->route('goods-receipts.show', $goodsReceipt->id) + ->with('error', '只有草稿或被退回的進貨單可以編輯。'); + } + + // 載入品項與產品資訊 + $goodsReceipt->load('items'); + + // 取得品項關聯的商品資訊 + $productIds = $goodsReceipt->items->pluck('product_id')->unique()->toArray(); + $products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id'); + + // 如果是標準採購,取得對應採購單的品項,以帶出預定數量與已收數量 + $poItems = collect(); + $po = null; + if ($goodsReceipt->type === 'standard' && $goodsReceipt->purchase_order_id) { + $po = clone $this->procurementService->getPurchaseOrdersByIds([$goodsReceipt->purchase_order_id], ['items', 'vendor'])->first(); + if ($po) { + $poItems = $po->items->keyBy('id'); + } + } + + // 格式化品項資料 + $formattedItems = $goodsReceipt->items->map(function ($item) use ($products, $poItems) { + $product = $products->get($item->product_id); + $poItem = $poItems->get($item->purchase_order_item_id); + + return [ + 'product_id' => $item->product_id, + 'purchase_order_item_id' => $item->purchase_order_item_id, + 'product_name' => $product?->name ?? '', + 'product_code' => $product?->code ?? '', + 'unit' => $product?->baseUnit?->name ?? '個', + 'quantity_ordered' => $poItem ? $poItem->quantity : null, + 'quantity_received_so_far' => $poItem ? ($poItem->received_quantity ?? 0) : null, + 'quantity_received' => (float) $item->quantity_received, + 'unit_price' => (float) $item->unit_price, + 'subtotal' => (float) $item->total_amount, + 'batch_number' => $item->batch_number ?? '', + 'batchMode' => 'existing', + 'originCountry' => 'TW', + 'expiry_date' => $item->expiry_date ?? '', + ]; + })->values(); + + // 同 create() 一樣傳入所需的 props + $pendingPOs = $this->procurementService->getPendingPurchaseOrders(); + $productIdsForPOs = $pendingPOs->flatMap(fn($po) => $po->items->pluck('product_id'))->unique()->filter()->toArray(); + $productsForPOs = $this->inventoryService->getProductsByIds($productIdsForPOs)->keyBy('id'); + + $formattedPOs = $pendingPOs->map(function ($po) use ($productsForPOs) { + return [ + 'id' => $po->id, + 'code' => $po->code, + 'status' => $po->status, + 'vendor_id' => $po->vendor_id, + 'vendor_name' => $po->vendor?->name ?? '', + 'warehouse_id' => $po->warehouse_id, + 'order_date' => $po->order_date, + 'items' => $po->items->map(function ($item) use ($productsForPOs) { + $product = $productsForPOs->get($item->product_id); + $remaining = max(0, $item->quantity - ($item->received_quantity ?? 0)); + return [ + 'id' => $item->id, + 'product_id' => $item->product_id, + 'product_name' => $product?->name ?? '', + 'product_code' => $product?->code ?? '', + 'unit' => $product?->baseUnit?->name ?? '個', + 'quantity' => $item->quantity, + 'received_quantity' => $item->received_quantity ?? 0, + 'remaining' => $remaining, + 'unit_price' => $item->unit_price, + ]; + })->filter(fn($item) => $item['remaining'] > 0)->values(), + ]; + })->filter(fn($po) => $po['items']->count() > 0)->values(); + + $vendors = $this->procurementService->getAllVendors(); + + // Manual Hydration for Vendor + $vendor = null; + if ($goodsReceipt->vendor_id) { + $vendor = $this->procurementService->getVendorsByIds([$goodsReceipt->vendor_id])->first(); + } + + // 格式化 Purchase Order 給前端顯示 + $formattedPO = null; + if ($po) { + $formattedPO = [ + 'id' => $po->id, + 'code' => $po->code, + 'status' => $po->status, + 'vendor_id' => $po->vendor_id, + 'vendor_name' => $po->vendor?->name ?? '', + 'warehouse_id' => $po->warehouse_id, + 'order_date' => $po->order_date, + 'items' => $po->items->toArray(), // simplified since we just need items.length for display + ]; + } + + return Inertia::render('Inventory/GoodsReceipt/Create', [ + 'warehouses' => $this->inventoryService->getAllWarehouses(), + 'pendingPurchaseOrders' => $formattedPOs, + 'vendors' => $vendors, + 'receipt' => [ + 'id' => $goodsReceipt->id, + 'code' => $goodsReceipt->code, + 'type' => $goodsReceipt->type, + 'warehouse_id' => $goodsReceipt->warehouse_id, + 'vendor_id' => $goodsReceipt->vendor_id, + 'vendor' => $vendor, + 'purchase_order_id' => $goodsReceipt->purchase_order_id, + 'purchase_order' => $formattedPO, + 'received_date' => \Carbon\Carbon::parse($goodsReceipt->received_date)->format('Y-m-d'), + 'remarks' => $goodsReceipt->remarks, + 'items' => $formattedItems, + ], + ]); + } + + /** + * 更新進貨單 + */ + public function update(Request $request, GoodsReceipt $goodsReceipt) + { + $validated = $request->validate($this->getItemValidationRules()); + + try { + $this->goodsReceiptService->update($goodsReceipt, $request->all()); + return redirect()->route('goods-receipts.show', $goodsReceipt->id)->with('success', '進貨單已更新'); + } catch (\Exception $e) { + return back()->with('error', $e->getMessage()); + } + } + + /** + * 取得品項驗證規則 + */ + private function getItemValidationRules(): array + { + return [ 'warehouse_id' => 'required|exists:warehouses,id', 'type' => 'required|in:standard,miscellaneous,other', 'purchase_order_id' => 'nullable|required_if:type,standard|exists:purchase_orders,id', @@ -178,18 +333,12 @@ class GoodsReceiptController extends Controller 'items.*.product_id' => 'required|integer|exists:products,id', 'items.*.purchase_order_item_id' => 'nullable|required_if:type,standard|integer', 'items.*.quantity_received' => 'required|numeric|min:0', - 'items.*.unit_price' => 'required|numeric|min:0', + 'items.*.unit_price' => 'nullable|numeric|min:0', + 'items.*.subtotal' => 'nullable|numeric|min:0', 'items.*.batch_number' => 'nullable|string', 'items.*.expiry_date' => 'nullable|date', 'force' => 'nullable|boolean', - ]); - - try { - $this->goodsReceiptService->store($request->all()); - return redirect()->route('goods-receipts.index')->with('success', '進貨草稿已建立'); - } catch (\Exception $e) { - return back()->with('error', $e->getMessage()); - } + ]; } /** @@ -229,6 +378,7 @@ class GoodsReceiptController extends Controller } // API to search Products for Manual Entry + // 支援 query='*' 回傳所有商品(用於 SearchableSelect 下拉選單) public function searchProducts(Request $request) { $search = $request->input('query'); @@ -236,7 +386,12 @@ class GoodsReceiptController extends Controller return response()->json([]); } - $products = $this->inventoryService->getProductsByName($search); + // 萬用字元:回傳所有商品 + if ($search === '*') { + $products = $this->inventoryService->getProductsByName(''); + } else { + $products = $this->inventoryService->getProductsByName($search); + } // Format for frontend $mapped = $products->map(function($product) { @@ -244,8 +399,8 @@ class GoodsReceiptController extends Controller 'id' => $product->id, 'name' => $product->name, 'code' => $product->code, - 'unit' => $product->baseUnit?->name ?? '個', // Ensure unit is included - 'price' => $product->purchase_price ?? 0, // Suggest price from product info if available + 'unit' => $product->baseUnit?->name ?? '個', + 'price' => $product->purchase_price ?? 0, ]; }); diff --git a/app/Modules/Inventory/Routes/web.php b/app/Modules/Inventory/Routes/web.php index 5b7f953..3979c41 100644 --- a/app/Modules/Inventory/Routes/web.php +++ b/app/Modules/Inventory/Routes/web.php @@ -184,6 +184,8 @@ Route::middleware('auth')->group(function () { Route::get('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'index'])->name('goods-receipts.index'); Route::get('/goods-receipts/create', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'create'])->middleware('permission:goods_receipts.create')->name('goods-receipts.create'); Route::get('/goods-receipts/{goods_receipt}', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'show'])->name('goods-receipts.show'); + Route::get('/goods-receipts/{goods_receipt}/edit', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'edit'])->middleware('permission:goods_receipts.edit')->name('goods-receipts.edit'); + Route::put('/goods-receipts/{goods_receipt}', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'update'])->middleware('permission:goods_receipts.edit')->name('goods-receipts.update'); Route::post('/goods-receipts/check-duplicate', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'checkDuplicate']) ->middleware('permission:goods_receipts.create') ->name('goods-receipts.check-duplicate'); diff --git a/app/Modules/Inventory/Services/GoodsReceiptService.php b/app/Modules/Inventory/Services/GoodsReceiptService.php index bd52690..adf57bd 100644 --- a/app/Modules/Inventory/Services/GoodsReceiptService.php +++ b/app/Modules/Inventory/Services/GoodsReceiptService.php @@ -48,13 +48,18 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei $products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id'); foreach ($data['items'] as $itemData) { + // 非標準類型:使用手動輸入的小計;標準類型:自動計算 + $totalAmount = !empty($itemData['subtotal']) && $data['type'] !== 'standard' + ? (float) $itemData['subtotal'] + : $itemData['quantity_received'] * $itemData['unit_price']; + // Create GR Item $grItem = new GoodsReceiptItem([ 'product_id' => $itemData['product_id'], 'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null, 'quantity_received' => $itemData['quantity_received'], 'unit_price' => $itemData['unit_price'], - 'total_amount' => $itemData['quantity_received'] * $itemData['unit_price'], + 'total_amount' => $totalAmount, 'batch_number' => $itemData['batch_number'] ?? null, 'expiry_date' => $itemData['expiry_date'] ?? null, ]); @@ -66,7 +71,7 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei 'new' => [ 'quantity_received' => (float)$itemData['quantity_received'], 'unit_price' => (float)$itemData['unit_price'], - 'total_amount' => (float)($itemData['quantity_received'] * $itemData['unit_price']), + 'total_amount' => (float)$totalAmount, ] ]; } @@ -142,12 +147,17 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei $goodsReceipt->items()->delete(); foreach ($data['items'] as $itemData) { + // 非標準類型:使用手動輸入的小計;標準類型:自動計算 + $totalAmount = !empty($itemData['subtotal']) && $goodsReceipt->type !== 'standard' + ? (float) $itemData['subtotal'] + : $itemData['quantity_received'] * $itemData['unit_price']; + $grItem = new GoodsReceiptItem([ 'product_id' => $itemData['product_id'], 'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null, 'quantity_received' => $itemData['quantity_received'], 'unit_price' => $itemData['unit_price'], - 'total_amount' => $itemData['quantity_received'] * $itemData['unit_price'], + 'total_amount' => $totalAmount, 'batch_number' => $itemData['batch_number'] ?? null, 'expiry_date' => $itemData['expiry_date'] ?? null, ]); diff --git a/resources/js/Components/Inventory/GoodsReceiptActions.tsx b/resources/js/Components/Inventory/GoodsReceiptActions.tsx index 6b1ff7d..6ad8d0d 100644 --- a/resources/js/Components/Inventory/GoodsReceiptActions.tsx +++ b/resources/js/Components/Inventory/GoodsReceiptActions.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Eye, Trash2 } from "lucide-react"; +import { Eye, Pencil, Trash2 } from "lucide-react"; import { Button } from "@/Components/ui/button"; import { Link, useForm } from "@inertiajs/react"; import { toast } from "sonner"; @@ -62,6 +62,22 @@ export default function GoodsReceiptActions({ + {/* 草稿或退回狀態才可編輯 */} + {(receipt.status === 'draft' || receipt.status === 'rejected') && ( + + + + + + )} + {/* 只允許刪除草稿或已退回的進貨單 */} {(receipt.status === 'draft' || receipt.status === 'rejected') && ( diff --git a/resources/js/Pages/Inventory/GoodsReceipt/Create.tsx b/resources/js/Pages/Inventory/GoodsReceipt/Create.tsx index a048264..4df37bf 100644 --- a/resources/js/Pages/Inventory/GoodsReceipt/Create.tsx +++ b/resources/js/Pages/Inventory/GoodsReceipt/Create.tsx @@ -1,5 +1,5 @@ import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; -import { Head, useForm, Link } from '@inertiajs/react'; +import { Head, useForm, Link, router } from '@inertiajs/react'; import { Button } from '@/Components/ui/button'; import { Input } from '@/Components/ui/input'; import { Label } from '@/Components/ui/label'; @@ -25,7 +25,7 @@ import { StatusBadge } from "@/Components/shared/StatusBadge"; import { - Search, + Plus, Trash2, Calendar as CalendarIcon, Save, @@ -52,7 +52,7 @@ interface PendingPOItem { received_quantity: number; remaining: number; unit_price: number; - batchMode?: 'existing' | 'new'; + batchMode?: 'existing' | 'new' | 'none'; originCountry?: string; // For new batch generation } @@ -75,52 +75,92 @@ interface Vendor { code: string; } +// 編輯模式的進貨單資料 +interface ReceiptData { + id: number; + code: string; + type: string; + warehouse_id: number; + vendor_id: number | null; + vendor: Vendor | null; + purchase_order_id: number | null; + purchase_order?: any; + received_date: string; + remarks: string; + items: any[]; +} + interface Props { warehouses: { id: number; name: string; type: string }[]; pendingPurchaseOrders: PendingPO[]; vendors: Vendor[]; + receipt?: ReceiptData; } -export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, vendors }: Props) { +export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, vendors, receipt }: Props) { + const isEditMode = !!receipt; const [selectedPO, setSelectedPO] = useState(null); const [selectedVendor, setSelectedVendor] = useState(null); - const [isSearching, setIsSearching] = useState(false); - // Manual Product Search States - const [productSearch, setProductSearch] = useState(''); - const [foundProducts, setFoundProducts] = useState([]); + + // 全商品清單(用於雜項入庫/其他類型的 SearchableSelect) + const [allProducts, setAllProducts] = useState([]); + const [isLoadingProducts, setIsLoadingProducts] = useState(false); // Duplicate Check States const [warningOpen, setWarningOpen] = useState(false); const [warnings, setWarnings] = useState([]); const [isCheckingDuplicate, setIsCheckingDuplicate] = useState(false); - const { data, setData, post, processing, errors } = useForm({ - type: 'standard', // 'standard', 'miscellaneous', 'other' - warehouse_id: '', - purchase_order_id: '', - vendor_id: '', - received_date: new Date().toISOString().split('T')[0], - remarks: '', - items: [] as any[], + const { data, setData, processing, errors } = useForm({ + type: receipt?.type || 'standard', + warehouse_id: receipt?.warehouse_id?.toString() || '', + purchase_order_id: receipt?.purchase_order_id?.toString() || '', + vendor_id: receipt?.vendor_id?.toString() || '', + received_date: receipt?.received_date || new Date().toISOString().split('T')[0], + remarks: receipt?.remarks || '', + items: (receipt?.items || []) as any[], }); - // 搜尋商品 API(用於雜項入庫/其他類型) - const searchProducts = async () => { - if (!productSearch) return; - setIsSearching(true); + // 編輯模式下初始化 vendor 與 PO 狀態 + useEffect(() => { + if (isEditMode) { + if (receipt?.vendor) { + setSelectedVendor(receipt.vendor); + } + if (receipt?.purchase_order) { + setSelectedPO(receipt.purchase_order); + } + } + }, []); + + // 判斷是否為非標準採購類型 + const isNonStandard = data.type !== 'standard'; + + + + // 載入所有商品(用於雜項入庫/其他類型的 SearchableSelect) + const fetchAllProducts = async () => { + setIsLoadingProducts(true); try { const response = await axios.get(route('goods-receipts.search-products'), { - params: { query: productSearch }, + params: { query: '*' }, }); - setFoundProducts(response.data); + setAllProducts(response.data); } catch (error) { - console.error('Failed to search products', error); + console.error('Failed to load products', error); } finally { - setIsSearching(false); + setIsLoadingProducts(false); } }; + // 當選擇非標準類型且已選供應商時,載入所有商品 + useEffect(() => { + if (isNonStandard && selectedVendor) { + fetchAllProducts(); + } + }, [isNonStandard, selectedVendor]); + // 選擇採購單 const handleSelectPO = (po: PendingPO) => { setSelectedPO(po); @@ -159,21 +199,37 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, } }; - const handleAddProduct = (product: any) => { + + const handleAddEmptyItem = () => { const newItem = { - product_id: product.id, - product_name: product.name, - product_code: product.code, + product_id: '', + product_name: '', + product_code: '', quantity_received: 0, - unit_price: product.price || 0, + unit_price: 0, + subtotal: 0, batch_number: '', batchMode: 'new', originCountry: 'TW', expiry_date: '', }; setData('items', [...data.items, newItem]); - setFoundProducts([]); - setProductSearch(''); + }; + + // 選擇商品後填入該列(用於空白列的 SearchableSelect) + const handleSelectProduct = (index: number, productId: string) => { + const product = allProducts.find(p => p.id.toString() === productId); + if (product) { + const newItems = [...data.items]; + newItems[index] = { + ...newItems[index], + product_id: product.id, + product_name: product.name, + product_code: product.code, + unit_price: product.price || 0, + }; + setData('items', newItems); + } }; const removeItem = (index: number) => { @@ -184,7 +240,26 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, const updateItem = (index: number, field: string, value: any) => { const newItems = [...data.items]; - newItems[index] = { ...newItems[index], [field]: value }; + const item = { ...newItems[index], [field]: value }; + + const qty = parseFloat(item.quantity_received) || 0; + const price = parseFloat(item.unit_price) || 0; + const subtotal = parseFloat(item.subtotal) || 0; + + if (field === 'quantity_received') { + // 修改數量 -> 更新小計 (保持單價) + item.subtotal = (qty * price).toString(); + } else if (field === 'unit_price') { + // 修改單價 -> 更新小計 (價格 * 數量) + item.subtotal = (qty * price).toString(); + } else if (field === 'subtotal') { + // 修改小計 -> 更新單價 (小計 / 數量) + if (qty > 0) { + item.unit_price = (subtotal / qty).toString(); + } + } + + newItems[index] = item; setData('items', newItems); }; @@ -261,23 +336,23 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, useEffect(() => { data.items.forEach((item, index) => { - if (item.batchMode === 'new' && item.originCountry && data.received_date) { + if (item.batchMode === 'none') { + if (item.batch_number !== 'NO-BATCH') { + const newItems = [...data.items]; + newItems[index].batch_number = 'NO-BATCH'; + setData('items', newItems); + } + } else if (item.batchMode === 'new' && item.originCountry && data.received_date) { const country = item.originCountry; // Use date from form or today const dateStr = data.received_date || new Date().toISOString().split('T')[0]; const seqKey = `${item.product_id}-${country}-${dateStr}`; const seq = nextSequences[seqKey]?.toString().padStart(3, '0') || '001'; - // Only generate if we have a sequence (or default) - // Note: fetch might not have returned yet, so seq might be default 001 until fetch updates nextSequences - const datePart = dateStr.replace(/-/g, ''); const generatedBatch = `${item.product_code}-${country}-${datePart}-${seq}`; if (item.batch_number !== generatedBatch) { - // Update WITHOUT triggering re-render loop - // Need a way to update item silently or check condition carefully - // Using setBatchNumber might trigger this effect again but value will be same. const newItems = [...data.items]; newItems[index].batch_number = generatedBatch; setData('items', newItems); @@ -289,25 +364,54 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, const submit = async (e: React.FormEvent, force: boolean = false) => { if (e) e.preventDefault(); - // 如果不是強制提交,先檢查重複 + // 格式化日期數據 + const formattedItems = data.items.map(item => ({ + ...item, + expiry_date: item.expiry_date && item.expiry_date.includes('T') + ? item.expiry_date.split('T')[0] + : item.expiry_date + })); + + const formattedDate = data.received_date.includes('T') + ? data.received_date.split('T')[0] + : data.received_date; + + // 建立一個臨時的提交資料對象 + const submitData = { + ...data, + received_date: formattedDate, + items: formattedItems + }; + + // 編輯模式直接 PUT 更新 + if (isEditMode) { + // 使用 router.put 因為 useForm 的 put 不支援傳入自定義 data 物件而不影響 state + // 或者先 setData 再 put,但 setData 是非同步的 + // 這裡採用在提交前手動傳遞格式化後的資料給 router + router.put(route('goods-receipts.update', receipt!.id), submitData, { + onSuccess: () => setWarningOpen(false), + }); + return; + } + + // 新增模式:先檢查重複 if (!force) { setIsCheckingDuplicate(true); try { - const response = await axios.post(route('goods-receipts.check-duplicate'), data); + const response = await axios.post(route('goods-receipts.check-duplicate'), submitData); if (response.data.has_warnings) { setWarnings(response.data.warnings); setWarningOpen(true); - return; // 停止並顯示警告 + return; } } catch (error) { console.error("Duplicate check failed", error); - // 檢查失敗則繼續,或視為阻擋?這裡選擇繼續 } finally { setIsCheckingDuplicate(false); } } - post(route('goods-receipts.store'), { + router.post(route('goods-receipts.store'), submitData, { onSuccess: () => setWarningOpen(false), }); }; @@ -319,28 +423,33 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, breadcrumbs={[ { label: '供應鏈管理', href: '#' }, { label: '進貨單管理', href: route('goods-receipts.index') }, - { label: '新增進貨單', href: route('goods-receipts.create'), isPage: true }, + { label: isEditMode ? `編輯進貨單: ${receipt!.code}` : '新增進貨單', href: isEditMode ? route('goods-receipts.edit', receipt!.id) : route('goods-receipts.create'), isPage: true }, ]} > - +
{/* Header */}
-

- 新增進貨單 + {isEditMode ? `編輯進貨單: ${receipt!.code}` : '新增進貨單'}

- 建立新的進貨單並入庫 + {isEditMode ? '修改進貨單內容' : '建立新的進貨單並入庫'}

@@ -358,6 +467,7 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
- + {!isEditMode && ( + + )} ) ) : ( @@ -497,9 +610,11 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, {selectedVendor.code} - + {!isEditMode && ( + + )} ) )} @@ -507,7 +622,7 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, {/* Step 2: Details & Items */} - {((data.type === 'standard' && selectedPO) || (data.type !== 'standard' && selectedVendor)) && ( + {(isEditMode || (data.type === 'standard' && selectedPO) || (data.type !== 'standard' && selectedVendor)) && (
2
@@ -560,42 +675,24 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,

商品明細

- {data.type !== 'standard' && ( -
-
- - setProductSearch(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && searchProducts()} - className="h-9 w-64 pl-9" - /> - {foundProducts.length > 0 && ( -
- {foundProducts.map(p => ( - - ))} -
- )} -
- -
+ {isNonStandard && ( + )}
{/* Calculated Totals for usage in Table Footer or Summary */} {(() => { const subTotal = data.items.reduce((acc, item) => { + if (isNonStandard) { + // 非標準類型:使用手動輸入的小計 + return acc + (parseFloat(item.subtotal) || 0); + } + // 標準類型:自動計算 qty × price const qty = parseFloat(item.quantity_received) || 0; const price = parseFloat(item.unit_price) || 0; return acc + (qty * price); @@ -609,21 +706,28 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, - 商品資訊 - 總數量 - 待收貨 - 本次收貨 * + 商品資訊 + {!isNonStandard && ( + <> + 總數量 + 待收貨 + + )} + {isNonStandard ? '數量' : '本次收貨'} * + {isNonStandard && ( + 單價 + )} 批號設定 * 效期 - 小計 + 小計{isNonStandard && <> *} {data.items.length === 0 ? ( - - 尚無明細,請搜尋商品加入。 + + {isNonStandard ? '尚無明細,請點擊「新增品項」加入。' : '尚無明細,請搜尋商品加入。'} ) : ( @@ -635,25 +739,41 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, {/* Product Info */} -
- {item.product_name} - {item.product_code} -
+ {isNonStandard && !item.product_id ? ( + handleSelectProduct(index, val)} + options={allProducts.map(p => ({ + label: `${p.name} (${p.code})`, + value: p.id.toString() + }))} + placeholder={isLoadingProducts ? '載入中...' : '選擇商品...'} + searchPlaceholder="搜尋商品名稱或代碼..." + className="w-full" + /> + ) : ( +
+ {item.product_name} + {item.product_code} +
+ )}
- {/* Total Quantity */} - - - {Math.round(item.quantity_ordered)} - - - - {/* Remaining */} - - - {Math.round(item.quantity_ordered - item.quantity_received_so_far)} - - + {/* Total Quantity & Remaining - 僅標準採購顯示 */} + {!isNonStandard && ( + <> + + + {Math.round(item.quantity_ordered)} + + + + + {Math.round(item.quantity_ordered - item.quantity_received_so_far)} + + + + )} {/* Received Quantity */} @@ -670,19 +790,88 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, )} + {/* Unit Price - 僅非標準類型顯示 */} + {isNonStandard && ( + + updateItem(index, 'unit_price', e.target.value)} + className="w-full text-right" + placeholder="0" + /> + + )} + {/* Batch Settings */} -
- updateItem(index, 'originCountry', e.target.value.toUpperCase().slice(0, 2))} - placeholder="產地" - maxLength={2} - className="w-16 text-center px-1" +
+ {/* 統一批號選擇器 */} + { + if (value === 'new_batch') { + const updatedItem = { + ...item, + batchMode: 'new', + inventory_id: undefined, + originCountry: 'TW', + expiry_date: '', + }; + const newItems = [...data.items]; + newItems[index] = updatedItem; + setData('items', newItems); + } else if (value === 'no_batch') { + const updatedItem = { + ...item, + batchMode: 'none', + batch_number: 'NO-BATCH', + inventory_id: undefined, + originCountry: 'TW', + expiry_date: '', + }; + const newItems = [...data.items]; + newItems[index] = updatedItem; + setData('items', newItems); + } else { + // 選擇現有批號 (如果有快照的話,目前架構下先保留 basic 選項) + updateItem(index, 'batchMode', 'existing'); + updateItem(index, 'inventory_id', value); + } + }} + options={[ + { label: "📦 不使用批號 (自動累加)", value: "no_batch" }, + { label: "+ 建立新批號", value: "new_batch" }, + // 若有現有批號列表可在此擴充,目前進貨單主要處理 new/none + ]} + placeholder="選擇或建立批號" + className="border-gray-200" /> -
- {getBatchPreview(item.product_id, item.product_code, item.originCountry || 'TW', data.received_date)} -
+ + {/* 建立新批號時的附加欄位 */} + {item.batchMode === 'new' && ( +
+ updateItem(index, 'originCountry', e.target.value.toUpperCase().slice(0, 2))} + placeholder="產地" + maxLength={2} + className="w-12 h-8 text-center px-1 text-xs" + /> +
+ {getBatchPreview(item.product_id, item.product_code, item.originCountry || 'TW', data.received_date)} +
+
+ )} + + {/* 不使用批號時的提示 */} + {item.batchMode === 'none' && ( +

+ 系統將自動累計至該商品的通用庫存紀錄 +

+ )}
@@ -701,8 +890,20 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, {/* Subtotal */} - - ${itemTotal.toLocaleString()} + + {isNonStandard ? ( + updateItem(index, 'subtotal', e.target.value)} + className="w-full text-right" + placeholder="0" + /> + ) : ( + ${itemTotal.toLocaleString()} + )} {/* Actions */} @@ -765,10 +966,10 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders, size="lg" className="button-filled-primary px-12 h-14 rounded-xl shadow-lg text-lg font-bold transition-all hover:scale-[1.02] active:scale-[0.98]" onClick={submit} - disabled={processing || isCheckingDuplicate || (data.type === 'standard' ? !selectedPO : !selectedVendor)} + disabled={processing || isCheckingDuplicate || (!isEditMode && (data.type === 'standard' ? !selectedPO : !selectedVendor))} > - {processing || isCheckingDuplicate ? '處理中...' : '確認進貨'} + {processing || isCheckingDuplicate ? '處理中...' : (isEditMode ? '儲存變更' : '確認進貨')}