feat:검사 기준서 동적화 + 외부 키 매핑 동적화

- 템플릿별 동적 필드 정의 (document_template_section_fields)
- 외부 키 매핑 동적화 (document_template_links + link_values)
- 문서 레벨 연결 (document_links)
- 시스템 프리셋 (document_template_field_presets)
- section_items에 field_values JSON 컬럼 추가
- 기존 고정 필드 → 동적 field_values 데이터 마이그레이션
- search_api → source_table 전환 마이그레이션

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-04 08:37:55 +09:00
parent 3d20c6979d
commit af42c115ae
14 changed files with 763 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('document_template_section_fields', function (Blueprint $table) {
$table->id();
$table->foreignId('template_id')->constrained('document_templates')->cascadeOnDelete();
$table->string('field_key', 50)->comment('내부 키 (category, item, standard 등)');
$table->string('label', 50)->comment('표시명 (구분, 검사항목 등)');
$table->string('field_type', 30)->default('text')
->comment('text|number|select|select_api|json_tolerance|json_criteria|composite_frequency');
$table->json('options')->nullable()->comment('선택지, API경로 등 부가 설정');
$table->string('width', 20)->default('100px')->comment('에디터 UI 컬럼 너비');
$table->boolean('is_required')->default(false);
$table->unsignedSmallInteger('sort_order')->default(0);
$table->timestamps();
$table->index(['template_id', 'sort_order'], 'idx_template_sort');
});
}
public function down(): void
{
Schema::dropIfExists('document_template_section_fields');
}
};

View File

@@ -0,0 +1,59 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
// 템플릿별 외부 키 매핑 정의
Schema::create('document_template_links', function (Blueprint $table) {
$table->id();
$table->foreignId('template_id')->constrained('document_templates')->cascadeOnDelete();
$table->string('link_key', 50)->comment('UI 식별 키 (items, process, lot)');
$table->string('label', 50)->comment('표시명 (연결 품목, 연결 공정)');
$table->string('link_type', 20)->default('single')->comment('single|multiple');
$table->string('search_api', 255)->comment('검색 API (/api/admin/items/search)');
$table->json('search_params')->nullable()->comment('API 추가 파라미터');
$table->json('display_fields')->nullable()->comment('표시 필드 (title, subtitle)');
$table->boolean('is_required')->default(false);
$table->unsignedSmallInteger('sort_order')->default(0);
$table->timestamps();
$table->index(['template_id', 'sort_order'], 'idx_tpl_link_sort');
});
// 템플릿 레벨 연결 값 (기존 linked_item_ids/linked_process_id 대체)
Schema::create('document_template_link_values', function (Blueprint $table) {
$table->id();
$table->foreignId('template_id')->constrained('document_templates')->cascadeOnDelete();
$table->foreignId('link_id')->constrained('document_template_links')->cascadeOnDelete();
$table->unsignedBigInteger('linkable_id')->comment('연결 대상 PK');
$table->unsignedSmallInteger('sort_order')->default(0);
$table->timestamp('created_at')->nullable();
$table->index(['link_id', 'linkable_id'], 'idx_link_value');
});
// 문서 레벨 연결 (기존 linkable_type/linkable_id 대체)
Schema::create('document_links', function (Blueprint $table) {
$table->id();
$table->foreignId('document_id')->constrained('documents')->cascadeOnDelete();
$table->unsignedBigInteger('link_id')->comment('FK: document_template_links');
$table->unsignedBigInteger('linkable_id');
$table->unsignedSmallInteger('sort_order')->default(0);
$table->timestamp('created_at')->nullable();
$table->index(['document_id', 'link_id'], 'idx_doc_link');
});
}
public function down(): void
{
Schema::dropIfExists('document_links');
Schema::dropIfExists('document_template_link_values');
Schema::dropIfExists('document_template_links');
}
};

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('document_template_field_presets', function (Blueprint $table) {
$table->id();
$table->string('name', 100)->comment('프리셋명 (수입검사 기본 필드 세트)');
$table->string('category', 50)->nullable()->comment('연관 카테고리');
$table->json('fields')->comment('section_fields 배열');
$table->json('links')->nullable()->comment('template_links 배열');
$table->unsignedSmallInteger('sort_order')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('document_template_field_presets');
}
};

View File

