feat: 修正庫存與撥補單邏輯並整合文件
All checks were successful
Koori-ERP-Deploy-System / deploy-demo (push) Successful in 53s
Koori-ERP-Deploy-System / deploy-production (push) Has been skipped

1. 修復倉庫統計數據加總與樣式。
2. 修正可用庫存計算邏輯(排除不可銷售倉庫)。
3. 撥補單商品列表加入批號與效期顯示。
4. 修正撥補單儲存邏輯以支援精確批號轉移。
5. 整合 FEATURES.md 至 README.md。
This commit is contained in:
2026-01-26 14:59:24 +08:00
parent b0848a6bb8
commit 106de4e945
81 changed files with 4118 additions and 1023 deletions

View File

@@ -6,18 +6,30 @@ use App\Http\Controllers\Controller;
use App\Modules\Procurement\Models\PurchaseOrder;
use App\Modules\Procurement\Models\Vendor;
use App\Modules\Inventory\Models\Warehouse;
// use App\Modules\Inventory\Models\Warehouse; // REFACTORED: 移除直接依賴
use App\Modules\Inventory\Contracts\InventoryServiceInterface; // NEW: 使用契約
use App\Modules\Core\Contracts\CoreServiceInterface; // NEW: 使用核心服務契約
use Illuminate\Http\Request;
use Inertia\Inertia;
use Illuminate\Support\Facades\DB;
class PurchaseOrderController extends Controller
{
protected $inventoryService;
protected $coreService;
public function __construct(InventoryServiceInterface $inventoryService, CoreServiceInterface $coreService)
{
$this->inventoryService = $inventoryService;
$this->coreService = $coreService;
}
public function index(Request $request)
{
$query = PurchaseOrder::with(['vendor', 'warehouse', 'user']);
// 1. 從關聯中移除 'warehouse''user'
$query = PurchaseOrder::with(['vendor']);
// Search
// 搜尋
if ($request->search) {
$query->where(function($q) use ($request) {
$q->where('code', 'like', "%{$request->search}%")
@@ -27,7 +39,7 @@ class PurchaseOrderController extends Controller
});
}
// Filters
// 篩選
if ($request->status && $request->status !== 'all') {
$query->where('status', $request->status);
}
@@ -36,7 +48,7 @@ class PurchaseOrderController extends Controller
$query->where('warehouse_id', $request->warehouse_id);
}
// Date Range
// 日期範圍
if ($request->date_start) {
$query->whereDate('created_at', '>=', $request->date_start);
}
@@ -45,7 +57,7 @@ class PurchaseOrderController extends Controller
$query->whereDate('created_at', '<=', $request->date_end);
}
// Sorting
// 排序
$sortField = $request->sort_field ?? 'id';
$sortDirection = $request->sort_direction ?? 'desc';
$allowedSortFields = ['id', 'code', 'status', 'total_amount', 'created_at', 'expected_delivery_date'];
@@ -57,36 +69,89 @@ class PurchaseOrderController extends Controller
$perPage = $request->input('per_page', 10);
$orders = $query->paginate($perPage)->withQueryString();
// 2. 手動注入倉庫與使用者資料
$warehouses = $this->inventoryService->getAllWarehouses();
$userIds = $orders->getCollection()->pluck('user_id')->unique()->toArray();
$users = $this->coreService->getUsersByIds($userIds)->keyBy('id');
$orders->getCollection()->transform(function ($order) use ($warehouses, $users) {
// 水和倉庫
$warehouse = $warehouses->firstWhere('id', $order->warehouse_id);
$order->setRelation('warehouse', $warehouse);
// 水和使用者
$user = $users->get($order->user_id);
$order->setRelation('user', $user);
// 轉換為前端期望的格式 (camelCase)
return (object) [
'id' => (string) $order->id,
'poNumber' => $order->code,
'supplierId' => (string) $order->vendor_id,
'supplierName' => $order->vendor?->name ?? 'Unknown',
'expectedDate' => $order->expected_delivery_date?->toISOString(),
'status' => $order->status,
'totalAmount' => (float) $order->total_amount,
'taxAmount' => (float) $order->tax_amount,
'grandTotal' => (float) $order->grand_total,
'createdAt' => $order->created_at->toISOString(),
'createdBy' => $user?->name ?? 'System',
'warehouse_id' => (int) $order->warehouse_id,
'warehouse_name' => $warehouse?->name ?? 'Unknown',
'remark' => $order->remark,
];
});
return Inertia::render('PurchaseOrder/Index', [
'orders' => $orders,
'filters' => $request->only(['search', 'status', 'warehouse_id', 'date_start', 'date_end', 'sort_field', 'sort_direction', 'per_page']),
'warehouses' => Warehouse::all(['id', 'name']),
'warehouses' => $warehouses->map(fn($w)=>(object)['id'=>$w->id, 'name'=>$w->name]),
]);
}
public function create()
{
$vendors = Vendor::with(['products.baseUnit', 'products.largeUnit', 'products.purchaseUnit'])->get()->map(function ($vendor) {
// 1. 獲取廠商(無關聯)
$vendors = Vendor::all();
// 2. 手動注入:獲取 Pivot 資料
$vendorIds = $vendors->pluck('id')->toArray();
$pivots = DB::table('product_vendor')->whereIn('vendor_id', $vendorIds)->get();
$productIds = $pivots->pluck('product_id')->unique()->toArray();
// 3. 從服務獲取商品
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
// 4. 重建前端結構
$vendors = $vendors->map(function ($vendor) use ($pivots, $products) {
$vendorProductPivots = $pivots->where('vendor_id', $vendor->id);
$commonProducts = $vendorProductPivots->map(function($pivot) use ($products) {
$product = $products[$pivot->product_id] ?? null;
if (!$product) return null;
return [
'productId' => (string) $product->id,
'productName' => $product->name,
'base_unit_id' => $product->base_unit_id,
'base_unit_name' => $product->baseUnit?->name,
'large_unit_id' => $product->large_unit_id,
'large_unit_name' => $product->largeUnit?->name,
'purchase_unit_id' => $product->purchase_unit_id,
'conversion_rate' => (float) $product->conversion_rate,
'lastPrice' => (float) $pivot->last_price,
];
})->filter()->values();
return [
'id' => (string) $vendor->id,
'name' => $vendor->name,
'commonProducts' => $vendor->products->map(function ($product) {
return [
'productId' => (string) $product->id,
'productName' => $product->name,
'base_unit_id' => $product->base_unit_id,
'base_unit_name' => $product->baseUnit?->name,
'large_unit_id' => $product->large_unit_id,
'large_unit_name' => $product->largeUnit?->name,
'purchase_unit_id' => $product->purchase_unit_id,
'conversion_rate' => (float) $product->conversion_rate,
'lastPrice' => (float) ($product->pivot->last_price ?? 0),
];
})
'commonProducts' => $commonProducts
];
});
$warehouses = Warehouse::all()->map(function ($w) {
$warehouses = $this->inventoryService->getAllWarehouses()->map(function ($w) {
return [
'id' => (string) $w->id,
'name' => $w->name,
@@ -141,7 +206,7 @@ class PurchaseOrderController extends Controller
$totalAmount += $item['subtotal'];
}
// Tax calculation
// 稅額計算
$taxAmount = isset($validated['tax_amount']) ? $validated['tax_amount'] : round($totalAmount * 0.05, 2);
$grandTotal = $totalAmount + $taxAmount;
@@ -200,120 +265,148 @@ class PurchaseOrderController extends Controller
public function show($id)
{
$order = PurchaseOrder::with(['vendor', 'warehouse', 'user', 'items.product.baseUnit', 'items.product.largeUnit'])->findOrFail($id);
$order = PurchaseOrder::with(['vendor', 'items'])->findOrFail($id);
$order->items->transform(function ($item) use ($order) {
$product = $item->product;
if ($product) {
// 手動附加所有必要的屬性
$item->productId = (string) $product->id;
$item->productName = $product->name;
$item->base_unit_id = $product->base_unit_id;
$item->base_unit_name = $product->baseUnit?->name;
$item->large_unit_id = $product->large_unit_id;
$item->large_unit_name = $product->largeUnit?->name;
$item->purchase_unit_id = $product->purchase_unit_id;
$item->conversion_rate = (float) $product->conversion_rate;
// Fetch last price
$lastPrice = DB::table('product_vendor')
->where('vendor_id', $order->vendor_id)
->where('product_id', $product->id)
->value('last_price');
$item->previousPrice = (float) ($lastPrice ?? 0);
// 手動注入
$order->setRelation('warehouse', $this->inventoryService->getWarehouse($order->warehouse_id));
$order->setRelation('user', $this->coreService->getUser($order->user_id));
// 設定當前選中的單位 ID (from saved item)
$item->unitId = $item->unit_id;
$productIds = $order->items->pluck('product_id')->unique()->toArray();
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
// 決定 selectedUnit (用於 UI 顯示)
if ($item->unitId && $item->large_unit_id && $item->unitId == $item->large_unit_id) {
$item->selectedUnit = 'large';
} else {
$item->selectedUnit = 'base';
}
$item->unitPrice = (float) $item->unit_price;
}
return $item;
$formattedItems = $order->items->map(function ($item) use ($order, $products) {
$product = $products[$item->product_id] ?? null;
return (object) [
'productId' => (string) $item->product_id,
'productName' => $product?->name ?? 'Unknown',
'quantity' => (float) $item->quantity,
'unitId' => $item->unit_id,
'base_unit_id' => $product?->base_unit_id,
'base_unit_name' => $product?->baseUnit?->name,
'large_unit_id' => $product?->large_unit_id,
'large_unit_name' => $product?->largeUnit?->name,
'purchase_unit_id' => $product?->purchase_unit_id,
'conversion_rate' => (float) ($product?->conversion_rate ?? 1),
'selectedUnit' => ($item->unit_id && $product?->large_unit_id && $item->unit_id == $product->large_unit_id) ? 'large' : 'base',
'unitPrice' => (float) $item->unit_price,
'previousPrice' => (float) (DB::table('product_vendor')->where('vendor_id', $order->vendor_id)->where('product_id', $item->product_id)->value('last_price') ?? 0),
'subtotal' => (float) $item->subtotal,
];
});
$formattedOrder = (object) [
'id' => (string) $order->id,
'poNumber' => $order->code,
'supplierId' => (string) $order->vendor_id,
'supplierName' => $order->vendor?->name ?? 'Unknown',
'expectedDate' => $order->expected_delivery_date?->toISOString(),
'status' => $order->status,
'items' => $formattedItems,
'totalAmount' => (float) $order->total_amount,
'taxAmount' => (float) $order->tax_amount,
'grandTotal' => (float) $order->grand_total,
'createdAt' => $order->created_at->toISOString(),
'createdBy' => $order->user?->name ?? 'System',
'warehouse_id' => (int) $order->warehouse_id,
'warehouse_name' => $order->warehouse?->name ?? 'Unknown',
'remark' => $order->remark,
'invoiceNumber' => $order->invoice_number,
'invoiceDate' => $order->invoice_date,
'invoiceAmount' => (float) $order->invoice_amount,
];
return Inertia::render('PurchaseOrder/Show', [
'order' => $order
'order' => $formattedOrder
]);
}
public function edit($id)
{
$order = PurchaseOrder::with(['items.product'])->findOrFail($id);
// 1. 獲取訂單
$order = PurchaseOrder::with(['items'])->findOrFail($id);
// 2. 獲取廠商與商品(與 create 邏輯一致)
$vendors = Vendor::all();
$vendorIds = $vendors->pluck('id')->toArray();
$pivots = DB::table('product_vendor')->whereIn('vendor_id', $vendorIds)->get();
$productIds = $pivots->pluck('product_id')->unique()->toArray();
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
$vendors = $vendors->map(function ($vendor) use ($pivots, $products) {
$vendorProductPivots = $pivots->where('vendor_id', $vendor->id);
$commonProducts = $vendorProductPivots->map(function($pivot) use ($products) {
$product = $products[$pivot->product_id] ?? null;
if (!$product) return null;
return [
'productId' => (string) $product->id,
'productName' => $product->name,
'base_unit_id' => $product->base_unit_id,
'base_unit_name' => $product->baseUnit?->name,
'large_unit_id' => $product->large_unit_id,
'large_unit_name' => $product->largeUnit?->name,
'purchase_unit_id' => $product->purchase_unit_id,
'conversion_rate' => (float) $product->conversion_rate,
'lastPrice' => (float) $pivot->last_price,
];
})->filter()->values();
$vendors = Vendor::with(['products.baseUnit', 'products.largeUnit', 'products.purchaseUnit'])->get()->map(function ($vendor) {
return [
'id' => (string) $vendor->id,
'name' => $vendor->name,
'commonProducts' => $vendor->products->map(function ($product) {
return [
'productId' => (string) $product->id,
'productName' => $product->name,
'base_unit_id' => $product->base_unit_id,
'base_unit_name' => $product->baseUnit?->name,
'large_unit_id' => $product->large_unit_id,
'large_unit_name' => $product->largeUnit?->name,
'purchase_unit_id' => $product->purchase_unit_id,
'conversion_rate' => (float) $product->conversion_rate,
'lastPrice' => (float) ($product->pivot->last_price ?? 0),
];
})
'commonProducts' => $commonProducts
];
});
$warehouses = Warehouse::all()->map(function ($w) {
// 3. 獲取倉庫
$warehouses = $this->inventoryService->getAllWarehouses()->map(function ($w) {
return [
'id' => (string) $w->id,
'name' => $w->name,
];
});
// Transform items for frontend form
// Transform items for frontend form
// 4. 注入訂單項目特定資料
// 2. 注入訂單項目
$itemProductIds = $order->items->pluck('product_id')->toArray();
$itemProducts = $this->inventoryService->getProductsByIds($itemProductIds)->keyBy('id');
$vendorId = $order->vendor_id;
$order->items->transform(function ($item) use ($vendorId) {
$product = $item->product;
if ($product) {
// 手動附加所有必要的屬性
$item->productId = (string) $product->id;
$item->productName = $product->name;
$item->base_unit_id = $product->base_unit_id;
$item->base_unit_name = $product->baseUnit?->name;
$item->large_unit_id = $product->large_unit_id;
$item->large_unit_name = $product->largeUnit?->name;
$item->conversion_rate = (float) $product->conversion_rate;
// Fetch last price
$lastPrice = DB::table('product_vendor')
->where('vendor_id', $vendorId)
->where('product_id', $product->id)
->value('last_price');
$item->previousPrice = (float) ($lastPrice ?? 0);
// 設定當前選中的單位 ID
$item->unitId = $item->unit_id; // 資料庫中的 unit_id
// 決定 selectedUnit (用於 UI 狀態)
if ($item->unitId && $item->large_unit_id && $item->unitId == $item->large_unit_id) {
$item->selectedUnit = 'large';
} else {
$item->selectedUnit = 'base';
}
$item->unitPrice = (float) $item->unit_price;
}
return $item;
$formattedItems = $order->items->map(function ($item) use ($vendorId, $itemProducts) {
$product = $itemProducts[$item->product_id] ?? null;
return (object) [
'productId' => (string) $item->product_id,
'productName' => $product?->name ?? 'Unknown',
'quantity' => (float) $item->quantity,
'unitId' => $item->unit_id,
'base_unit_id' => $product?->base_unit_id,
'base_unit_name' => $product?->baseUnit?->name,
'large_unit_id' => $product?->large_unit_id,
'large_unit_name' => $product?->largeUnit?->name,
'conversion_rate' => (float) ($product?->conversion_rate ?? 1),
'selectedUnit' => ($item->unit_id && $product?->large_unit_id && $item->unit_id == $product->large_unit_id) ? 'large' : 'base',
'unitPrice' => (float) $item->unit_price,
'previousPrice' => (float) (DB::table('product_vendor')->where('vendor_id', $vendorId)->where('product_id', $item->product_id)->value('last_price') ?? 0),
'subtotal' => (float) $item->subtotal,
];
});
$formattedOrder = (object) [
'id' => (string) $order->id,
'poNumber' => $order->code,
'supplierId' => (string) $order->vendor_id,
'warehouse_id' => (int) $order->warehouse_id,
'expectedDate' => $order->expected_delivery_date?->format('Y-m-d'),
'status' => $order->status,
'items' => $formattedItems,
'remark' => $order->remark,
'invoiceNumber' => $order->invoice_number,
'invoiceDate' => $order->invoice_date,
'invoiceAmount' => (float) $order->invoice_amount,
'taxAmount' => (float) $order->tax_amount,
];
return Inertia::render('PurchaseOrder/Create', [
'order' => $order,
'order' => $formattedOrder,
'suppliers' => $vendors,
'warehouses' => $warehouses,
]);
@@ -337,7 +430,7 @@ class PurchaseOrderController extends Controller
'items.*.quantity' => 'required|numeric|min:0.01',
'items.*.subtotal' => 'required|numeric|min:0', // 總金額
'items.*.unitId' => 'nullable|exists:units,id',
// Allow both tax_amount and taxAmount for compatibility
// 允許 tax_amount taxAmount 以保持相容性
'tax_amount' => 'nullable|numeric|min:0',
'taxAmount' => 'nullable|numeric|min:0',
]);
@@ -350,12 +443,12 @@ class PurchaseOrderController extends Controller
$totalAmount += $item['subtotal'];
}
// Tax calculation (handle both keys)
// 稅額計算(處理兩個鍵)
$inputTax = $validated['tax_amount'] ?? $validated['taxAmount'] ?? null;
$taxAmount = !is_null($inputTax) ? $inputTax : round($totalAmount * 0.05, 2);
$grandTotal = $totalAmount + $taxAmount;
// 1. Fill attributes but don't save yet to capture changes
// 1. 填充屬性但暫不儲存以捕捉變更
$order->fill([
'vendor_id' => $validated['vendor_id'],
'warehouse_id' => $validated['warehouse_id'],
@@ -370,7 +463,7 @@ class PurchaseOrderController extends Controller
'invoice_amount' => $validated['invoice_amount'] ?? null,
]);
// Capture attribute changes for manual logging
// 捕捉變更屬性以進行手動記錄
$dirty = $order->getDirty();
$oldAttributes = [];
$newAttributes = [];
@@ -380,10 +473,10 @@ class PurchaseOrderController extends Controller
$newAttributes[$key] = $value;
}
// Save without triggering events (prevents duplicate log)
// 儲存但不觸發事件(防止重複記錄)
$order->saveQuietly();
// 2. Capture old items with product names for diffing
// 2. 捕捉包含商品名稱的舊項目以進行比對
$oldItems = $order->items()->with('product', 'unit')->get()->map(function($item) {
return [
'id' => $item->id,
@@ -396,7 +489,7 @@ class PurchaseOrderController extends Controller
];
})->keyBy('product_id');
// Sync items (Original logic)
// 同步項目(原始邏輯)
$order->items()->delete();
$newItemsData = [];
@@ -414,14 +507,14 @@ class PurchaseOrderController extends Controller
$newItemsData[] = $newItem;
}
// 3. Calculate Item Diffs
// 3. 計算項目差異
$itemDiffs = [
'added' => [],
'removed' => [],
'updated' => [],
];
// Re-fetch new items to ensure we have fresh relations
// 重新獲取新項目以確保擁有最新的關聯
$newItemsFormatted = $order->items()->with('product', 'unit')->get()->map(function($item) {
return [
'product_id' => $item->product_id,
@@ -433,20 +526,20 @@ class PurchaseOrderController extends Controller
];
})->keyBy('product_id');
// Find removed
// 找出已移除的項目
foreach ($oldItems as $productId => $oldItem) {
if (!$newItemsFormatted->has($productId)) {
$itemDiffs['removed'][] = $oldItem;
}
}
// Find added and updated
// 找出新增和更新的項目
foreach ($newItemsFormatted as $productId => $newItem) {
if (!$oldItems->has($productId)) {
$itemDiffs['added'][] = $newItem;
} else {
$oldItem = $oldItems[$productId];
// Compare fields
// 比對欄位
if (
$oldItem['quantity'] != $newItem['quantity'] ||
$oldItem['unit_id'] != $newItem['unit_id'] ||
@@ -469,8 +562,8 @@ class PurchaseOrderController extends Controller
}
}
// 4. Manually Log activity (Single Consolidated Log)
// Log if there are attribute changes OR item changes
// 4. 手動記錄活動(單一整合記錄)
// 如果有屬性變更或項目變更則記錄
if (!empty($newAttributes) || !empty($itemDiffs['added']) || !empty($itemDiffs['removed']) || !empty($itemDiffs['updated'])) {
activity()
->performedOn($order)
@@ -505,19 +598,24 @@ class PurchaseOrderController extends Controller
try {
DB::beginTransaction();
$order = PurchaseOrder::with(['items.product', 'items.unit'])->findOrFail($id);
$order = PurchaseOrder::with(['items'])->findOrFail($id);
// Capture items for logging
$items = $order->items->map(function ($item) {
// 為記錄注入資料
$productIds = $order->items->pluck('product_id')->unique()->toArray();
$products = $this->inventoryService->getProductsByIds($productIds)->keyBy('id');
// 捕捉項目以進行記錄
$items = $order->items->map(function ($item) use ($products) {
$product = $products[$item->product_id] ?? null;
return [
'product_name' => $item->product_name,
'product_name' => $product?->name ?? 'Unknown',
'quantity' => floatval($item->quantity),
'unit_name' => $item->unit_name,
'unit_name' => 'N/A',
'subtotal' => floatval($item->subtotal),
];
})->toArray();
// Manually log the deletion with items
// 手動記錄包含項目的刪除操作
activity()
->performedOn($order)
->causedBy(auth()->user())
@@ -538,10 +636,10 @@ class PurchaseOrderController extends Controller
])
->log('deleted');
// Disable automatic logging for this operation
// 對此操作停用自動記錄
$order->disableLogging();
// Delete associated items first
// 先刪除關聯項目
$order->items()->delete();
$order->delete();