From 911c8a36ad5a59b8fb846b2ead9dfcbe2c43908a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Thu, 12 Feb 2026 00:01:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(API):=EB=AC=B8=EC=84=9C=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=A0=95=EA=B7=9C=ED=99=94=20=EC=BB=A4?= =?UTF-8?q?=EB=A7=A8=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NormalizeDocumentData: 기존 document_data를 정규화 형식으로 일괄 변환 Co-Authored-By: Claude Opus 4.6 --- .../Commands/NormalizeDocumentData.php | 253 ++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 app/Console/Commands/NormalizeDocumentData.php diff --git a/app/Console/Commands/NormalizeDocumentData.php b/app/Console/Commands/NormalizeDocumentData.php new file mode 100644 index 0000000..5156c1c --- /dev/null +++ b/app/Console/Commands/NormalizeDocumentData.php @@ -0,0 +1,253 @@ +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, + ]; + } +}