option('dry-run'); $documentIds = $this->option('document') ? array_map('intval', explode(',', $this->option('document'))) : null; $this->info('=== document_data 정규화 마이그레이션 ==='); $this->info('Mode: ' . ($dryRun ? 'DRY-RUN (시뮬레이션)' : 'LIVE')); if ($documentIds) { $this->info('대상 문서: ' . implode(', ', $documentIds)); } $this->newLine(); // 정규화 대상 레코드 조회: section_id가 NULL이고 field_key가 s{N}_ 패턴인 레코드 $query = DocumentData::query() ->whereNull('section_id') ->where(function ($q) { // MNG 형식: s{sectionId}_r{rowIndex}_c{colId}... $q->where('field_key', 'regexp', '^s[0-9]+_r[0-9]+_c[0-9]+') // React 형식: {itemId}_n{N}, {itemId}_okng_n{N}, {itemId}_result ->orWhere('field_key', 'regexp', '^[0-9]+_(n[0-9]+|okng_n[0-9]+|result)$') // 푸터 형식: footer_remark, footer_judgement ->orWhereIn('field_key', ['footer_remark', 'footer_judgement']); }); if ($documentIds) { $query->whereIn('document_id', $documentIds); } $records = $query->get(); if ($records->isEmpty()) { $this->info('정규화 대상 레코드가 없습니다.'); return self::SUCCESS; } $this->info("대상 레코드: {$records->count()}개"); // 관련 문서 ID 수집 및 템플릿 정보 사전 로드 $docIds = $records->pluck('document_id')->unique(); $documents = Document::whereIn('id', $docIds) ->with(['template.sections.items', 'template.columns']) ->get() ->keyBy('id'); // 변환 결과 집계 $stats = ['mng' => 0, 'react' => 0, 'footer' => 0, 'skipped' => 0]; $updates = []; foreach ($records as $record) { $doc = $documents->get($record->document_id); if (! $doc || ! $doc->template) { $stats['skipped']++; continue; } $result = $this->normalizeRecord($record, $doc); if ($result) { $updates[] = $result; $stats[$result['type']]++; } else { $stats['skipped']++; } } // 결과 테이블 출력 $this->newLine(); $this->table( ['유형', '건수'], [ ['MNG 형식 (s{}_r{}_c{})', $stats['mng']], ['React 형식 ({itemId}_*)', $stats['react']], ['Footer 형식', $stats['footer']], ['건너뜀', $stats['skipped']], ['총 변환', count($updates)], ] ); if (empty($updates)) { $this->info('변환할 레코드가 없습니다.'); return self::SUCCESS; } // 변환 상세 샘플 $this->newLine(); $this->info('변환 샘플 (최대 10건):'); $this->table( ['ID', 'doc_id', '기존 field_key', '→ section_id', '→ column_id', '→ row_index', '→ field_key'], collect($updates)->take(10)->map(fn ($u) => [ $u['id'], $u['document_id'], $u['old_field_key'], $u['section_id'] ?? 'NULL', $u['column_id'] ?? 'NULL', $u['row_index'], $u['field_key'], ])->toArray() ); if ($dryRun) { $this->warn('DRY-RUN 모드: 실제 변경 없음. --dry-run 제거하여 실행.'); return self::SUCCESS; } // 실행 확인 if (! $this->option('force') && ! $this->confirm(count($updates) . '건의 레코드를 정규화하시겠습니까?')) { $this->info('취소되었습니다.'); return self::SUCCESS; } // 트랜잭션으로 일괄 업데이트 DB::transaction(function () use ($updates) { foreach ($updates as $update) { DocumentData::where('id', $update['id'])->update([ 'section_id' => $update['section_id'], 'column_id' => $update['column_id'], 'row_index' => $update['row_index'], 'field_key' => $update['field_key'], ]); } }); $this->info(count($updates) . '건 정규화 완료.'); return self::SUCCESS; } /** * 단일 레코드 정규화 */ private function normalizeRecord(DocumentData $record, Document $doc): ?array { $key = $record->field_key; // 1. Footer 형식 if ($key === 'footer_remark') { return $this->buildUpdate($record, null, null, 0, 'remark', 'footer'); } if ($key === 'footer_judgement') { return $this->buildUpdate($record, null, null, 0, 'overall_result', 'footer'); } // 2. MNG 형식: s{sectionId}_r{rowIndex}_c{colId}[_suffix] if (preg_match('/^s(\d+)_r(\d+)_c(\d+)(?:_(.+))?$/', $key, $m)) { $sectionId = (int) $m[1]; $rowIndex = (int) $m[2]; $columnId = (int) $m[3]; $suffix = $m[4] ?? null; // suffix 정규화: n1, n1_ok, n1_ng, sub0 등은 그대로, 없으면 value $fieldKey = $suffix ?: 'value'; return $this->buildUpdate($record, $sectionId, $columnId, $rowIndex, $fieldKey, 'mng'); } // 3. React 형식: {itemId}_n{N} 또는 {itemId}_okng_n{N} 또는 {itemId}_result if (preg_match('/^(\d+)_(n\d+|okng_n\d+|result)$/', $key, $m)) { $itemId = (int) $m[1]; $suffix = $m[2]; $template = $doc->template; $sectionId = null; $rowIndex = 0; // 아이템 ID로 섹션과 행 인덱스 찾기 foreach ($template->sections as $section) { foreach ($section->items as $idx => $item) { if ($item->id === $itemId) { $sectionId = $section->id; $rowIndex = $idx; break 2; } } } if ($sectionId === null) { return null; // 아이템을 찾을 수 없음 } // suffix → 정규화된 field_key if ($suffix === 'result') { $fieldKey = 'value'; // 결과 컬럼 ID 찾기 $resultCol = $template->columns ->first(fn ($c) => in_array($c->column_type, ['select', 'check']) || str_contains($c->label, '판정')); $columnId = $resultCol?->id; } elseif (str_starts_with($suffix, 'okng_')) { // okng_n1 → n1_ok (checked value로 저장된 경우) $nPart = str_replace('okng_', '', $suffix); $fieldKey = $nPart . '_ok'; $complexCol = $template->columns->first(fn ($c) => $c->column_type === 'complex'); $columnId = $complexCol?->id; } else { // n1, n2, ... 그대로 $fieldKey = $suffix; $complexCol = $template->columns->first(fn ($c) => $c->column_type === 'complex'); $columnId = $complexCol?->id; } return $this->buildUpdate($record, $sectionId, $columnId, $rowIndex, $fieldKey, 'react'); } return null; } private function buildUpdate( DocumentData $record, ?int $sectionId, ?int $columnId, int $rowIndex, string $fieldKey, string $type ): array { return [ 'id' => $record->id, 'document_id' => $record->document_id, 'old_field_key' => $record->field_key, 'section_id' => $sectionId, 'column_id' => $columnId, 'row_index' => $rowIndex, 'field_key' => $fieldKey, 'type' => $type, ]; } }