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:
@@ -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'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 생산지시 되돌리기 (작업지시 및 관련 데이터 삭제)
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user