refactor: [performance] N+1 쿼리 3건 배치 조회로 최적화

- WorkOrderService.getMaterials(): 기존 BOM 루프 내 find() x2 제거
  → 루프 전 bomItemsMap/bomChildItemsMap 일괄 사전 로드
- OrderService.createWorkOrderFromOrder(): 루프 내 DB 쿼리 x2 제거
  → item_code→id, process_items 사전 배치 조회
- OrderService.checkBendingStockForOrder(): 루프 내 StockService 호출 제거
  → Stock 배치 조회 후 맵 참조
This commit is contained in:
김보곤
2026-03-14 14:40:04 +09:00
parent 877d15420a
commit 926a7c7da6
2 changed files with 77 additions and 32 deletions

View File

@@ -1236,7 +1236,7 @@ public function createProductionOrder(int $orderId, array $data)
}
}
// item_code → item_id 매핑 구축 (fallback용)
// item_code → item_id 매핑 구축 (fallback용 — N+1 방지를 위해 사전 일괄 조회)
$codeToIdMap = [];
if (! empty($bomItemIds)) {
$codeToIdRows = DB::table('items')
@@ -1250,6 +1250,38 @@ public function createProductionOrder(int $orderId, array $data)
}
}
// order_items의 item_code로 추가 매핑 사전 구축 (루프 내 DB 조회 방지)
$orderItemCodes = $order->items->pluck('item_code')->filter()->unique()->values()->all();
$unmappedCodes = array_diff($orderItemCodes, array_keys($codeToIdMap));
if (! empty($unmappedCodes)) {
$extraRows = DB::table('items')
->where('tenant_id', $tenantId)
->whereIn('code', $unmappedCodes)
->whereNull('deleted_at')
->select('id', 'code')
->get();
foreach ($extraRows as $row) {
$codeToIdMap[$row->code] = $row->id;
}
}
// 사전 매핑된 item_id에 대한 process_items도 일괄 조회
$allResolvedIds = array_values(array_unique(array_merge(
array_keys($itemProcessMap),
array_values($codeToIdMap)
)));
$unmappedProcessIds = array_diff($allResolvedIds, array_keys($itemProcessMap));
if (! empty($unmappedProcessIds)) {
$extraProcessItems = DB::table('process_items')
->whereIn('item_id', $unmappedProcessIds)
->where('is_active', true)
->select('item_id', 'process_id')
->get();
foreach ($extraProcessItems as $pi) {
$itemProcessMap[$pi->item_id] = $pi->process_id;
}
}
// order_items를 공정별로 그룹화 (BOM item_id → process 매핑 활용)
$itemsByProcess = [];
foreach ($order->items as $orderItem) {
@@ -1268,31 +1300,11 @@ public function createProductionOrder(int $orderId, array $data)
}
}
// 3. fallback: item_code로 items 마스터 조회 → process_items 매핑
// 3. fallback: 사전 구축된 맵에서 item_code → process 매핑 (N+1 제거)
if ($processId === null && $orderItem->item_code) {
$resolvedId = $codeToIdMap[$orderItem->item_code] ?? null;
if (! $resolvedId) {
$resolvedId = DB::table('items')
->where('tenant_id', $tenantId)
->where('code', $orderItem->item_code)
->whereNull('deleted_at')
->value('id');
if ($resolvedId) {
$codeToIdMap[$orderItem->item_code] = $resolvedId;
}
}
if ($resolvedId && isset($itemProcessMap[$resolvedId])) {
$processId = $itemProcessMap[$resolvedId];
} elseif ($resolvedId) {
// process_items에서도 조회
$pi = DB::table('process_items')
->where('item_id', $resolvedId)
->where('is_active', true)
->value('process_id');
if ($pi) {
$processId = $pi;
$itemProcessMap[$resolvedId] = $pi;
}
}
}
@@ -1893,16 +1905,22 @@ public function checkBendingStockForOrder(int $orderId): array
return [];
}
$stockService = app(StockService::class);
// 배치 조회로 N+1 방지 (루프 내 개별 Stock 조회 제거)
$bendingItemIds = $bendingItems->pluck('id')->all();
$stocksMap = \App\Models\Tenants\Stock::where('tenant_id', $tenantId)
->whereIn('item_id', $bendingItemIds)
->get()
->keyBy('item_id');
$result = [];
foreach ($bendingItems as $item) {
$neededQty = $itemQtyMap[$item->id];
$stockInfo = $stockService->getAvailableStock($item->id);
$stock = $stocksMap->get($item->id);
$availableQty = $stockInfo ? (float) $stockInfo['available_qty'] : 0;
$reservedQty = $stockInfo ? (float) $stockInfo['reserved_qty'] : 0;
$stockQty = $stockInfo ? (float) $stockInfo['stock_qty'] : 0;
$availableQty = $stock ? (float) $stock->available_qty : 0;
$reservedQty = $stock ? (float) $stock->reserved_qty : 0;
$stockQty = $stock ? (float) $stock->stock_qty : 0;
$shortfallQty = max(0, $neededQty - $availableQty);
$result[] = [

View File

@@ -1486,6 +1486,35 @@ public function getMaterials(int $workOrderId): array
->keyBy('id');
}
// ── Step 1.5: 기존 BOM용 item_id 일괄 사전 로드 (N+1 방지) ──
$bomParentItemIds = $workOrder->items->pluck('item_id')->filter()->unique()->values()->all();
$bomItemsMap = collect();
$bomChildItemsMap = collect();
if (! empty($bomParentItemIds)) {
$bomItemsMap = \App\Models\Items\Item::where('tenant_id', $tenantId)
->whereIn('id', $bomParentItemIds)
->get()
->keyBy('id');
// BOM 자식 item_id 수집 및 배치 조회
$allChildIds = [];
foreach ($bomItemsMap as $parentItem) {
if (! empty($parentItem->bom)) {
foreach ($parentItem->bom as $bomEntry) {
if (! empty($bomEntry['child_item_id'])) {
$allChildIds[] = $bomEntry['child_item_id'];
}
}
}
}
if (! empty($allChildIds)) {
$bomChildItemsMap = \App\Models\Items\Item::where('tenant_id', $tenantId)
->whereIn('id', array_unique($allChildIds))
->get()
->keyBy('id');
}
}
// ── Step 2: 유니크 자재 목록 수집 ──
// 키: dynamic_bom → "{item_id}_{woItem_id}", 기존 BOM → "{item_id}"
$uniqueMaterials = [];
@@ -1527,12 +1556,11 @@ public function getMaterials(int $workOrderId): array
continue; // dynamic_bom이 있으면 기존 BOM fallback 건너뜀
}
// 기존 BOM 로직 (하위 호환)
// 기존 BOM 로직 (하위 호환) — 사전 로드된 맵 사용
$materialItems = [];
if ($woItem->item_id) {
$item = \App\Models\Items\Item::where('tenant_id', $tenantId)
->find($woItem->item_id);
$item = $bomItemsMap[$woItem->item_id] ?? null;
if ($item && ! empty($item->bom)) {
foreach ($item->bom as $bomItem) {
@@ -1543,8 +1571,7 @@ public function getMaterials(int $workOrderId): array
continue;
}
$childItem = \App\Models\Items\Item::where('tenant_id', $tenantId)
->find($childItemId);
$childItem = $bomChildItemsMap[$childItemId] ?? null;
if (! $childItem) {
continue;