feat(API):문서 데이터 정규화 커맨드 추가

- NormalizeDocumentData: 기존 document_data를 정규화 형식으로 일괄 변환

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 00:01:30 +09:00
parent 376348a491
commit 911c8a36ad

View File

@@ -0,0 +1,253 @@
<?php
namespace App\Console\Commands;
use App\Models\Documents\Document;
use App\Models\Documents\DocumentData;
use App\Models\Documents\DocumentTemplateSection;
use Illuminate\Console\Attributes\AsCommand;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
#[AsCommand(name: 'documents:normalize-data', description: '기존 document_data의 field_key를 정규화 형식으로 변환')]
class NormalizeDocumentData extends Command
{
protected $signature = 'documents:normalize-data
{--document= : 특정 문서 ID만 처리 (쉼표 구분)}
{--dry-run : 실제 변경 없이 시뮬레이션만 수행}
{--force : 확인 없이 실행}';
public function handle(): int
{
$dryRun = $this->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,
];
}
}