From a6363111680f6c180f79e3bb8a6ab0754acbae76 Mon Sep 17 00:00:00 2001 From: hskwon Date: Fri, 12 Dec 2025 08:52:00 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20Items=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=ED=86=B5=ED=95=A9=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EB=B0=8F=20=EB=AC=B8=EC=84=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Items 테이블 생성 마이그레이션 추가 - ID 매핑 테이블 생성 마이그레이션 추가 - Products/Materials → Items 데이터 이관 마이그레이션 추가 - Products 테이블 BOM 컬럼 추가 마이그레이션 - item_fields unique 제약조건 수정 마이그레이션 - LOGICAL_RELATIONSHIPS.md 업데이트 - AuthApi Swagger 수정 --- LOGICAL_RELATIONSHIPS.md | 3 +- app/Swagger/v1/AuthApi.php | 4 +- ...8_modify_item_fields_unique_constraint.php | 160 ++++++++++++++++++ ...04116_add_bom_column_to_products_table.php | 28 +++ .../2025_12_11_220000_create_items_table.php | 103 +++++++++++ ...1_220100_create_item_id_mappings_table.php | 41 +++++ ...00_migrate_products_materials_to_items.php | 120 +++++++++++++ 7 files changed, 456 insertions(+), 3 deletions(-) create mode 100644 database/migrations/2025_12_11_121758_modify_item_fields_unique_constraint.php create mode 100644 database/migrations/2025_12_11_204116_add_bom_column_to_products_table.php create mode 100644 database/migrations/2025_12_11_220000_create_items_table.php create mode 100644 database/migrations/2025_12_11_220100_create_item_id_mappings_table.php create mode 100644 database/migrations/2025_12_11_220200_migrate_products_materials_to_items.php diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index 28f754b..1822676 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2025-12-09 11:02:18 +> **자동 생성**: 2025-12-11 21:54:15 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 @@ -177,6 +177,7 @@ ### main_request_flows ### materials **모델**: `App\Models\Materials\Material` +- **category()**: belongsTo → `categories` - **receipts()**: hasMany → `material_receipts` - **lots()**: hasMany → `lots` - **files()**: morphMany → `files` diff --git a/app/Swagger/v1/AuthApi.php b/app/Swagger/v1/AuthApi.php index 4ceb783..8bf8d18 100644 --- a/app/Swagger/v1/AuthApi.php +++ b/app/Swagger/v1/AuthApi.php @@ -87,8 +87,8 @@ public function debugApiKey() {} * @OA\JsonContent( * required={"user_id","user_pwd"}, * - * @OA\Property(property="user_id", type="string", example="hamss"), - * @OA\Property(property="user_pwd", type="string", example="StrongPass!1234") + * @OA\Property(property="user_id", type="string", example="TestUser5"), + * @OA\Property(property="user_pwd", type="string", example="password123!") * ) * ), * diff --git a/database/migrations/2025_12_11_121758_modify_item_fields_unique_constraint.php b/database/migrations/2025_12_11_121758_modify_item_fields_unique_constraint.php new file mode 100644 index 0000000..35a2488 --- /dev/null +++ b/database/migrations/2025_12_11_121758_modify_item_fields_unique_constraint.php @@ -0,0 +1,160 @@ +dropUnique('uq_item_fields_tenant_field_key'); + + // 새로운 유니크 인덱스 추가 (tenant_id, field_key, source_table) + $table->unique( + ['tenant_id', 'field_key', 'source_table'], + 'uq_item_fields_tenant_field_key_source' + ); + }); + + // 2. 기존 데이터의 접두사 제거 + $this->removeFieldKeyPrefixes(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // 1. 접두사 복원 + $this->restoreFieldKeyPrefixes(); + + // 2. 유니크 인덱스 복원 + Schema::table('item_fields', function (Blueprint $table) { + // 새 유니크 인덱스 삭제 + $table->dropUnique('uq_item_fields_tenant_field_key_source'); + + // 기존 유니크 인덱스 복원 + $table->unique( + ['tenant_id', 'field_key'], + 'uq_item_fields_tenant_field_key' + ); + }); + } + + /** + * 기존 데이터의 접두사 제거 + * + * materials: materials_name → name, materials_unit → unit, materials_category_id → category_id + * material_inspections: material_inspections_quantity → quantity, material_inspections_note → note + * material_receipts: material_receipts_lot_no → lot_no, material_receipts_quantity → quantity, material_receipts_note → note + */ + private function removeFieldKeyPrefixes(): void + { + // materials 테이블 필드 + DB::table('item_fields') + ->where('source_table', 'materials') + ->where('field_key', 'materials_name') + ->update(['field_key' => 'name']); + + DB::table('item_fields') + ->where('source_table', 'materials') + ->where('field_key', 'materials_unit') + ->update(['field_key' => 'unit']); + + DB::table('item_fields') + ->where('source_table', 'materials') + ->where('field_key', 'materials_category_id') + ->update(['field_key' => 'category_id']); + + // material_inspections 테이블 필드 + DB::table('item_fields') + ->where('source_table', 'material_inspections') + ->where('field_key', 'material_inspections_quantity') + ->update(['field_key' => 'quantity']); + + DB::table('item_fields') + ->where('source_table', 'material_inspections') + ->where('field_key', 'material_inspections_note') + ->update(['field_key' => 'note']); + + // material_receipts 테이블 필드 + DB::table('item_fields') + ->where('source_table', 'material_receipts') + ->where('field_key', 'material_receipts_lot_no') + ->update(['field_key' => 'lot_no']); + + DB::table('item_fields') + ->where('source_table', 'material_receipts') + ->where('field_key', 'material_receipts_quantity') + ->update(['field_key' => 'quantity']); + + DB::table('item_fields') + ->where('source_table', 'material_receipts') + ->where('field_key', 'material_receipts_note') + ->update(['field_key' => 'note']); + } + + /** + * 접두사 복원 (롤백용) + */ + private function restoreFieldKeyPrefixes(): void + { + // materials 테이블 필드 + DB::table('item_fields') + ->where('source_table', 'materials') + ->where('field_key', 'name') + ->update(['field_key' => 'materials_name']); + + DB::table('item_fields') + ->where('source_table', 'materials') + ->where('field_key', 'unit') + ->update(['field_key' => 'materials_unit']); + + DB::table('item_fields') + ->where('source_table', 'materials') + ->where('field_key', 'category_id') + ->update(['field_key' => 'materials_category_id']); + + // material_inspections 테이블 필드 + DB::table('item_fields') + ->where('source_table', 'material_inspections') + ->where('field_key', 'quantity') + ->update(['field_key' => 'material_inspections_quantity']); + + DB::table('item_fields') + ->where('source_table', 'material_inspections') + ->where('field_key', 'note') + ->update(['field_key' => 'material_inspections_note']); + + // material_receipts 테이블 필드 + DB::table('item_fields') + ->where('source_table', 'material_receipts') + ->where('field_key', 'lot_no') + ->update(['field_key' => 'material_receipts_lot_no']); + + DB::table('item_fields') + ->where('source_table', 'material_receipts') + ->where('field_key', 'quantity') + ->update(['field_key' => 'material_receipts_quantity']); + + DB::table('item_fields') + ->where('source_table', 'material_receipts') + ->where('field_key', 'note') + ->update(['field_key' => 'material_receipts_note']); + } +}; \ No newline at end of file diff --git a/database/migrations/2025_12_11_204116_add_bom_column_to_products_table.php b/database/migrations/2025_12_11_204116_add_bom_column_to_products_table.php new file mode 100644 index 0000000..4f6fab8 --- /dev/null +++ b/database/migrations/2025_12_11_204116_add_bom_column_to_products_table.php @@ -0,0 +1,28 @@ +json('bom')->nullable()->after('options')->comment('BOM 구성품 정보 [{child_item_id, quantity}, ...]'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + $table->dropColumn('bom'); + }); + } +}; \ No newline at end of file diff --git a/database/migrations/2025_12_11_220000_create_items_table.php b/database/migrations/2025_12_11_220000_create_items_table.php new file mode 100644 index 0000000..f00bbce --- /dev/null +++ b/database/migrations/2025_12_11_220000_create_items_table.php @@ -0,0 +1,103 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + + // 기본 정보 + $table->string('item_type', 10)->comment('품목 유형: FG, PT, SM, RM, CS'); + $table->string('code', 100)->comment('품목 코드'); + $table->string('name', 255)->comment('품목명'); + $table->string('unit', 20)->nullable()->comment('단위'); + $table->unsignedBigInteger('category_id')->nullable()->comment('카테고리 ID'); + + // 상세 정보 + $table->text('specification')->nullable()->comment('규격 (Materials용)'); + $table->text('description')->nullable()->comment('설명'); + + // JSON 필드 + $table->json('attributes')->nullable()->comment('동적 속성'); + $table->json('attributes_archive')->nullable()->comment('속성 아카이브'); + $table->json('options')->nullable()->comment('옵션 [{label, value, unit}]'); + $table->json('bom')->nullable()->comment('BOM [{child_item_id, quantity}]'); + + // Products 전용 필드 + $table->boolean('is_sellable')->default(true)->comment('판매 가능'); + $table->boolean('is_purchasable')->default(false)->comment('구매 가능'); + $table->boolean('is_producible')->default(false)->comment('생산 가능'); + $table->integer('safety_stock')->nullable()->comment('안전 재고'); + $table->integer('lead_time')->nullable()->comment('리드타임 (일)'); + $table->boolean('is_variable_size')->default(false)->comment('가변 사이즈 여부'); + $table->string('product_category', 50)->nullable()->comment('제품 카테고리'); + $table->string('part_type', 50)->nullable()->comment('부품 유형'); + + // 파일 필드 (Products) + $table->string('bending_diagram', 255)->nullable()->comment('절곡 도면 파일'); + $table->json('bending_details')->nullable()->comment('절곡 상세 정보'); + $table->string('specification_file', 255)->nullable()->comment('시방서 파일'); + $table->string('specification_file_name', 255)->nullable()->comment('시방서 파일명'); + $table->string('certification_file', 255)->nullable()->comment('인증서 파일'); + $table->string('certification_file_name', 255)->nullable()->comment('인증서 파일명'); + $table->string('certification_number', 100)->nullable()->comment('인증 번호'); + $table->date('certification_start_date')->nullable()->comment('인증 시작일'); + $table->date('certification_end_date')->nullable()->comment('인증 종료일'); + + // Materials 전용 필드 + $table->string('item_name', 255)->nullable()->comment('품명 (Materials)'); + $table->string('is_inspection', 1)->default('N')->comment('검사 대상 여부'); + $table->string('search_tag', 255)->nullable()->comment('검색 태그'); + $table->text('remarks')->nullable()->comment('비고'); + + // 레거시 참조 (전환기간용) + $table->string('legacy_table', 20)->nullable()->comment('원본 테이블: products | materials'); + $table->unsignedBigInteger('legacy_id')->nullable()->comment('원본 테이블 ID'); + + // 공통 필드 + $table->boolean('is_active')->default(true)->comment('활성 상태'); + $table->unsignedBigInteger('created_by')->nullable()->comment('생성자'); + $table->unsignedBigInteger('updated_by')->nullable()->comment('수정자'); + $table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자'); + $table->timestamps(); + $table->softDeletes(); + + // 인덱스 + $table->index(['tenant_id', 'item_type'], 'idx_items_tenant_type'); + $table->index(['tenant_id', 'code'], 'idx_items_tenant_code'); + $table->index(['tenant_id', 'category_id'], 'idx_items_tenant_category'); + $table->index(['tenant_id', 'is_active'], 'idx_items_tenant_active'); + $table->index(['legacy_table', 'legacy_id'], 'idx_items_legacy'); + + // 유니크 제약 (tenant_id + code + deleted_at) + // MySQL에서는 deleted_at이 NULL인 경우를 위해 함수 인덱스 대신 별도 처리 + $table->unique(['tenant_id', 'code', 'deleted_at'], 'uq_items_tenant_code'); + + // 외래키 + $table->foreign('tenant_id')->references('id')->on('tenants'); + $table->foreign('category_id')->references('id')->on('categories')->onDelete('set null'); + }); + + // 테이블 코멘트 + \DB::statement("ALTER TABLE items COMMENT = 'Products + Materials 통합 품목 테이블'"); + } + + public function down(): void + { + Schema::dropIfExists('items'); + } +}; \ No newline at end of file diff --git a/database/migrations/2025_12_11_220100_create_item_id_mappings_table.php b/database/migrations/2025_12_11_220100_create_item_id_mappings_table.php new file mode 100644 index 0000000..7a6643d --- /dev/null +++ b/database/migrations/2025_12_11_220100_create_item_id_mappings_table.php @@ -0,0 +1,41 @@ +id(); + $table->unsignedBigInteger('new_item_id')->comment('새 items 테이블 ID'); + $table->string('legacy_table', 20)->comment('원본 테이블: products | materials'); + $table->unsignedBigInteger('legacy_id')->comment('원본 테이블 ID'); + $table->timestamp('created_at')->useCurrent(); + + // 인덱스 + $table->unique(['legacy_table', 'legacy_id'], 'uq_legacy'); + $table->index('new_item_id', 'idx_new_item'); + + // 외래키 + $table->foreign('new_item_id')->references('id')->on('items')->onDelete('cascade'); + }); + + // 테이블 코멘트 + \DB::statement("ALTER TABLE item_id_mappings COMMENT = 'Products/Materials → Items ID 매핑 (전환기간용)'"); + } + + public function down(): void + { + Schema::dropIfExists('item_id_mappings'); + } +}; \ No newline at end of file diff --git a/database/migrations/2025_12_11_220200_migrate_products_materials_to_items.php b/database/migrations/2025_12_11_220200_migrate_products_materials_to_items.php new file mode 100644 index 0000000..3825af8 --- /dev/null +++ b/database/migrations/2025_12_11_220200_migrate_products_materials_to_items.php @@ -0,0 +1,120 @@ +count(); + echo "- Products 원본: {$productsBefore}건\n"; + + DB::statement(" + INSERT INTO items ( + tenant_id, item_type, code, name, unit, category_id, + description, attributes, attributes_archive, options, bom, + is_sellable, is_purchasable, is_producible, + safety_stock, lead_time, is_variable_size, product_category, part_type, + bending_diagram, bending_details, specification_file, specification_file_name, + certification_file, certification_file_name, certification_number, + certification_start_date, certification_end_date, + is_active, created_by, updated_by, deleted_by, + created_at, updated_at, deleted_at, + legacy_table, legacy_id + ) + SELECT + tenant_id, product_type, code, name, unit, category_id, + description, attributes, attributes_archive, options, bom, + is_sellable, is_purchasable, is_producible, + safety_stock, lead_time, is_variable_size, product_category, part_type, + bending_diagram, bending_details, specification_file, specification_file_name, + certification_file, certification_file_name, certification_number, + certification_start_date, certification_end_date, + is_active, created_by, updated_by, deleted_by, + created_at, updated_at, deleted_at, + 'products', id + FROM products + "); + + $productsAfter = DB::table('items')->where('legacy_table', 'products')->count(); + echo "- Items로 이관: {$productsAfter}건\n"; + + // 2. Materials → Items 이관 + echo "\nMaterials 데이터 이관 시작...\n"; + + $materialsBefore = DB::table('materials')->count(); + echo "- Materials 원본: {$materialsBefore}건\n"; + + DB::statement(" + INSERT INTO items ( + tenant_id, item_type, code, name, unit, category_id, + specification, attributes, options, + item_name, is_inspection, search_tag, remarks, + is_active, created_by, updated_by, deleted_by, + created_at, updated_at, deleted_at, + legacy_table, legacy_id + ) + SELECT + tenant_id, material_type, material_code, name, unit, category_id, + specification, attributes, options, + item_name, is_inspection, search_tag, remarks, + is_active, created_by, updated_by, deleted_by, + created_at, updated_at, deleted_at, + 'materials', id + FROM materials + "); + + $materialsAfter = DB::table('items')->where('legacy_table', 'materials')->count(); + echo "- Items로 이관: {$materialsAfter}건\n"; + + // 3. ID 매핑 테이블 생성 + echo "\nID 매핑 테이블 생성 시작...\n"; + + DB::statement(" + INSERT INTO item_id_mappings (new_item_id, legacy_table, legacy_id) + SELECT id, legacy_table, legacy_id + FROM items + WHERE legacy_table IS NOT NULL AND legacy_id IS NOT NULL + "); + + $mappingsCount = DB::table('item_id_mappings')->count(); + echo "- ID 매핑: {$mappingsCount}건 생성\n"; + + // 4. 검증 + echo "\n=== 이관 검증 ===\n"; + $totalItems = DB::table('items')->count(); + $totalOriginal = $productsBefore + $materialsBefore; + echo "- 원본 합계: {$totalOriginal}건 (Products: {$productsBefore} + Materials: {$materialsBefore})\n"; + echo "- Items 합계: {$totalItems}건\n"; + + if ($totalItems === $totalOriginal) { + echo "✅ 데이터 이관 성공!\n"; + } else { + echo "⚠️ 데이터 불일치! 확인 필요\n"; + } + } + + public function down(): void + { + // 롤백: Items 테이블 데이터 삭제 (매핑 테이블은 CASCADE로 자동 삭제) + echo "Items 데이터 롤백 시작...\n"; + + $countBefore = DB::table('items')->count(); + DB::table('items')->truncate(); + $countAfter = DB::table('items')->count(); + + echo "- 삭제된 Items: {$countBefore}건\n"; + echo "- 롤백 완료\n"; + } +}; \ No newline at end of file