feat: 統一進貨單 UI、修復庫存異動紀錄與廠商詳情顯示報錯
This commit is contained in:
@@ -7,6 +7,7 @@ use App\Modules\Inventory\Services\GoodsReceiptService;
|
||||
use App\Modules\Inventory\Services\InventoryService;
|
||||
use App\Modules\Procurement\Contracts\ProcurementServiceInterface;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Modules\Procurement\Models\Vendor;
|
||||
use Inertia\Inertia;
|
||||
use App\Modules\Inventory\Models\GoodsReceipt;
|
||||
|
||||
@@ -29,58 +30,125 @@ class GoodsReceiptController extends Controller
|
||||
public function index(Request $request)
|
||||
{
|
||||
$query = GoodsReceipt::query()
|
||||
->with(['warehouse']); // Vendor info might need fetching separately or stored as snapshot if cross-module strict
|
||||
->select(['id', 'code', 'type', 'warehouse_id', 'vendor_id', 'received_date', 'status', 'created_at'])
|
||||
->with(['warehouse'])
|
||||
->withSum('items', 'total_amount');
|
||||
|
||||
if ($request->has('search')) {
|
||||
// 關鍵字搜尋(單號)
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->input('search');
|
||||
$query->where('code', 'like', "%{$search}%");
|
||||
}
|
||||
|
||||
// 狀態篩選
|
||||
if ($request->filled('status') && $request->input('status') !== 'all') {
|
||||
$query->where('status', $request->input('status'));
|
||||
}
|
||||
|
||||
// 倉庫篩選
|
||||
if ($request->filled('warehouse_id') && $request->input('warehouse_id') !== 'all') {
|
||||
$query->where('warehouse_id', $request->input('warehouse_id'));
|
||||
}
|
||||
|
||||
// 日期範圍篩選
|
||||
if ($request->filled('date_start')) {
|
||||
$query->whereDate('received_date', '>=', $request->input('date_start'));
|
||||
}
|
||||
if ($request->filled('date_end')) {
|
||||
$query->whereDate('received_date', '<=', $request->input('date_end'));
|
||||
}
|
||||
|
||||
// 每頁筆數
|
||||
$perPage = $request->input('per_page', 10);
|
||||
|
||||
$receipts = $query->orderBy('created_at', 'desc')
|
||||
->paginate(10)
|
||||
->paginate($perPage)
|
||||
->withQueryString();
|
||||
|
||||
// Hydrate Vendor Names (Manual hydration to avoid cross-module relation)
|
||||
// Or if we stored vendor_name in DB, we could use that.
|
||||
// For now, let's fetch vendors via Service if needed, or just let frontend handle it if we passed IDs?
|
||||
// Let's implement hydration properly.
|
||||
$vendorIds = $receipts->pluck('vendor_id')->unique()->toArray();
|
||||
if (!empty($vendorIds)) {
|
||||
// Check if ProcurementService has getVendorsByIds? No directly exposed method in interface yet.
|
||||
// Let's assume we can add it or just fetch POs to get vendors?
|
||||
// Actually, for simplicity and performance in Strict Mode, often we just fetch minimal data.
|
||||
// Or we can use `App\Modules\Procurement\Models\Vendor` directly ONLY for reading if allowed, but strict mode says NO.
|
||||
// But we don't have getVendorsByIds in interface.
|
||||
// User requirement: "從採購單帶入".
|
||||
// Let's just pass IDs for now, or use a method if available.
|
||||
// Wait, I can't modify Interface easily without user approval if it's big change.
|
||||
// But I just added updateReceivedQuantity.
|
||||
// Let's skip vendor name hydration for index for a moment and focus on Create first, or use a direct DB query via a DTO service?
|
||||
// Actually, I can use `DB::table('vendors')` as a workaround if needed, but that's dirty.
|
||||
// Let's revisit Service Interface.
|
||||
}
|
||||
|
||||
// Quick fix: Add `vendor` relation to GoodsReceipt only if we decided to allow it or if we stored snapshot.
|
||||
// Plan said: `vendor_id`: foreignId.
|
||||
// Ideally we should have stored `vendor_name` in `goods_receipts` table for snapshot.
|
||||
// I didn't add it in migration.
|
||||
// Let's rely on `ProcurementServiceInterface` to get vendor info if possible.
|
||||
// I will add a method to get Vendors or POs.
|
||||
// Manual Hydration for Vendors (Cross-Module)
|
||||
$vendorIds = collect($receipts->items())->pluck('vendor_id')->unique()->filter()->toArray();
|
||||
$vendors = $this->procurementService->getVendorsByIds($vendorIds)->keyBy('id');
|
||||
|
||||
$receipts->getCollection()->transform(function ($receipt) use ($vendors) {
|
||||
$receipt->vendor = $vendors->get($receipt->vendor_id);
|
||||
return $receipt;
|
||||
});
|
||||
|
||||
// 取得倉庫列表用於篩選
|
||||
$warehouses = $this->inventoryService->getAllWarehouses();
|
||||
|
||||
return Inertia::render('Inventory/GoodsReceipt/Index', [
|
||||
'receipts' => $receipts,
|
||||
'filters' => $request->only(['search']),
|
||||
'filters' => $request->only(['search', 'status', 'warehouse_id', 'date_start', 'date_end', 'per_page']),
|
||||
'warehouses' => $warehouses,
|
||||
]);
|
||||
}
|
||||
|
||||
public function show($id)
|
||||
{
|
||||
$receipt = GoodsReceipt::with([
|
||||
'warehouse',
|
||||
'items.product.category',
|
||||
'items.product.baseUnit'
|
||||
])->findOrFail($id);
|
||||
|
||||
// Manual Hydration for Vendor (Cross-Module)
|
||||
if ($receipt->vendor_id) {
|
||||
$receipt->vendor = $this->procurementService->getVendorsByIds([$receipt->vendor_id])->first();
|
||||
}
|
||||
|
||||
// 手動計算統計資訊 (如果 Model 沒有定義對應的 Attribute)
|
||||
$receipt->items_sum_total_amount = $receipt->items->sum('total_amount');
|
||||
|
||||
return Inertia::render('Inventory/GoodsReceipt/Show', [
|
||||
'receipt' => $receipt
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
// 取得待進貨的採購單列表(用於標準採購類型選擇)
|
||||
$pendingPOs = $this->procurementService->getPendingPurchaseOrders();
|
||||
|
||||
// 提取所有產品 ID 以便跨模組水和資料
|
||||
$productIds = $pendingPOs->flatMap(fn($po) => $po->items->pluck('product_id'))->unique()->filter()->toArray();
|
||||
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
|
||||
|
||||
// 處理採購單資料,計算剩餘可收貨數量
|
||||
$formattedPOs = $pendingPOs->map(function ($po) use ($products) {
|
||||
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 ($products) {
|
||||
$product = $products->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();
|
||||
|
||||
return Inertia::render('Inventory/GoodsReceipt/Create', [
|
||||
'warehouses' => $this->inventoryService->getAllWarehouses(),
|
||||
// Vendors? We need to select PO, not Vendor directly maybe?
|
||||
// Designing the UI: Select PO -> fills Vendor and Items.
|
||||
// So we need a way to search POs by code or vendor.
|
||||
// We can provide an API for searching POs.
|
||||
'pendingPurchaseOrders' => $formattedPOs,
|
||||
'vendors' => $vendors,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -140,7 +208,7 @@ class GoodsReceiptController extends Controller
|
||||
'id' => $product->id,
|
||||
'name' => $product->name,
|
||||
'code' => $product->code,
|
||||
'unit' => $product->unit, // Ensure unit is included
|
||||
'unit' => $product->baseUnit?->name ?? '個', // Ensure unit is included
|
||||
'price' => $product->purchase_price ?? 0, // Suggest price from product info if available
|
||||
];
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ use Inertia\Inertia;
|
||||
use App\Modules\Inventory\Models\Warehouse;
|
||||
use App\Modules\Inventory\Models\Product;
|
||||
use App\Modules\Inventory\Models\Inventory;
|
||||
use App\Modules\Inventory\Models\InventoryTransaction;
|
||||
use App\Modules\Inventory\Models\WarehouseProductSafetyStock;
|
||||
|
||||
use App\Modules\Core\Contracts\CoreServiceInterface;
|
||||
@@ -482,7 +483,60 @@ class InventoryController extends Controller
|
||||
$productId = $request->query('productId');
|
||||
|
||||
if ($productId) {
|
||||
// ... (略) ...
|
||||
$product = Product::findOrFail($productId);
|
||||
// 取得該倉庫中該商品的所有批號 ID
|
||||
$inventoryIds = Inventory::where('warehouse_id', $warehouse->id)
|
||||
->where('product_id', $productId)
|
||||
->pluck('id')
|
||||
->toArray();
|
||||
|
||||
$transactionsRaw = InventoryTransaction::whereIn('inventory_id', $inventoryIds)
|
||||
->with('inventory') // 需要批號資訊
|
||||
->orderBy('actual_time', 'desc')
|
||||
->orderBy('id', 'desc')
|
||||
->get();
|
||||
|
||||
// 手動 Hydrate 使用者資料
|
||||
$userIds = $transactionsRaw->pluck('user_id')->filter()->unique()->toArray();
|
||||
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
|
||||
|
||||
// 計算商品在該倉庫的總量(不分批號)
|
||||
$currentRunningTotal = (float) Inventory::whereIn('id', $inventoryIds)->sum('quantity');
|
||||
|
||||
$transactions = $transactionsRaw->map(function ($tx) use ($users, &$currentRunningTotal) {
|
||||
$user = $tx->user_id ? ($users[$tx->user_id] ?? null) : null;
|
||||
$balanceAfter = $currentRunningTotal;
|
||||
|
||||
// 為下一筆(較舊的)紀錄更新 Running Total
|
||||
$currentRunningTotal -= (float) $tx->quantity;
|
||||
|
||||
return [
|
||||
'id' => (string) $tx->id,
|
||||
'type' => $tx->type,
|
||||
'quantity' => (float) $tx->quantity,
|
||||
'unit_cost' => (float) $tx->unit_cost,
|
||||
'balanceAfter' => (float) $balanceAfter, // 顯示該商品在倉庫的累計結餘
|
||||
'reason' => $tx->reason,
|
||||
'userName' => $user ? $user->name : '系統',
|
||||
'actualTime' => $tx->actual_time ? $tx->actual_time->format('Y-m-d H:i') : $tx->created_at->format('Y-m-d H:i'),
|
||||
'batchNumber' => $tx->inventory?->batch_number ?? '-', // 補上批號資訊
|
||||
];
|
||||
});
|
||||
|
||||
// 重新計算目前的總量(用於 Header 顯示,確保一致性)
|
||||
$totalQuantity = Inventory::whereIn('id', $inventoryIds)->sum('quantity');
|
||||
|
||||
return Inertia::render('Warehouse/InventoryHistory', [
|
||||
'warehouse' => $warehouse,
|
||||
'inventory' => [
|
||||
'id' => null, // 跨批號查詢沒有單一 ID
|
||||
'productName' => $product->name,
|
||||
'productCode' => $product->code,
|
||||
'batchNumber' => '所有批號',
|
||||
'quantity' => (float) $totalQuantity,
|
||||
],
|
||||
'transactions' => $transactions
|
||||
]);
|
||||
}
|
||||
|
||||
if ($inventoryId) {
|
||||
|
||||
@@ -24,7 +24,7 @@ class GoodsReceipt extends Model
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'received_date' => 'date',
|
||||
'received_date' => 'date:Y-m-d',
|
||||
];
|
||||
|
||||
public function getActivitylogOptions(): \Spatie\Activitylog\LogOptions
|
||||
|
||||
@@ -24,7 +24,7 @@ class GoodsReceiptItem extends Model
|
||||
'quantity_received' => 'decimal:2',
|
||||
'unit_price' => 'decimal:2', // 暫定價格
|
||||
'total_amount' => 'decimal:2',
|
||||
'expiry_date' => 'date',
|
||||
'expiry_date' => 'date:Y-m-d',
|
||||
];
|
||||
|
||||
public function goodsReceipt()
|
||||
|
||||
@@ -82,6 +82,7 @@ Route::middleware('auth')->group(function () {
|
||||
Route::middleware('permission:goods_receipts.view')->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::post('/goods-receipts', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'store'])->middleware('permission:goods_receipts.create')->name('goods-receipts.store');
|
||||
Route::get('/api/goods-receipts/search-pos', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'searchPOs'])->name('goods-receipts.search-pos');
|
||||
Route::get('/api/goods-receipts/search-products', [\App\Modules\Inventory\Controllers\GoodsReceiptController::class, 'searchProducts'])->name('goods-receipts.search-products');
|
||||
|
||||
@@ -17,7 +17,7 @@ class InventoryService implements InventoryServiceInterface
|
||||
|
||||
public function getAllProducts()
|
||||
{
|
||||
return Product::with(['baseUnit'])->get();
|
||||
return Product::with(['baseUnit', 'largeUnit'])->get();
|
||||
}
|
||||
|
||||
public function getUnits()
|
||||
@@ -32,17 +32,17 @@ class InventoryService implements InventoryServiceInterface
|
||||
|
||||
public function getProduct(int $id)
|
||||
{
|
||||
return Product::find($id);
|
||||
return Product::with(['baseUnit', 'largeUnit'])->find($id);
|
||||
}
|
||||
|
||||
public function getProductsByIds(array $ids)
|
||||
{
|
||||
return Product::whereIn('id', $ids)->get();
|
||||
return Product::whereIn('id', $ids)->with(['baseUnit', 'largeUnit'])->get();
|
||||
}
|
||||
|
||||
public function getProductsByName(string $name)
|
||||
{
|
||||
return Product::where('name', 'like', "%{$name}%")->get();
|
||||
return Product::where('name', 'like', "%{$name}%")->with(['baseUnit', 'largeUnit'])->get();
|
||||
}
|
||||
|
||||
public function getWarehouse(int $id)
|
||||
|
||||
@@ -56,4 +56,27 @@ interface ProcurementServiceInterface
|
||||
* @return Collection
|
||||
*/
|
||||
public function searchVendors(string $query): Collection;
|
||||
|
||||
/**
|
||||
* 取得所有待進貨的採購單列表(不需搜尋條件)。
|
||||
* 用於進貨單頁面直接顯示可選擇的採購單。
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getPendingPurchaseOrders(): Collection;
|
||||
|
||||
/**
|
||||
* 取得所有廠商列表。
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getAllVendors(): Collection;
|
||||
|
||||
/**
|
||||
* Get vendors by multiple IDs.
|
||||
*
|
||||
* @param array $ids
|
||||
* @return Collection
|
||||
*/
|
||||
public function getVendorsByIds(array $ids): Collection;
|
||||
}
|
||||
|
||||
@@ -420,7 +420,7 @@ class PurchaseOrderController extends Controller
|
||||
'order_date' => 'required|date', // 新增驗證
|
||||
'expected_delivery_date' => 'nullable|date',
|
||||
'remark' => 'nullable|string',
|
||||
'status' => 'required|string|in:draft,pending,processing,shipping,confirming,completed,cancelled,partial',
|
||||
'status' => 'required|string|in:draft,pending,approved,partial,completed,closed,cancelled',
|
||||
'invoice_number' => ['nullable', 'string', 'max:11', 'regex:/^[A-Z]{2}-\d{8}$/'],
|
||||
'invoice_date' => 'nullable|date',
|
||||
'invoice_amount' => 'nullable|numeric|min:0',
|
||||
|
||||
@@ -95,14 +95,15 @@ class VendorController extends Controller
|
||||
if (!$product) return null;
|
||||
|
||||
return (object) [
|
||||
'id' => (string) $pivot->id,
|
||||
'productId' => (string) $product->id,
|
||||
'productName' => $product->name,
|
||||
'unit' => $product->baseUnit?->name ?? 'N/A',
|
||||
'baseUnit' => $product->baseUnit?->name,
|
||||
'largeUnit' => $product->largeUnit?->name,
|
||||
'conversionRate' => (float) $product->conversion_rate,
|
||||
'lastPrice' => (float) $pivot->last_price,
|
||||
'id' => (string) $product->id, // Frontend expects product ID here as p.id
|
||||
'name' => $product->name,
|
||||
'baseUnit' => $product->baseUnit ? (object)['name' => $product->baseUnit->name] : null,
|
||||
'largeUnit' => $product->largeUnit ? (object)['name' => $product->largeUnit->name] : null,
|
||||
'conversion_rate' => (float) $product->conversion_rate,
|
||||
'purchase_unit' => $product->purchaseUnit?->name,
|
||||
'pivot' => (object) [
|
||||
'last_price' => (float) $pivot->last_price,
|
||||
],
|
||||
];
|
||||
})->filter()->values();
|
||||
|
||||
@@ -119,7 +120,7 @@ class VendorController extends Controller
|
||||
'email' => $vendor->email,
|
||||
'address' => $vendor->address,
|
||||
'remark' => $vendor->remark,
|
||||
'supplyProducts' => $supplyProducts,
|
||||
'products' => $supplyProducts, // Changed from supplyProducts to products
|
||||
];
|
||||
|
||||
return Inertia::render('Vendor/Show', [
|
||||
|
||||
@@ -62,7 +62,7 @@ class ProcurementService implements ProcurementServiceInterface
|
||||
public function searchPendingPurchaseOrders(string $query): Collection
|
||||
{
|
||||
return PurchaseOrder::with(['vendor', 'items'])
|
||||
->whereIn('status', ['processing', 'shipping', 'partial'])
|
||||
->whereIn('status', ['approved', 'partial'])
|
||||
->where(function($q) use ($query) {
|
||||
$q->where('code', 'like', "%{$query}%")
|
||||
->orWhereHas('vendor', function($vq) use ($query) {
|
||||
@@ -80,4 +80,23 @@ class ProcurementService implements ProcurementServiceInterface
|
||||
->limit(20)
|
||||
->get(['id', 'name', 'code']);
|
||||
}
|
||||
|
||||
public function getPendingPurchaseOrders(): Collection
|
||||
{
|
||||
return PurchaseOrder::with(['vendor', 'items'])
|
||||
->whereIn('status', ['approved', 'partial'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->limit(50)
|
||||
->get();
|
||||
}
|
||||
|
||||
public function getAllVendors(): Collection
|
||||
{
|
||||
return \App\Modules\Procurement\Models\Vendor::orderBy('name')->get(['id', 'name', 'code']);
|
||||
}
|
||||
|
||||
public function getVendorsByIds(array $ids): Collection
|
||||
{
|
||||
return \App\Modules\Procurement\Models\Vendor::whereIn('id', $ids)->get(['id', 'name', 'code']);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user