feat(inventory): 實作進貨單草稿編輯功能、價格雙向連動與批號 UI 優化
All checks were successful
ERP-Deploy-Demo / deploy-demo (push) Successful in 57s
ERP-Deploy-Production / deploy-production (push) Successful in 1m14s

1. 實作進貨單編輯功能,支援草稿資料預填與 PUT 更新。
2. 修復進貨單儲存時 received_date 與 expiry_date 的日期格式錯誤 (Y-m-d)。
3. 實作非標準進貨類型(雜項、其他)的單價與小計雙向連動邏輯。
4. 優化品項批號 UI 為 SearchableSelect 整合模式,支援不使用批號 (NO-BATCH) 與建立新批號,與倉庫管理頁面風格統一。
This commit is contained in:
2026-03-03 16:57:28 +08:00
parent 183583c739
commit f543b98d0f
5 changed files with 527 additions and 143 deletions

View File

@@ -167,7 +167,162 @@ class GoodsReceiptController extends Controller
public function store(Request $request) 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', 'warehouse_id' => 'required|exists:warehouses,id',
'type' => 'required|in:standard,miscellaneous,other', 'type' => 'required|in:standard,miscellaneous,other',
'purchase_order_id' => 'nullable|required_if:type,standard|exists:purchase_orders,id', '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.*.product_id' => 'required|integer|exists:products,id',
'items.*.purchase_order_item_id' => 'nullable|required_if:type,standard|integer', 'items.*.purchase_order_item_id' => 'nullable|required_if:type,standard|integer',
'items.*.quantity_received' => 'required|numeric|min:0', '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.*.batch_number' => 'nullable|string',
'items.*.expiry_date' => 'nullable|date', 'items.*.expiry_date' => 'nullable|date',
'force' => 'nullable|boolean', '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 // API to search Products for Manual Entry
// 支援 query='*' 回傳所有商品(用於 SearchableSelect 下拉選單)
public function searchProducts(Request $request) public function searchProducts(Request $request)
{ {
$search = $request->input('query'); $search = $request->input('query');
@@ -236,7 +386,12 @@ class GoodsReceiptController extends Controller
return response()->json([]); return response()->json([]);
} }
// 萬用字元:回傳所有商品
if ($search === '*') {
$products = $this->inventoryService->getProductsByName('');
} else {
$products = $this->inventoryService->getProductsByName($search); $products = $this->inventoryService->getProductsByName($search);
}
// Format for frontend // Format for frontend
$mapped = $products->map(function($product) { $mapped = $products->map(function($product) {
@@ -244,8 +399,8 @@ class GoodsReceiptController extends Controller
'id' => $product->id, 'id' => $product->id,
'name' => $product->name, 'name' => $product->name,
'code' => $product->code, 'code' => $product->code,
'unit' => $product->baseUnit?->name ?? '個', // Ensure unit is included 'unit' => $product->baseUnit?->name ?? '個',
'price' => $product->purchase_price ?? 0, // Suggest price from product info if available 'price' => $product->purchase_price ?? 0,
]; ];
}); });

View File

