tenantId(); $page = (int) ($params['page'] ?? 1); $size = (int) ($params['size'] ?? 20); $q = trim((string) ($params['q'] ?? '')); $status = $params['status'] ?? null; $orderType = $params['order_type'] ?? null; $clientId = $params['client_id'] ?? null; $dateFrom = $params['date_from'] ?? null; $dateTo = $params['date_to'] ?? null; $forWorkOrder = filter_var($params['for_work_order'] ?? false, FILTER_VALIDATE_BOOLEAN); $query = Order::query() ->where('tenant_id', $tenantId) ->with(['client:id,name,manager_name', 'items', 'quote:id,quote_number', 'rootNodes:id,order_id,name,options']) ->withSum('rootNodes', 'quantity'); // 작업지시 생성 가능한 수주만 필터링 if ($forWorkOrder) { // 1. DRAFT(등록) 상태만 (생산지시 전) $query->where('status_code', Order::STATUS_DRAFT); // 2. 작업지시가 아직 없는 수주만 $query->whereDoesntHave('workOrders'); } // 검색어 (수주번호, 현장명, 거래처명) if ($q !== '') { $query->where(function ($qq) use ($q) { $qq->where('order_no', 'like', "%{$q}%") ->orWhere('site_name', 'like', "%{$q}%") ->orWhere('client_name', 'like', "%{$q}%"); }); } // 상태 필터 (for_work_order와 함께 사용시 무시) if ($status !== null && ! $forWorkOrder) { $query->where('status_code', $status); } // 주문유형 필터 (ORDER/PURCHASE) if ($orderType !== null) { $query->where('order_type_code', $orderType); } // 거래처 필터 if ($clientId !== null) { $query->where('client_id', $clientId); } // 날짜 범위 (수주일 기준) if ($dateFrom !== null) { $query->where('received_at', '>=', $dateFrom); } if ($dateTo !== null) { $query->where('received_at', '<=', $dateTo); } $query->orderByDesc('created_at'); return $query->paginate($size, ['*'], 'page', $page); } /** * 통계 조회 */ public function stats(): array { $tenantId = $this->tenantId(); $counts = Order::where('tenant_id', $tenantId) ->select('status_code', DB::raw('count(*) as count')) ->groupBy('status_code') ->pluck('count', 'status_code') ->toArray(); $amounts = Order::where('tenant_id', $tenantId) ->select('status_code', DB::raw('sum(total_amount) as total')) ->groupBy('status_code') ->pluck('total', 'status_code') ->toArray(); return [ 'total' => array_sum($counts), 'draft' => $counts[Order::STATUS_DRAFT] ?? 0, 'confirmed' => $counts[Order::STATUS_CONFIRMED] ?? 0, 'in_progress' => $counts[Order::STATUS_IN_PROGRESS] ?? 0, 'completed' => $counts[Order::STATUS_COMPLETED] ?? 0, 'cancelled' => $counts[Order::STATUS_CANCELLED] ?? 0, 'total_amount' => array_sum($amounts), 'confirmed_amount' => ($amounts[Order::STATUS_CONFIRMED] ?? 0) + ($amounts[Order::STATUS_IN_PROGRESS] ?? 0), ]; } /** * 단건 조회 */ public function show(int $id) { $tenantId = $this->tenantId(); $order = Order::where('tenant_id', $tenantId) ->with([ 'client:id,name,contact_person,phone,email,manager_name', 'items' => fn ($q) => $q->orderBy('sort_order'), 'rootNodes' => fn ($q) => $q->withRecursiveChildren(), 'quote:id,quote_number,site_name,calculation_inputs', ]) ->find($id); if (! $order) { throw new NotFoundHttpException(__('error.not_found')); } return $order; } /** * 생성 */ public function store(array $data) { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); return DB::transaction(function () use ($data, $tenantId, $userId) { // 수주번호 자동 생성 $pairCode = $data['pair_code'] ?? null; unset($data['pair_code']); $data['order_no'] = $this->generateOrderNo($tenantId, $pairCode); $data['tenant_id'] = $tenantId; $data['created_by'] = $userId; $data['updated_by'] = $userId; // 기본 상태 설정 $data['status_code'] = $data['status_code'] ?? Order::STATUS_DRAFT; $data['order_type_code'] = $data['order_type_code'] ?? Order::TYPE_ORDER; $items = $data['items'] ?? []; unset($data['items']); $order = Order::create($data); // quote_id가 있으면 OrderNode 생성 (개소별 사이즈 정보) $nodeMap = []; $productItems = []; if ($order->quote_id) { $quote = Quote::withoutGlobalScopes()->find($order->quote_id); if ($quote) { $ci = $quote->calculation_inputs ?? []; $productItems = $ci['items'] ?? []; $bomResults = $ci['bomResults'] ?? []; foreach ($productItems as $idx => $locItem) { $bomResult = $bomResults[$idx] ?? null; $grandTotal = $bomResult['grand_total'] ?? 0; $bomVars = $bomResult['variables'] ?? []; $qty = (int) ($locItem['quantity'] ?? 1); $floor = $locItem['floor'] ?? ''; $symbol = $locItem['code'] ?? ''; $nodeMap[$idx] = OrderNode::create([ 'tenant_id' => $tenantId, 'order_id' => $order->id, 'parent_id' => null, 'node_type' => 'location', 'code' => trim("{$floor}-{$symbol}", '-') ?: "LOC-{$idx}", 'name' => trim("{$floor} {$symbol}") ?: '개소 '.($idx + 1), 'status_code' => OrderNode::STATUS_PENDING, 'quantity' => $qty, 'unit_price' => $grandTotal, 'total_price' => $grandTotal * $qty, 'options' => [ 'floor' => $floor, 'symbol' => $symbol, 'product_code' => $locItem['productCode'] ?? null, 'product_name' => $locItem['productName'] ?? null, 'open_width' => $locItem['openWidth'] ?? null, 'open_height' => $locItem['openHeight'] ?? null, 'width' => $bomVars['W1'] ?? $locItem['openWidth'] ?? null, 'height' => $bomVars['H1'] ?? $locItem['openHeight'] ?? null, 'bom_result' => $bomResult, ], 'depth' => 0, 'sort_order' => $idx, 'created_by' => $userId, ]); } } } // 품목 저장 foreach ($items as $index => $item) { $item['tenant_id'] = $tenantId; $item['serial_no'] = $index + 1; // 1부터 시작하는 순번 $item['sort_order'] = $index; $this->calculateItemAmounts($item); // item_id가 없고 item_code가 있으면 item_code로 조회하여 보완 if (empty($item['item_id']) && ! empty($item['item_code'])) { $foundItem = Item::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->where('code', $item['item_code']) ->first(); if ($foundItem) { $item['item_id'] = $foundItem->id; } } // 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; } } } } $order->items()->create($item); } // 합계 재계산 $order->refresh(); $order->recalculateTotals()->save(); return $order->load(['client:id,name', 'items']); }); } /** * 수정 */ public function update(int $id, array $data) { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $order = Order::where('tenant_id', $tenantId)->find($id); if (! $order) { throw new NotFoundHttpException(__('error.not_found')); } // 완료/취소 상태에서는 수정 불가 if (in_array($order->status_code, [Order::STATUS_COMPLETED, Order::STATUS_CANCELLED])) { throw new BadRequestHttpException(__('error.order.cannot_update_completed')); } return DB::transaction(function () use ($order, $data, $tenantId, $userId) { $data['updated_by'] = $userId; $items = $data['items'] ?? null; unset($data['items'], $data['order_no']); // 번호 변경 불가 $order->update($data); // 품목 교체 (있는 경우) if ($items !== null) { // 기존 품목의 floor_code/symbol_code 매핑 저장 (item_name + specification → floor_code/symbol_code) $existingMappings = []; foreach ($order->items as $existingItem) { $key = ($existingItem->item_name ?? '').'|'.($existingItem->specification ?? ''); $existingMappings[$key] = [ 'floor_code' => $existingItem->floor_code, 'symbol_code' => $existingItem->symbol_code, ]; } $order->items()->delete(); foreach ($items as $index => $item) { $item['tenant_id'] = $tenantId; $item['serial_no'] = $index + 1; // 1부터 시작하는 순번 $item['sort_order'] = $index; // floor_code/symbol_code 보존: 프론트엔드에서 전달되지 않으면 기존 값 사용 if (empty($item['floor_code']) || empty($item['symbol_code'])) { $key = ($item['item_name'] ?? '').'|'.($item['specification'] ?? ''); if (isset($existingMappings[$key])) { $item['floor_code'] = $item['floor_code'] ?? $existingMappings[$key]['floor_code']; $item['symbol_code'] = $item['symbol_code'] ?? $existingMappings[$key]['symbol_code']; } } $this->calculateItemAmounts($item); $order->items()->create($item); } // 합계 재계산 $order->refresh(); $order->recalculateTotals()->save(); } return $order->load(['client:id,name', 'items']); }); } /** * 삭제 */ public function destroy(int $id) { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $order = Order::where('tenant_id', $tenantId)->find($id); if (! $order) { throw new NotFoundHttpException(__('error.not_found')); } // 진행 중이거나 완료된 수주는 삭제 불가 if (in_array($order->status_code, [ Order::STATUS_IN_PROGRESS, Order::STATUS_IN_PRODUCTION, Order::STATUS_PRODUCED, Order::STATUS_SHIPPING, Order::STATUS_SHIPPED, Order::STATUS_COMPLETED, ])) { throw new BadRequestHttpException(__('error.order.cannot_delete_in_progress')); } // 작업지시가 존재하면 삭제 불가 if ($order->workOrders()->exists()) { throw new BadRequestHttpException(__('error.order.cannot_delete_has_work_orders')); } // 출하 정보가 존재하면 삭제 불가 if ($order->shipments()->exists()) { throw new BadRequestHttpException(__('error.order.cannot_delete_has_shipments')); } return DB::transaction(function () use ($order, $userId) { // 0. 연결된 견적의 수주 연결 해제 (order_id → null, status → finalized) if ($order->quote_id) { Quote::withoutGlobalScopes() ->where('id', $order->quote_id) ->where('order_id', $order->id) ->update([ 'order_id' => null, 'status' => Quote::STATUS_FINALIZED, ]); } // 1. order_item_components soft delete foreach ($order->items as $item) { $item->components()->update(['deleted_by' => $userId]); $item->components()->delete(); } // 2. order_items soft delete $order->items()->update(['deleted_by' => $userId]); $order->items()->delete(); // 3. order_nodes soft delete $order->nodes()->update(['deleted_by' => $userId]); $order->nodes()->delete(); // 4. order 마스터 soft delete $order->deleted_by = $userId; $order->save(); $order->delete(); // order_histories, order_versions는 감사 기록이므로 보존 return 'success'; }); } /** * 상태 변경 */ public function updateStatus(int $id, string $status) { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $order = Order::where('tenant_id', $tenantId)->find($id); if (! $order) { throw new NotFoundHttpException(__('error.not_found')); } // 상태 유효성 검증 $validStatuses = [ Order::STATUS_DRAFT, Order::STATUS_CONFIRMED, Order::STATUS_IN_PROGRESS, Order::STATUS_COMPLETED, Order::STATUS_CANCELLED, ]; if (! in_array($status, $validStatuses)) { throw new BadRequestHttpException(__('error.invalid_status')); } // 상태 전환 규칙 검증 $this->validateStatusTransition($order->status_code, $status); return DB::transaction(function () use ($order, $status, $userId) { $createdSale = null; $previousStatus = $order->status_code; // 수주확정 시 매출 자동 생성 (sales_recognition = on_order_confirm인 경우) if ($status === Order::STATUS_CONFIRMED && $order->shouldCreateSaleOnConfirm()) { $createdSale = $this->createSaleFromOrder($order, $userId); $order->sale_id = $createdSale->id; } // 🆕 수주확정 시 재고 예약 if ($status === Order::STATUS_CONFIRMED && $previousStatus !== Order::STATUS_CONFIRMED) { $order->load('items'); app(StockService::class)->reserveForOrder($order->items, $order->id); } // 🆕 수주취소 시 재고 예약 해제 if ($status === Order::STATUS_CANCELLED && $previousStatus === Order::STATUS_CONFIRMED) { $order->load('items'); app(StockService::class)->releaseReservationForOrder($order->items, $order->id); } $order->status_code = $status; $order->updated_by = $userId; $order->save(); $result = $order->load(['client:id,name', 'items']); // 매출이 생성된 경우 응답에 포함 if ($createdSale) { $result->setAttribute('created_sale', $createdSale); } return $result; }); } /** * 수주에서 매출 생성 */ private function createSaleFromOrder(Order $order, int $userId): Sale { $saleNumber = $this->generateSaleNumber($order->tenant_id); $sale = Sale::createFromOrder($order, $saleNumber); $sale->created_by = $userId; $sale->save(); return $sale; } /** * 매출번호 자동 생성 */ private function generateSaleNumber(int $tenantId): string { $prefix = 'SAL'; $yearMonth = now()->format('Ym'); // 해당 월 기준 마지막 번호 조회 $lastNo = Sale::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->where('sale_number', 'like', "{$prefix}-{$yearMonth}-%") ->orderByDesc('sale_number') ->value('sale_number'); if ($lastNo) { $seq = (int) substr($lastNo, -4) + 1; } else { $seq = 1; } return sprintf('%s-%s-%04d', $prefix, $yearMonth, $seq); } /** * 상태 전환 규칙 검증 */ private function validateStatusTransition(string $from, string $to): void { $allowedTransitions = [ Order::STATUS_DRAFT => [Order::STATUS_CONFIRMED, Order::STATUS_CANCELLED], Order::STATUS_CONFIRMED => [Order::STATUS_IN_PROGRESS, Order::STATUS_CANCELLED], Order::STATUS_IN_PROGRESS => [Order::STATUS_COMPLETED, Order::STATUS_CANCELLED], Order::STATUS_COMPLETED => [], // 완료 상태에서는 변경 불가 Order::STATUS_CANCELLED => [Order::STATUS_DRAFT], // 취소에서 임시저장으로만 복구 가능 ]; if (! in_array($to, $allowedTransitions[$from] ?? [])) { throw new BadRequestHttpException(__('error.order.invalid_status_transition')); } } /** * 품목 금액 계산 */ private function calculateItemAmounts(array &$item): void { $quantity = (float) ($item['quantity'] ?? 0); $unitPrice = (float) ($item['unit_price'] ?? 0); $item['supply_amount'] = $quantity * $unitPrice; $item['tax_amount'] = round($item['supply_amount'] * 0.1, 2); $item['total_amount'] = $item['supply_amount'] + $item['tax_amount']; } /** * 수주번호 자동 생성 * * 채번규칙이 있으면 NumberingService 사용 (KD-{pairCode}-{YYMMDD}-{NN}), * 없으면 레거시 로직 (ORD{YYYYMMDD}{NNNN}) */ private function generateOrderNo(int $tenantId, ?string $pairCode = null): string { $this->numberingService->setContext($tenantId, $this->apiUserId()); $number = $this->numberingService->generate('order', [ 'pair_code' => $pairCode ?? 'SS', ]); if ($number !== null) { return $number; } return $this->generateOrderNoLegacy($tenantId); } /** * 레거시 수주번호 생성 (ORD{YYYYMMDD}{NNNN}) */ private function generateOrderNoLegacy(int $tenantId): string { $prefix = 'ORD'; $date = now()->format('Ymd'); // 오늘 날짜 기준 마지막 번호 조회 $lastNo = Order::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->where('order_no', 'like', "{$prefix}{$date}%") ->orderByDesc('order_no') ->value('order_no'); if ($lastNo) { $seq = (int) substr($lastNo, -4) + 1; } else { $seq = 1; } return sprintf('%s%s%04d', $prefix, $date, $seq); } /** * 견적에서 수주 생성 */ public function createFromQuote(int $quoteId, array $data = []) { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); // 견적 조회 $quote = Quote::where('tenant_id', $tenantId) ->with(['items', 'client']) ->find($quoteId); if (! $quote) { throw new NotFoundHttpException(__('error.quote.not_found')); } // 이미 수주가 생성된 견적인지 확인 $existingOrder = Order::where('tenant_id', $tenantId) ->where('quote_id', $quoteId) ->first(); if ($existingOrder) { throw new BadRequestHttpException(__('error.order.already_created_from_quote')); } return DB::transaction(function () use ($quote, $data, $tenantId, $userId) { // 수주번호 생성 $pairCode = $data['pair_code'] ?? null; $orderNo = $this->generateOrderNo($tenantId, $pairCode); // Order 모델의 createFromQuote 사용 $order = Order::createFromQuote($quote, $orderNo); $order->created_by = $userId; $order->updated_by = $userId; // 추가 데이터 병합 (납품일, 메모 등) if (! empty($data['delivery_date'])) { $order->delivery_date = $data['delivery_date']; } if (! empty($data['memo'])) { $order->memo = $data['memo']; } $order->save(); // calculation_inputs에서 제품 정보 추출 $calculationInputs = $quote->calculation_inputs ?? []; $productItems = $calculationInputs['items'] ?? []; $bomResults = $calculationInputs['bomResults'] ?? []; // OrderNode 생성 (개소별) $nodeMap = []; foreach ($productItems as $idx => $locItem) { $bomResult = $bomResults[$idx] ?? null; $bomVars = $bomResult['variables'] ?? []; $grandTotal = $bomResult['grand_total'] ?? 0; $qty = (int) ($locItem['quantity'] ?? 1); $floor = $locItem['floor'] ?? ''; $symbol = $locItem['code'] ?? ''; $nodeMap[$idx] = OrderNode::create([ 'tenant_id' => $tenantId, 'order_id' => $order->id, 'parent_id' => null, 'node_type' => 'location', 'code' => trim("{$floor}-{$symbol}", '-') ?: "LOC-{$idx}", 'name' => trim("{$floor} {$symbol}") ?: '개소 '.($idx + 1), 'status_code' => OrderNode::STATUS_PENDING, 'quantity' => $qty, 'unit_price' => $grandTotal, 'total_price' => $grandTotal * $qty, 'options' => [ 'floor' => $floor, 'symbol' => $symbol, 'product_code' => $locItem['productCode'] ?? null, 'product_name' => $locItem['productName'] ?? null, 'open_width' => $locItem['openWidth'] ?? null, 'open_height' => $locItem['openHeight'] ?? null, 'width' => $bomVars['W1'] ?? $locItem['openWidth'] ?? null, 'height' => $bomVars['H1'] ?? $locItem['openHeight'] ?? null, 'guide_rail_type' => $locItem['guideRailType'] ?? null, 'motor_power' => $locItem['motorPower'] ?? null, 'controller' => $locItem['controller'] ?? null, 'wing_size' => $locItem['wingSize'] ?? null, 'inspection_fee' => $locItem['inspectionFee'] ?? null, 'bom_result' => $bomResult, 'slat_info' => $this->extractSlatInfoFromBom($bomResult, $locItem), ], 'depth' => 0, 'sort_order' => $idx, 'created_by' => $userId, ]); } // 견적 품목을 수주 품목으로 변환 (노드 연결 포함) // 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; $locIdx = 0; // 1순위: formula_source에서 인덱스 추출 $formulaSource = $quoteItem->formula_source ?? ''; if (preg_match('/product_(\d+)/', $formulaSource, $matches)) { $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; $symbolCode = $productItems[$locIdx]['code'] ?? null; } elseif (count($productItems) === 1) { $floorCode = $productItems[0]['floor'] ?? null; $symbolCode = $productItems[0]['code'] ?? null; } // calculation_inputs에서 못 찾은 경우 note에서 파싱 시도 if (empty($floorCode) && empty($symbolCode)) { $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; } } // note 파싱으로 locIdx 결정 (formula_source 없는 경우) if ($locIdx === 0 && ! $itemsPerLocation && ! empty($floorCode) && ! empty($symbolCode)) { foreach ($productItems as $pidx => $pItem) { if (($pItem['floor'] ?? '') === $floorCode && ($pItem['code'] ?? '') === $symbolCode) { $locIdx = $pidx; break; } } } $order->items()->create([ 'tenant_id' => $tenantId, 'order_node_id' => $nodeMap[$locIdx]->id ?? null, 'serial_no' => $index + 1, 'item_id' => $quoteItem->item_id, 'item_code' => $quoteItem->item_code, 'item_name' => $quoteItem->item_name, 'specification' => $quoteItem->specification, 'floor_code' => $floorCode, 'symbol_code' => $symbolCode, 'quantity' => $quoteItem->calculated_quantity, 'unit' => $quoteItem->unit, 'unit_price' => $quoteItem->unit_price, 'supply_amount' => $quoteItem->total_price, 'tax_amount' => round($quoteItem->total_price * 0.1, 2), 'total_amount' => round($quoteItem->total_price * 1.1, 2), 'note' => $quoteItem->formula_category, 'sort_order' => $index, ]); } // 합계 재계산 $order->refresh(); $order->recalculateTotals()->save(); // 견적 상태를 '수주전환완료'로 변경 $quote->update([ 'status' => Quote::STATUS_CONVERTED, 'order_id' => $order->id, 'updated_by' => $userId, ]); return $order->load(['client:id,name', 'items', 'quote:id,quote_number']); }); } /** * 견적 변경사항을 수주에 동기화 * * 견적이 수정되면 연결된 수주도 함께 업데이트하고 히스토리를 생성합니다. * * @param Quote $quote 수정된 견적 * @param int $revision 견적 수정 차수 * @return Order|null 업데이트된 수주 또는 null (연결된 수주가 없는 경우) */ public function syncFromQuote(Quote $quote, int $revision): ?Order { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); // 연결된 수주 확인 $order = Order::where('tenant_id', $tenantId) ->where('quote_id', $quote->id) ->first(); if (! $order) { return null; } // 생산 진행 이상의 상태면 동기화 불가 (DRAFT, CONFIRMED, IN_PROGRESS만 허용) $allowedStatuses = [ Order::STATUS_DRAFT, Order::STATUS_CONFIRMED, Order::STATUS_IN_PROGRESS, ]; if (! in_array($order->status_code, $allowedStatuses)) { throw new BadRequestHttpException(__('error.order.cannot_sync_after_production')); } return DB::transaction(function () use ($order, $quote, $tenantId, $userId, $revision) { // 변경 전 데이터 스냅샷 (히스토리용) $beforeData = [ 'site_name' => $order->site_name, 'client_name' => $order->client_name, 'total_amount' => $order->total_amount, 'items_count' => $order->items()->count(), ]; // 수주 기본 정보 업데이트 $order->update([ 'site_name' => $quote->site_name, 'client_id' => $quote->client_id, 'client_name' => $quote->client_name, 'discount_rate' => $quote->discount_rate ?? 0, 'discount_amount' => $quote->discount_amount ?? 0, 'total_amount' => $quote->total_amount, 'updated_by' => $userId, ]); // 기존 품목 및 노드 삭제 후 새로 생성 $order->items()->delete(); $order->nodes()->delete(); // calculation_inputs에서 제품 정보 추출 $calculationInputs = $quote->calculation_inputs ?? []; $productItems = $calculationInputs['items'] ?? []; $bomResults = $calculationInputs['bomResults'] ?? []; // OrderNode 생성 (개소별) $nodeMap = []; foreach ($productItems as $idx => $locItem) { $bomResult = $bomResults[$idx] ?? null; $bomVars = $bomResult['variables'] ?? []; $grandTotal = $bomResult['grand_total'] ?? 0; $qty = (int) ($locItem['quantity'] ?? 1); $floor = $locItem['floor'] ?? ''; $symbol = $locItem['code'] ?? ''; $nodeMap[$idx] = OrderNode::create([ 'tenant_id' => $tenantId, 'order_id' => $order->id, 'parent_id' => null, 'node_type' => 'location', 'code' => trim("{$floor}-{$symbol}", '-') ?: "LOC-{$idx}", 'name' => trim("{$floor} {$symbol}") ?: '개소 '.($idx + 1), 'status_code' => OrderNode::STATUS_PENDING, 'quantity' => $qty, 'unit_price' => $grandTotal, 'total_price' => $grandTotal * $qty, 'options' => [ 'floor' => $floor, 'symbol' => $symbol, 'product_code' => $locItem['productCode'] ?? null, 'product_name' => $locItem['productName'] ?? null, 'open_width' => $locItem['openWidth'] ?? null, 'open_height' => $locItem['openHeight'] ?? null, 'width' => $bomVars['W1'] ?? $locItem['openWidth'] ?? null, 'height' => $bomVars['H1'] ?? $locItem['openHeight'] ?? null, 'guide_rail_type' => $locItem['guideRailType'] ?? null, 'motor_power' => $locItem['motorPower'] ?? null, 'controller' => $locItem['controller'] ?? null, 'wing_size' => $locItem['wingSize'] ?? null, 'inspection_fee' => $locItem['inspectionFee'] ?? null, 'bom_result' => $bomResult, 'slat_info' => $this->extractSlatInfoFromBom($bomResult, $locItem), ], 'depth' => 0, 'sort_order' => $idx, 'created_by' => $userId, ]); } // 견적 품목을 수주 품목으로 변환 (노드 연결 포함) // 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; $locIdx = 0; // 1순위: formula_source에서 인덱스 추출 $formulaSource = $quoteItem->formula_source ?? ''; if (preg_match('/product_(\d+)/', $formulaSource, $matches)) { $locIdx = (int) $matches[1]; } // 2순위: sort_order 기반 분배 (formula_source 없는 레거시 데이터) if ($locIdx === 0 && $itemsPerLocation > 0) { $locIdx = min(intdiv($index, $itemsPerLocation), $locationCount - 1); } // 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; $symbolCode = $productItems[$locIdx]['code'] ?? null; } elseif (count($productItems) === 1) { $floorCode = $productItems[0]['floor'] ?? null; $symbolCode = $productItems[0]['code'] ?? null; } } // note 파싱으로 locIdx 결정 (formula_source 없는 경우) if ($locIdx === 0 && ! $itemsPerLocation && ! empty($floorCode) && ! empty($symbolCode)) { foreach ($productItems as $pidx => $pItem) { if (($pItem['floor'] ?? '') === ($floorCode ?? '') && ($pItem['code'] ?? '') === ($symbolCode ?? '')) { $locIdx = $pidx; break; } } } $order->items()->create([ 'tenant_id' => $tenantId, 'order_node_id' => $nodeMap[$locIdx]->id ?? null, 'serial_no' => $index + 1, 'item_id' => $quoteItem->item_id, 'item_code' => $quoteItem->item_code, 'item_name' => $quoteItem->item_name, 'specification' => $quoteItem->specification, 'floor_code' => $floorCode, 'symbol_code' => $symbolCode, 'quantity' => $quoteItem->calculated_quantity, 'unit' => $quoteItem->unit, 'unit_price' => $quoteItem->unit_price, 'supply_amount' => $quoteItem->total_price, 'tax_amount' => round($quoteItem->total_price * 0.1, 2), 'total_amount' => round($quoteItem->total_price * 1.1, 2), 'note' => $quoteItem->formula_category, 'sort_order' => $index, ]); } // 합계 재계산 $order->refresh(); $order->recalculateTotals()->save(); // 변경 후 데이터 스냅샷 $afterData = [ 'site_name' => $order->site_name, 'client_name' => $order->client_name, 'total_amount' => $order->total_amount, 'items_count' => $order->items()->count(), ]; // 히스토리 생성 OrderHistory::create([ 'tenant_id' => $tenantId, 'order_id' => $order->id, 'history_type' => 'quote_updated', 'content' => json_encode([ 'message' => "견적 {$revision}차 수정으로 수주 정보가 업데이트되었습니다.", 'quote_id' => $quote->id, 'quote_number' => $quote->quote_number, 'revision' => $revision, 'before' => $beforeData, 'after' => $afterData, ], JSON_UNESCAPED_UNICODE), 'created_by' => $userId, ]); return $order->load(['client:id,name', 'items', 'quote:id,quote_number']); }); } /** * 생산지시 생성 (공정별 작업지시 다중 생성) */ public function createProductionOrder(int $orderId, array $data) { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); // 수주 + 노드 조회 $order = Order::where('tenant_id', $tenantId) ->with(['items', 'rootNodes']) ->find($orderId); if (! $order) { throw new NotFoundHttpException(__('error.not_found')); } // 상태 확인 (CONFIRMED 상태에서만 생산지시 가능) if ($order->status_code !== Order::STATUS_CONFIRMED) { throw new BadRequestHttpException(__('error.order.must_be_confirmed_for_production')); } // 이미 생산지시가 존재하는지 확인 $existingWorkOrder = WorkOrder::where('tenant_id', $tenantId) ->where('sales_order_id', $orderId) ->first(); if ($existingWorkOrder) { throw new BadRequestHttpException(__('error.order.production_order_already_exists')); } // order_nodes의 BOM 결과를 기반으로 공정별 자동 분류 $bomItemIds = []; $nodesBomMap = []; // node_id => [item_name => bom_item] foreach ($order->rootNodes as $node) { $bomResult = $node->options['bom_result'] ?? []; $bomItems = $bomResult['items'] ?? []; foreach ($bomItems as $bomItem) { if (! empty($bomItem['item_id'])) { $bomItemIds[] = $bomItem['item_id']; $nodesBomMap[$node->id][$bomItem['item_name']] = $bomItem; } } } $bomItemIds = array_unique($bomItemIds); // process_items 테이블에서 item_id → process_id 매핑 조회 $itemProcessMap = []; if (! empty($bomItemIds)) { $processItems = DB::table('process_items as pi') ->join('processes as p', 'pi.process_id', '=', 'p.id') ->where('p.tenant_id', $tenantId) ->whereIn('pi.item_id', $bomItemIds) ->where('pi.is_active', true) ->select('pi.item_id', 'pi.process_id') ->get(); foreach ($processItems as $pi) { $itemProcessMap[$pi->item_id] = $pi->process_id; } } // item_code → item_id 매핑 구축 (fallback용) $codeToIdMap = []; if (! empty($bomItemIds)) { $codeToIdRows = DB::table('items') ->where('tenant_id', $tenantId) ->whereIn('id', $bomItemIds) ->whereNull('deleted_at') ->select('id', 'code') ->get(); foreach ($codeToIdRows as $row) { $codeToIdMap[$row->code] = $row->id; } } // order_items를 공정별로 그룹화 (BOM item_id → process 매핑 활용) $itemsByProcess = []; foreach ($order->items as $orderItem) { $processId = null; // 1. order_item의 item_id가 있으면 직접 매핑 if ($orderItem->item_id && isset($itemProcessMap[$orderItem->item_id])) { $processId = $itemProcessMap[$orderItem->item_id]; } // 2. item_id가 없으면 노드의 BOM에서 item_name으로 찾기 elseif ($orderItem->order_node_id && isset($nodesBomMap[$orderItem->order_node_id])) { $nodeBom = $nodesBomMap[$orderItem->order_node_id]; $bomItem = $nodeBom[$orderItem->item_name] ?? null; if ($bomItem && ! empty($bomItem['item_id']) && isset($itemProcessMap[$bomItem['item_id']])) { $processId = $itemProcessMap[$bomItem['item_id']]; } } // 3. fallback: item_code로 items 마스터 조회 → process_items 매핑 if ($processId === null && $orderItem->item_code) { $resolvedId = $codeToIdMap[$orderItem->item_code] ?? null; if (! $resolvedId) { $resolvedId = DB::table('items') ->where('tenant_id', $tenantId) ->where('code', $orderItem->item_code) ->whereNull('deleted_at') ->value('id'); if ($resolvedId) { $codeToIdMap[$orderItem->item_code] = $resolvedId; } } if ($resolvedId && isset($itemProcessMap[$resolvedId])) { $processId = $itemProcessMap[$resolvedId]; } elseif ($resolvedId) { // process_items에서도 조회 $pi = DB::table('process_items') ->where('item_id', $resolvedId) ->where('is_active', true) ->value('process_id'); if ($pi) { $processId = $pi; $itemProcessMap[$resolvedId] = $pi; } } } $key = $processId ?? 'none'; if (! isset($itemsByProcess[$key])) { $itemsByProcess[$key] = [ 'process_id' => $processId, 'items' => [], ]; } $itemsByProcess[$key]['items'][] = $orderItem; } return DB::transaction(function () use ($order, $data, $tenantId, $userId, $itemsByProcess, $nodesBomMap) { $workOrders = []; // 담당자 ID 배열 처리 (assignee_ids 우선, fallback으로 assignee_id) $assigneeIds = $data['assignee_ids'] ?? []; if (empty($assigneeIds) && ! empty($data['assignee_id'])) { $assigneeIds = [$data['assignee_id']]; } $assigneeIds = array_unique(array_filter($assigneeIds)); $primaryAssigneeId = $assigneeIds[0] ?? null; foreach ($itemsByProcess as $key => $group) { $processId = $group['process_id']; $items = $group['items']; // 작업지시번호 생성 $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, 'work_order_no' => $workOrderNo, 'sales_order_id' => $order->id, 'project_name' => $order->site_name ?? $order->client_name, 'process_id' => $processId, 'status' => ! empty($assigneeIds) ? WorkOrder::STATUS_PENDING : WorkOrder::STATUS_UNASSIGNED, 'assignee_id' => $primaryAssigneeId, '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, ]); // 다중 담당자 저장 (work_order_assignees) foreach ($assigneeIds as $index => $assigneeId) { $workOrder->assignees()->create([ 'tenant_id' => $tenantId, 'user_id' => $assigneeId, 'is_primary' => $index === 0, ]); } // work_order_items에 아이템 추가 $sortOrder = 1; foreach ($items as $orderItem) { // item_id 결정: order_item에 있으면 사용, 없으면 item_code로 조회, 최후에 BOM에서 가져오기 $itemId = $orderItem->item_id; if (! $itemId && $orderItem->item_code) { $foundItem = Item::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->where('code', $orderItem->item_code) ->first(); $itemId = $foundItem?->id; } if (! $itemId && $orderItem->order_node_id && isset($nodesBomMap[$orderItem->order_node_id])) { $bomItem = $nodesBomMap[$orderItem->order_node_id][$orderItem->item_name] ?? null; $itemId = $bomItem['item_id'] ?? null; } // 수주 품목의 노드에서 options(사이즈 등) 조합 $nodeOptions = []; if ($orderItem->order_node_id) { $node = $order->rootNodes->firstWhere('id', $orderItem->order_node_id); $nodeOptions = $node ? ($node->options ?? []) : []; } // slat_info: nodeOptions에 있으면 사용, 없으면 bom_result에서 추출 (하위 호환) $slatInfo = $nodeOptions['slat_info'] ?? null; if (! $slatInfo && isset($nodeOptions['bom_result'])) { $slatInfo = $this->extractSlatInfoFromBom($nodeOptions['bom_result']); } // slat_info의 joint_bar가 0이면 레거시 공식으로 자동 계산 $woWidth = $nodeOptions['width'] ?? $nodeOptions['open_width'] ?? null; if ($slatInfo && ($slatInfo['joint_bar'] ?? 0) <= 0 && $woWidth > 0) { $qty = (int) $orderItem->quantity; $slatInfo['joint_bar'] = (2 + (int) floor(((float) $woWidth - 500) / 1000)) * $qty; } $woItemOptions = array_filter([ 'floor' => $orderItem->floor_code, 'code' => $orderItem->symbol_code, 'width' => $woWidth, 'height' => $nodeOptions['height'] ?? $nodeOptions['open_height'] ?? null, 'cutting_info' => $nodeOptions['cutting_info'] ?? null, 'slat_info' => $slatInfo, 'bending_info' => $nodeOptions['bending_info'] ?? null, 'wip_info' => $nodeOptions['wip_info'] ?? null, ], fn ($v) => $v !== null); DB::table('work_order_items')->insert([ 'tenant_id' => $tenantId, 'work_order_id' => $workOrder->id, 'source_order_item_id' => $orderItem->id, 'item_id' => $itemId, 'item_name' => $orderItem->item_name, 'specification' => $orderItem->specification, 'quantity' => $orderItem->quantity, 'unit' => $orderItem->unit, 'sort_order' => $sortOrder++, 'status' => 'pending', 'options' => ! empty($woItemOptions) ? json_encode($woItemOptions) : null, 'created_at' => now(), 'updated_at' => now(), ]); } $workOrders[] = $workOrder->load(['assignee:id,name', 'team:id,name', 'process:id,process_name,process_code']); } // 수주 상태를 IN_PROGRESS로 변경 $order->status_code = Order::STATUS_IN_PROGRESS; $order->updated_by = $userId; $order->save(); return [ 'work_orders' => $workOrders, 'work_order' => $workOrders[0] ?? null, // 하위 호환성 'order' => $order->load(['client:id,name', 'items']), ]; }); } /** * BOM 결과에서 슬랫 공정 정보 추출 * * 조인트바 수량: BOM items에서 '조인트바' 항목의 quantity, 없으면 inputVariables의 joint_bar_qty * 방화유리 수량: 프론트 입력값(glass_qty) 우선, 없으면 QTY(개소당 수량) 사용 */ private function extractSlatInfoFromBom(?array $bomResult, array $locItem = []): ?array { if (! $bomResult) { return null; } $bomVars = $bomResult['variables'] ?? []; $bomItems = $bomResult['items'] ?? []; $productType = $bomVars['product_type'] ?? 'screen'; // 스크린 전용 제품은 슬랫 정보 불필요 if ($productType === 'screen') { return null; } // 조인트바 수량: BOM items에서 추출, 없으면 입력 변수에서 $jointBarQty = (int) ($bomVars['joint_bar_qty'] ?? 0); if ($jointBarQty === 0) { foreach ($bomItems as $item) { if (str_contains($item['item_name'] ?? '', '조인트바')) { $jointBarQty = (int) ($item['quantity'] ?? 0); break; } } } // 프론트 미전달 시 레거시 5130 자동 계산 (Slat_updateCo76) // col76 = (2 + floor((제작가로 - 500) / 1000)) * 셔터수량 if ($jointBarQty <= 0) { $width = (float) ($bomVars['W0'] ?? $locItem['width'] ?? 0); $quantity = (int) ($bomVars['QTY'] ?? $locItem['quantity'] ?? 1); if ($width > 0) { $jointBarQty = (2 + (int) floor(($width - 500) / 1000)) * $quantity; } } // 방화유리 수량: 투시창 선택 시에만 유효 (프론트 입력값 또는 견적 BOM 변수) // 레거시: col4_quartz='투시창'일 때만 col14에 수량 표시 $glassQty = (int) ($locItem['glass_qty'] ?? $bomVars['glass_qty'] ?? 0); return [ 'joint_bar' => $jointBarQty, 'glass_qty' => $glassQty, ]; } /** * 작업지시번호 자동 생성 */ private function generateWorkOrderNo(int $tenantId): string { $prefix = 'WO'; $date = now()->format('Ymd'); $lastNo = WorkOrder::withoutGlobalScopes() ->where('tenant_id', $tenantId) ->where('work_order_no', 'like', "{$prefix}{$date}%") ->orderByDesc('work_order_no') ->value('work_order_no'); if ($lastNo) { $seq = (int) substr($lastNo, -4) + 1; } else { $seq = 1; } return sprintf('%s%s%04d', $prefix, $date, $seq); } /** * 수주확정 되돌리기 (수주등록 상태로 변경) */ public function revertOrderConfirmation(int $orderId): array { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); // 수주 조회 $order = Order::where('tenant_id', $tenantId) ->find($orderId); if (! $order) { throw new NotFoundHttpException(__('error.not_found')); } // 수주확정 상태에서만 되돌리기 가능 if ($order->status_code !== Order::STATUS_CONFIRMED) { throw new BadRequestHttpException(__('error.order.cannot_revert_not_confirmed')); } return DB::transaction(function () use ($order, $tenantId, $userId) { $deletedSaleId = null; // 수주확정 시 생성된 매출이 있으면 삭제 if ($order->sale_id) { $sale = Sale::where('tenant_id', $tenantId) ->where('id', $order->sale_id) ->first(); if ($sale) { // 수주확정 시 생성된 매출만 삭제 (draft 상태이고 order_confirm 타입) if ($sale->source_type === Sale::SOURCE_ORDER_CONFIRM && $sale->status === 'draft') { $deletedSaleId = $sale->id; $sale->deleted_by = $userId; $sale->save(); $sale->delete(); } } // 수주의 매출 연결 해지 $order->sale_id = null; } // 상태 변경 $previousStatus = $order->status_code; $order->status_code = Order::STATUS_DRAFT; $order->updated_by = $userId; $order->save(); return [ 'order' => $order->load(['client:id,name', 'items']), 'previous_status' => $previousStatus, 'deleted_sale_id' => $deletedSaleId, ]; }); } /** * 생산지시 되돌리기 (작업지시 및 관련 데이터 삭제) */ public function revertProductionOrder(int $orderId): array { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); // 수주 조회 $order = Order::where('tenant_id', $tenantId) ->find($orderId); if (! $order) { throw new NotFoundHttpException(__('error.not_found')); } // 완료된 수주는 되돌리기 불가 if ($order->status_code === Order::STATUS_COMPLETED) { throw new BadRequestHttpException(__('error.order.cannot_revert_completed')); } return DB::transaction(function () use ($order, $tenantId, $userId) { // 관련 작업지시 ID 조회 $workOrderIds = WorkOrder::where('tenant_id', $tenantId) ->where('sales_order_id', $order->id) ->pluck('id') ->toArray(); $deletedCounts = [ 'work_results' => 0, 'work_order_items' => 0, 'work_orders' => 0, 'material_inputs' => 0, 'documents' => 0, 'step_progress' => 0, 'assignees' => 0, 'bending_details' => 0, 'issues' => 0, ]; if (count($workOrderIds) > 0) { // 1. 자재 투입 재고 복구 + 삭제 $materialInputs = WorkOrderMaterialInput::whereIn('work_order_id', $workOrderIds)->get(); if ($materialInputs->isNotEmpty()) { $stockService = app(StockService::class); foreach ($materialInputs as $input) { try { $stockService->increaseToLot( stockLotId: $input->stock_lot_id, qty: (float) $input->qty, reason: 'work_order_input_cancel', referenceId: $input->work_order_id ); } catch (\Exception $e) { Log::warning('생산지시 되돌리기: 재고 복원 실패', [ 'input_id' => $input->id, 'stock_lot_id' => $input->stock_lot_id, 'error' => $e->getMessage(), ]); } } $deletedCounts['material_inputs'] = WorkOrderMaterialInput::whereIn('work_order_id', $workOrderIds)->delete(); } // 2. 문서 삭제 (검사 성적서, 작업일지 등) $documentIds = Document::where('linkable_type', 'work_order') ->whereIn('linkable_id', $workOrderIds) ->pluck('id') ->toArray(); if (count($documentIds) > 0) { DocumentData::whereIn('document_id', $documentIds)->delete(); DocumentApproval::whereIn('document_id', $documentIds)->delete(); $deletedCounts['documents'] = Document::whereIn('id', $documentIds)->forceDelete(); } // 3. 출하 정보에서 작업지시 참조 해제 (출하 자체는 보존) DB::table('shipments') ->whereIn('work_order_id', $workOrderIds) ->update(['work_order_id' => null]); // 4. 작업지시 부속 데이터 삭제 $deletedCounts['step_progress'] = DB::table('work_order_step_progress') ->whereIn('work_order_id', $workOrderIds) ->delete(); $deletedCounts['assignees'] = DB::table('work_order_assignees') ->whereIn('work_order_id', $workOrderIds) ->delete(); $deletedCounts['bending_details'] = DB::table('work_order_bending_details') ->whereIn('work_order_id', $workOrderIds) ->delete(); $deletedCounts['issues'] = DB::table('work_order_issues') ->whereIn('work_order_id', $workOrderIds) ->delete(); // 5. 작업결과 삭제 $deletedCounts['work_results'] = DB::table('work_results') ->whereIn('work_order_id', $workOrderIds) ->delete(); // 6. 작업지시 품목 삭제 $deletedCounts['work_order_items'] = DB::table('work_order_items') ->whereIn('work_order_id', $workOrderIds) ->delete(); // 7. 작업지시 삭제 $deletedCounts['work_orders'] = WorkOrder::whereIn('id', $workOrderIds)->delete(); } // 8. 수주 상태를 CONFIRMED로 되돌리기 $previousStatus = $order->status_code; $order->status_code = Order::STATUS_CONFIRMED; $order->updated_by = $userId; $order->save(); return [ 'order' => $order->load(['client:id,name', 'items']), 'deleted_counts' => $deletedCounts, 'previous_status' => $previousStatus, ]; }); } }