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'));
|
}, __('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) {
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user