@@ -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', [\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/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}', [\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']) Route::post('/goods-receipts/check-duplicate', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'checkDuplicate'])
->middleware('permission:goods_receipts.create') ->middleware('permission:goods_receipts.create')
->name('goods-receipts.check-duplicate'); ->name('goods-receipts.check-duplicate');

View File

@@ -48,13 +48,18 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id'); $products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
foreach ($data['items'] as $itemData) { foreach ($data['items'] as $itemData) {
// 非標準類型:使用手動輸入的小計;標準類型:自動計算
$totalAmount = !empty($itemData['subtotal']) && $data['type'] !== 'standard'
? (float) $itemData['subtotal']
: $itemData['quantity_received'] * $itemData['unit_price'];
// Create GR Item // Create GR Item
$grItem = new GoodsReceiptItem([ $grItem = new GoodsReceiptItem([
'product_id' => $itemData['product_id'], 'product_id' => $itemData['product_id'],
'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null, 'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null,
'quantity_received' => $itemData['quantity_received'], 'quantity_received' => $itemData['quantity_received'],
'unit_price' => $itemData['unit_price'], 'unit_price' => $itemData['unit_price'],
'total_amount' => $itemData['quantity_received'] * $itemData['unit_price'], 'total_amount' => $totalAmount,
'batch_number' => $itemData['batch_number'] ?? null, 'batch_number' => $itemData['batch_number'] ?? null,
'expiry_date' => $itemData['expiry_date'] ?? null, 'expiry_date' => $itemData['expiry_date'] ?? null,
]); ]);
@@ -66,7 +71,7 @@ class GoodsReceiptService implements \App\Modules\Inventory\Contracts\GoodsRecei
'new' => [ 'new' => [
'quantity_received' => (float)$itemData['quantity_received'], 'quantity_received' => (float)$itemData['quantity_received'],
'unit_price' => (float)$itemData['unit_price'], '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(); $goodsReceipt->items()->delete();
foreach ($data['items'] as $itemData) { foreach ($data['items'] as $itemData) {
// 非標準類型:使用手動輸入的小計;標準類型:自動計算
$totalAmount = !empty($itemData['subtotal']) && $goodsReceipt->type !== 'standard'
? (float) $itemData['subtotal']
: $itemData['quantity_received'] * $itemData['unit_price'];
$grItem = new GoodsReceiptItem([ $grItem = new GoodsReceiptItem([
'product_id' => $itemData['product_id'], 'product_id' => $itemData['product_id'],
'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null, 'purchase_order_item_id' => $itemData['purchase_order_item_id'] ?? null,
'quantity_received' => $itemData['quantity_received'], 'quantity_received' => $itemData['quantity_received'],
'unit_price' => $itemData['unit_price'], 'unit_price' => $itemData['unit_price'],
'total_amount' => $itemData['quantity_received'] * $itemData['unit_price'], 'total_amount' => $totalAmount,
'batch_number' => $itemData['batch_number'] ?? null, 'batch_number' => $itemData['batch_number'] ?? null,
'expiry_date' => $itemData['expiry_date'] ?? null, 'expiry_date' => $itemData['expiry_date'] ?? null,
]); ]);

View File

@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { Eye, Trash2 } from "lucide-react"; import { Eye, Pencil, Trash2 } from "lucide-react";
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
import { Link, useForm } from "@inertiajs/react"; import { Link, useForm } from "@inertiajs/react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -62,6 +62,22 @@ export default function GoodsReceiptActions({
</Button> </Button>
</Link> </Link>
{/* 草稿或退回狀態才可編輯 */}
{(receipt.status === 'draft' || receipt.status === 'rejected') && (
<Can permission="goods_receipts.edit">
<Link href={route('goods-receipts.edit', receipt.id)}>
<Button
variant="outline"
size="sm"
className="button-outlined-primary"
title="編輯"
>
<Pencil className="h-4 w-4" />
</Button>
</Link>
</Can>
)}
{/* 只允許刪除草稿或已退回的進貨單 */} {/* 只允許刪除草稿或已退回的進貨單 */}
{(receipt.status === 'draft' || receipt.status === 'rejected') && ( {(receipt.status === 'draft' || receipt.status === 'rejected') && (
<Can permission="goods_receipts.delete"> <Can permission="goods_receipts.delete">

View File

@@ -1,5 +1,5 @@
import AuthenticatedLayout from '@/Layouts/AuthenticatedLayout'; 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 { Button } from '@/Components/ui/button';
import { Input } from '@/Components/ui/input'; import { Input } from '@/Components/ui/input';
import { Label } from '@/Components/ui/label'; import { Label } from '@/Components/ui/label';
@@ -25,7 +25,7 @@ import { StatusBadge } from "@/Components/shared/StatusBadge";
import { import {
Search, Plus,
Trash2, Trash2,
Calendar as CalendarIcon, Calendar as CalendarIcon,
Save, Save,
@@ -52,7 +52,7 @@ interface PendingPOItem {
received_quantity: number; received_quantity: number;
remaining: number; remaining: number;
unit_price: number; unit_price: number;
batchMode?: 'existing' | 'new'; batchMode?: 'existing' | 'new' | 'none';
originCountry?: string; // For new batch generation originCountry?: string; // For new batch generation
} }
@@ -75,52 +75,92 @@ interface Vendor {
code: string; 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 { interface Props {
warehouses: { id: number; name: string; type: string }[]; warehouses: { id: number; name: string; type: string }[];
pendingPurchaseOrders: PendingPO[]; pendingPurchaseOrders: PendingPO[];
vendors: Vendor[]; 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<PendingPO | null>(null); const [selectedPO, setSelectedPO] = useState<PendingPO | null>(null);
const [selectedVendor, setSelectedVendor] = useState<Vendor | null>(null); const [selectedVendor, setSelectedVendor] = useState<Vendor | null>(null);
const [isSearching, setIsSearching] = useState(false);
// Manual Product Search States
const [productSearch, setProductSearch] = useState(''); // 全商品清單(用於雜項入庫/其他類型的 SearchableSelect
const [foundProducts, setFoundProducts] = useState<any[]>([]); const [allProducts, setAllProducts] = useState<any[]>([]);
const [isLoadingProducts, setIsLoadingProducts] = useState(false);
// Duplicate Check States // Duplicate Check States
const [warningOpen, setWarningOpen] = useState(false); const [warningOpen, setWarningOpen] = useState(false);
const [warnings, setWarnings] = useState<any[]>([]); const [warnings, setWarnings] = useState<any[]>([]);
const [isCheckingDuplicate, setIsCheckingDuplicate] = useState(false); const [isCheckingDuplicate, setIsCheckingDuplicate] = useState(false);
const { data, setData, post, processing, errors } = useForm({ const { data, setData, processing, errors } = useForm({
type: 'standard', // 'standard', 'miscellaneous', 'other' type: receipt?.type || 'standard',
warehouse_id: '', warehouse_id: receipt?.warehouse_id?.toString() || '',
purchase_order_id: '', purchase_order_id: receipt?.purchase_order_id?.toString() || '',
vendor_id: '', vendor_id: receipt?.vendor_id?.toString() || '',
received_date: new Date().toISOString().split('T')[0], received_date: receipt?.received_date || new Date().toISOString().split('T')[0],
remarks: '', remarks: receipt?.remarks || '',
items: [] as any[], items: (receipt?.items || []) as any[],
}); });
// 搜尋商品 API用於雜項入庫/其他類型) // 編輯模式下初始化 vendor 與 PO 狀態
const searchProducts = async () => { useEffect(() => {
if (!productSearch) return; if (isEditMode) {
setIsSearching(true); 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 { try {
const response = await axios.get(route('goods-receipts.search-products'), { const response = await axios.get(route('goods-receipts.search-products'), {
params: { query: productSearch }, params: { query: '*' },
}); });
setFoundProducts(response.data); setAllProducts(response.data);
} catch (error) { } catch (error) {
console.error('Failed to search products', error); console.error('Failed to load products', error);
} finally { } finally {
setIsSearching(false); setIsLoadingProducts(false);
} }
}; };
// 當選擇非標準類型且已選供應商時,載入所有商品
useEffect(() => {
if (isNonStandard && selectedVendor) {
fetchAllProducts();
}
}, [isNonStandard, selectedVendor]);
// 選擇採購單 // 選擇採購單
const handleSelectPO = (po: PendingPO) => { const handleSelectPO = (po: PendingPO) => {
setSelectedPO(po); setSelectedPO(po);
@@ -159,21 +199,37 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
} }
}; };
const handleAddProduct = (product: any) => {
const handleAddEmptyItem = () => {
const newItem = { const newItem = {
product_id: product.id, product_id: '',
product_name: product.name, product_name: '',
product_code: product.code, product_code: '',
quantity_received: 0, quantity_received: 0,
unit_price: product.price || 0, unit_price: 0,
subtotal: 0,
batch_number: '', batch_number: '',
batchMode: 'new', batchMode: 'new',
originCountry: 'TW', originCountry: 'TW',
expiry_date: '', expiry_date: '',
}; };
setData('items', [...data.items, newItem]); 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) => { const removeItem = (index: number) => {
@@ -184,7 +240,26 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
const updateItem = (index: number, field: string, value: any) => { const updateItem = (index: number, field: string, value: any) => {
const newItems = [...data.items]; 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); setData('items', newItems);
}; };
@@ -261,23 +336,23 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
useEffect(() => { useEffect(() => {
data.items.forEach((item, index) => { 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; const country = item.originCountry;
// Use date from form or today // Use date from form or today
const dateStr = data.received_date || new Date().toISOString().split('T')[0]; const dateStr = data.received_date || new Date().toISOString().split('T')[0];
const seqKey = `${item.product_id}-${country}-${dateStr}`; const seqKey = `${item.product_id}-${country}-${dateStr}`;
const seq = nextSequences[seqKey]?.toString().padStart(3, '0') || '001'; 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 datePart = dateStr.replace(/-/g, '');
const generatedBatch = `${item.product_code}-${country}-${datePart}-${seq}`; const generatedBatch = `${item.product_code}-${country}-${datePart}-${seq}`;
if (item.batch_number !== generatedBatch) { 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]; const newItems = [...data.items];
newItems[index].batch_number = generatedBatch; newItems[index].batch_number = generatedBatch;
setData('items', newItems); setData('items', newItems);
@@ -289,25 +364,54 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
const submit = async (e: React.FormEvent, force: boolean = false) => { const submit = async (e: React.FormEvent, force: boolean = false) => {
if (e) e.preventDefault(); 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) { if (!force) {
setIsCheckingDuplicate(true); setIsCheckingDuplicate(true);
try { 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) { if (response.data.has_warnings) {
setWarnings(response.data.warnings); setWarnings(response.data.warnings);
setWarningOpen(true); setWarningOpen(true);
return; // 停止並顯示警告 return;
} }
} catch (error) { } catch (error) {
console.error("Duplicate check failed", error); console.error("Duplicate check failed", error);
// 檢查失敗則繼續,或視為阻擋?這裡選擇繼續
} finally { } finally {
setIsCheckingDuplicate(false); setIsCheckingDuplicate(false);
} }
} }
post(route('goods-receipts.store'), { router.post(route('goods-receipts.store'), submitData, {
onSuccess: () => setWarningOpen(false), onSuccess: () => setWarningOpen(false),
}); });
}; };
@@ -319,28 +423,33 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
breadcrumbs={[ breadcrumbs={[
{ label: '供應鏈管理', href: '#' }, { label: '供應鏈管理', href: '#' },
{ label: '進貨單管理', href: route('goods-receipts.index') }, { 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 },
]} ]}
> >
<Head title="新增進貨單" /> <Head title={isEditMode ? `編輯進貨單 - ${receipt!.code}` : '新增進貨單'} />
<div className="container mx-auto p-6 max-w-7xl"> <div className="container mx-auto p-6 max-w-7xl">
{/* 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" type="button" className="gap-2 mb-4 w-fit button-outlined-primary"> <Button variant="outline" type="button" className="gap-2 mb-4 w-fit button-outlined-primary" onClick={(e) => {
if (isEditMode) {
e.preventDefault();
window.history.back();
}
}}>
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
{isEditMode ? '返回' : '返回進貨單'}
</Button> </Button>
</Link> </Link>
<div className="mb-4"> <div className="mb-4">
<h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2"> <h1 className="text-2xl font-bold text-grey-0 flex items-center gap-2">
<Package className="h-6 w-6 text-primary-main" /> <Package className="h-6 w-6 text-primary-main" />
{isEditMode ? `編輯進貨單: ${receipt!.code}` : '新增進貨單'}
</h1> </h1>
<p className="text-gray-500 mt-1"> <p className="text-gray-500 mt-1">
{isEditMode ? '修改進貨單內容' : '建立新的進貨單並入庫'}
</p> </p>
</div> </div>
</div> </div>
@@ -358,6 +467,7 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
<button <button
key={t.id} key={t.id}
onClick={() => { onClick={() => {
if (isEditMode) return; // 編輯模式禁止切換類型
setData((prev) => ({ setData((prev) => ({
...prev, ...prev,
type: t.id, type: t.id,
@@ -368,10 +478,11 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
setSelectedPO(null); setSelectedPO(null);
if (t.id !== 'standard') setSelectedVendor(null); if (t.id !== 'standard') setSelectedVendor(null);
}} }}
disabled={isEditMode}
className={`flex-1 p-4 rounded-xl border-2 text-left transition-all ${data.type === t.id className={`flex-1 p-4 rounded-xl border-2 text-left transition-all ${data.type === t.id
? 'border-primary-main bg-primary-main/5' ? 'border-primary-main bg-primary-main/5'
: 'border-gray-100 hover:border-gray-200' : 'border-gray-100 hover:border-gray-200'
}`} } ${isEditMode ? 'cursor-not-allowed opacity-60' : ''}`}
> >
<div className={`font-bold ${data.type === t.id ? 'text-primary-main' : 'text-gray-700'}`}> <div className={`font-bold ${data.type === t.id ? 'text-primary-main' : 'text-gray-700'}`}>
{t.label} {t.label}
@@ -457,9 +568,11 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
<span className="font-bold text-gray-800">{selectedPO.items.length} </span> <span className="font-bold text-gray-800">{selectedPO.items.length} </span>
</div> </div>
</div> </div>
{!isEditMode && (
<Button variant="ghost" size="sm" onClick={() => setSelectedPO(null)} className="text-gray-500 hover:text-red-500"> <Button variant="ghost" size="sm" onClick={() => setSelectedPO(null)} className="text-gray-500 hover:text-red-500">
</Button> </Button>
)}
</div> </div>
) )
) : ( ) : (
@@ -497,9 +610,11 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
<span className="font-bold text-gray-800">{selectedVendor.code}</span> <span className="font-bold text-gray-800">{selectedVendor.code}</span>
</div> </div>
</div> </div>
{!isEditMode && (
<Button variant="ghost" size="sm" onClick={() => setSelectedVendor(null)} className="text-gray-500 hover:text-red-500"> <Button variant="ghost" size="sm" onClick={() => setSelectedVendor(null)} className="text-gray-500 hover:text-red-500">
</Button> </Button>
)}
</div> </div>
) )
)} )}
@@ -507,7 +622,7 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
</div> </div>
{/* Step 2: Details & Items */} {/* Step 2: Details & Items */}
{((data.type === 'standard' && selectedPO) || (data.type !== 'standard' && selectedVendor)) && ( {(isEditMode || (data.type === 'standard' && selectedPO) || (data.type !== 'standard' && selectedVendor)) && (
<div className="bg-white rounded-lg border shadow-sm overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-500"> <div className="bg-white rounded-lg border shadow-sm overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="p-6 bg-gray-50/50 border-b flex items-center gap-3"> <div className="p-6 bg-gray-50/50 border-b flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-primary-main text-white flex items-center justify-center font-bold text-sm shadow-sm">2</div> <div className="w-8 h-8 rounded-full bg-primary-main text-white flex items-center justify-center font-bold text-sm shadow-sm">2</div>
@@ -560,42 +675,24 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="font-bold text-gray-700"></h3> <h3 className="font-bold text-gray-700"></h3>
{data.type !== 'standard' && ( {isNonStandard && (
<div className="flex gap-2 items-center"> <Button
<div className="relative"> onClick={handleAddEmptyItem}
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-gray-400" /> className="button-filled-primary h-10 gap-2"
<Input
placeholder="搜尋商品加入..."
value={productSearch}
onChange={(e) => setProductSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && searchProducts()}
className="h-9 w-64 pl-9"
/>
{foundProducts.length > 0 && (
<div className="absolute top-10 left-0 w-full bg-white border rounded-lg shadow-xl z-50 max-h-60 overflow-y-auto">
{foundProducts.map(p => (
<button
key={p.id}
onClick={() => handleAddProduct(p)}
className="w-full text-left p-3 hover:bg-gray-50 border-b last:border-0 flex flex-col"
> >
<span className="font-bold text-sm">{p.name}</span> <Plus className="h-4 w-4" />
<span className="text-xs text-gray-500">{p.code}</span>
</button>
))}
</div>
)}
</div>
<Button onClick={searchProducts} disabled={isSearching} size="sm" className="button-filled-primary h-9">
</Button> </Button>
</div>
)} )}
</div> </div>
{/* Calculated Totals for usage in Table Footer or Summary */} {/* Calculated Totals for usage in Table Footer or Summary */}
{(() => { {(() => {
const subTotal = data.items.reduce((acc, item) => { 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 qty = parseFloat(item.quantity_received) || 0;
const price = parseFloat(item.unit_price) || 0; const price = parseFloat(item.unit_price) || 0;
return acc + (qty * price); return acc + (qty * price);
@@ -609,21 +706,28 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
<Table> <Table>
<TableHeader className="bg-gray-50/50"> <TableHeader className="bg-gray-50/50">
<TableRow> <TableRow>
<TableHead className="w-[180px]"></TableHead> <TableHead className={isNonStandard ? 'w-[220px]' : 'w-[180px]'}></TableHead>
{!isNonStandard && (
<>
<TableHead className="w-[80px] text-center"></TableHead> <TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[80px] text-center"></TableHead> <TableHead className="w-[80px] text-center"></TableHead>
<TableHead className="w-[120px]"> <span className="text-red-500">*</span></TableHead> </>
)}
<TableHead className="w-[120px]">{isNonStandard ? '數量' : '本次收貨'} <span className="text-red-500">*</span></TableHead>
{isNonStandard && (
<TableHead className="w-[100px]"></TableHead>
)}
<TableHead className="w-[200px]"> <span className="text-red-500">*</span></TableHead> <TableHead className="w-[200px]"> <span className="text-red-500">*</span></TableHead>
<TableHead className="w-[150px]"></TableHead> <TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[80px] text-right"></TableHead> <TableHead className={isNonStandard ? 'w-[120px]' : 'w-[80px] text-right'}>{isNonStandard && <> <span className="text-red-500">*</span></>}</TableHead>
<TableHead className="w-[50px]"></TableHead> <TableHead className="w-[50px]"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{data.items.length === 0 ? ( {data.items.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={8} className="text-center py-8 text-gray-400 italic"> <TableCell colSpan={isNonStandard ? 6 : 8} className="text-center py-8 text-gray-400 italic">
{isNonStandard ? '尚無明細,請點擊「新增品項」加入。' : '尚無明細,請搜尋商品加入。'}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
@@ -635,25 +739,41 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
<TableRow key={index} className="hover:bg-gray-50/50 text-sm"> <TableRow key={index} className="hover:bg-gray-50/50 text-sm">
{/* Product Info */} {/* Product Info */}
<TableCell> <TableCell>
{isNonStandard && !item.product_id ? (
<SearchableSelect
value=""
onValueChange={(val) => handleSelectProduct(index, val)}
options={allProducts.map(p => ({
label: `${p.name} (${p.code})`,
value: p.id.toString()
}))}
placeholder={isLoadingProducts ? '載入中...' : '選擇商品...'}
searchPlaceholder="搜尋商品名稱或代碼..."
className="w-full"
/>
) : (
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium text-gray-900">{item.product_name}</span> <span className="font-medium text-gray-900">{item.product_name}</span>
<span className="text-xs text-gray-500">{item.product_code}</span> <span className="text-xs text-gray-500">{item.product_code}</span>
</div> </div>
)}
</TableCell> </TableCell>
{/* Total Quantity */} {/* Total Quantity & Remaining - 僅標準採購顯示 */}
{!isNonStandard && (
<>
<TableCell className="text-center"> <TableCell className="text-center">
<span className="text-gray-500 text-sm"> <span className="text-gray-500 text-sm">
{Math.round(item.quantity_ordered)} {Math.round(item.quantity_ordered)}
</span> </span>
</TableCell> </TableCell>
{/* Remaining */}
<TableCell className="text-center"> <TableCell className="text-center">
<span className="text-gray-900 font-medium text-sm"> <span className="text-gray-900 font-medium text-sm">
{Math.round(item.quantity_ordered - item.quantity_received_so_far)} {Math.round(item.quantity_ordered - item.quantity_received_so_far)}
</span> </span>
</TableCell> </TableCell>
</>
)}
{/* Received Quantity */} {/* Received Quantity */}
<TableCell> <TableCell>
@@ -670,20 +790,89 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
)} )}
</TableCell> </TableCell>
{/* Unit Price - 僅非標準類型顯示 */}
{isNonStandard && (
<TableCell>
<Input
type="number"
step="any"
min="0"
value={item.unit_price || ''}
onChange={(e) => updateItem(index, 'unit_price', e.target.value)}
className="w-full text-right"
placeholder="0"
/>
</TableCell>
)}
{/* Batch Settings */} {/* Batch Settings */}
<TableCell> <TableCell>
<div className="flex gap-2 items-center"> <div className="flex flex-col gap-2">
{/* 統一批號選擇器 */}
<SearchableSelect
value={item.batchMode === 'none' ? 'no_batch' : (item.batchMode === 'new' ? 'new_batch' : (item.inventory_id || ""))}
onValueChange={(value) => {
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"
/>
{/* 建立新批號時的附加欄位 */}
{item.batchMode === 'new' && (
<div className="flex items-center gap-2">
<Input <Input
value={item.originCountry || 'TW'} value={item.originCountry || 'TW'}
onChange={(e) => updateItem(index, 'originCountry', e.target.value.toUpperCase().slice(0, 2))} onChange={(e) => updateItem(index, 'originCountry', e.target.value.toUpperCase().slice(0, 2))}
placeholder="產地" placeholder="產地"
maxLength={2} maxLength={2}
className="w-16 text-center px-1" className="w-12 h-8 text-center px-1 text-xs"
/> />
<div className="flex-1 text-sm font-mono bg-gray-50 px-3 py-2 rounded text-gray-600 truncate"> <div className="flex-1 text-[10px] font-mono px-2 py-1.5 rounded truncate bg-primary-50 text-primary-main border border-primary-100 flex items-center">
{getBatchPreview(item.product_id, item.product_code, item.originCountry || 'TW', data.received_date)} {getBatchPreview(item.product_id, item.product_code, item.originCountry || 'TW', data.received_date)}
</div> </div>
</div> </div>
)}
{/* 不使用批號時的提示 */}
{item.batchMode === 'none' && (
<p className="text-[10px] text-amber-600 bg-amber-50/50 px-2 py-1 rounded border border-amber-100/50">
</p>
)}
</div>
</TableCell> </TableCell>
{/* Expiry Date */} {/* Expiry Date */}
@@ -701,8 +890,20 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
</TableCell> </TableCell>
{/* Subtotal */} {/* Subtotal */}
<TableCell className="text-right font-medium"> <TableCell className={isNonStandard ? '' : 'text-right font-medium'}>
${itemTotal.toLocaleString()} {isNonStandard ? (
<Input
type="number"
step="any"
min="0"
value={item.subtotal || ''}
onChange={(e) => updateItem(index, 'subtotal', e.target.value)}
className="w-full text-right"
placeholder="0"
/>
) : (
<span>${itemTotal.toLocaleString()}</span>
)}
</TableCell> </TableCell>
{/* Actions */} {/* Actions */}
@@ -765,10 +966,10 @@ export default function GoodsReceiptCreate({ warehouses, pendingPurchaseOrders,
size="lg" 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]" 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} onClick={submit}
disabled={processing || isCheckingDuplicate || (data.type === 'standard' ? !selectedPO : !selectedVendor)} disabled={processing || isCheckingDuplicate || (!isEditMode && (data.type === 'standard' ? !selectedPO : !selectedVendor))}
> >
<Save className="mr-2 h-5 w-5" /> <Save className="mr-2 h-5 w-5" />
{processing || isCheckingDuplicate ? '處理中...' : '確認進貨'} {processing || isCheckingDuplicate ? '處理中...' : (isEditMode ? '儲存變更' : '確認進貨')}
</Button> </Button>
</div> </div>
</div> </div>