feat(inventory): 實作進貨單草稿編輯功能、價格雙向連動與批號 UI 優化
1. 實作進貨單編輯功能,支援草稿資料預填與 PUT 更新。 2. 修復進貨單儲存時 received_date 與 expiry_date 的日期格式錯誤 (Y-m-d)。 3. 實作非標準進貨類型(雜項、其他)的單價與小計雙向連動邏輯。 4. 優化品項批號 UI 為 SearchableSelect 整合模式,支援不使用批號 (NO-BATCH) 與建立新批號,與倉庫管理頁面風格統一。
This commit is contained in:
@@ -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,
|
||||
];
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user