From 4f777d8cf9abc3dcd78a0a30cb0c32b73cbefe45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Sat, 21 Feb 2026 16:26:09 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20=EC=A0=88=EA=B3=A1=ED=92=88=20?= =?UTF-8?q?=EC=84=A0=EC=83=9D=EC=82=B0=E2=86=92=EC=9E=AC=EA=B3=A0=EC=A0=81?= =?UTF-8?q?=EC=9E=AC=20Phase=203=20-=20=EC=88=98=EC=A3=BC=20=EC=A0=88?= =?UTF-8?q?=EA=B3=A1=20=EC=9E=AC=EA=B3=A0=20=ED=99=95=EC=9D=B8=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OrderService: checkBendingStockForOrder() 메서드 추가 - order_items에서 item_category='BENDING'인 품목 추출 - 각 품목의 가용재고/부족수량 계산 후 반환 - OrderController: checkBendingStock() 엔드포인트 추가 - Route: GET /api/v1/orders/{id}/bending-stock Co-Authored-By: Claude Opus 4.6 --- .../Controllers/Api/V1/OrderController.php | 10 ++ app/Services/OrderService.php | 137 +++++++++++++++--- routes/api/v1/sales.php | 3 + 3 files changed, 126 insertions(+), 24 deletions(-) diff --git a/app/Http/Controllers/Api/V1/OrderController.php b/app/Http/Controllers/Api/V1/OrderController.php index 0138723..d7b4b2c 100644 --- a/app/Http/Controllers/Api/V1/OrderController.php +++ b/app/Http/Controllers/Api/V1/OrderController.php @@ -134,6 +134,16 @@ public function revertOrderConfirmation(int $id) }, __('message.order.order_confirmation_reverted')); } + /** + * 절곡 BOM 품목 재고 확인 + */ + public function checkBendingStock(int $id) + { + return ApiResponse::handle(function () use ($id) { + return $this->service->checkBendingStockForOrder($id); + }, __('message.fetched')); + } + /** * 생산지시 되돌리기 (작업지시 및 관련 데이터 삭제) */ diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index 70fd65e..de33722 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -228,6 +228,19 @@ public function store(array $data) } // 품목 저장 + // sort_order 기반 분배 준비 + $locationCount = count($productItems); + $itemsPerLocation = ($locationCount > 1) + ? intdiv(count($items), $locationCount) + : 0; + + // floor/code 조합이 개소별로 고유한지 확인 (모두 동일하면 매칭 무의미) + $uniqueLocations = collect($productItems) + ->map(fn ($p) => ($p['floor'] ?? '').'-'.($p['code'] ?? '')) + ->unique() + ->count(); + $canMatchByFloorCode = $uniqueLocations > 1; + foreach ($items as $index => $item) { $item['tenant_id'] = $tenantId; $item['serial_no'] = $index + 1; // 1부터 시작하는 순번 @@ -245,18 +258,32 @@ public function store(array $data) } } - // floor_code/symbol_code로 노드 매칭 + // 노드 매칭 (개소 분배) if (! empty($nodeMap) && ! empty($productItems)) { - $floorCode = $item['floor_code'] ?? null; - $symbolCode = $item['symbol_code'] ?? null; - if ($floorCode && $symbolCode) { - foreach ($productItems as $pidx => $pItem) { - if (($pItem['floor'] ?? '') === $floorCode && ($pItem['code'] ?? '') === $symbolCode) { - $item['order_node_id'] = $nodeMap[$pidx]->id ?? null; - break; + $locIdx = 0; + $matched = false; + + // 1순위: floor_code/symbol_code로 매칭 (개소별 고유값이 있는 경우만) + if ($canMatchByFloorCode) { + $floorCode = $item['floor_code'] ?? null; + $symbolCode = $item['symbol_code'] ?? null; + if ($floorCode && $symbolCode) { + foreach ($productItems as $pidx => $pItem) { + if (($pItem['floor'] ?? '') === $floorCode && ($pItem['code'] ?? '') === $symbolCode) { + $locIdx = $pidx; + $matched = true; + break; + } } } } + + // 2순위: sort_order 기반 균등 분배 + if (! $matched && $itemsPerLocation > 0) { + $locIdx = min(intdiv($index, $itemsPerLocation), $locationCount - 1); + } + + $item['order_node_id'] = $nodeMap[$locIdx]->id ?? null; } $order->items()->create($item); @@ -852,17 +879,6 @@ public function createFromQuote(int $quoteId, array $data = []) ? intdiv($quote->items->count(), $locationCount) : 0; - // DEBUG: 분배 로직 디버깅 (임시) - \Log::info('[createFromQuote] Distribution params', [ - 'quoteId' => $quote->id, - 'itemCount' => $quote->items->count(), - 'locationCount' => $locationCount, - 'hasFormulaSource' => $hasFormulaSource, - 'itemsPerLocation' => $itemsPerLocation, - 'collectionKeys_first5' => $quote->items->keys()->take(5)->all(), - 'nodeMapKeys' => array_keys($nodeMap), - ]); - foreach ($quote->items as $index => $quoteItem) { $floorCode = null; $symbolCode = null; @@ -879,11 +895,6 @@ public function createFromQuote(int $quoteId, array $data = []) $locIdx = min(intdiv($index, $itemsPerLocation), $locationCount - 1); } - // DEBUG: 처음 3개와 전환점(17-19) 로깅 (임시) - if ($index < 3 || ($index >= 17 && $index <= 19)) { - \Log::info("[createFromQuote] item idx={$index} locIdx={$locIdx} fs='{$formulaSource}'"); - } - // calculation_inputs에서 floor/code 가져오기 if (isset($productItems[$locIdx])) { $floorCode = $productItems[$locIdx]['floor'] ?? null; @@ -1794,4 +1805,82 @@ private function revertProductionOrderCancel(Order $order, int $tenantId, int $u ]; }); } + + /** + * 수주의 절곡 BOM 품목별 재고 현황 조회 + * + * order_items에서 item_category='BENDING'인 품목을 추출하고 + * 각 품목의 재고 가용량/부족량을 반환합니다. + */ + public function checkBendingStockForOrder(int $orderId): array + { + $tenantId = $this->tenantId(); + + $order = Order::where('tenant_id', $tenantId) + ->with(['items']) + ->find($orderId); + + if (! $order) { + throw new NotFoundHttpException(__('error.not_found')); + } + + // order_items에서 item_id가 있는 품목의 ID 수집 + 수량 합산 + $itemQtyMap = []; // item_id => total_qty + foreach ($order->items as $orderItem) { + $itemId = $orderItem->item_id; + if (! $itemId) { + continue; + } + $qty = (float) ($orderItem->quantity ?? 0); + if ($qty <= 0) { + continue; + } + $itemQtyMap[$itemId] = ($itemQtyMap[$itemId] ?? 0) + $qty; + } + + if (empty($itemQtyMap)) { + return []; + } + + // items 테이블에서 item_category = 'BENDING'인 것만 필터 + $bendingItems = DB::table('items') + ->where('tenant_id', $tenantId) + ->whereIn('id', array_keys($itemQtyMap)) + ->where('item_category', 'BENDING') + ->whereNull('deleted_at') + ->select('id', 'code', 'name', 'unit') + ->get(); + + if ($bendingItems->isEmpty()) { + return []; + } + + $stockService = app(StockService::class); + $result = []; + + foreach ($bendingItems as $item) { + $neededQty = $itemQtyMap[$item->id]; + $stockInfo = $stockService->getAvailableStock($item->id); + + $availableQty = $stockInfo ? (float) $stockInfo['available_qty'] : 0; + $reservedQty = $stockInfo ? (float) $stockInfo['reserved_qty'] : 0; + $stockQty = $stockInfo ? (float) $stockInfo['stock_qty'] : 0; + $shortfallQty = max(0, $neededQty - $availableQty); + + $result[] = [ + 'item_id' => $item->id, + 'item_code' => $item->code, + 'item_name' => $item->name, + 'unit' => $item->unit, + 'needed_qty' => $neededQty, + 'stock_qty' => $stockQty, + 'reserved_qty' => $reservedQty, + 'available_qty' => $availableQty, + 'shortfall_qty' => $shortfallQty, + 'status' => $shortfallQty > 0 ? 'insufficient' : 'sufficient', + ]; + } + + return $result; + } } diff --git a/routes/api/v1/sales.php b/routes/api/v1/sales.php index 1398d41..f588ea9 100644 --- a/routes/api/v1/sales.php +++ b/routes/api/v1/sales.php @@ -163,6 +163,9 @@ // 견적에서 수주 생성 Route::post('/from-quote/{quoteId}', [OrderController::class, 'createFromQuote'])->whereNumber('quoteId')->name('v1.orders.from-quote'); + // 절곡 재고 현황 확인 + Route::get('/{id}/bending-stock', [OrderController::class, 'checkBendingStock'])->whereNumber('id')->name('v1.orders.bending-stock'); + // 생산지시 생성 Route::post('/{id}/production-order', [OrderController::class, 'createProductionOrder'])->whereNumber('id')->name('v1.orders.production-order');