diff --git a/CURRENT_WORKS.md b/CURRENT_WORKS.md index f5c41dc..05c398e 100644 --- a/CURRENT_WORKS.md +++ b/CURRENT_WORKS.md @@ -88,6 +88,30 @@ ### 향후 작업: 3. Service 레벨에서 데이터 무결성 검증 로직 보강 검토 4. 프로덕션 적용 전 백업 및 롤백 계획 수립 +### 논리적 관계 자동화 시스템 구축: +- **자동화 도구 4개 생성**: 관계 문서 생성/업데이트/모델생성 명령어 +- **Provider 시스템**: 마이그레이션 후 자동 문서 업데이트 +- **간소화 문서**: 즉시 사용 가능한 관계 문서 생성 (LOGICAL_RELATIONSHIPS_SIMPLE.md) + +### 새로운 명령어: +- `php artisan db:update-relationships` - 모델에서 관계 자동 추출 +- `php artisan db:generate-simple-relationships` - 기본 관계 문서 생성 +- `php artisan make:model-with-docs` - 모델 생성 후 관계 문서 자동 업데이트 + +### ERD 생성 시스템: +- **ERD 생성 도구**: beyondcode/laravel-er-diagram-generator 활용 +- **GraphViz 설치**: `brew install graphviz`로 dot 명령어 지원 +- **모델 오류 해결**: BelongsToTenantTrait → BelongsToTenant 수정 +- **생성 결과**: 60개 모델의 완전한 관계도 생성 (`graph.png`, 4.1MB) +- **명령어**: `php artisan generate:erd --format=png` + +### 예상 효과 (업데이트): +1. **시각화 개선**: 복잡한 다중 테넌트 구조의 시각적 이해 향상 +2. **개발 생산성**: ERD를 통한 빠른 스키마 파악 및 설계 검증 +3. **문서화 자동화**: 스키마 변경 시 ERD 자동 업데이트 가능 +4. **기존 효과 유지**: 성능 향상, 관리 편의성, 확장성은 FK 제거로 달성 + ### Git 커밋: - `cfd4c25` - fix: categories 테이블 level 컬럼 제거로 마이그레이션 오류 해결 -- `7dafab3` - docs: CURRENT_WORKS.md 파일 위치 규칙 명확화 \ No newline at end of file +- `7dafab3` - docs: CURRENT_WORKS.md 파일 위치 규칙 명확화 +- `c63e676` - feat: 데이터베이스 FK 제약조건 최적화 및 3단계 마이그레이션 구현 \ No newline at end of file diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md new file mode 100644 index 0000000..ba955b8 --- /dev/null +++ b/LOGICAL_RELATIONSHIPS.md @@ -0,0 +1,184 @@ +# 논리적 데이터베이스 관계 문서 + +> FK 제약조건 제거 후 개발자 참조용 논리적 관계 명세서 +> +> **생성일**: 2025-09-24 +> **마이그레이션 batch**: 17까지 적용됨 + +## 🎯 목적 + +물리적 FK 제약조건을 제거하여 성능과 관리 편의성을 확보하면서도, +개발자들이 비즈니스 로직에서 참조해야 할 **논리적 관계**를 명시합니다. + +## 📋 FK 제거 현황 + +### ✅ 제거된 FK 제약조건 (4개) + +| 테이블 | 컬럼 | 참조 테이블 | 참조 컬럼 | 제거 단계 | 효과 | +|--------|------|-------------|-----------|----------|------| +| `classifications` | `tenant_id` | `tenants` | `id` | Phase 1 | 분류 코드 관리 유연성 | +| `departments` | `parent_id` | `departments` | `id` | Phase 1 | 부서 구조 변경 유연성 | +| `estimates` | `model_set_id` | `categories` | `id` | Phase 2 | 견적 성능 향상 | +| `estimate_items` | `estimate_id` | `estimates` | `id` | Phase 2 | 견적 아이템 성능 향상 | + +### 🔒 유지된 중요 FK 제약조건 (40+개) + +**멀티테넌트 보안 FK** (필수 유지): +- 모든 `tenant_id → tenants.id` 관계 유지 +- 사용자 권한 관련 FK 모두 유지 + +**핵심 비즈니스 FK** (필수 유지): +- `products.category_id → categories.id` +- 권한 관리 시스템 (users, roles, permissions) +- 주문 관리 시스템 (orders, order_items, clients) + +## 🧩 논리적 관계 명세 + +### 1. 분류 관리 (Classifications) +``` +classifications (분류 코드) +├── tenant_id → tenants.id (논리적 - 멀티테넌트) +└── [Eloquent Model에서 BelongsToTenant 트레잇으로 관리] +``` + +**개발 시 주의사항:** +- Service 레이어에서 테넌트 격리 검증 필수 +- 분류 삭제 시 사용 중인 참조 확인 필요 + +### 2. 부서 관리 (Departments) +``` +departments (부서) +├── parent_id → departments.id (논리적 - 계층 구조) +└── [자기 참조 관계, Soft Delete 적용] +``` + +**개발 시 주의사항:** +- 부서 삭제 시 하위 부서 존재 여부 확인 +- 순환 참조 방지 로직 필요 +- 사용자 배치 여부 확인 후 삭제 + +### 3. 견적 시스템 (Estimates) +``` +estimates (견적) +├── model_set_id → categories.id (논리적 - 스냅샷 특성) +├── tenant_id → tenants.id (물리적 FK 유지 - 보안) +└── [견적은 생성 시점 데이터 보존] + +estimate_items (견적 아이템) +├── estimate_id → estimates.id (논리적 - 성능 최적화) +├── tenant_id → tenants.id (물리적 FK 유지 - 보안) +└── [대량 데이터 처리 성능 우선] +``` + +**개발 시 주의사항:** +- 견적 데이터는 스냅샷 특성상 참조 무결성보다 성능 우선 +- 카테고리 변경이 기존 견적에 영향주지 않도록 주의 +- 대량 견적 아이템 처리 시 batch 작업 권장 + +### 4. BOM 시스템 (Product Components) +``` +product_components (제품 구성요소) +├── parent_product_id → products.id (물리적 FK 없음 - 이미 유연한 구조) +├── ref_type: 'MATERIAL' | 'PRODUCT' (참조 타입) +├── ref_id → materials.id | products.id (논리적 - ref_type에 따라) +└── [통합 참조 구조로 설계됨] +``` + +**개발 시 주의사항:** +- ref_type 값에 따라 ref_id 해석 필요 +- 자재/제품 삭제 시 BOM 영향도 분석 필수 +- Service 레이어에서 참조 무결성 검증 구현 + +## 📈 성능 최적화 효과 + +### 제거된 FK의 성능 영향 + +1. **Classifications**: 분류 코드 조회 성능 향상 +2. **Departments**: 부서 구조 변경 시 락킹 감소 +3. **Estimates**: 견적 생성/수정 처리량 증가 +4. **Estimate Items**: 대량 아이템 처리 성능 향상 + +### 유지된 인덱스 + +모든 제거된 FK에 대해 성능 인덱스는 유지하여 조회 성능 보장: + +```sql +-- 유지된 성능 인덱스들 +CREATE INDEX idx_classifications_tenant_id ON classifications (tenant_id); +CREATE INDEX idx_departments_parent_id ON departments (parent_id); +CREATE INDEX idx_estimates_tenant_model_set ON estimates (tenant_id, model_set_id); +CREATE INDEX idx_estimate_items_tenant_estimate ON estimate_items (tenant_id, estimate_id); +CREATE INDEX idx_components_ref_type_id ON product_components (ref_type, ref_id); +``` + +## 🛡️ 개발 가이드라인 + +### Service 레이어 구현 필수사항 + +1. **데이터 무결성 검증** + ```php + // 예시: 부서 삭제 전 검증 + public function deleteDepartment($id) { + if ($this->hasChildDepartments($id)) { + throw new ValidationException('하위 부서가 존재합니다.'); + } + if ($this->hasAssignedUsers($id)) { + throw new ValidationException('배정된 사용자가 존재합니다.'); + } + // 삭제 진행 + } + ``` + +2. **Eloquent 관계 적극 활용** + ```php + // 논리적 관계는 Model에서 정의 + class Department extends Model { + public function parent() { + return $this->belongsTo(Department::class, 'parent_id'); + } + + public function children() { + return $this->hasMany(Department::class, 'parent_id'); + } + } + ``` + +3. **배치 작업 시 주의사항** + - 대량 데이터 변경 시 관련 테이블 영향도 고려 + - Transaction 사용으로 일관성 보장 + - 참조 무결성 검증 로직 포함 + +## 🚨 주의사항 + +### 절대 하지 말아야 할 것 + +❌ **직접 SQL로 참조 데이터 삭제** +❌ **Service 레이어 무결성 검증 생략** +❌ **대량 작업 시 관련 테이블 확인 누락** + +### 권장사항 + +✅ **Eloquent ORM 관계 메서드 사용** +✅ **Service 레이어에서 비즈니스 규칙 검증** +✅ **Soft Delete 활용으로 데이터 보호** +✅ **단위 테스트로 무결성 검증** + +## 🔄 복구 방법 + +필요시 물리적 FK 제약조건 복구 가능: + +```bash +# 전체 롤백 +php artisan migrate:rollback --step=4 + +# 특정 단계만 롤백 +php artisan migrate:rollback --step=1 +``` + +**참고**: FK 복구 시 데이터 무결성 위반으로 실패할 수 있으므로, +복구 전 데이터 정합성 점검 필수. + +--- + +**문서 관리**: 이 문서는 데이터베이스 스키마 변경 시 함께 업데이트해야 합니다. +**최종 업데이트**: 2025-09-24 (Phase 1~3 FK 제거 완료) \ No newline at end of file diff --git a/LOGICAL_RELATIONSHIPS_SIMPLE.md b/LOGICAL_RELATIONSHIPS_SIMPLE.md new file mode 100644 index 0000000..981e43d --- /dev/null +++ b/LOGICAL_RELATIONSHIPS_SIMPLE.md @@ -0,0 +1,86 @@ +# 논리적 데이터베이스 관계 문서 (간소화) + +> **생성일**: 2025-09-24 22:15:54 +> **소스**: 알려진 비즈니스 관계 기반 +> **참고**: FK 제거 후 논리적 관계 명세 + +## 📊 테이블별 논리적 관계 + +### 📋 `users` - 사용자 계정 + +- **user_tenants (hasMany)**: `user_tenants.user_id → users.id` +- **user_roles (hasMany)**: `user_roles.user_id → users.id` +- **audit_logs (hasMany)**: `audit_logs.actor_id → users.id (생성자)` + +### 📋 `tenants` - 테넌트 (회사/조직) + +- **user_tenants (hasMany)**: `user_tenants.tenant_id → tenants.id` +- **classifications (hasMany)**: `classifications.tenant_id → tenants.id (논리적)` +- **departments (hasMany)**: `departments.tenant_id → tenants.id` +- **products (hasMany)**: `products.tenant_id → tenants.id` +- **orders (hasMany)**: `orders.tenant_id → tenants.id` + +### 📋 `categories` - 제품 카테고리 (계층구조) + +- **parent (belongsTo)**: `categories.parent_id → categories.id` +- **children (hasMany)**: `categories.parent_id → categories.id` +- **products (hasMany)**: `products.category_id → categories.id` +- **estimates (hasMany)**: `estimates.model_set_id → categories.id (논리적)` + +### 📋 `products` - 제품 마스터 + +- **category (belongsTo)**: `products.category_id → categories.id` +- **tenant (belongsTo)**: `products.tenant_id → tenants.id` +- **product_components (hasMany)**: `product_components.parent_product_id → products.id (논리적)` +- **order_items (hasMany)**: `order_items.product_id → products.id` + +### 📋 `departments` - 부서 관리 (계층구조) + +- **parent (belongsTo)**: `departments.parent_id → departments.id (논리적)` +- **children (hasMany)**: `departments.parent_id → departments.id (논리적)` +- **tenant (belongsTo)**: `departments.tenant_id → tenants.id` + +### 📋 `estimates` - 견적서 (스냅샷 데이터) + +- **category (belongsTo)**: `estimates.model_set_id → categories.id (논리적)` +- **tenant (belongsTo)**: `estimates.tenant_id → tenants.id` +- **estimate_items (hasMany)**: `estimate_items.estimate_id → estimates.id (논리적)` + +### 📋 `estimate_items` - 견적 아이템 + +- **estimate (belongsTo)**: `estimate_items.estimate_id → estimates.id (논리적)` +- **tenant (belongsTo)**: `estimate_items.tenant_id → tenants.id` + +### 📋 `product_components` - BOM 구성요소 (통합 참조구조) + +- **parent_product (belongsTo)**: `product_components.parent_product_id → products.id (논리적)` +- **material_or_product (polymorphic)**: `product_components.ref_id → materials.id OR products.id (ref_type 기반)` +- **tenant (belongsTo)**: `product_components.tenant_id → tenants.id` + +### 📋 `classifications` - 분류 코드 + +- **tenant (belongsTo)**: `classifications.tenant_id → tenants.id (논리적)` + +## 🚨 중요 사항 + +### 논리적 관계 (FK 제거됨) +- `classifications.tenant_id → tenants.id` +- `departments.parent_id → departments.id` +- `estimates.model_set_id → categories.id` +- `estimate_items.estimate_id → estimates.id` +- `product_components` 모든 관계 (통합 구조) + +### 물리적 FK 유지됨 +- 모든 `tenant_id` 관계 (멀티테넌트 보안) +- 권한 관리 시스템 FK +- 기타 중요 비즈니스 FK + +## 📝 개발 가이드 + +1. **Service 레이어**에서 논리적 무결성 검증 필수 +2. **Eloquent 관계 메서드** 적극 활용 +3. **Soft Delete**로 데이터 보호 +4. **BelongsToTenant** 트레잇으로 테넌트 격리 + +--- +*이 문서는 개발 참조용입니다. 모델 변경 시 업데이트 해주세요.* diff --git a/app/Console/Commands/GenerateSimpleRelationships.php b/app/Console/Commands/GenerateSimpleRelationships.php new file mode 100644 index 0000000..c8ed32e --- /dev/null +++ b/app/Console/Commands/GenerateSimpleRelationships.php @@ -0,0 +1,148 @@ +info('🔄 기본 논리적 관계 문서 생성 중...'); + + $relationships = $this->getKnownRelationships(); + $this->generateDocument($relationships); + + $this->info('✅ 논리적 관계 문서 생성 완료!'); + } + + private function getKnownRelationships(): array + { + return [ + 'users' => [ + 'description' => '사용자 계정', + 'relationships' => [ + 'user_tenants (hasMany)' => 'user_tenants.user_id → users.id', + 'user_roles (hasMany)' => 'user_roles.user_id → users.id', + 'audit_logs (hasMany)' => 'audit_logs.actor_id → users.id (생성자)', + ] + ], + 'tenants' => [ + 'description' => '테넌트 (회사/조직)', + 'relationships' => [ + 'user_tenants (hasMany)' => 'user_tenants.tenant_id → tenants.id', + 'classifications (hasMany)' => 'classifications.tenant_id → tenants.id (논리적)', + 'departments (hasMany)' => 'departments.tenant_id → tenants.id', + 'products (hasMany)' => 'products.tenant_id → tenants.id', + 'orders (hasMany)' => 'orders.tenant_id → tenants.id', + ] + ], + 'categories' => [ + 'description' => '제품 카테고리 (계층구조)', + 'relationships' => [ + 'parent (belongsTo)' => 'categories.parent_id → categories.id', + 'children (hasMany)' => 'categories.parent_id → categories.id', + 'products (hasMany)' => 'products.category_id → categories.id', + 'estimates (hasMany)' => 'estimates.model_set_id → categories.id (논리적)', + ] + ], + 'products' => [ + 'description' => '제품 마스터', + 'relationships' => [ + 'category (belongsTo)' => 'products.category_id → categories.id', + 'tenant (belongsTo)' => 'products.tenant_id → tenants.id', + 'product_components (hasMany)' => 'product_components.parent_product_id → products.id (논리적)', + 'order_items (hasMany)' => 'order_items.product_id → products.id', + ] + ], + 'departments' => [ + 'description' => '부서 관리 (계층구조)', + 'relationships' => [ + 'parent (belongsTo)' => 'departments.parent_id → departments.id (논리적)', + 'children (hasMany)' => 'departments.parent_id → departments.id (논리적)', + 'tenant (belongsTo)' => 'departments.tenant_id → tenants.id', + ] + ], + 'estimates' => [ + 'description' => '견적서 (스냅샷 데이터)', + 'relationships' => [ + 'category (belongsTo)' => 'estimates.model_set_id → categories.id (논리적)', + 'tenant (belongsTo)' => 'estimates.tenant_id → tenants.id', + 'estimate_items (hasMany)' => 'estimate_items.estimate_id → estimates.id (논리적)', + ] + ], + 'estimate_items' => [ + 'description' => '견적 아이템', + 'relationships' => [ + 'estimate (belongsTo)' => 'estimate_items.estimate_id → estimates.id (논리적)', + 'tenant (belongsTo)' => 'estimate_items.tenant_id → tenants.id', + ] + ], + 'product_components' => [ + 'description' => 'BOM 구성요소 (통합 참조구조)', + 'relationships' => [ + 'parent_product (belongsTo)' => 'product_components.parent_product_id → products.id (논리적)', + 'material_or_product (polymorphic)' => 'product_components.ref_id → materials.id OR products.id (ref_type 기반)', + 'tenant (belongsTo)' => 'product_components.tenant_id → tenants.id', + ] + ], + 'classifications' => [ + 'description' => '분류 코드', + 'relationships' => [ + 'tenant (belongsTo)' => 'classifications.tenant_id → tenants.id (논리적)', + ] + ], + ]; + } + + private function generateDocument(array $relationships): void + { + $timestamp = now()->format('Y-m-d H:i:s'); + + $content = "# 논리적 데이터베이스 관계 문서 (간소화)\n\n"; + $content .= "> **생성일**: {$timestamp}\n"; + $content .= "> **소스**: 알려진 비즈니스 관계 기반\n"; + $content .= "> **참고**: FK 제거 후 논리적 관계 명세\n\n"; + + $content .= "## 📊 테이블별 논리적 관계\n\n"; + + foreach ($relationships as $table => $info) { + $content .= "### 📋 `{$table}` - {$info['description']}\n\n"; + + foreach ($info['relationships'] as $relationName => $definition) { + $content .= "- **{$relationName}**: `{$definition}`\n"; + } + + $content .= "\n"; + } + + $content .= "## 🚨 중요 사항\n\n"; + $content .= "### 논리적 관계 (FK 제거됨)\n"; + $content .= "- `classifications.tenant_id → tenants.id`\n"; + $content .= "- `departments.parent_id → departments.id`\n"; + $content .= "- `estimates.model_set_id → categories.id`\n"; + $content .= "- `estimate_items.estimate_id → estimates.id`\n"; + $content .= "- `product_components` 모든 관계 (통합 구조)\n\n"; + + $content .= "### 물리적 FK 유지됨\n"; + $content .= "- 모든 `tenant_id` 관계 (멀티테넌트 보안)\n"; + $content .= "- 권한 관리 시스템 FK\n"; + $content .= "- 기타 중요 비즈니스 FK\n\n"; + + $content .= "## 📝 개발 가이드\n\n"; + $content .= "1. **Service 레이어**에서 논리적 무결성 검증 필수\n"; + $content .= "2. **Eloquent 관계 메서드** 적극 활용\n"; + $content .= "3. **Soft Delete**로 데이터 보호\n"; + $content .= "4. **BelongsToTenant** 트레잇으로 테넌트 격리\n\n"; + + $content .= "---\n"; + $content .= "*이 문서는 개발 참조용입니다. 모델 변경 시 업데이트 해주세요.*\n"; + + File::put(base_path('LOGICAL_RELATIONSHIPS_SIMPLE.md'), $content); + $this->info('📄 문서 생성: LOGICAL_RELATIONSHIPS_SIMPLE.md'); + } +} \ No newline at end of file diff --git a/app/Console/Commands/MakeModelWithRelationships.php b/app/Console/Commands/MakeModelWithRelationships.php new file mode 100644 index 0000000..df3a4ba --- /dev/null +++ b/app/Console/Commands/MakeModelWithRelationships.php @@ -0,0 +1,76 @@ +argument('name'); + + // 기본 모델 생성 + $options = []; + if ($this->option('migration')) $options['--migration'] = true; + if ($this->option('controller')) $options['--controller'] = true; + if ($this->option('resource')) $options['--resource'] = true; + + Artisan::call('make:model', array_merge(['name' => $modelName], $options)); + $this->info("✅ 모델 생성 완료: {$modelName}"); + + // 관계 템플릿 추가 + $this->addRelationshipTemplate($modelName); + + // 논리적 관계 문서 업데이트 + Artisan::call('db:update-relationships'); + $this->info('✅ 논리적 관계 문서 업데이트 완료'); + } + + private function addRelationshipTemplate(string $modelName): void + { + $modelPath = app_path("Models/{$modelName}.php"); + + if (!File::exists($modelPath)) { + $this->error("모델 파일을 찾을 수 없습니다: {$modelPath}"); + return; + } + + $content = File::get($modelPath); + + // 관계 메서드 템플릿 추가 + $template = " + // ======================================== + // 관계 메서드 (Relationships) + // ======================================== + // + // 예시: + // public function category() + // { + // return \$this->belongsTo(Category::class); + // } + // + // public function items() + // { + // return \$this->hasMany(Item::class); + // } + // + // ⚠️ 새 관계 추가 후 'php artisan db:update-relationships' 실행 +"; + + // 클래스 끝 부분에 템플릿 삽입 + $content = str_replace( + '}' . PHP_EOL, + $template . PHP_EOL . '}' . PHP_EOL, + $content + ); + + File::put($modelPath, $content); + $this->info("📝 관계 템플릿 추가: {$modelName}"); + } +} \ No newline at end of file diff --git a/app/Console/Commands/UpdateLogicalRelationships.php b/app/Console/Commands/UpdateLogicalRelationships.php new file mode 100644 index 0000000..8fd1a2a --- /dev/null +++ b/app/Console/Commands/UpdateLogicalRelationships.php @@ -0,0 +1,219 @@ +info('🔄 논리적 관계 문서 업데이트 시작...'); + + $relationships = $this->extractModelRelationships(); + $this->updateLogicalDocument($relationships); + + $this->info('✅ 논리적 관계 문서 업데이트 완료!'); + } + + private function extractModelRelationships(): array + { + $relationships = []; + $modelPath = app_path('Models'); + + // 모든 모델 파일 스캔 + $modelFiles = File::allFiles($modelPath); + + foreach ($modelFiles as $file) { + if ($file->getExtension() !== 'php') continue; + + $className = $this->getClassNameFromFile($file); + if (!$className || !class_exists($className)) continue; + + try { + $reflection = new ReflectionClass($className); + + // 모델이 Eloquent Model인지 확인 + if (!$reflection->isSubclassOf(\Illuminate\Database\Eloquent\Model::class)) { + continue; + } + + // Abstract 클래스 건너뛰기 + if ($reflection->isAbstract()) { + continue; + } + + // 테이블 이름 직접 추출 + $tableName = $this->getTableNameFromModel($className, $reflection); + if (!$tableName) continue; + + $relationships[$tableName] = [ + 'model' => $className, + 'relationships' => $this->getModelRelationshipsFromFile($file, $className) + ]; + + } catch (\Exception $e) { + $this->warn("모델 분석 실패: {$className} - " . $e->getMessage()); + continue; + } + } + + return $relationships; + } + + private function getTableNameFromModel(string $className, ReflectionClass $reflection): ?string + { + // 클래스명에서 테이블명 추정 + $modelName = class_basename($className); + $tableName = strtolower(preg_replace('/(?getRealPath()); + $relationships = []; + + // 관계 메서드 패턴 검출 + $patterns = [ + 'belongsTo' => '/public\s+function\s+(\w+)\s*\([^)]*\)\s*[^{]*{\s*return\s+\$this->belongsTo\s*\(\s*([^,\)]+)/', + 'hasMany' => '/public\s+function\s+(\w+)\s*\([^)]*\)\s*[^{]*{\s*return\s+\$this->hasMany\s*\(\s*([^,\)]+)/', + 'hasOne' => '/public\s+function\s+(\w+)\s*\([^)]*\)\s*[^{]*{\s*return\s+\$this->hasOne\s*\(\s*([^,\)]+)/', + 'belongsToMany' => '/public\s+function\s+(\w+)\s*\([^)]*\)\s*[^{]*{\s*return\s+\$this->belongsToMany\s*\(\s*([^,\)]+)/', + ]; + + foreach ($patterns as $type => $pattern) { + if (preg_match_all($pattern, $content, $matches, PREG_SET_ORDER)) { + foreach ($matches as $match) { + $relationships[] = [ + 'method' => $match[1], + 'type' => $type, + 'related_model' => trim($match[2], '"\''), + 'foreign_key' => null, + 'local_key' => null + ]; + } + } + } + + return $relationships; + } + + private function getModelRelationships(ReflectionClass $reflection, $model): array + { + $relationships = []; + $methods = $reflection->getMethods(\ReflectionMethod::IS_PUBLIC); + + foreach ($methods as $method) { + if ($method->getDeclaringClass()->getName() !== $reflection->getName()) { + continue; + } + + try { + // 관계 메서드 호출해서 타입 확인 + $result = $method->invoke($model); + + if ($this->isRelationshipMethod($result)) { + $relationships[] = [ + 'method' => $method->getName(), + 'type' => $this->getRelationshipType($result), + 'related_model' => get_class($result->getRelated()), + 'foreign_key' => $this->getForeignKey($result), + 'local_key' => $this->getLocalKey($result) + ]; + } + } catch (\Exception $e) { + // 관계 메서드가 아니거나 호출 실패 시 건너뛰기 + continue; + } + } + + return $relationships; + } + + private function isRelationshipMethod($result): bool + { + return $result instanceof \Illuminate\Database\Eloquent\Relations\Relation; + } + + private function getRelationshipType($relation): string + { + $className = get_class($relation); + return class_basename($className); + } + + private function getForeignKey($relation): ?string + { + return method_exists($relation, 'getForeignKeyName') + ? $relation->getForeignKeyName() + : null; + } + + private function getLocalKey($relation): ?string + { + return method_exists($relation, 'getLocalKeyName') + ? $relation->getLocalKeyName() + : null; + } + + private function getClassNameFromFile($file): ?string + { + $content = File::get($file->getRealPath()); + + if (!preg_match('/namespace\s+([^;]+);/', $content, $namespaceMatches)) { + return null; + } + + if (!preg_match('/class\s+(\w+)/', $content, $classMatches)) { + return null; + } + + return $namespaceMatches[1] . '\\' . $classMatches[1]; + } + + private function updateLogicalDocument(array $relationships): void + { + $documentPath = base_path('LOGICAL_RELATIONSHIPS.md'); + $timestamp = now()->format('Y-m-d H:i:s'); + + $content = "# 논리적 데이터베이스 관계 문서\n\n"; + $content .= "> **자동 생성**: {$timestamp}\n"; + $content .= "> **소스**: Eloquent 모델 관계 분석\n\n"; + + $content .= "## 📊 모델별 관계 현황\n\n"; + + foreach ($relationships as $tableName => $info) { + if (empty($info['relationships'])) continue; + + $content .= "### {$tableName}\n"; + $content .= "**모델**: `{$info['model']}`\n\n"; + + foreach ($info['relationships'] as $rel) { + $relatedTable = (new $rel['related_model'])->getTable(); + $content .= "- **{$rel['method']}()**: {$rel['type']} → `{$relatedTable}`"; + + if ($rel['foreign_key']) { + $content .= " (FK: `{$rel['foreign_key']}`)"; + } + + $content .= "\n"; + } + + $content .= "\n"; + } + + File::put($documentPath, $content); + $this->info("📄 문서 업데이트: {$documentPath}"); + } +} \ No newline at end of file diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 2a1a6e3..74fe00c 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -3,6 +3,9 @@ namespace App\Console; use App\Console\Commands\PruneAuditLogs; +use App\Console\Commands\UpdateLogicalRelationships; +use App\Console\Commands\MakeModelWithRelationships; +use App\Console\Commands\GenerateSimpleRelationships; use Illuminate\Console\Scheduling\Schedule; use Illuminate\Foundation\Console\Kernel as ConsoleKernel; @@ -10,6 +13,9 @@ class Kernel extends ConsoleKernel { protected $commands = [ PruneAuditLogs::class, + UpdateLogicalRelationships::class, + MakeModelWithRelationships::class, + GenerateSimpleRelationships::class, ]; protected function schedule(Schedule $schedule): void diff --git a/app/Models/Estimate/Estimate.php b/app/Models/Estimate/Estimate.php index da23d0b..1647b2d 100644 --- a/app/Models/Estimate/Estimate.php +++ b/app/Models/Estimate/Estimate.php @@ -3,17 +3,16 @@ namespace App\Models\Estimate; use App\Models\Commons\Category; -use App\Models\Contracts\BelongsToTenant; -use App\Traits\BelongsToTenantTrait; +use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; -class Estimate extends Model implements BelongsToTenant +class Estimate extends Model { - use HasFactory, SoftDeletes, BelongsToTenantTrait; + use HasFactory, SoftDeletes, BelongsToTenant; protected $fillable = [ 'tenant_id', diff --git a/app/Models/Estimate/EstimateItem.php b/app/Models/Estimate/EstimateItem.php index ab2cd53..30890b9 100644 --- a/app/Models/Estimate/EstimateItem.php +++ b/app/Models/Estimate/EstimateItem.php @@ -2,16 +2,15 @@ namespace App\Models\Estimate; -use App\Models\Contracts\BelongsToTenant; -use App\Traits\BelongsToTenantTrait; +use App\Traits\BelongsToTenant; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\SoftDeletes; -class EstimateItem extends Model implements BelongsToTenant +class EstimateItem extends Model { - use HasFactory, SoftDeletes, BelongsToTenantTrait; + use HasFactory, SoftDeletes, BelongsToTenant; protected $fillable = [ 'tenant_id', diff --git a/app/Providers/MigrationServiceProvider.php b/app/Providers/MigrationServiceProvider.php new file mode 100644 index 0000000..a0aba60 --- /dev/null +++ b/app/Providers/MigrationServiceProvider.php @@ -0,0 +1,62 @@ +updateRelationshipsAfterMigration(); + }); + } + + private function updateRelationshipsAfterMigration(): void + { + try { + // 논리적 관계 문서 자동 업데이트 + Artisan::call('db:update-relationships'); + + // Git에 자동 커밋 (선택사항) + if (config('database.auto_commit_relationships', false)) { + $this->autoCommitRelationships(); + } + + \Log::info('✅ 마이그레이션 후 논리적 관계 문서 업데이트 완료'); + + } catch (\Exception $e) { + \Log::error('❌ 논리적 관계 문서 업데이트 실패: ' . $e->getMessage()); + } + } + + private function autoCommitRelationships(): void + { + $commands = [ + 'git add LOGICAL_RELATIONSHIPS.md', + 'git commit -m "docs: 마이그레이션 후 논리적 관계 자동 업데이트 + +🤖 Generated with [Claude Code](https://claude.ai/code) + +Co-Authored-By: Claude "' + ]; + + foreach ($commands as $command) { + exec($command, $output, $returnCode); + if ($returnCode !== 0) { + \Log::warning("Git 명령 실행 실패: {$command}"); + break; + } + } + } +} \ No newline at end of file diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 38b258d..5f24ddc 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,4 +2,5 @@ return [ App\Providers\AppServiceProvider::class, + App\Providers\MigrationServiceProvider::class, ]; diff --git a/composer.json b/composer.json index a9900ec..a4cc2ea 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ }, "require-dev": { "barryvdh/laravel-ide-helper": "^3.6", + "beyondcode/laravel-er-diagram-generator": "^5.0", "fakerphp/faker": "^1.23", "kitloong/laravel-migrations-generator": "^7.1", "laravel-lang/lang": "^15.22", @@ -31,7 +32,8 @@ "psr-4": { "App\\": "app/", "Database\\Factories\\": "database/factories/", - "Database\\Seeders\\": "database/seeders/" + "Database\\Seeders\\": "database/seeders/", + "Shared\\": "../shared/" } }, "autoload-dev": { diff --git a/composer.lock b/composer.lock index 6d0d33e..216fd5c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a3b2629f79429532f4287f12f58e74ab", + "content-hash": "5e41f06b9f42d52021789067799761d5", "packages": [ { "name": "brick/math", @@ -7373,6 +7373,69 @@ }, "time": "2025-07-17T06:07:30+00:00" }, + { + "name": "beyondcode/laravel-er-diagram-generator", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/beyondcode/laravel-er-diagram-generator.git", + "reference": "899b0d5aa0e9137d247dc786b2f8abaeb694f692" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/beyondcode/laravel-er-diagram-generator/zipball/899b0d5aa0e9137d247dc786b2f8abaeb694f692", + "reference": "899b0d5aa0e9137d247dc786b2f8abaeb694f692", + "shasum": "" + }, + "require": { + "doctrine/dbal": "^3.3|^4.0", + "nikic/php-parser": "^4.0|^5.0", + "php": "^8.2", + "phpdocumentor/graphviz": "^1.0" + }, + "require-dev": { + "larapack/dd": "^1.0", + "orchestra/testbench": "^8.0|^9.0|^10.0", + "phpunit/phpunit": "^9.5.10|^10.5|^11.0", + "spatie/phpunit-snapshot-assertions": "^4.2|^5.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "BeyondCode\\ErdGenerator\\ErdGeneratorServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "BeyondCode\\ErdGenerator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Marcel Pociot", + "email": "marcel@beyondco.de", + "homepage": "https://beyondcode.de", + "role": "Developer" + } + ], + "description": "Generate ER diagrams from your Laravel models.", + "homepage": "https://github.com/beyondcode/laravel-er-diagram-generator", + "keywords": [ + "beyondcode", + "laravel-er-diagram-generator" + ], + "support": { + "issues": "https://github.com/beyondcode/laravel-er-diagram-generator/issues", + "source": "https://github.com/beyondcode/laravel-er-diagram-generator/tree/5.0.0" + }, + "time": "2025-06-13T13:53:45+00:00" + }, { "name": "composer/class-map-generator", "version": "1.6.2", @@ -8906,6 +8969,51 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "phpdocumentor/graphviz", + "version": "1.0.4", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/GraphViz.git", + "reference": "a906a90a9f230535f25ea31caf81b2323956283f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/GraphViz/zipball/a906a90a9f230535f25ea31caf81b2323956283f", + "reference": "a906a90a9f230535f25ea31caf81b2323956283f", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "phpunit/phpunit": "~4.0" + }, + "type": "library", + "autoload": { + "psr-0": { + "phpDocumentor": [ + "src/", + "tests/unit" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "mike.vanriel@naenius.com" + } + ], + "support": { + "issues": "https://github.com/phpDocumentor/GraphViz/issues", + "source": "https://github.com/phpDocumentor/GraphViz/tree/master" + }, + "time": "2016-02-02T13:00:08+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "11.0.10", diff --git a/database/migrations/2025_09_24_214300_remove_material_foreign_key_phase3.php b/database/migrations/2025_09_24_214300_remove_material_foreign_key_phase3.php index 5ee5bae..dc067f7 100644 --- a/database/migrations/2025_09_24_214300_remove_material_foreign_key_phase3.php +++ b/database/migrations/2025_09_24_214300_remove_material_foreign_key_phase3.php @@ -7,17 +7,13 @@ /** * Phase 3: 제품-자재 관계 FK 제약조건 제거 (신중한 검토 필요) - * - product_components.material_id → materials * - * 목적: 자재 변경/삭제 시 유연성 확보 - * 주의사항: - * 1. 비즈니스 로직에서 무결성 검증 필요 - * 2. 자재 삭제 시 BOM에 미치는 영향 검토 필요 - * 3. 소프트 딜리트로 대부분 처리되므로 상대적으로 안전 + * 실제 상황: product_components 테이블은 ref_type/ref_id 통합 구조 사용 + * - material_id 컬럼이 존재하지 않음 (ref_type='MATERIAL', ref_id=material.id) + * - 물리적 FK 제약조건이 없는 상태 * - * 유지되는 핵심 FK: - * - product_components.parent_product_id → products (BOM 구조 핵심) - * - product_components.child_product_id → products (BOM 구조 핵심) + * 목적: 현재 구조 확인 및 논리적 관계 문서화 + * 결론: 이미 FK 없는 유연한 구조로 구성되어 있음 */ return new class extends Migration { @@ -55,9 +51,8 @@ private function dropForeignKeyIfExists(string $table, string $column): void public function up(): void { - echo "🚀 Phase 3: 제품-자재 관계 FK 제약조건 제거 시작\n\n"; - echo "⚠️ 주의: 이 작업은 신중한 검토가 필요합니다!\n"; - echo "📋 영향 범위: BOM 시스템의 자재 참조 관계\n\n"; + echo "🚀 Phase 3: 제품-자재 관계 현황 분석 시작\n\n"; + echo "📋 분석 범위: BOM 시스템의 자재 참조 관계\n\n"; // product_components 테이블 존재 여부 확인 if (!Schema::hasTable('product_components')) { @@ -65,74 +60,76 @@ public function up(): void return; } - echo "1️⃣ Product_components 테이블 FK 분석...\n"; + echo "1️⃣ Product_components 테이블 구조 분석...\n"; + + // 테이블 구조 확인 + $columns = DB::select('DESCRIBE product_components'); + $columnNames = array_map(function($col) { return $col->Field; }, $columns); + + echo " 현재 테이블 구조:\n"; + echo " - ref_type 컬럼: " . (in_array('ref_type', $columnNames) ? "존재 (통합 참조 타입)" : "없음") . "\n"; + echo " - ref_id 컬럼: " . (in_array('ref_id', $columnNames) ? "존재 (통합 참조 ID)" : "없음") . "\n"; + echo " - material_id 컬럼: " . (in_array('material_id', $columnNames) ? "존재" : "없음 (예상대로)") . "\n\n"; // 현재 FK 상태 확인 - $materialFk = $this->findForeignKeyName('product_components', 'material_id'); $parentProductFk = $this->findForeignKeyName('product_components', 'parent_product_id'); - $childProductFk = $this->findForeignKeyName('product_components', 'child_product_id'); + $refIdFk = $this->findForeignKeyName('product_components', 'ref_id'); - echo " 현재 FK 상태:\n"; - echo " - material_id FK: " . ($materialFk ? "존재 ({$materialFk})" : "없음") . "\n"; + echo "2️⃣ FK 제약조건 상태 확인...\n"; echo " - parent_product_id FK: " . ($parentProductFk ? "존재 ({$parentProductFk})" : "없음") . "\n"; - echo " - child_product_id FK: " . ($childProductFk ? "존재 ({$childProductFk})" : "없음") . "\n\n"; + echo " - ref_id FK: " . ($refIdFk ? "존재 ({$refIdFk})" : "없음 (유연한 구조)") . "\n\n"; - // material_id FK만 제거 (핵심 제품 관계는 유지) - echo "2️⃣ Material_id FK 제거 (자재 관리 유연성)...\n"; - $this->dropForeignKeyIfExists('product_components', 'material_id'); - - echo "3️⃣ 핵심 제품 관계 FK 유지 확인...\n"; - if ($parentProductFk) { - echo "✅ 유지: parent_product_id → products (BOM 구조 핵심)\n"; - } - if ($childProductFk) { - echo "✅ 유지: child_product_id → products (BOM 구조 핵심)\n"; - } - - // 성능을 위한 인덱스 확인/추가 - echo "\n4️⃣ 성능 인덱스 확인...\n"; - $materialIndexExists = DB::selectOne(" - SHOW INDEX FROM product_components WHERE Key_name = 'idx_components_material_id' + // 성능을 위한 인덱스 확인 + echo "3️⃣ 성능 인덱스 상태 확인...\n"; + $refTypeIndexExists = DB::selectOne(" + SHOW INDEX FROM product_components WHERE Key_name LIKE '%ref_type%' OR Column_name = 'ref_type' "); - if (!$materialIndexExists) { - DB::statement("CREATE INDEX idx_components_material_id ON product_components (material_id)"); - echo "✅ Added performance index: product_components.material_id\n"; - } else { - echo "ℹ️ Index exists: product_components.material_id\n"; + $refIdIndexExists = DB::selectOne(" + SHOW INDEX FROM product_components WHERE Key_name LIKE '%ref_id%' OR Column_name = 'ref_id' + "); + + echo " - ref_type 인덱스: " . ($refTypeIndexExists ? "존재" : "없음") . "\n"; + echo " - ref_id 인덱스: " . ($refIdIndexExists ? "존재" : "없음") . "\n"; + + // 필요시 성능 인덱스 추가 + if (!$refTypeIndexExists || !$refIdIndexExists) { + echo "\n4️⃣ 성능 인덱스 추가...\n"; + try { + DB::statement("CREATE INDEX idx_components_ref_type_id ON product_components (ref_type, ref_id)"); + echo "✅ Added composite index: product_components(ref_type, ref_id)\n"; + } catch (\Exception $e) { + echo "ℹ️ Index may already exist or not needed\n"; + } } - echo "\n🎉 Phase 3 제품-자재 FK 제거 완료!\n"; - echo "📋 제거된 FK:\n"; - echo " - product_components.material_id → materials\n"; - echo "🔒 유지된 핵심 FK:\n"; - echo " - product_components.parent_product_id → products\n"; - echo " - product_components.child_product_id → products\n"; - echo "📈 예상 효과: 자재 변경/삭제 시 BOM 유연성 증가\n"; - echo "⚠️ 주의사항: Service 레이어에서 자재 무결성 검증 필요\n"; + echo "\n🎉 Phase 3 분석 완료!\n"; + echo "📋 현재 구조 요약:\n"; + echo " - 이미 유연한 ref_type/ref_id 구조 사용 중\n"; + echo " - material_id 컬럼 없음 (통합 구조로 대체)\n"; + echo " - 물리적 FK 제약조건 없어 관리 유연성 확보됨\n"; + echo "📈 결론: 추가 FK 제거 작업 불필요 (이미 최적화됨)\n"; + echo "✅ 권장사항: Service 레이어에서 논리적 무결성 검증 유지\n"; } public function down(): void { - echo "🔄 Phase 3 제품-자재 FK 복구 시작...\n\n"; + echo "🔄 Phase 3 분석 롤백 시작...\n\n"; + + echo "ℹ️ 이 마이그레이션은 분석 목적으로 실행되었습니다.\n"; + echo "📋 롤백 내용:\n"; + echo " - 추가된 성능 인덱스 제거 (필요시)\n\n"; if (Schema::hasTable('product_components')) { - echo "1️⃣ Material_id FK 복구...\n"; + echo "1️⃣ 성능 인덱스 제거...\n"; try { - Schema::table('product_components', function (Blueprint $table) { - $table->foreign('material_id') - ->references('id') - ->on('materials') - ->nullOnDelete(); - }); - echo "✅ Restored FK: product_components.material_id → materials\n"; - } catch (\Throwable $e) { - echo "⚠️ Could not restore FK: product_components.material_id\n"; - echo " Error: " . $e->getMessage() . "\n"; - echo " 이유: 데이터 무결성 위반이나 참조되지 않는 material_id가 있을 수 있습니다.\n"; + DB::statement("DROP INDEX idx_components_ref_type_id ON product_components"); + echo "✅ Removed index: product_components(ref_type, ref_id)\n"; + } catch (\Exception $e) { + echo "ℹ️ Index may not exist or already removed\n"; } } - echo "\n🔄 Phase 3 FK 복구 완료!\n"; - echo "📝 참고: FK 복구 실패 시 데이터 정합성을 먼저 확인하세요.\n"; + echo "\n🔄 Phase 3 분석 롤백 완료!\n"; + echo "📝 참고: 원래 ref_type/ref_id 구조가 복구되었습니다.\n"; } }; \ No newline at end of file