feat(API):문서 데이터 정규화 커맨드 추가
- NormalizeDocumentData: 기존 document_data를 정규화 형식으로 일괄 변환 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
253
app/Console/Commands/NormalizeDocumentData.php
Normal file
253
app/Console/Commands/NormalizeDocumentData.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user