tenantId(); $perPage = (int) ($params['per_page'] ?? 20); $q = trim((string) ($params['q'] ?? '')); $status = $params['status'] ?? null; $dateFrom = $params['date_from'] ?? null; $dateTo = $params['date_to'] ?? null; $query = QualityDocument::query() ->where('tenant_id', $tenantId) ->with(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations']); if ($q !== '') { $query->where(function ($qq) use ($q) { $qq->where('quality_doc_number', 'like', "%{$q}%") ->orWhere('site_name', 'like', "%{$q}%"); }); } if ($status !== null) { $dbStatus = QualityDocument::mapStatusFromFrontend($status); $query->where('status', $dbStatus); } if ($dateFrom !== null) { $query->where('received_date', '>=', $dateFrom); } if ($dateTo !== null) { $query->where('received_date', '<=', $dateTo); } $query->orderByDesc('id'); $paginated = $query->paginate($perPage); $transformedData = $paginated->getCollection()->map(fn ($doc) => $this->transformToFrontend($doc)); return [ 'items' => $transformedData, 'current_page' => $paginated->currentPage(), 'last_page' => $paginated->lastPage(), 'per_page' => $paginated->perPage(), 'total' => $paginated->total(), ]; } /** * 통계 조회 */ public function stats(array $params = []): array { $tenantId = $this->tenantId(); $query = QualityDocument::where('tenant_id', $tenantId); if (! empty($params['date_from'])) { $query->where('received_date', '>=', $params['date_from']); } if (! empty($params['date_to'])) { $query->where('received_date', '<=', $params['date_to']); } $counts = (clone $query) ->select('status', DB::raw('count(*) as count')) ->groupBy('status') ->pluck('count', 'status') ->toArray(); return [ 'reception_count' => $counts[QualityDocument::STATUS_RECEIVED] ?? 0, 'in_progress_count' => $counts[QualityDocument::STATUS_IN_PROGRESS] ?? 0, 'completed_count' => $counts[QualityDocument::STATUS_COMPLETED] ?? 0, ]; } /** * 캘린더 스케줄 조회 */ public function calendar(array $params): array { $tenantId = $this->tenantId(); $year = (int) ($params['year'] ?? now()->year); $month = (int) ($params['month'] ?? now()->month); $startDate = sprintf('%04d-%02d-01', $year, $month); $endDate = date('Y-m-t', strtotime($startDate)); $query = QualityDocument::query() ->where('tenant_id', $tenantId) ->with(['inspector:id,name']); // options JSON 내 inspection.start_date / inspection.end_date 기준 필터링 // received_date 기준으로 범위 필터 (options JSON은 직접 SQL 필터 어려우므로) $query->where(function ($q) use ($startDate, $endDate) { $q->whereBetween('received_date', [$startDate, $endDate]); }); if (! empty($params['status'])) { $dbStatus = QualityDocument::mapStatusFromFrontend($params['status']); $query->where('status', $dbStatus); } return $query->orderBy('received_date') ->get() ->map(function (QualityDocument $doc) { $options = $doc->options ?? []; $inspection = $options['inspection'] ?? []; return [ 'id' => $doc->id, 'start_date' => $inspection['start_date'] ?? $doc->received_date?->format('Y-m-d'), 'end_date' => $inspection['end_date'] ?? $doc->received_date?->format('Y-m-d'), 'inspector' => $doc->inspector?->name ?? '', 'site_name' => $doc->site_name, 'status' => QualityDocument::mapStatusToFrontend($doc->status), ]; }) ->values() ->toArray(); } /** * 단건 조회 */ public function show(int $id) { $tenantId = $this->tenantId(); $doc = QualityDocument::where('tenant_id', $tenantId) ->with([ 'client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations.orderItem.node', ]) ->find($id); if (! $doc) { throw new NotFoundHttpException(__('error.not_found')); } return $this->transformToFrontend($doc, true); } /** * 생성 */ public function store(array $data) { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); return DB::transaction(function () use ($data, $tenantId, $userId) { // order_ids는 별도 처리 후 $data에서 제거 $orderIds = $data['order_ids'] ?? null; unset($data['order_ids']); $data['tenant_id'] = $tenantId; $data['quality_doc_number'] = QualityDocument::generateDocNumber($tenantId); $data['status'] = QualityDocument::STATUS_RECEIVED; $data['created_by'] = $userId; $doc = QualityDocument::create($data); // 수주 연결 if (! empty($orderIds)) { $this->syncOrders($doc, $orderIds, $tenantId); } $this->auditLogger->log( $tenantId, self::AUDIT_TARGET, $doc->id, 'created', null, $doc->toArray() ); $doc->load(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations.orderItem.node']); // 요청서 Document(EAV) 자동생성 $this->syncRequestDocument($doc); return $this->transformToFrontend($doc); }); } /** * 수정 */ public function update(int $id, array $data) { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $doc = QualityDocument::where('tenant_id', $tenantId)->find($id); if (! $doc) { throw new NotFoundHttpException(__('error.not_found')); } $beforeData = $doc->toArray(); return DB::transaction(function () use ($doc, $data, $userId, $beforeData, $tenantId) { // order_ids, locations는 별도 처리 후 $data에서 제거 $orderIds = $data['order_ids'] ?? null; $locations = $data['locations'] ?? null; unset($data['order_ids'], $data['locations']); $data['updated_by'] = $userId; // options는 기존 값과 병합 if (isset($data['options'])) { $existingOptions = $doc->options ?? []; $data['options'] = array_replace_recursive($existingOptions, $data['options']); } $doc->update($data); // 수주 동기화 (order_ids가 전달된 경우만) if ($orderIds !== null) { $this->syncOrders($doc, $orderIds, $tenantId); } // 개소별 데이터 업데이트 (시공규격, 변경사유, 검사데이터) if (! empty($locations)) { $this->updateLocations($doc->id, $locations); } $this->auditLogger->log( $doc->tenant_id, self::AUDIT_TARGET, $doc->id, 'updated', $beforeData, $doc->fresh()->toArray() ); $doc->load(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations.orderItem.node']); // 요청서 Document(EAV) 동기화 $this->syncRequestDocument($doc); return $this->transformToFrontend($doc); }); } /** * 삭제 */ public function destroy(int $id) { $tenantId = $this->tenantId(); $doc = QualityDocument::where('tenant_id', $tenantId)->find($id); if (! $doc) { throw new NotFoundHttpException(__('error.not_found')); } if ($doc->isCompleted()) { throw new BadRequestHttpException(__('error.quality.cannot_delete_completed')); } $beforeData = $doc->toArray(); $doc->deleted_by = $this->apiUserId(); $doc->save(); $doc->delete(); $this->auditLogger->log( $tenantId, self::AUDIT_TARGET, $doc->id, 'deleted', $beforeData, null ); return 'success'; } /** * 검사 완료 처리 */ public function complete(int $id) { $tenantId = $this->tenantId(); $userId = $this->apiUserId(); $doc = QualityDocument::where('tenant_id', $tenantId) ->with(['locations']) ->find($id); if (! $doc) { throw new NotFoundHttpException(__('error.not_found')); } if ($doc->isCompleted()) { throw new BadRequestHttpException(__('error.quality.already_completed')); } // 미완료 개소 확인 $pendingCount = $doc->locations->where('inspection_status', QualityDocumentLocation::STATUS_PENDING)->count(); if ($pendingCount > 0) { throw new BadRequestHttpException(__('error.quality.pending_locations', ['count' => $pendingCount])); } $beforeData = $doc->toArray(); return DB::transaction(function () use ($doc, $userId, $tenantId, $beforeData) { $doc->update([ 'status' => QualityDocument::STATUS_COMPLETED, 'updated_by' => $userId, ]); // 실적신고 자동 생성 $now = now(); PerformanceReport::firstOrCreate( [ 'tenant_id' => $tenantId, 'quality_document_id' => $doc->id, ], [ 'year' => $now->year, 'quarter' => (int) ceil($now->month / 3), 'confirmation_status' => PerformanceReport::STATUS_UNCONFIRMED, 'created_by' => $userId, ] ); $this->auditLogger->log( $tenantId, self::AUDIT_TARGET, $doc->id, 'completed', $beforeData, $doc->fresh()->toArray() ); return $this->transformToFrontend($doc->load(['client', 'inspector:id,name', 'creator:id,name'])); }); } /** * 검사 미등록 수주 목록 */ public function availableOrders(array $params): array { $tenantId = $this->tenantId(); $q = trim((string) ($params['q'] ?? '')); $clientId = $params['client_id'] ?? null; $itemId = $params['item_id'] ?? null; // 이미 연결된 수주 ID 목록 $linkedOrderIds = QualityDocumentOrder::whereHas('qualityDocument', function ($query) use ($tenantId) { $query->where('tenant_id', $tenantId); })->pluck('order_id'); $query = Order::where('tenant_id', $tenantId) ->whereNotIn('id', $linkedOrderIds) ->with(['item:id,name', 'nodes' => function ($q) { $q->whereNull('parent_id')->orderBy('sort_order') ->with(['items' => function ($q2) { $q2->orderBy('sort_order')->limit(1); }]); }]) ->withCount(['nodes as location_count' => function ($q) { $q->whereNull('parent_id'); }]); if ($q !== '') { $query->where(function ($qq) use ($q) { $qq->where('order_no', 'like', "%{$q}%") ->orWhere('site_name', 'like', "%{$q}%"); }); } // 같은 거래처(발주처) 필터 if ($clientId) { $query->where('client_id', $clientId); } // 같은 모델(품목) 필터 if ($itemId) { $query->where('item_id', $itemId); } return $query->orderByDesc('id') ->limit(50) ->get() ->map(fn ($order) => [ 'id' => $order->id, 'order_number' => $order->order_no, 'site_name' => $order->site_name ?? '', 'client_id' => $order->client_id, 'client_name' => $order->client_name ?? '', 'item_id' => $order->item_id, 'item_name' => $order->item?->name ?? '', 'delivery_date' => $order->delivery_date?->format('Y-m-d') ?? '', 'location_count' => $order->location_count, 'locations' => $order->nodes->where('parent_id', null)->map(function ($node) { $item = $node->items->first(); $options = $node->options ?? []; return [ 'node_id' => $node->id, 'floor' => $item?->floor_code ?? $node->code ?? '', 'symbol' => $item?->symbol_code ?? '', 'order_width' => $options['width'] ?? 0, 'order_height' => $options['height'] ?? 0, ]; })->values()->toArray(), ]) ->toArray(); } /** * 개소별 데이터 업데이트 */ private function updateLocations(int $docId, array $locations): void { foreach ($locations as $locData) { $locId = $locData['id'] ?? null; if (! $locId) { continue; } $location = QualityDocumentLocation::where('quality_document_id', $docId)->find($locId); if (! $location) { continue; } $updateData = []; if (array_key_exists('post_width', $locData)) { $updateData['post_width'] = $locData['post_width']; } if (array_key_exists('post_height', $locData)) { $updateData['post_height'] = $locData['post_height']; } if (array_key_exists('change_reason', $locData)) { $updateData['change_reason'] = $locData['change_reason']; } if (array_key_exists('inspection_data', $locData)) { $updateData['inspection_data'] = $locData['inspection_data']; } if (! empty($updateData)) { $location->update($updateData); } } } /** * 수주 동기화 (update 시 사용) */ private function syncOrders(QualityDocument $doc, array $orderIds, int $tenantId): void { $existingOrderIds = QualityDocumentOrder::where('quality_document_id', $doc->id) ->pluck('order_id') ->toArray(); $toAttach = array_diff($orderIds, $existingOrderIds); $toDetach = array_diff($existingOrderIds, $orderIds); // 새로 연결 foreach ($toAttach as $orderId) { $order = Order::where('tenant_id', $tenantId)->find($orderId); if (! $order) { continue; } $docOrder = QualityDocumentOrder::firstOrCreate([ 'quality_document_id' => $doc->id, 'order_id' => $orderId, ]); // 개소(root OrderNode) 기준으로 location 생성 $rootNodes = OrderNode::where('order_id', $orderId) ->whereNull('parent_id') ->orderBy('sort_order') ->get(); foreach ($rootNodes as $node) { // 각 개소의 대표 OrderItem (해당 노드 하위 첫 번째 품목) $representativeItem = OrderItem::where('order_node_id', $node->id) ->orderBy('sort_order') ->first(); if ($representativeItem) { QualityDocumentLocation::firstOrCreate([ 'quality_document_id' => $doc->id, 'quality_document_order_id' => $docOrder->id, 'order_item_id' => $representativeItem->id, ]); } } } // 연결 해제 foreach ($toDetach as $orderId) { $docOrder = QualityDocumentOrder::where('quality_document_id', $doc->id) ->where('order_id', $orderId) ->first(); if ($docOrder) { QualityDocumentLocation::where('quality_document_order_id', $docOrder->id)->delete(); $docOrder->delete(); } } } /** * 수주 연결 */ public function attachOrders(int $docId, array $orderIds) { $tenantId = $this->tenantId(); $doc = QualityDocument::where('tenant_id', $tenantId)->find($docId); if (! $doc) { throw new NotFoundHttpException(__('error.not_found')); } return DB::transaction(function () use ($doc, $orderIds, $tenantId) { foreach ($orderIds as $orderId) { $order = Order::where('tenant_id', $tenantId)->find($orderId); if (! $order) { continue; } // 중복 체크 $docOrder = QualityDocumentOrder::firstOrCreate([ 'quality_document_id' => $doc->id, 'order_id' => $orderId, ]); // 수주 연결 시 개소(root OrderNode)를 locations에 자동 생성 $rootNodes = OrderNode::where('order_id', $orderId) ->whereNull('parent_id') ->orderBy('sort_order') ->get(); foreach ($rootNodes as $node) { $representativeItem = OrderItem::where('order_node_id', $node->id) ->orderBy('sort_order') ->first(); if ($representativeItem) { QualityDocumentLocation::firstOrCreate([ 'quality_document_id' => $doc->id, 'quality_document_order_id' => $docOrder->id, 'order_item_id' => $representativeItem->id, ]); } } } // 상태를 진행중으로 변경 (접수 상태일 때) if ($doc->isReceived()) { $doc->update(['status' => QualityDocument::STATUS_IN_PROGRESS]); } $doc->load(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations.orderItem.node']); // 요청서 Document(EAV) 동기화 (개소 추가됨) $this->syncRequestDocument($doc); return $this->transformToFrontend($doc); }); } /** * 수주 연결 해제 */ public function detachOrder(int $docId, int $orderId) { $tenantId = $this->tenantId(); $doc = QualityDocument::where('tenant_id', $tenantId)->find($docId); if (! $doc) { throw new NotFoundHttpException(__('error.not_found')); } if ($doc->isCompleted()) { throw new BadRequestHttpException(__('error.quality.cannot_modify_completed')); } $docOrder = QualityDocumentOrder::where('quality_document_id', $docId) ->where('order_id', $orderId) ->first(); if ($docOrder) { // 해당 수주의 locations 삭제 QualityDocumentLocation::where('quality_document_order_id', $docOrder->id)->delete(); $docOrder->delete(); } return $this->transformToFrontend( $doc->load(['client', 'inspector:id,name', 'creator:id,name', 'documentOrders.order', 'locations.orderItem.node']) ); } /** * 필수정보 계산 */ public function calculateRequiredInfo(QualityDocument $doc): string { $options = $doc->options ?? []; $missing = 0; $sections = [ 'construction_site' => ['name', 'land_location', 'lot_number'], 'material_distributor' => ['company', 'address', 'ceo', 'phone'], 'contractor' => ['company', 'address', 'name', 'phone'], 'supervisor' => ['office', 'address', 'name', 'phone'], ]; foreach ($sections as $section => $fields) { $data = $options[$section] ?? []; foreach ($fields as $field) { if (empty($data[$field])) { $missing++; break; // 섹션 단위 } } } return $missing === 0 ? '완료' : "{$missing}건 누락"; } /** * DB → 프론트엔드 변환 */ private function transformToFrontend(QualityDocument $doc, bool $detail = false): array { $options = $doc->options ?? []; $result = [ 'id' => $doc->id, 'quality_doc_number' => $doc->quality_doc_number, 'site_name' => $doc->site_name, 'client' => $doc->client?->name ?? '', 'location_count' => $doc->locations?->count() ?? 0, 'required_info' => $this->calculateRequiredInfo($doc), 'inspection_period' => $this->formatInspectionPeriod($options), 'inspector' => $doc->inspector?->name ?? '', 'status' => QualityDocument::mapStatusToFrontend($doc->status), 'author' => $doc->creator?->name ?? '', 'reception_date' => $doc->received_date?->format('Y-m-d'), 'manager' => $options['manager']['name'] ?? '', 'manager_contact' => $options['manager']['phone'] ?? '', ]; // 요청서 Document ID (EAV) $requestDoc = Document::where('tenant_id', $doc->tenant_id) ->where('template_id', self::REQUEST_TEMPLATE_ID) ->where('linkable_type', QualityDocument::class) ->where('linkable_id', $doc->id) ->first(); $result['request_document_id'] = $requestDoc?->id; if ($detail) { $result['construction_site'] = [ 'site_name' => $options['construction_site']['name'] ?? '', 'land_location' => $options['construction_site']['land_location'] ?? '', 'lot_number' => $options['construction_site']['lot_number'] ?? '', ]; $result['material_distributor'] = [ 'company_name' => $options['material_distributor']['company'] ?? '', 'company_address' => $options['material_distributor']['address'] ?? '', 'representative_name' => $options['material_distributor']['ceo'] ?? '', 'phone' => $options['material_distributor']['phone'] ?? '', ]; $result['constructor_info'] = [ 'company_name' => $options['contractor']['company'] ?? '', 'company_address' => $options['contractor']['address'] ?? '', 'name' => $options['contractor']['name'] ?? '', 'phone' => $options['contractor']['phone'] ?? '', ]; $result['supervisor'] = [ 'office_name' => $options['supervisor']['office'] ?? '', 'office_address' => $options['supervisor']['address'] ?? '', 'name' => $options['supervisor']['name'] ?? '', 'phone' => $options['supervisor']['phone'] ?? '', ]; $result['schedule_info'] = [ 'visit_request_date' => $options['inspection']['request_date'] ?? '', 'start_date' => $options['inspection']['start_date'] ?? '', 'end_date' => $options['inspection']['end_date'] ?? '', 'inspector' => $doc->inspector?->name ?? '', 'site_postal_code' => $options['site_address']['postal_code'] ?? '', 'site_address' => $options['site_address']['address'] ?? '', 'site_address_detail' => $options['site_address']['detail'] ?? '', ]; // 개소 목록 (각 location은 1개 root OrderNode = 1개 개소) $result['order_items'] = $doc->locations->map(function ($loc) { $orderItem = $loc->orderItem; $node = $orderItem?->node; $nodeOptions = $node?->options ?? []; $order = $loc->qualityDocumentOrder?->order; return [ 'id' => (string) $loc->id, 'order_id' => $order?->id, 'order_number' => $order?->order_no ?? '', 'site_name' => $order?->site_name ?? '', 'client_id' => $order?->client_id, 'client_name' => $order?->client_name ?? '', 'item_id' => $order?->item_id, 'item_name' => $order?->item?->name ?? '', 'delivery_date' => $order?->delivery_date ? $order->delivery_date->format('Y-m-d') : '', 'floor' => $orderItem?->floor_code ?? '', 'symbol' => $orderItem?->symbol_code ?? '', 'order_width' => $nodeOptions['width'] ?? 0, 'order_height' => $nodeOptions['height'] ?? 0, 'construction_width' => $loc->post_width ?? 0, 'construction_height' => $loc->post_height ?? 0, 'change_reason' => $loc->change_reason ?? '', 'inspection_data' => $loc->inspection_data, 'document_id' => $loc->document_id, ]; })->toArray(); } return $result; } /** * 개소별 검사 저장 (시공후 규격 + 검사 성적서) */ public function inspectLocation(int $docId, int $locId, array $data) { $tenantId = $this->tenantId(); $doc = QualityDocument::where('tenant_id', $tenantId)->find($docId); if (! $doc) { throw new NotFoundHttpException(__('error.not_found')); } $location = QualityDocumentLocation::where('quality_document_id', $docId)->find($locId); if (! $location) { throw new NotFoundHttpException(__('error.not_found')); } return DB::transaction(function () use ($location, $data, $doc) { $updateData = []; if (isset($data['post_width'])) { $updateData['post_width'] = $data['post_width']; } if (isset($data['post_height'])) { $updateData['post_height'] = $data['post_height']; } if (isset($data['change_reason'])) { $updateData['change_reason'] = $data['change_reason']; } if (isset($data['inspection_status'])) { $updateData['inspection_status'] = $data['inspection_status']; } if (array_key_exists('inspection_data', $data)) { $updateData['inspection_data'] = $data['inspection_data']; } if (! empty($updateData)) { $location->update($updateData); } // 상태를 진행중으로 변경 (접수 상태일 때) if ($doc->isReceived()) { $doc->update(['status' => QualityDocument::STATUS_IN_PROGRESS]); } return $location->fresh()->toArray(); }); } /** * 검사제품요청서 데이터 (PDF/프린트용) */ public function requestDocument(int $id): array { $tenantId = $this->tenantId(); $doc = QualityDocument::where('tenant_id', $tenantId) ->with([ 'client', 'inspector:id,name', 'documentOrders.order', 'locations.orderItem.node', ]) ->find($id); if (! $doc) { throw new NotFoundHttpException(__('error.not_found')); } $options = $doc->options ?? []; return [ 'quality_doc_number' => $doc->quality_doc_number, 'site_name' => $doc->site_name, 'client' => $doc->client?->name ?? '', 'received_date' => $doc->received_date?->format('Y-m-d'), 'inspector' => $doc->inspector?->name ?? '', 'construction_site' => $options['construction_site'] ?? [], 'material_distributor' => $options['material_distributor'] ?? [], 'contractor' => $options['contractor'] ?? [], 'supervisor' => $options['supervisor'] ?? [], 'inspection' => $options['inspection'] ?? [], 'site_address' => $options['site_address'] ?? [], 'manager' => $options['manager'] ?? [], 'items' => $doc->locations->map(function ($loc) { $orderItem = $loc->orderItem; $node = $orderItem?->node; $nodeOptions = $node?->options ?? []; $order = $loc->qualityDocumentOrder?->order; return [ 'order_number' => $order?->order_no ?? '', 'floor' => $orderItem?->floor_code ?? '', 'symbol' => $orderItem?->symbol_code ?? '', 'item_name' => $orderItem?->item_name ?? '', 'specification' => $orderItem?->specification ?? '', 'order_width' => $nodeOptions['width'] ?? 0, 'order_height' => $nodeOptions['height'] ?? 0, 'quantity' => $orderItem?->quantity ?? 1, ]; })->toArray(), ]; } /** * 제품검사성적서 데이터 (documents EAV 연동) */ public function resultDocument(int $id): array { $tenantId = $this->tenantId(); $doc = QualityDocument::where('tenant_id', $tenantId) ->with([ 'client', 'inspector:id,name', 'locations.orderItem.node', 'locations.document.data', 'locations.document.template', ]) ->find($id); if (! $doc) { throw new NotFoundHttpException(__('error.not_found')); } $options = $doc->options ?? []; return [ 'quality_doc_number' => $doc->quality_doc_number, 'site_name' => $doc->site_name, 'client' => $doc->client?->name ?? '', 'inspector' => $doc->inspector?->name ?? '', 'status' => QualityDocument::mapStatusToFrontend($doc->status), 'locations' => $doc->locations->map(function ($loc) { $orderItem = $loc->orderItem; $node = $orderItem?->node; $nodeOptions = $node?->options ?? []; $document = $loc->document; $result = [ 'id' => $loc->id, 'floor' => $orderItem?->floor_code ?? '', 'symbol' => $orderItem?->symbol_code ?? '', 'order_width' => $nodeOptions['width'] ?? 0, 'order_height' => $nodeOptions['height'] ?? 0, 'post_width' => $loc->post_width, 'post_height' => $loc->post_height, 'change_reason' => $loc->change_reason, 'inspection_status' => $loc->inspection_status, 'document_id' => $loc->document_id, ]; // EAV 문서 데이터가 있으면 포함 if ($document) { $result['document'] = [ 'id' => $document->id, 'document_no' => $document->document_no, 'status' => $document->status, 'template_id' => $document->template_id, 'data' => $document->data?->map(fn ($d) => [ 'field_key' => $d->field_key, 'field_value' => $d->field_value, 'section_id' => $d->section_id, 'column_id' => $d->column_id, ])->toArray() ?? [], ]; } return $result; })->toArray(), ]; } // ========================================================================= // 제품검사 요청서 Document 자동생성/동기화 // ========================================================================= private const REQUEST_TEMPLATE_ID = 66; /** * 요청서 Document(EAV) 동기화 * * quality_document 생성/수정 시 호출. * - Document 없으면 생성 (template_id=66, linkable=QualityDocument) * - 기본필드 + 섹션 데이터 + 사전고지 테이블을 EAV로 매핑 */ private function syncRequestDocument(QualityDocument $doc): void { $tenantId = $doc->tenant_id; // 템플릿 존재 확인 $template = DocumentTemplate::where('tenant_id', $tenantId) ->where('id', self::REQUEST_TEMPLATE_ID) ->with(['basicFields', 'sections.items', 'columns']) ->first(); if (! $template) { return; // 템플릿 미등록 시 스킵 } // 기존 Document 조회 또는 생성 $document = Document::where('tenant_id', $tenantId) ->where('template_id', self::REQUEST_TEMPLATE_ID) ->where('linkable_type', QualityDocument::class) ->where('linkable_id', $doc->id) ->first(); if (! $document) { $documentNo = $this->generateRequestDocumentNo($tenantId); $document = Document::create([ 'tenant_id' => $tenantId, 'template_id' => self::REQUEST_TEMPLATE_ID, 'document_no' => $documentNo, 'title' => '제품검사 요청서 - '.($doc->site_name ?? $doc->quality_doc_number), 'status' => Document::STATUS_DRAFT, 'linkable_type' => QualityDocument::class, 'linkable_id' => $doc->id, 'created_by' => $doc->created_by, 'updated_by' => $doc->updated_by ?? $doc->created_by, ]); } else { // rendered_html 초기화 (데이터 변경 시 재캡처 필요) $document->update([ 'rendered_html' => null, 'updated_by' => $doc->updated_by ?? $doc->created_by, ]); } // 기존 EAV 데이터 삭제 후 재생성 DocumentData::where('document_id', $document->id)->delete(); $options = $doc->options ?? []; $eavData = []; // 1. 기본필드 매핑 (quality_document → basicFields) $fieldMapping = $this->buildBasicFieldMapping($doc, $options); foreach ($template->basicFields as $bf) { $value = $fieldMapping[$bf->field_key] ?? ''; if ($value !== '') { $eavData[] = [ 'document_id' => $document->id, 'section_id' => null, 'column_id' => null, 'row_index' => 0, 'field_key' => $bf->field_key, 'field_value' => (string) $value, ]; } } // 2. 섹션 아이템 매핑 (options → section items) $sectionMapping = $this->buildSectionMapping($options); foreach ($template->sections as $section) { if ($section->items->isEmpty()) { continue; // 사전 고지 정보 섹션은 items가 없으므로 스킵 } $sectionData = $sectionMapping[$section->title] ?? []; foreach ($section->items as $item) { $value = $sectionData[$item->item] ?? ''; if ($value !== '') { $eavData[] = [ 'document_id' => $document->id, 'section_id' => $section->id, 'column_id' => null, 'row_index' => 0, 'field_key' => $item->item, // item name as key 'field_value' => (string) $value, ]; } } } // 3. 사전고지 테이블 매핑 (locations → columns) $doc->loadMissing(['locations.orderItem.node', 'locations.qualityDocumentOrder.order']); $columns = $template->columns->sortBy('sort_order'); foreach ($doc->locations as $rowIdx => $loc) { $orderItem = $loc->orderItem; $node = $orderItem?->node; $nodeOptions = $node?->options ?? []; $order = $loc->qualityDocumentOrder?->order; $rowData = [ 'No.' => (string) ($rowIdx + 1), '층수' => $orderItem?->floor_code ?? '', '부호' => $orderItem?->symbol_code ?? '', '발주 가로' => (string) ($nodeOptions['width'] ?? ''), '발주 세로' => (string) ($nodeOptions['height'] ?? ''), '시공 가로' => (string) ($loc->post_width ?? ''), '시공 세로' => (string) ($loc->post_height ?? ''), '변경사유' => $loc->change_reason ?? '', ]; foreach ($columns as $col) { $value = $rowData[$col->label] ?? ''; $eavData[] = [ 'document_id' => $document->id, 'section_id' => null, 'column_id' => $col->id, 'row_index' => $rowIdx, 'field_key' => $col->label, 'field_value' => $value, ]; } } // EAV 일괄 삽입 if (! empty($eavData)) { DocumentData::insert(array_map(function ($d) { $d['created_at'] = now(); $d['updated_at'] = now(); return $d; }, $eavData)); } } /** * 기본필드 매핑 (quality_document → template basicFields) */ private function buildBasicFieldMapping(QualityDocument $doc, array $options): array { $manager = $options['manager'] ?? []; $inspection = $options['inspection'] ?? []; $siteAddress = $options['site_address'] ?? []; $order = $doc->documentOrders?->first()?->order; return [ 'client' => $doc->client?->name ?? '', 'company_name' => $manager['company'] ?? '', 'manager' => $manager['name'] ?? '', 'order_number' => $order?->order_no ?? '', 'manager_contact' => $manager['phone'] ?? '', 'site_name' => $doc->site_name ?? '', 'delivery_date' => $order?->delivery_date?->format('Y-m-d') ?? '', 'site_address' => trim(($siteAddress['address'] ?? '').' '.($siteAddress['detail'] ?? '')), 'total_locations' => (string) ($doc->locations?->count() ?? 0), 'receipt_date' => $doc->received_date?->format('Y-m-d') ?? '', 'inspection_request_date' => $inspection['request_date'] ?? '', ]; } /** * 섹션 데이터 매핑 (options → section items by section title) */ private function buildSectionMapping(array $options): array { $cs = $options['construction_site'] ?? []; $md = $options['material_distributor'] ?? []; $ct = $options['contractor'] ?? []; $sv = $options['supervisor'] ?? []; return [ '건축공사장 정보' => [ '현장명' => $cs['name'] ?? '', '대지위치' => $cs['land_location'] ?? '', '지번' => $cs['lot_number'] ?? '', ], '자재유통업자 정보' => [ '회사명' => $md['company'] ?? '', '주소' => $md['address'] ?? '', '대표자' => $md['ceo'] ?? '', '전화번호' => $md['phone'] ?? '', ], '공사시공자 정보' => [ '회사명' => $ct['company'] ?? '', '주소' => $ct['address'] ?? '', '성명' => $ct['name'] ?? '', '전화번호' => $ct['phone'] ?? '', ], '공사감리자 정보' => [ '사무소명' => $sv['office'] ?? '', '주소' => $sv['address'] ?? '', '성명' => $sv['name'] ?? '', '전화번호' => $sv['phone'] ?? '', ], ]; } /** * 요청서 문서번호 생성 */ private function generateRequestDocumentNo(int $tenantId): string { $prefix = 'REQ'; $date = now()->format('Ymd'); $lastNumber = Document::where('tenant_id', $tenantId) ->where('document_no', 'like', "{$prefix}-{$date}-%") ->orderByDesc('document_no') ->value('document_no'); $sequence = $lastNumber ? (int) substr($lastNumber, -4) + 1 : 1; return sprintf('%s-%s-%04d', $prefix, $date, $sequence); } private function formatInspectionPeriod(array $options): string { $inspection = $options['inspection'] ?? []; $start = $inspection['start_date'] ?? ''; $end = $inspection['end_date'] ?? ''; if ($start && $end) { return "{$start}~{$end}"; } return $start ?: $end ?: ''; } }