feat: Item Master 하이브리드 구조 전환 및 독립 API 추가

- CASCADE FK → 독립 엔티티 + entity_relationships 링크 테이블
- 독립 API 10개 추가 (섹션/필드/BOM CRUD, clone, usage)
- SectionTemplate 모델 제거 → ItemSection.is_template 통합
- 페이지-섹션, 섹션-필드, 섹션-BOM 링크/언링크 API 14개 추가
- Swagger 문서 업데이트
This commit is contained in:
2025-11-26 14:09:31 +09:00
parent 3fefb8ce26
commit bccfa19791
38 changed files with 5888 additions and 92 deletions

View File

@@ -63,7 +63,17 @@ public function up(): void
DB::statement("ALTER TABLE categories
MODIFY COLUMN is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '활성여부(1=활성,0=비활성)'");
// A-3. 유니크 (tenant_id, code) → (tenant_id, code_group, code)
// A-3. code_group 컬럼 추가 (유니크 인덱스 추가 전에 필요)
Schema::table('categories', function (Blueprint $table) {
if (! Schema::hasColumn('categories', 'code_group')) {
$table->string('code_group', 30)
->default('category')
->after('code')
->comment('코드 그룹');
}
});
// A-4. 유니크 (tenant_id, code) → (tenant_id, code_group, code)
$this->dropUniqueIfColumns('categories', ['tenant_id', 'code']);
$idx = collect(DB::select('SHOW INDEX FROM `categories`'))->groupBy('Key_name');
$hasTarget = false;
@@ -80,7 +90,7 @@ public function up(): void
ADD UNIQUE KEY `uq_tenant_codegroup_code` (`tenant_id`,`code_group`,`code`)');
}
// A-4. profile_code 추가 (common_codes.code, code_group='capability_profile')
// A-5. profile_code 추가 (common_codes.code, code_group='capability_profile')
Schema::table('categories', function (Blueprint $table) {
if (! Schema::hasColumn('categories', 'profile_code')) {
$table->string('profile_code', 30)

View File

@@ -12,32 +12,39 @@
*/
public function up(): void
{
Schema::table('materials', function (Blueprint $table) {
// material_type 컬럼 추가 (SM=부자재, RM=원자재, CS=소모품)
$table->string('material_type', 10)
->nullable()
->after('category_id')
->comment('자재 타입: SM=부자재, RM=원자재, CS=소모품');
// material_type 컬럼이 없을 때만 추가
if (! Schema::hasColumn('materials', 'material_type')) {
Schema::table('materials', function (Blueprint $table) {
// material_type 컬럼 추가 (SM=부자재, RM=원자재, CS=소모품)
$table->string('material_type', 10)
->nullable()
->after('category_id')
->comment('자재 타입: SM=부자재, RM=원자재, CS=소모품');
});
}
// 조회 성능을 위한 인덱스
$table->index('material_type');
});
// 인덱스가 없을 때만 추가
$indexes = collect(DB::select('SHOW INDEX FROM materials'))
->pluck('Key_name')
->toArray();
// 기존 데이터 업데이트
if (! in_array('materials_material_type_index', $indexes)) {
Schema::table('materials', function (Blueprint $table) {
$table->index('material_type');
});
}
// 기존 데이터 업데이트 (options 컬럼이 없으므로 기본값 'SM' 설정)
DB::statement("
UPDATE materials
SET material_type = CASE
WHEN JSON_EXTRACT(options, '$.categories.item_type.code') = 'RAW' THEN 'RM'
WHEN JSON_EXTRACT(options, '$.categories.item_type.code') = 'SUB' THEN 'SM'
ELSE 'SM'
END
WHERE tenant_id = 1
AND deleted_at IS NULL
SET material_type = 'SM'
WHERE deleted_at IS NULL
AND material_type IS NULL
");
// 모든 자재에 타입이 설정되었으므로 NOT NULL로 변경
Schema::table('materials', function (Blueprint $table) {
$table->string('material_type', 10)->nullable(false)->change();
$table->string('material_type', 10)->default('SM')->nullable(false)->change();
});
}

View File

@@ -0,0 +1,127 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Item Master 하이브리드 구조 전환 - Phase 1
*
* 목적: CASCADE FK 기반 계층 구조를 독립 엔티티 + 링크 테이블 구조로 전환
*
* 변경 내용:
* 1. item_sections에서 page_id FK 제거 (컬럼은 유지, FK 제약만 제거)
* 2. item_fields에서 section_id FK 제거
* 3. item_bom_items에서 section_id FK 제거
* 4. 모든 item 관련 테이블에 group_id 추가 (카테고리 격리용, 기본값 1 = 품목관리)
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 1. item_sections: page_id FK 제거 (컬럼 유지, FK만 제거)
Schema::table('item_sections', function (Blueprint $table) {
$table->dropForeign('fk_item_sections_page');
});
// 2. item_fields: section_id FK 제거 (컬럼 유지, FK만 제거)
Schema::table('item_fields', function (Blueprint $table) {
$table->dropForeign('fk_item_fields_section');
});
// 3. item_bom_items: section_id FK 제거 (컬럼 유지, FK만 제거)
Schema::table('item_bom_items', function (Blueprint $table) {
$table->dropForeign('fk_item_bom_items_section');
});
// 4. 모든 테이블에 group_id 추가 (기본값 1 = 품목관리)
Schema::table('item_pages', function (Blueprint $table) {
$table->unsignedInteger('group_id')->default(1)->after('tenant_id')->comment('그룹 ID (1: 품목관리)');
$table->index(['tenant_id', 'group_id'], 'idx_item_pages_tenant_group');
});
Schema::table('item_sections', function (Blueprint $table) {
$table->unsignedInteger('group_id')->default(1)->after('tenant_id')->comment('그룹 ID (1: 품목관리)');
$table->index(['tenant_id', 'group_id'], 'idx_item_sections_tenant_group');
});
Schema::table('item_fields', function (Blueprint $table) {
$table->unsignedInteger('group_id')->default(1)->after('tenant_id')->comment('그룹 ID (1: 품목관리)');
$table->index(['tenant_id', 'group_id'], 'idx_item_fields_tenant_group');
});
Schema::table('item_bom_items', function (Blueprint $table) {
$table->unsignedInteger('group_id')->default(1)->after('tenant_id')->comment('그룹 ID (1: 품목관리)');
$table->index(['tenant_id', 'group_id'], 'idx_item_bom_items_tenant_group');
});
Schema::table('section_templates', function (Blueprint $table) {
$table->unsignedInteger('group_id')->default(1)->after('tenant_id')->comment('그룹 ID (1: 품목관리)');
$table->index(['tenant_id', 'group_id'], 'idx_section_templates_tenant_group');
});
Schema::table('item_master_fields', function (Blueprint $table) {
$table->unsignedInteger('group_id')->default(1)->after('tenant_id')->comment('그룹 ID (1: 품목관리)');
$table->index(['tenant_id', 'group_id'], 'idx_item_master_fields_tenant_group');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// group_id 인덱스 및 컬럼 제거
Schema::table('item_master_fields', function (Blueprint $table) {
$table->dropIndex('idx_item_master_fields_tenant_group');
$table->dropColumn('group_id');
});
Schema::table('section_templates', function (Blueprint $table) {
$table->dropIndex('idx_section_templates_tenant_group');
$table->dropColumn('group_id');
});
Schema::table('item_bom_items', function (Blueprint $table) {
$table->dropIndex('idx_item_bom_items_tenant_group');
$table->dropColumn('group_id');
});
Schema::table('item_fields', function (Blueprint $table) {
$table->dropIndex('idx_item_fields_tenant_group');
$table->dropColumn('group_id');
});
Schema::table('item_sections', function (Blueprint $table) {
$table->dropIndex('idx_item_sections_tenant_group');
$table->dropColumn('group_id');
});
Schema::table('item_pages', function (Blueprint $table) {
$table->dropIndex('idx_item_pages_tenant_group');
$table->dropColumn('group_id');
});
// FK 복원
Schema::table('item_bom_items', function (Blueprint $table) {
$table->foreign('section_id', 'fk_item_bom_items_section')
->references('id')->on('item_sections')
->onDelete('cascade');
});
Schema::table('item_fields', function (Blueprint $table) {
$table->foreign('section_id', 'fk_item_fields_section')
->references('id')->on('item_sections')
->onDelete('cascade');
});
Schema::table('item_sections', function (Blueprint $table) {
$table->foreign('page_id', 'fk_item_sections_page')
->references('id')->on('item_pages')
->onDelete('cascade');
});
}
};

View File

@@ -0,0 +1,79 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Item Master 하이브리드 구조 전환 - Phase 2
*
* 목적: 엔티티 간 관계를 관리하는 범용 링크 테이블 생성
*
* 지원하는 관계 유형:
* - page-section: 페이지와 섹션 연결
* - page-field: 페이지와 필드 직접 연결
* - section-field: 섹션과 필드 연결
* - section-bom: 섹션과 BOM 항목 연결
*
* 특징:
* - 다대다(Many-to-Many) 관계 지원
* - 동일 엔티티를 여러 부모에 연결 가능
* - 부모 삭제 시 링크만 제거, 자식 엔티티는 유지
* - group_id로 카테고리 격리
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('entity_relationships', function (Blueprint $table) {
$table->id()->comment('관계 ID');
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->unsignedInteger('group_id')->default(1)->comment('그룹 ID (1: 품목관리)');
// 부모 엔티티 정보
$table->string('parent_type', 50)->comment('부모 엔티티 타입 (page, section)');
$table->unsignedBigInteger('parent_id')->comment('부모 엔티티 ID');
// 자식 엔티티 정보
$table->string('child_type', 50)->comment('자식 엔티티 타입 (section, field, bom)');
$table->unsignedBigInteger('child_id')->comment('자식 엔티티 ID');
// 관계 메타데이터
$table->integer('order_no')->default(0)->comment('정렬 순서');
$table->json('metadata')->nullable()->comment('관계 메타데이터 (추가 설정)');
// 감사 컬럼
$table->unsignedBigInteger('created_by')->nullable()->comment('생성자 ID');
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자 ID');
$table->timestamps();
// 인덱스
$table->index(['tenant_id', 'group_id'], 'idx_entity_rel_tenant_group');
$table->index(['parent_type', 'parent_id'], 'idx_entity_rel_parent');
$table->index(['child_type', 'child_id'], 'idx_entity_rel_child');
$table->index(['parent_type', 'parent_id', 'order_no'], 'idx_entity_rel_parent_order');
// 유니크 제약 (동일 부모-자식 관계 중복 방지)
$table->unique(
['tenant_id', 'group_id', 'parent_type', 'parent_id', 'child_type', 'child_id'],
'uq_entity_rel_parent_child'
);
// 외래키
$table->foreign('tenant_id', 'fk_entity_rel_tenant')
->references('id')->on('tenants')
->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('entity_relationships');
}
};

View File

@@ -0,0 +1,99 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Item Master 하이브리드 구조 전환 - Phase 3
*
* 목적: 기존 FK 기반 관계 데이터를 entity_relationships 테이블로 이관
*
* 이관 대상:
* 1. item_sections.page_id → entity_relationships (page-section)
* 2. item_fields.section_id → entity_relationships (section-field)
* 3. item_bom_items.section_id → entity_relationships (section-bom)
*
* 주의사항:
* - 기존 컬럼(page_id, section_id)의 값은 유지 (하위 호환성)
* - soft delete된 레코드도 이관 대상에 포함
* - order_no 값을 그대로 복사
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 1. item_sections → entity_relationships (page-section)
DB::statement("
INSERT INTO entity_relationships (tenant_id, group_id, parent_type, parent_id, child_type, child_id, order_no, created_by, created_at, updated_at)
SELECT
s.tenant_id,
COALESCE(s.group_id, 1) as group_id,
'page' as parent_type,
s.page_id as parent_id,
'section' as child_type,
s.id as child_id,
s.order_no,
s.created_by,
NOW(),
NOW()
FROM item_sections s
WHERE s.page_id IS NOT NULL
ON DUPLICATE KEY UPDATE updated_at = NOW()
");
// 2. item_fields → entity_relationships (section-field)
DB::statement("
INSERT INTO entity_relationships (tenant_id, group_id, parent_type, parent_id, child_type, child_id, order_no, created_by, created_at, updated_at)
SELECT
f.tenant_id,
COALESCE(f.group_id, 1) as group_id,
'section' as parent_type,
f.section_id as parent_id,
'field' as child_type,
f.id as child_id,
f.order_no,
f.created_by,
NOW(),
NOW()
FROM item_fields f
WHERE f.section_id IS NOT NULL
ON DUPLICATE KEY UPDATE updated_at = NOW()
");
// 3. item_bom_items → entity_relationships (section-bom)
// BOM 항목은 order_no가 없으므로 id 기준으로 순서 부여
DB::statement("
INSERT INTO entity_relationships (tenant_id, group_id, parent_type, parent_id, child_type, child_id, order_no, created_by, created_at, updated_at)
SELECT
b.tenant_id,
COALESCE(b.group_id, 1) as group_id,
'section' as parent_type,
b.section_id as parent_id,
'bom' as child_type,
b.id as child_id,
@rownum := @rownum + 1 as order_no,
b.created_by,
NOW(),
NOW()
FROM item_bom_items b, (SELECT @rownum := 0) r
WHERE b.section_id IS NOT NULL
ORDER BY b.section_id, b.id
ON DUPLICATE KEY UPDATE updated_at = NOW()
");
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// 이관된 데이터 삭제 (page-section, section-field, section-bom 관계만)
DB::table('entity_relationships')
->whereIn('parent_type', ['page', 'section'])
->whereIn('child_type', ['section', 'field', 'bom'])
->delete();
}
};

View File

@@ -0,0 +1,107 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* 1. item_sections에 is_template, description 컬럼 추가
* 2. section_templates 데이터를 item_sections로 이관
* 3. section_templates 테이블 제거
*/
public function up(): void
{
// 1. item_sections에 is_template, description 컬럼 추가
Schema::table('item_sections', function (Blueprint $table) {
$table->boolean('is_template')->default(false)->after('order_no')->comment('템플릿 여부');
$table->boolean('is_default')->default(false)->after('is_template')->comment('기본 템플릿 여부');
$table->text('description')->nullable()->after('is_default')->comment('설명');
});
// 2. section_templates 데이터를 item_sections로 이관
if (Schema::hasTable('section_templates')) {
$templates = DB::table('section_templates')->whereNull('deleted_at')->get();
foreach ($templates as $template) {
DB::table('item_sections')->insert([
'tenant_id' => $template->tenant_id,
'group_id' => $template->group_id ?? 1,
'title' => $template->title,
'type' => $template->type,
'order_no' => 0,
'is_template' => true,
'is_default' => $template->is_default,
'description' => $template->description,
'created_by' => $template->created_by,
'updated_by' => $template->updated_by,
'created_at' => $template->created_at,
'updated_at' => $template->updated_at,
]);
}
// 3. section_templates 테이블 제거
Schema::dropIfExists('section_templates');
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// 1. section_templates 테이블 복원
Schema::create('section_templates', function (Blueprint $table) {
$table->id()->comment('섹션 템플릿 ID');
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->unsignedInteger('group_id')->default(1)->comment('그룹 ID');
$table->string('title')->comment('템플릿명');
$table->enum('type', ['fields', 'bom'])->default('fields')->comment('섹션 타입');
$table->text('description')->nullable()->comment('설명');
$table->boolean('is_default')->default(false)->comment('기본 템플릿 여부');
$table->unsignedBigInteger('created_by')->nullable()->comment('생성자 ID');
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자 ID');
$table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자 ID');
$table->timestamps();
$table->softDeletes()->comment('소프트 삭제');
$table->index('tenant_id', 'idx_section_templates_tenant_id');
$table->foreign('tenant_id', 'fk_section_templates_tenant')
->references('id')->on('tenants')
->onDelete('cascade');
});
// 2. item_sections의 is_template=true 데이터를 section_templates로 이관
$templates = DB::table('item_sections')
->where('is_template', true)
->whereNull('deleted_at')
->get();
foreach ($templates as $template) {
DB::table('section_templates')->insert([
'tenant_id' => $template->tenant_id,
'group_id' => $template->group_id,
'title' => $template->title,
'type' => $template->type,
'description' => $template->description,
'is_default' => $template->is_default,
'created_by' => $template->created_by,
'updated_by' => $template->updated_by,
'created_at' => $template->created_at,
'updated_at' => $template->updated_at,
]);
}
// 3. item_sections에서 is_template=true 데이터 삭제
DB::table('item_sections')->where('is_template', true)->delete();
// 4. item_sections에서 컬럼 제거
Schema::table('item_sections', function (Blueprint $table) {
$table->dropColumn(['is_template', 'is_default', 'description']);
});
}
};