diff --git a/CURRENT_WORKS.md b/CURRENT_WORKS.md index bf5cb14..57272c5 100644 --- a/CURRENT_WORKS.md +++ b/CURRENT_WORKS.md @@ -1,3 +1,21 @@ +## 2026-02-19 (수) - 작업지시 show() materialInputs eager loading 누락 수정 + +### 커밋 내역 +- `23029b1` fix: 작업지시 단건조회(show)에 materialInputs eager loading 추가 + +### 수정된 파일 +| 파일명 | 설명 | +|--------|------| +| `app/Services/WorkOrderService.php` | show()에 items.materialInputs + items.materialInputs.stockLot eager loading 추가 | + +### 원인 +- 목록조회(Line 59-64)에만 `items.materialInputs.stockLot` 있고, 단건조회 `show()`에는 누락 +- 프론트 슬랫 작업일지에서 개소별 입고 LOT NO 표시 불가 + +### 상태: ✅ 완료 + +--- + ## 2026-02-19 (수) - 슬랫 조인트바 자동 계산 및 데이터 파이프라인 완성 ### 작업 목표 diff --git a/app/Services/OrderService.php b/app/Services/OrderService.php index ae7c29b..8e8399b 100644 --- a/app/Services/OrderService.php +++ b/app/Services/OrderService.php @@ -13,6 +13,7 @@ use App\Models\Production\WorkOrderMaterialInput; use App\Models\Quote\Quote; use App\Models\Tenants\Sale; +use App\Services\Production\BendingInfoBuilder; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; @@ -43,7 +44,7 @@ public function index(array $params) $query = Order::query() ->where('tenant_id', $tenantId) - ->with(['client:id,name,manager_name', 'items', 'quote:id,quote_number']) + ->with(['client:id,name,manager_name', 'items', 'quote:id,quote_number', 'rootNodes:id,order_id,name,options']) ->withSum('rootNodes', 'quantity'); // 작업지시 생성 가능한 수주만 필터링 @@ -677,6 +678,13 @@ public function createFromQuote(int $quoteId, array $data = []) } // 견적 품목을 수주 품목으로 변환 (노드 연결 포함) + // formula_source 없는 레거시 데이터용: sort_order 기반 분배 준비 + $locationCount = count($productItems); + $hasFormulaSource = $quote->items->contains(fn ($item) => ! empty($item->formula_source)); + $itemsPerLocation = (! $hasFormulaSource && $locationCount > 1) + ? intdiv($quote->items->count(), $locationCount) + : 0; + foreach ($quote->items as $index => $quoteItem) { $floorCode = null; $symbolCode = null; @@ -688,6 +696,11 @@ public function createFromQuote(int $quoteId, array $data = []) $locIdx = (int) $matches[1]; } + // 2순위: sort_order 기반 분배 (formula_source 없는 레거시 데이터) + if ($locIdx === 0 && $itemsPerLocation > 0) { + $locIdx = min(intdiv($index, $itemsPerLocation), $locationCount - 1); + } + // calculation_inputs에서 floor/code 가져오기 if (isset($productItems[$locIdx])) { $floorCode = $productItems[$locIdx]['floor'] ?? null; @@ -700,15 +713,15 @@ public function createFromQuote(int $quoteId, array $data = []) // calculation_inputs에서 못 찾은 경우 note에서 파싱 시도 if (empty($floorCode) && empty($symbolCode)) { $note = trim($quoteItem->note ?? ''); - if ($note !== '') { + if ($note !== '' && $note !== '-' && $note !== '- -') { $parts = preg_split('/\s+/', $note, 2); - $floorCode = $parts[0] ?? null; - $symbolCode = $parts[1] ?? null; + $floorCode = ($parts[0] ?? null) !== '-' ? ($parts[0] ?? null) : null; + $symbolCode = ($parts[1] ?? null) !== '-' ? ($parts[1] ?? null) : null; } } // note 파싱으로 locIdx 결정 (formula_source 없는 경우) - if ($locIdx === 0 && ! empty($floorCode) && ! empty($symbolCode)) { + if ($locIdx === 0 && ! $itemsPerLocation && ! empty($floorCode) && ! empty($symbolCode)) { foreach ($productItems as $pidx => $pItem) { if (($pItem['floor'] ?? '') === $floorCode && ($pItem['code'] ?? '') === $symbolCode) { $locIdx = $pidx; @@ -860,6 +873,13 @@ public function syncFromQuote(Quote $quote, int $revision): ?Order } // 견적 품목을 수주 품목으로 변환 (노드 연결 포함) + // formula_source 없는 레거시 데이터용: sort_order 기반 분배 준비 + $locationCount = count($productItems); + $hasFormulaSource = $quote->items->contains(fn ($item) => ! empty($item->formula_source)); + $itemsPerLocation = (! $hasFormulaSource && $locationCount > 1) + ? intdiv($quote->items->count(), $locationCount) + : 0; + foreach ($quote->items as $index => $quoteItem) { $floorCode = null; $symbolCode = null; @@ -871,15 +891,20 @@ public function syncFromQuote(Quote $quote, int $revision): ?Order $locIdx = (int) $matches[1]; } - // note에서 floor/code 파싱 - $note = trim($quoteItem->note ?? ''); - if ($note !== '') { - $parts = preg_split('/\s+/', $note, 2); - $floorCode = $parts[0] ?? null; - $symbolCode = $parts[1] ?? null; + // 2순위: sort_order 기반 분배 (formula_source 없는 레거시 데이터) + if ($locIdx === 0 && $itemsPerLocation > 0) { + $locIdx = min(intdiv($index, $itemsPerLocation), $locationCount - 1); } - // 2순위: note에서 파싱 실패 시 calculation_inputs에서 가져오기 + // note에서 floor/code 파싱 + $note = trim($quoteItem->note ?? ''); + if ($note !== '' && $note !== '-' && $note !== '- -') { + $parts = preg_split('/\s+/', $note, 2); + $floorCode = ($parts[0] ?? null) !== '-' ? ($parts[0] ?? null) : null; + $symbolCode = ($parts[1] ?? null) !== '-' ? ($parts[1] ?? null) : null; + } + + // 3순위: note에서 파싱 실패 시 calculation_inputs에서 가져오기 if (empty($floorCode) && empty($symbolCode)) { if (isset($productItems[$locIdx])) { $floorCode = $productItems[$locIdx]['floor'] ?? null; @@ -891,7 +916,7 @@ public function syncFromQuote(Quote $quote, int $revision): ?Order } // note 파싱으로 locIdx 결정 (formula_source 없는 경우) - if ($locIdx === 0 && $note !== '') { + if ($locIdx === 0 && ! $itemsPerLocation && ! empty($floorCode) && ! empty($symbolCode)) { foreach ($productItems as $pidx => $pItem) { if (($pItem['floor'] ?? '') === ($floorCode ?? '') && ($pItem['code'] ?? '') === ($symbolCode ?? '')) { $locIdx = $pidx; @@ -1107,6 +1132,23 @@ public function createProductionOrder(int $orderId, array $data) // 작업지시번호 생성 $workOrderNo = $this->generateWorkOrderNo($tenantId); + // 절곡 공정이면 bending_info 자동 생성 + $workOrderOptions = null; + if ($processId) { + // 이 작업지시에 포함되는 노드 ID만 추출 + $nodeIds = collect($items) + ->pluck('order_node_id') + ->filter() + ->unique() + ->values() + ->all(); + + $bendingInfo = app(BendingInfoBuilder::class)->build($order, $processId, $nodeIds ?: null); + if ($bendingInfo) { + $workOrderOptions = ['bending_info' => $bendingInfo]; + } + } + // 작업지시 생성 $workOrder = WorkOrder::create([ 'tenant_id' => $tenantId, @@ -1119,6 +1161,7 @@ public function createProductionOrder(int $orderId, array $data) 'team_id' => $data['team_id'] ?? null, 'scheduled_date' => $data['scheduled_date'] ?? $order->delivery_date, 'memo' => $data['memo'] ?? null, + 'options' => $workOrderOptions, 'is_active' => true, 'created_by' => $userId, 'updated_by' => $userId, @@ -1255,8 +1298,9 @@ private function extractSlatInfoFromBom(?array $bomResult, array $locItem = []): } } - // 방화유리 수량: 프론트 입력값 우선, 없으면 개소 수량(QTY) - $glassQty = (int) ($locItem['glass_qty'] ?? $bomVars['glass_qty'] ?? $locItem['quantity'] ?? 0); + // 방화유리 수량: 투시창 선택 시에만 유효 (프론트 입력값 또는 견적 BOM 변수) + // 레거시: col4_quartz='투시창'일 때만 col14에 수량 표시 + $glassQty = (int) ($locItem['glass_qty'] ?? $bomVars['glass_qty'] ?? 0); return [ 'joint_bar' => $jointBarQty, diff --git a/app/Services/Quote/FormulaEvaluatorService.php b/app/Services/Quote/FormulaEvaluatorService.php index 6684a25..8cf6974 100644 --- a/app/Services/Quote/FormulaEvaluatorService.php +++ b/app/Services/Quote/FormulaEvaluatorService.php @@ -1649,7 +1649,8 @@ private function calculateTenantBom( // 핸들러는 파라미터로 이미 전달됨 (FormulaHandlerFactory가 생성) // Step 3: 경동 전용 변수 계산 (제품타입별 면적/중량 공식) - $W1 = $W0 + 160; + // 스크린: +160, 철재: +110 (5130 레거시 기준) + $W1 = $W0 + ($productType === 'steel' ? 110 : 160); $H1 = $H0 + 350; if ($productType === 'slat') { @@ -1730,8 +1731,8 @@ private function calculateTenantBom( [ 'var' => 'W1', 'desc' => '제작 폭', - 'formula' => 'W0 + 160', - 'calculation' => "{$W0} + 160", + 'formula' => $productType === 'steel' ? 'W0 + 110' : 'W0 + 160', + 'calculation' => $productType === 'steel' ? "{$W0} + 110" : "{$W0} + 160", 'result' => $W1, 'unit' => 'mm', ], diff --git a/app/Services/WorkOrderService.php b/app/Services/WorkOrderService.php index a661675..890fa51 100644 --- a/app/Services/WorkOrderService.php +++ b/app/Services/WorkOrderService.php @@ -264,11 +264,23 @@ public function store(array $data) // 품목 저장: 직접 전달된 품목이 없고 수주 ID가 있으면 수주에서 복사 if (empty($items) && $salesOrderId) { - $salesOrder = \App\Models\Orders\Order::with('items.node')->find($salesOrderId); + $salesOrder = \App\Models\Orders\Order::with(['items.node', 'rootNodes'])->find($salesOrderId); if ($salesOrder && $salesOrder->items->isNotEmpty()) { + // order_node_id가 null인 품목용 fallback: 수주의 root node를 인덱스로 매핑 + $rootNodes = $salesOrder->rootNodes; + $rootNodeCount = $rootNodes->count(); + foreach ($salesOrder->items as $index => $orderItem) { // 수주 품목 + 노드에서 options 조합 + // 1순위: 품목에 직접 연결된 node, 2순위: root node fallback $nodeOptions = $orderItem->node?->options ?? []; + if (empty($nodeOptions) && $rootNodeCount > 0) { + // root node가 1개면 모든 품목이 해당 node, 여러 개면 인덱스 기반 분배 + $fallbackNode = $rootNodeCount === 1 + ? $rootNodes->first() + : $rootNodes->values()->get($index % $rootNodeCount); + $nodeOptions = $fallbackNode?->options ?? []; + } $options = array_filter([ 'floor' => $orderItem->floor_code, 'code' => $orderItem->symbol_code,