feat(WEB): 절곡품 선생산→재고적재 Phase 3 - 수주 절곡 재고 확인 API

- OrderService: checkBendingStockForOrder() 메서드 추가
  - order_items에서 item_category='BENDING'인 품목 추출
  - 각 품목의 가용재고/부족수량 계산 후 반환
- OrderController: checkBendingStock() 엔드포인트 추가
- Route: GET /api/v1/orders/{id}/bending-stock

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 16:26:09 +09:00
parent 25e21ee6d7
commit 4f777d8cf9
3 changed files with 126 additions and 24 deletions

View File

@@ -134,6 +134,16 @@ public function revertOrderConfirmation(int $id)
}, __('message.order.order_confirmation_reverted')); }, __('message.order.order_confirmation_reverted'));
} }
/**
* 절곡 BOM 품목 재고 확인
*/
public function checkBendingStock(int $id)
{
return ApiResponse::handle(function () use ($id) {
return $this->service->checkBendingStockForOrder($id);
}, __('message.fetched'));
}
/** /**
* 생산지시 되돌리기 (작업지시 및 관련 데이터 삭제) * 생산지시 되돌리기 (작업지시 및 관련 데이터 삭제)
*/ */

View File

@@ -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) { foreach ($items as $index => $item) {
$item['tenant_id'] = $tenantId; $item['tenant_id'] = $tenantId;
$item['serial_no'] = $index + 1; // 1부터 시작하는 순번 $item['serial_no'] = $index + 1; // 1부터 시작하는 순번
@@ -245,18 +258,32 @@ public function store(array $data)
} }
} }
// floor_code/symbol_code로 노드 매칭 // 노드 매칭 (개소 분배)
if (! empty($nodeMap) && ! empty($productItems)) { if (! empty($nodeMap) && ! empty($productItems)) {
$floorCode = $item['floor_code'] ?? null; $locIdx = 0;
$symbolCode = $item['symbol_code'] ?? null; $matched = false;
if ($floorCode && $symbolCode) {
foreach ($productItems as $pidx => $pItem) { // 1순위: floor_code/symbol_code로 매칭 (개소별 고유값이 있는 경우만)
if (($pItem['floor'] ?? '') === $floorCode && ($pItem['code'] ?? '') === $symbolCode) { if ($canMatchByFloorCode) {
$item['order_node_id'] = $nodeMap[$pidx]->id ?? null; $floorCode = $item['floor_code'] ?? null;
break; $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); $order->items()->create($item);
@@ -852,17 +879,6 @@ public function createFromQuote(int $quoteId, array $data = [])
? intdiv($quote->items->count(), $locationCount) ? intdiv($quote->items->count(), $locationCount)
: 0; : 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) { foreach ($quote->items as $index => $quoteItem) {
$floorCode = null; $floorCode = null;
$symbolCode = null; $symbolCode = null;
@@ -879,11 +895,6 @@ public function createFromQuote(int $quoteId, array $data = [])
$locIdx = min(intdiv($index, $itemsPerLocation), $locationCount - 1); $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 가져오기 // calculation_inputs에서 floor/code 가져오기
if (isset($productItems[$locIdx])) { if (isset($productItems[$locIdx])) {
$floorCode = $productItems[$locIdx]['floor'] ?? null; $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;
}
} }

View File

@@ -163,6 +163,9 @@
// 견적에서 수주 생성 // 견적에서 수주 생성
Route::post('/from-quote/{quoteId}', [OrderController::class, 'createFromQuote'])->whereNumber('quoteId')->name('v1.orders.from-quote'); 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'); Route::post('/{id}/production-order', [OrderController::class, 'createProductionOrder'])->whereNumber('id')->name('v1.orders.production-order');