@@ -0,0 +1,23 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('document_template_section_items', function (Blueprint $table) {
$table->json('field_values')->nullable()->after('regulation')
->comment('동적 필드 값 {"field_key": value, ...}');
});
}
public function down(): void
{
Schema::table('document_template_section_items', function (Blueprint $table) {
$table->dropColumn('field_values');
});
}
};

View File

@@ -0,0 +1,191 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* 기존 고정 필드 데이터를 동적 구조로 변환
*
* 1. 각 template에 대해 section_fields 레코드 생성
* 2. linked_item_ids → template_links + link_values 변환
* 3. linked_process_id → template_links + link_values 변환
* 4. 각 section_item의 기존 컬럼값을 field_values JSON으로 변환
*/
public function up(): void
{
// 기본 필드 정의 (기존 고정 컬럼 기반)
$defaultFields = [
['field_key' => 'category', 'label' => '구분', 'field_type' => 'text', 'width' => '65px', 'is_required' => false, 'sort_order' => 0],
['field_key' => 'item', 'label' => '검사항목', 'field_type' => 'text', 'width' => '130px', 'is_required' => true, 'sort_order' => 1],
['field_key' => 'standard', 'label' => '검사기준', 'field_type' => 'text', 'width' => '180px', 'is_required' => false, 'sort_order' => 2],
['field_key' => 'standard_criteria', 'label' => '기준범위', 'field_type' => 'json_criteria', 'width' => '100px', 'is_required' => false, 'sort_order' => 3],
['field_key' => 'tolerance', 'label' => '공차/범위', 'field_type' => 'json_tolerance', 'width' => '120px', 'is_required' => false, 'sort_order' => 4],
['field_key' => 'method', 'label' => '검사방식', 'field_type' => 'select_api', 'width' => '110px', 'is_required' => false, 'sort_order' => 5, 'options' => json_encode([
'api_endpoint' => '/api/admin/common-codes/inspection_method',
'auto_map' => [
'target_field' => 'measurement_type',
'mapping' => [
'visual' => 'checkbox',
'check' => 'numeric',
'mill_sheet' => 'single_value',
'certified_agency' => 'single_value',
'substitute_cert' => 'substitute',
'other' => 'text',
],
],
])],
['field_key' => 'measurement_type', 'label' => '측정유형', 'field_type' => 'select', 'width' => '100px', 'is_required' => false, 'sort_order' => 6, 'options' => json_encode([
'choices' => [
['code' => 'checkbox', 'name' => 'OK/NG 체크'],
['code' => 'numeric', 'name' => '수치입력(3)'],
['code' => 'single_value', 'name' => '단일값'],
['code' => 'substitute', 'name' => '성적서 대체'],
['code' => 'text', 'name' => '자유입력'],
],
])],
['field_key' => 'frequency', 'label' => '검사주기', 'field_type' => 'composite_frequency', 'width' => '120px', 'is_required' => false, 'sort_order' => 7],
['field_key' => 'regulation', 'label' => '관련규정', 'field_type' => 'text', 'width' => '80px', 'is_required' => false, 'sort_order' => 8],
];
$now = now();
// 1. 각 template에 section_fields 생성
$templates = DB::table('document_templates')->get();
foreach ($templates as $template) {
// section_fields 생성
foreach ($defaultFields as $field) {
DB::table('document_template_section_fields')->insert([
'template_id' => $template->id,
'field_key' => $field['field_key'],
'label' => $field['label'],
'field_type' => $field['field_type'],
'options' => $field['options'] ?? null,
'width' => $field['width'],
'is_required' => $field['is_required'],
'sort_order' => $field['sort_order'],
'created_at' => $now,
'updated_at' => $now,
]);
}
// linked_item_ids → template_links + link_values
$linkedItemIds = json_decode($template->linked_item_ids ?? '[]', true);
if (! empty($linkedItemIds)) {
$linkId = DB::table('document_template_links')->insertGetId([
'template_id' => $template->id,
'link_key' => 'items',
'label' => '연결 품목 (RM, SM)',
'link_type' => 'multiple',
'search_api' => '/api/admin/items/search',
'search_params' => json_encode(['item_type' => 'RM,SM']),
'display_fields' => json_encode(['title' => 'name', 'subtitle' => 'code']),
'is_required' => false,
'sort_order' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
foreach ($linkedItemIds as $idx => $itemId) {
DB::table('document_template_link_values')->insert([
'template_id' => $template->id,
'link_id' => $linkId,
'linkable_id' => $itemId,
'sort_order' => $idx,
'created_at' => $now,
]);
}
}
// linked_process_id → template_links + link_values
if (! empty($template->linked_process_id)) {
$linkId = DB::table('document_template_links')->insertGetId([
'template_id' => $template->id,
'link_key' => 'process',
'label' => '연결 공정',
'link_type' => 'single',
'search_api' => '/api/admin/processes/search',
'search_params' => null,
'display_fields' => json_encode(['title' => 'process_name', 'subtitle' => 'process_code']),
'is_required' => false,
'sort_order' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('document_template_link_values')->insert([
'template_id' => $template->id,
'link_id' => $linkId,
'linkable_id' => $template->linked_process_id,
'sort_order' => 0,
'created_at' => $now,
]);
}
}
// 2. 각 section_item의 기존 컬럼값을 field_values JSON으로 변환
$items = DB::table('document_template_section_items')->get();
foreach ($items as $item) {
$fieldValues = [];
if (! empty($item->category)) {
$fieldValues['category'] = $item->category;
}
if (! empty($item->item)) {
$fieldValues['item'] = $item->item;
}
if (! empty($item->standard)) {
$fieldValues['standard'] = $item->standard;
}
if (! empty($item->standard_criteria)) {
$decoded = json_decode($item->standard_criteria, true);
$fieldValues['standard_criteria'] = $decoded ?? $item->standard_criteria;
}
if (! empty($item->tolerance)) {
$decoded = json_decode($item->tolerance, true);
$fieldValues['tolerance'] = $decoded ?? $item->tolerance;
}
if (! empty($item->method)) {
$fieldValues['method'] = $item->method;
}
if (! empty($item->measurement_type)) {
$fieldValues['measurement_type'] = $item->measurement_type;
}
if (! empty($item->frequency)) {
$fieldValues['frequency'] = $item->frequency;
}
if (! empty($item->frequency_n)) {
$fieldValues['frequency_n'] = $item->frequency_n;
}
if (! empty($item->frequency_c)) {
$fieldValues['frequency_c'] = $item->frequency_c;
}
if (! empty($item->regulation)) {
$fieldValues['regulation'] = $item->regulation;
}
if (! empty($fieldValues)) {
DB::table('document_template_section_items')
->where('id', $item->id)
->update(['field_values' => json_encode($fieldValues, JSON_UNESCAPED_UNICODE)]);
}
}
}
public function down(): void
{
// section_fields 삭제
DB::table('document_template_section_fields')->truncate();
// link_values, links, document_links 삭제
DB::table('document_template_link_values')->truncate();
DB::table('document_template_links')->truncate();
DB::table('document_links')->truncate();
// field_values NULL로 초기화
DB::table('document_template_section_items')->update(['field_values' => null]);
}
};

View File

@@ -0,0 +1,61 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
// 1) source_table 컬럼 추가
Schema::table('document_template_links', function (Blueprint $table) {
$table->string('source_table', 50)->nullable()->after('link_type')
->comment('소스 테이블 키 (system_field_definitions.source_table)');
});
// 2) 기존 search_api 데이터를 source_table로 변환
$mapping = [
'/api/admin/items/search' => 'items',
'/api/admin/processes/search' => 'processes',
'/api/admin/lots/search' => 'lots',
'/api/admin/tenant-users/search' => 'users',
];
foreach ($mapping as $api => $table) {
DB::table('document_template_links')
->where('search_api', $api)
->update(['source_table' => $table]);
}
// 3) search_api 컬럼 삭제
Schema::table('document_template_links', function (Blueprint $table) {
$table->dropColumn('search_api');
});
}
public function down(): void
{
Schema::table('document_template_links', function (Blueprint $table) {
$table->string('search_api', 255)->after('link_type');
});
$mapping = [
'items' => '/api/admin/items/search',
'processes' => '/api/admin/processes/search',
'lots' => '/api/admin/lots/search',
'users' => '/api/admin/tenant-users/search',
];
foreach ($mapping as $table => $api) {
DB::table('document_template_links')
->where('source_table', $table)
->update(['search_api' => $api]);
}
Schema::table('document_template_links', function (Blueprint $table) {
$table->dropColumn('source_table');
});
}
};