tenantId(); $page = (int) ($params['page'] ?? 1); $size = (int) ($params['size'] ?? 20); $q = trim((string) ($params['q'] ?? '')); $quoteType = $params['quote_type'] ?? null; $status = $params['status'] ?? null; $productCategory = $params['product_category'] ?? null; $clientId = $params['client_id'] ?? null; $dateFrom = $params['date_from'] ?? null; $dateTo = $params['date_to'] ?? null; $sortBy = $params['sort_by'] ?? 'registration_date'; $sortOrder = $params['sort_order'] ?? 'desc'; $withItems = filter_var($params['with_items'] ?? false, FILTER_VALIDATE_BOOLEAN); $forOrder = filter_var($params['for_order'] ?? false, FILTER_VALIDATE_BOOLEAN); $query = Quote::query()->where('tenant_id', $tenantId); // 수주 전환용 조회: 아직 수주가 생성되지 않은 견적만 if ($forOrder) { // 1. Quote.order_id가 null인 것 (빠른 체크) $query->whereNull('order_id'); // 2. Orders 테이블에 해당 quote_id가 없는 것 (이중 체크, 인덱스 있음) $query->whereDoesntHave('orders'); } // items 포함 (수주 전환용) if ($withItems) { $query->with(['items', 'client:id,name,contact_person,phone']); } // 검색어 if ($q !== '') { $query->search($q); } // 견적 유형 필터 if ($quoteType) { $query->where('quote_type', $quoteType); } // 상태 필터 if ($status) { $query->where('status', $status); } // 제품 카테고리 필터 if ($productCategory) { $query->where('product_category', $productCategory); } // 발주처 필터 if ($clientId) { $query->where('client_id', $clientId); } // 날짜 범위 $query->dateRange($dateFrom, $dateTo); // 정렬 $allowedSortColumns = ['registration_date', 'quote_number', 'client_name', 'total_amount', 'status', 'created_at']; if (in_array($sortBy, $allowedSortColumns)) { $query->orderBy($sortBy, $sortOrder === 'asc' ? 'asc' : 'desc'); } else { $query->orderBy('registration_date', 'desc'); } $query->orderBy('id', 'desc'); return $query->paginate($size, ['*'], 'page', $page); } /** * 견적 참조 데이터 조회 (현장명, 부호 목록) * * 기존 견적/수주에서 사용된 현장명과 부호를 DISTINCT로 조회합니다. */ public function referenceData(): array { $tenantId = $this->tenantId(); // 현장명: 견적 테이블에서 DISTINCT $siteNames = Quote::where('tenant_id', $tenantId) ->whereNotNull('site_name') ->where('site_name', '!=', '') ->distinct() ->orderBy('site_name') ->pluck('site_name') ->toArray(); // 부호(개소코드): calculation_inputs JSON 내 items[].code (예: FSS-01, SD-02) $locationCodes = collect(); // calculation_inputs JSON에서 items[].code 추출 $quotesWithInputs = Quote::where('tenant_id', $tenantId) ->whereNotNull('calculation_inputs') ->select('calculation_inputs') ->get(); foreach ($quotesWithInputs as $quote) { $inputs = is_string($quote->calculation_inputs) ? json_decode($quote->calculation_inputs, true) : $quote->calculation_inputs; if (! is_array($inputs)) { continue; } $items = $inputs['items'] ?? $inputs['locations'] ?? []; foreach ($items as $item) { $code = $item['code'] ?? null; if ($code && trim($code) !== '') { $locationCodes->push(trim($code)); } } } // 중복 제거, 정렬 $locationCodes = $locationCodes ->unique() ->sort() ->values() ->toArray(); return [ 'site_names' => $siteNames, 'location_codes' => $locationCodes, ]; } /** * 견적 단건 조회 */ public function show(int $id): Quote { $tenantId = $this->tenantId(); $quote = Quote::with(['items', 'revisions', 'client', 'creator', 'updater', 'finalizer', 'siteBriefing.partner']) ->where('tenant_id', $tenantId) ->find($id); if (! $quote) { throw new NotFoundHttpException(__('error.quote_not_found')); } // 역방향 참조 보정: order_id가 null인데 Order.quote_id로 연결된 수주가 있는 경우 if (! $quote->order_id) { $linkedOrder = Order::where('quote_id', $quote->id) ->where('tenant_id', $tenantId) ->first(); if ($linkedOrder) { $quote->update(['order_id' => $linkedOrder->id]); $quote->refresh()->load(['items', 'revisions', 'client', 'creator', 'updater', 'finalizer', 'siteBriefing.partner']); } } // BOM 자재 데이터 계산 및 추가 $bomMaterials = $this->calculateBomMaterials($quote); if (! empty($bomMaterials)) { $quote->setAttribute('bom_materials', $bomMaterials); } return $quote; } /** * 저장된 calculation_inputs를 기반으로 BOM 원자재(leaf nodes) 목록 조회 * * 세부산출내역과 달리, BOM 트리에서 실제 원자재만 추출합니다: * - 세부산출내역: BOM 계산 결과 (수식 기반 산출 품목) * - 소요자재내역: BOM 트리 leaf nodes (실제 구매 필요한 원자재) */ private function calculateBomMaterials(Quote $quote): array { $calculationInputs = $quote->calculation_inputs; // calculation_inputs가 없거나 items가 없으면 빈 배열 반환 if (empty($calculationInputs) || empty($calculationInputs['items'])) { return []; } $tenantId = $this->tenantId(); $inputItems = $calculationInputs['items']; $allMaterials = []; foreach ($inputItems as $index => $input) { // 완제품 코드 찾기 (productName에 저장됨) $finishedGoodsCode = $input['productName'] ?? null; if (! $finishedGoodsCode) { continue; } // 주문 수량 $orderQuantity = (float) ($input['quantity'] ?? 1); // BOM 계산을 위한 입력 변수 구성 $variables = [ 'W0' => (float) ($input['openWidth'] ?? 0), 'H0' => (float) ($input['openHeight'] ?? 0), 'QTY' => $orderQuantity, 'PC' => $input['productCategory'] ?? 'SCREEN', 'GT' => $input['guideRailType'] ?? 'wall', 'MP' => $input['motorPower'] ?? 'single', 'CT' => $input['controller'] ?? 'basic', 'WS' => (float) ($input['wingSize'] ?? 50), 'INSP' => (float) ($input['inspectionFee'] ?? 50000), ]; try { // BOM 트리에서 원자재(leaf nodes)만 추출 $leafMaterials = $this->calculationService->formulaEvaluator->getBomLeafMaterials( $finishedGoodsCode, $orderQuantity, $variables, $tenantId ); // 각 자재 항목에 인덱스 정보 추가 foreach ($leafMaterials as $material) { $allMaterials[] = [ 'item_index' => $index, 'finished_goods_code' => $finishedGoodsCode, 'item_id' => $material['item_id'] ?? null, 'item_code' => $material['item_code'] ?? '', 'item_name' => $material['item_name'] ?? '', 'item_type' => $material['item_type'] ?? '', 'item_category' => $material['item_category'] ?? '', 'specification' => $material['specification'] ?? '', 'unit' => $material['unit'] ?? 'EA', 'quantity' => $material['quantity'] ?? 0, 'unit_price' => $material['unit_price'] ?? 0, 'total_price' => $material['total_price'] ?? 0, 'process_type' => $material['process_type'] ?? '', ]; } } catch (\Throwable) { // BOM 조회 실패 시 해당 품목은 스킵 continue; } } return $allMaterials; } /** * 견적 생성 */ public function store(array $data): Quote { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); return DB::transaction(function () use ($data, $tenantId, $userId) { // 견적번호 생성 $quoteNumber = $data['quote_number'] ?? $this->numberService->generate($data['product_category'] ?? 'SCREEN'); // 금액 계산 $materialCost = (float) ($data['material_cost'] ?? 0); $laborCost = (float) ($data['labor_cost'] ?? 0); $installCost = (float) ($data['install_cost'] ?? 0); $subtotal = $materialCost + $laborCost + $installCost; $discountRate = (float) ($data['discount_rate'] ?? 0); // 프론트엔드에서 직접 계산한 할인금액이 있으면 사용, 없으면 subtotal에서 계산 $discountAmount = isset($data['discount_amount']) ? (float) $data['discount_amount'] : $subtotal * ($discountRate / 100); $totalAmount = $subtotal - $discountAmount; // 견적 생성 $quote = Quote::create([ 'tenant_id' => $tenantId, 'quote_number' => $quoteNumber, 'registration_date' => $data['registration_date'] ?? now()->toDateString(), 'receipt_date' => $data['receipt_date'] ?? null, 'author' => $data['author'] ?? null, // 발주처 정보 'client_id' => $data['client_id'] ?? null, 'client_name' => $data['client_name'] ?? null, 'manager' => $data['manager'] ?? null, 'contact' => $data['contact'] ?? null, // 현장 정보 'site_id' => $data['site_id'] ?? null, 'site_name' => $data['site_name'] ?? null, 'site_code' => $data['site_code'] ?? null, // 제품 정보 'product_category' => $data['product_category'] ?? Quote::CATEGORY_SCREEN, 'product_id' => $data['product_id'] ?? null, 'product_code' => $data['product_code'] ?? null, 'product_name' => $data['product_name'] ?? null, // 규격 정보 'open_size_width' => $data['open_size_width'] ?? null, 'open_size_height' => $data['open_size_height'] ?? null, 'quantity' => $data['quantity'] ?? 1, 'unit_symbol' => $data['unit_symbol'] ?? null, 'floors' => $data['floors'] ?? null, // 금액 정보 'material_cost' => $materialCost, 'labor_cost' => $laborCost, 'install_cost' => $installCost, 'subtotal' => $subtotal, 'discount_rate' => $discountRate, 'discount_amount' => $discountAmount, 'total_amount' => $data['total_amount'] ?? $totalAmount, // 상태 관리 'status' => Quote::STATUS_DRAFT, 'current_revision' => 0, 'is_final' => false, // 기타 정보 'completion_date' => $data['completion_date'] ?? null, 'remarks' => $data['remarks'] ?? null, 'memo' => $data['memo'] ?? null, 'notes' => $data['notes'] ?? null, // 자동산출 입력값 'calculation_inputs' => $data['calculation_inputs'] ?? null, // 감사 'created_by' => $userId, ]); // 견적 품목 생성 if (! empty($data['items']) && is_array($data['items'])) { $this->createItems($quote, $data['items'], $tenantId); } return $quote->load(['items', 'client']); }); } /** * 견적 수정 */ public function update(int $id, array $data): Quote { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $quote = Quote::where('tenant_id', $tenantId)->find($id); if (! $quote) { throw new NotFoundHttpException(__('error.quote_not_found')); } if (! $quote->isEditable()) { throw new BadRequestHttpException(__('error.quote_not_editable')); } return DB::transaction(function () use ($quote, $data, $tenantId, $userId) { // 수정 이력 생성 $this->createRevision($quote, $userId); // 상태 변경: pending(견적대기) → draft(작성중) // 현장설명회에서 자동 생성된 견적을 처음 수정하면 작성중 상태로 변경 $newStatus = $quote->status; if ($quote->status === Quote::STATUS_PENDING) { $newStatus = Quote::STATUS_DRAFT; } // 금액 재계산 $materialCost = (float) ($data['material_cost'] ?? $quote->material_cost); $laborCost = (float) ($data['labor_cost'] ?? $quote->labor_cost); $installCost = (float) ($data['install_cost'] ?? $quote->install_cost); $subtotal = $materialCost + $laborCost + $installCost; $discountRate = (float) ($data['discount_rate'] ?? $quote->discount_rate); // 프론트엔드에서 직접 계산한 할인금액이 있으면 사용, 없으면 subtotal에서 계산 $discountAmount = isset($data['discount_amount']) ? (float) $data['discount_amount'] : $subtotal * ($discountRate / 100); $totalAmount = $subtotal - $discountAmount; // 업데이트 $quote->update([ 'status' => $newStatus, 'receipt_date' => $data['receipt_date'] ?? $quote->receipt_date, 'author' => $data['author'] ?? $quote->author, // 발주처 정보 'client_id' => $data['client_id'] ?? $quote->client_id, 'client_name' => $data['client_name'] ?? $quote->client_name, 'manager' => $data['manager'] ?? $quote->manager, 'contact' => $data['contact'] ?? $quote->contact, // 현장 정보 'site_id' => $data['site_id'] ?? $quote->site_id, 'site_name' => $data['site_name'] ?? $quote->site_name, 'site_code' => $data['site_code'] ?? $quote->site_code, // 제품 정보 'product_category' => $data['product_category'] ?? $quote->product_category, 'product_id' => $data['product_id'] ?? $quote->product_id, 'product_code' => $data['product_code'] ?? $quote->product_code, 'product_name' => $data['product_name'] ?? $quote->product_name, // 규격 정보 'open_size_width' => $data['open_size_width'] ?? $quote->open_size_width, 'open_size_height' => $data['open_size_height'] ?? $quote->open_size_height, 'quantity' => $data['quantity'] ?? $quote->quantity, 'unit_symbol' => $data['unit_symbol'] ?? $quote->unit_symbol, 'floors' => $data['floors'] ?? $quote->floors, // 금액 정보 'material_cost' => $materialCost, 'labor_cost' => $laborCost, 'install_cost' => $installCost, 'subtotal' => $subtotal, 'discount_rate' => $discountRate, 'discount_amount' => $discountAmount, 'total_amount' => $data['total_amount'] ?? $totalAmount, // 기타 정보 'completion_date' => $data['completion_date'] ?? $quote->completion_date, 'remarks' => $data['remarks'] ?? $quote->remarks, 'memo' => $data['memo'] ?? $quote->memo, 'notes' => $data['notes'] ?? $quote->notes, // 자동산출 입력값 'calculation_inputs' => $data['calculation_inputs'] ?? $quote->calculation_inputs, // 견적 옵션 (summary_items, expense_items, price_adjustments, detail_items, price_adjustment_data) // 기존 options와 새 options를 병합 (새 데이터가 기존 데이터를 덮어씀) 'options' => $this->mergeOptions($quote->options, $data['options'] ?? null), // 감사 'updated_by' => $userId, 'current_revision' => $quote->current_revision + 1, ]); // 품목 업데이트 (전체 교체) if (isset($data['items']) && is_array($data['items'])) { $quote->items()->delete(); $this->createItems($quote, $data['items'], $tenantId); } $quote->refresh()->load(['items', 'revisions', 'client']); // 연결된 수주가 있으면 동기화 (역방향 참조도 포함) $syncOrderId = $quote->order_id; if (! $syncOrderId) { $linkedOrder = Order::where('quote_id', $quote->id) ->where('tenant_id', $tenantId) ->first(); if ($linkedOrder) { $quote->update(['order_id' => $linkedOrder->id]); $quote->refresh()->load(['items', 'revisions', 'client']); $syncOrderId = $linkedOrder->id; } } if ($syncOrderId) { try { $this->orderService->setContext($tenantId, $userId); $this->orderService->syncFromQuote($quote, $quote->current_revision); } catch (\Throwable $e) { // 수주 동기화 실패는 로그만 남기고 견적 수정은 성공 처리 Log::warning('Failed to sync order from quote', [ 'quote_id' => $quote->id, 'order_id' => $syncOrderId, 'error' => $e->getMessage(), ]); } } return $quote; }); } /** * 견적 삭제 (Soft Delete) */ public function destroy(int $id): bool { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $quote = Quote::where('tenant_id', $tenantId)->find($id); if (! $quote) { throw new NotFoundHttpException(__('error.quote_not_found')); } if (! $quote->isDeletable()) { throw new BadRequestHttpException(__('error.quote_not_deletable')); } $quote->deleted_by = $userId; $quote->save(); $quote->delete(); return true; } /** * 견적 일괄 삭제 */ public function bulkDestroy(array $ids): int { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $deletedCount = 0; foreach ($ids as $id) { $quote = Quote::where('tenant_id', $tenantId)->find($id); if ($quote && $quote->isDeletable()) { $quote->deleted_by = $userId; $quote->save(); $quote->delete(); $deletedCount++; } } return $deletedCount; } /** * 최종 확정 */ public function finalize(int $id): Quote { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $quote = Quote::where('tenant_id', $tenantId)->find($id); if (! $quote) { throw new NotFoundHttpException(__('error.quote_not_found')); } if (! $quote->isFinalizable()) { throw new BadRequestHttpException(__('error.quote_not_finalizable')); } // 확정 시 필수 필드 검증 (업체명, 현장명, 담당자, 연락처) $missing = []; if (empty($quote->client_name)) { $missing[] = '업체명'; } if (empty($quote->site_name)) { $missing[] = '현장명'; } if (empty($quote->manager)) { $missing[] = '담당자'; } if (empty($quote->contact)) { $missing[] = '연락처'; } if (! empty($missing)) { throw new BadRequestHttpException( __('error.quote_finalize_missing_fields', ['fields' => implode(', ', $missing)]) ); } $quote->update([ 'status' => Quote::STATUS_FINALIZED, 'is_final' => true, 'finalized_at' => now(), 'finalized_by' => $userId, 'updated_by' => $userId, ]); return $quote->refresh()->load(['items', 'client', 'finalizer']); } /** * 확정 취소 */ public function cancelFinalize(int $id): Quote { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $quote = Quote::where('tenant_id', $tenantId)->find($id); if (! $quote) { throw new NotFoundHttpException(__('error.quote_not_found')); } if ($quote->status !== Quote::STATUS_FINALIZED) { throw new BadRequestHttpException(__('error.quote_not_finalized')); } if ($quote->status === Quote::STATUS_CONVERTED) { throw new BadRequestHttpException(__('error.quote_already_converted')); } $quote->update([ 'status' => Quote::STATUS_DRAFT, 'is_final' => false, 'finalized_at' => null, 'finalized_by' => null, 'updated_by' => $userId, ]); return $quote->refresh()->load(['items', 'client']); } /** * 수주 전환 */ public function convertToOrder(int $id): Quote { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $quote = Quote::where('tenant_id', $tenantId) ->with(['items', 'client']) ->find($id); if (! $quote) { throw new NotFoundHttpException(__('error.quote_not_found')); } if (! $quote->isConvertible()) { throw new BadRequestHttpException(__('error.quote_not_convertible')); } return DB::transaction(function () use ($quote, $userId, $tenantId) { // 수주번호 생성 $orderNo = $this->generateOrderNumber($tenantId); // 수주 마스터 생성 $order = Order::createFromQuote($quote, $orderNo); $order->created_by = $userId; $order->save(); // calculation_inputs에서 개소(제품) 정보 추출 $calculationInputs = $quote->calculation_inputs ?? []; $productItems = $calculationInputs['items'] ?? []; $bomResults = $calculationInputs['bomResults'] ?? []; // OrderNode 생성 (개소별) $nodeMap = []; // productIndex → OrderNode foreach ($productItems as $idx => $locItem) { $bomResult = $bomResults[$idx] ?? null; $grandTotal = $bomResult['grand_total'] ?? 0; $qty = (int) ($locItem['quantity'] ?? 1); $floor = $locItem['floor'] ?? ''; $symbol = $locItem['code'] ?? ''; $node = 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, '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, ], 'depth' => 0, 'sort_order' => $idx, 'created_by' => $userId, ]); $nodeMap[$idx] = $node; } // 수주 상세 품목 생성 (노드 연결 포함) $serialIndex = 1; foreach ($quote->items as $quoteItem) { $productMapping = $this->resolveLocationMapping($quoteItem, $productItems); $locIdx = $this->resolveLocationIndex($quoteItem, $productItems); $productMapping['order_node_id'] = isset($nodeMap[$locIdx]) ? $nodeMap[$locIdx]->id : null; $orderItem = OrderItem::createFromQuoteItem($quoteItem, $order->id, $serialIndex, $productMapping); $orderItem->created_by = $userId; $orderItem->save(); $serialIndex++; } // 수주 합계 재계산 $order->load('items'); $order->recalculateTotals(); $order->save(); // 견적 상태 변경 $quote->update([ 'status' => Quote::STATUS_CONVERTED, 'order_id' => $order->id, 'updated_by' => $userId, ]); return $quote->refresh()->load(['items', 'client', 'order']); }); } /** * 견적 품목에서 개소(층/부호) 매핑 정보 추출 * * 1순위: note 필드 파싱 ("4F FSS-01" → floor_code:"4F", symbol_code:"FSS-01") * 2순위: formula_source → calculation_inputs.items[] 매칭 */ private function resolveLocationMapping(QuoteItem $quoteItem, array $productItems): array { $floorCode = null; $symbolCode = null; // 1순위: note에서 파싱 $note = trim($quoteItem->note ?? ''); if ($note !== '') { $parts = preg_split('/\s+/', $note, 2); $floorCode = $parts[0] ?? null; $symbolCode = $parts[1] ?? null; } // 2순위: formula_source → calculation_inputs if (empty($floorCode) && empty($symbolCode)) { $productIndex = 0; $formulaSource = $quoteItem->formula_source ?? ''; if (preg_match('/product_(\d+)/', $formulaSource, $matches)) { $productIndex = (int) $matches[1]; } if (isset($productItems[$productIndex])) { $floorCode = $productItems[$productIndex]['floor'] ?? null; $symbolCode = $productItems[$productIndex]['code'] ?? null; } elseif (count($productItems) === 1) { $floorCode = $productItems[0]['floor'] ?? null; $symbolCode = $productItems[0]['code'] ?? null; } } return ['floor_code' => $floorCode, 'symbol_code' => $symbolCode]; } /** * 견적 품목이 속하는 개소(productItems) 인덱스 반환 * * 1순위: formula_source에서 product_N 패턴 추출 * 2순위: note 파싱 후 productItems에서 floor/code 매칭 * 매칭 실패 시 0 반환 */ private function resolveLocationIndex(QuoteItem $quoteItem, array $productItems): int { // 1순위: formula_source $formulaSource = $quoteItem->formula_source ?? ''; if (preg_match('/product_(\d+)/', $formulaSource, $matches)) { return (int) $matches[1]; } // 2순위: note에서 floor/code 매칭 $note = trim($quoteItem->note ?? ''); if ($note !== '') { $parts = preg_split('/\s+/', $note, 2); $floor = $parts[0] ?? ''; $code = $parts[1] ?? ''; foreach ($productItems as $idx => $item) { if (($item['floor'] ?? '') === $floor && ($item['code'] ?? '') === $code) { return $idx; } } } return 0; } /** * 수주번호 생성 * 형식: ORD-YYMMDD-NNN (예: ORD-260105-001) */ private function generateOrderNumber(int $tenantId): string { $dateStr = now()->format('ymd'); $prefix = "ORD-{$dateStr}-"; $lastOrder = Order::withTrashed() ->where('tenant_id', $tenantId) ->where('order_no', 'like', $prefix.'%') ->orderBy('order_no', 'desc') ->first(); $sequence = 1; if ($lastOrder) { $parts = explode('-', $lastOrder->order_no); if (count($parts) >= 3) { $lastSeq = (int) end($parts); $sequence = $lastSeq + 1; } } $seqStr = str_pad((string) $sequence, 3, '0', STR_PAD_LEFT); return "{$prefix}{$seqStr}"; } /** * 견적 품목 생성 */ private function createItems(Quote $quote, array $items, int $tenantId): void { foreach ($items as $index => $item) { $quantity = (float) ($item['calculated_quantity'] ?? $item['base_quantity'] ?? 1); $unitPrice = (float) ($item['unit_price'] ?? 0); $totalPrice = $quantity * $unitPrice; // item_type: 전달된 값 또는 items 테이블에서 조회 $itemType = $item['item_type'] ?? null; if ($itemType === null && isset($item['item_id'])) { $itemType = Item::where('id', $item['item_id'])->value('item_type'); } QuoteItem::create([ 'quote_id' => $quote->id, 'tenant_id' => $tenantId, 'item_id' => $item['item_id'] ?? null, 'item_type' => $itemType, 'item_code' => $item['item_code'] ?? '', 'item_name' => $item['item_name'] ?? '', 'specification' => $item['specification'] ?? null, 'unit' => $item['unit'] ?? 'EA', 'base_quantity' => $item['base_quantity'] ?? 1, 'calculated_quantity' => $quantity, 'unit_price' => $unitPrice, 'total_price' => $item['total_price'] ?? $totalPrice, 'formula' => $item['formula'] ?? null, 'formula_result' => $item['formula_result'] ?? null, 'formula_source' => $item['formula_source'] ?? null, 'formula_category' => $item['formula_category'] ?? null, 'data_source' => $item['data_source'] ?? null, 'delivery_date' => $item['delivery_date'] ?? null, 'note' => $item['note'] ?? null, 'sort_order' => $item['sort_order'] ?? $index, ]); } } /** * 수정 이력 생성 */ private function createRevision(Quote $quote, int $userId): QuoteRevision { // 현재 견적 데이터 스냅샷 $previousData = $quote->toArray(); $previousData['items'] = $quote->items->toArray(); return QuoteRevision::create([ 'quote_id' => $quote->id, 'tenant_id' => $quote->tenant_id, 'revision_number' => $quote->current_revision + 1, 'revision_date' => now()->toDateString(), 'revision_by' => $userId, 'revision_by_name' => auth()->user()?->name ?? 'Unknown', 'revision_reason' => null, // 별도 입력 받지 않음 'previous_data' => $previousData, ]); } /** * 현장설명회에서 견적 Upsert (생성 또는 업데이트) * * 참석완료 상태일 때 견적을 자동 생성하거나 업데이트합니다. * - 견적이 없으면: 견적대기(pending) 상태로 신규 생성 * - 견적이 있으면: 현장설명회 정보로 업데이트 (거래처, 현장 등) */ public function upsertFromSiteBriefing(SiteBriefing $siteBriefing): Quote { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); // 기존 견적 조회 $existingQuote = Quote::where('tenant_id', $tenantId) ->where('site_briefing_id', $siteBriefing->id) ->first(); return DB::transaction(function () use ($siteBriefing, $tenantId, $userId, $existingQuote) { if ($existingQuote) { // 기존 견적 업데이트 (현장설명회 정보 동기화) $existingQuote->update([ // 발주처 정보 동기화 'client_id' => $siteBriefing->partner_id, 'client_name' => $siteBriefing->partner?->name, // 현장 정보 동기화 'site_id' => $siteBriefing->site_id, 'site_name' => $siteBriefing->title, // 감사 'updated_by' => $userId, ]); return $existingQuote->refresh(); } // 신규 견적 생성 $quoteNumber = $this->numberService->generate('SCREEN'); return Quote::create([ 'tenant_id' => $tenantId, 'quote_type' => Quote::TYPE_CONSTRUCTION, 'site_briefing_id' => $siteBriefing->id, 'quote_number' => $quoteNumber, 'registration_date' => now()->toDateString(), // 발주처 정보 (현장설명회에서 복사) 'client_id' => $siteBriefing->partner_id, 'client_name' => $siteBriefing->partner?->name, // 현장 정보 (현장설명회에서 복사) 'site_id' => $siteBriefing->site_id, 'site_name' => $siteBriefing->title, // 제품 카테고리 없음 (pending 상태이므로) 'product_category' => null, // 금액 정보 (빈 값) 'material_cost' => 0, 'labor_cost' => 0, 'install_cost' => 0, 'subtotal' => 0, 'discount_rate' => 0, 'discount_amount' => 0, 'total_amount' => 0, // 상태 관리 (견적대기) 'status' => Quote::STATUS_PENDING, 'current_revision' => 0, 'is_final' => false, // 비고 (현장설명회 정보 기록) 'remarks' => "현장설명회 참석완료로 자동생성 (현설번호: {$siteBriefing->briefing_code})", // 감사 'created_by' => $userId, ]); }); } /** * @deprecated Use upsertFromSiteBriefing() instead */ public function createFromSiteBriefing(SiteBriefing $siteBriefing): ?Quote { return $this->upsertFromSiteBriefing($siteBriefing); } /** * 현장설명회 ID로 연결된 견적 조회 */ public function findBySiteBriefingId(int $siteBriefingId): ?Quote { $tenantId = $this->tenantId(); return Quote::where('tenant_id', $tenantId) ->where('site_briefing_id', $siteBriefingId) ->first(); } /** * 견적 options 병합 * 기존 options와 새 options를 병합하여 반환 */ private function mergeOptions(?array $existingOptions, ?array $newOptions): ?array { \Log::info('🔍 [QuoteService::mergeOptions] 시작', [ 'existingOptions_keys' => $existingOptions ? array_keys($existingOptions) : null, 'newOptions_keys' => $newOptions ? array_keys($newOptions) : null, 'newOptions_detail_items_count' => isset($newOptions['detail_items']) ? count($newOptions['detail_items']) : 0, 'newOptions_price_adjustment_data' => isset($newOptions['price_adjustment_data']) ? 'exists' : 'null', ]); if ($newOptions === null) { return $existingOptions; } if ($existingOptions === null) { \Log::info('✅ [QuoteService::mergeOptions] 기존 없음, 새 options 반환', [ 'result_keys' => array_keys($newOptions), ]); return $newOptions; } $merged = array_merge($existingOptions, $newOptions); \Log::info('✅ [QuoteService::mergeOptions] 병합 완료', [ 'merged_keys' => array_keys($merged), 'merged_detail_items_count' => isset($merged['detail_items']) ? count($merged['detail_items']) : 0, ]); return $merged; } /** * 견적을 입찰로 변환 * 시공 견적(finalized)만 입찰로 변환 가능 * * @param int $quoteId 견적 ID * @return Bidding 생성된 입찰 정보 * * @throws \Illuminate\Database\Eloquent\ModelNotFoundException 견적이 존재하지 않는 경우 * @throws \RuntimeException 이미 입찰로 변환된 경우 또는 시공 견적이 아닌 경우 */ public function convertToBidding(int $quoteId): Bidding { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); // 1. 견적 조회 (시공 견적이어야 함) $quote = Quote::where('tenant_id', $tenantId) ->where('id', $quoteId) ->where('type', Quote::TYPE_CONSTRUCTION) // 시공 견적만 ->firstOrFail(); // 2. 이미 입찰로 변환되었는지 확인 $existingBidding = Bidding::where('tenant_id', $tenantId) ->where('quote_id', $quoteId) ->first(); if ($existingBidding) { throw new \RuntimeException(__('error.bidding.already_converted')); } // 3. 입찰번호 생성 (BID-YYYYMMDD-XXXX) $today = now()->format('Ymd'); $lastBidding = Bidding::where('tenant_id', $tenantId) ->where('bidding_code', 'like', "BID-{$today}-%") ->orderBy('bidding_code', 'desc') ->first(); $sequence = 1; if ($lastBidding) { $parts = explode('-', $lastBidding->bidding_code); $sequence = (int) ($parts[2] ?? 0) + 1; } $biddingCode = sprintf('BID-%s-%04d', $today, $sequence); // 4. 입찰 생성 $bidding = Bidding::create([ 'tenant_id' => $tenantId, 'bidding_code' => $biddingCode, 'quote_id' => $quote->id, // 거래처/현장 정보 'client_id' => $quote->client_id, 'client_name' => $quote->client_name ?? $quote->client?->name, 'project_name' => $quote->project_name, // 입찰 정보 (초기값) 'bidding_date' => now()->toDateString(), 'total_count' => 1, 'bidding_amount' => $quote->total_amount ?? 0, 'status' => Bidding::STATUS_WAITING, // 입찰자 정보 (현재 사용자) 'bidder_id' => $userId, // VAT 유형 'vat_type' => $quote->vat_type ?? Bidding::VAT_INCLUDED, // 견적 데이터 스냅샷 'expense_items' => $quote->options['expense_items'] ?? null, 'estimate_detail_items' => $quote->options['detail_items'] ?? null, // 감사 필드 'created_by' => $userId, ]); \Log::info('✅ [QuoteService::convertToBidding] 입찰 변환 완료', [ 'quote_id' => $quoteId, 'bidding_id' => $bidding->id, 'bidding_code' => $biddingCode, ]); return $bidding; } }