From 342d15196e11ce569ec02e9dcd1bdba210cd58e5 Mon Sep 17 00:00:00 2001 From: hskwon Date: Fri, 14 Nov 2025 10:57:02 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20BP-MES=20Phase=201=20=ED=95=98?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C=EB=A6=AC=EB=93=9C=20=EA=B5=AC=EC=A1=B0=20?= =?UTF-8?q?=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - products 테이블: 6개 고정 필드 + attributes JSON - product_components 테이블: 수식/조건 + attributes JSON - tenant_stat_fields 테이블: 테넌트별 통계 필드 설정 - stat_snapshots 테이블: 통계 캐싱 - BP-MES CategoryFields Seeder: 제품/부품/절곡품 카테고리 필드 - BP-MES TenantStatFields Seeder: 통계 필드 설정 [변경 사항] 삭제: - 2025_11_13_120000_extend_products_table_for_bp_mes.php - 2025_11_13_120001_extend_product_components_table_for_bp_mes.php 추가: - 2025_11_14_000001_add_hybrid_fields_to_products_table.php - 2025_11_14_000002_add_attributes_to_product_components_table.php - 2025_11_14_000003_create_tenant_stat_fields_table.php - 2025_11_14_000004_create_stat_snapshots_table.php - BpMesCategoryFieldsSeeder.php - BpMesTenantStatFieldsSeeder.php [배경] 멀티테넌트 시스템의 유연성 확보를 위해 고정 필드를 최소화하고 동적 필드 시스템(category_fields + attributes JSON)으로 전환. 통계 성능을 위해 자주 조회하는 분류 필드(product_category, part_type)는 고정 컬럼으로 유지하고 인덱싱. --- LOGICAL_RELATIONSHIPS.md | 2 +- ...20000_extend_products_table_for_bp_mes.php | 367 ------------------ ...nd_product_components_table_for_bp_mes.php | 104 ----- ...01_add_hybrid_fields_to_products_table.php | 138 +++++++ ...attributes_to_product_components_table.php | 72 ++++ ...000003_create_tenant_stat_fields_table.php | 80 ++++ ..._14_000004_create_stat_snapshots_table.php | 97 +++++ .../seeders/BpMesCategoryFieldsSeeder.php | 232 +++++++++++ .../seeders/BpMesTenantStatFieldsSeeder.php | 174 +++++++++ 9 files changed, 794 insertions(+), 472 deletions(-) delete mode 100644 database/migrations/2025_11_13_120000_extend_products_table_for_bp_mes.php delete mode 100644 database/migrations/2025_11_13_120001_extend_product_components_table_for_bp_mes.php create mode 100644 database/migrations/2025_11_14_000001_add_hybrid_fields_to_products_table.php create mode 100644 database/migrations/2025_11_14_000002_add_attributes_to_product_components_table.php create mode 100644 database/migrations/2025_11_14_000003_create_tenant_stat_fields_table.php create mode 100644 database/migrations/2025_11_14_000004_create_stat_snapshots_table.php create mode 100644 database/seeders/BpMesCategoryFieldsSeeder.php create mode 100644 database/seeders/BpMesTenantStatFieldsSeeder.php diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index d981d05..512c6df 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2025-11-14 08:41:17 +> **자동 생성**: 2025-11-14 10:55:53 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 diff --git a/database/migrations/2025_11_13_120000_extend_products_table_for_bp_mes.php b/database/migrations/2025_11_13_120000_extend_products_table_for_bp_mes.php deleted file mode 100644 index 69fe9ce..0000000 --- a/database/migrations/2025_11_13_120000_extend_products_table_for_bp_mes.php +++ /dev/null @@ -1,367 +0,0 @@ -boolean('is_active') - ->default(true) - ->after('product_type') - ->comment('활성 상태'); - } - - if (! Schema::hasColumn('products', 'margin_rate')) { - $table->decimal('margin_rate', 5, 2) - ->nullable() - ->after('is_active') - ->comment('마진율 (%)'); - } - - if (! Schema::hasColumn('products', 'processing_cost')) { - $table->decimal('processing_cost', 10, 2) - ->nullable() - ->after('margin_rate') - ->comment('가공비'); - } - - if (! Schema::hasColumn('products', 'labor_cost')) { - $table->decimal('labor_cost', 10, 2) - ->nullable() - ->after('processing_cost') - ->comment('인건비'); - } - - if (! Schema::hasColumn('products', 'install_cost')) { - $table->decimal('install_cost', 10, 2) - ->nullable() - ->after('labor_cost') - ->comment('설치비'); - } - - if (! Schema::hasColumn('products', 'safety_stock')) { - $table->integer('safety_stock') - ->nullable() - ->after('install_cost') - ->comment('안전 재고'); - } - - if (! Schema::hasColumn('products', 'lead_time')) { - $table->integer('lead_time') - ->nullable() - ->after('safety_stock') - ->comment('리드타임 (일)'); - } - - if (! Schema::hasColumn('products', 'is_variable_size')) { - $table->boolean('is_variable_size') - ->default(false) - ->after('lead_time') - ->comment('가변 치수 여부'); - } - - // ================================================ - // FG (제품) 전용 필드 (3개) - // ================================================ - if (! Schema::hasColumn('products', 'product_category')) { - $table->string('product_category', 20) - ->nullable() - ->after('is_variable_size') - ->comment('제품 분류: SCREEN, STEEL 등'); - } - - if (! Schema::hasColumn('products', 'lot_abbreviation')) { - $table->string('lot_abbreviation', 10) - ->nullable() - ->after('product_category') - ->comment('로트 약어 (예: KD)'); - } - - if (! Schema::hasColumn('products', 'note')) { - $table->text('note') - ->nullable() - ->after('lot_abbreviation') - ->comment('비고'); - } - - // ================================================ - // PT (부품) 전용 필드 (9개) - // ================================================ - if (! Schema::hasColumn('products', 'part_type')) { - $table->string('part_type', 20) - ->nullable() - ->after('note') - ->comment('부품 타입: ASSEMBLY, BENDING, PURCHASED'); - } - - if (! Schema::hasColumn('products', 'part_usage')) { - $table->string('part_usage', 30) - ->nullable() - ->after('part_type') - ->comment('부품 용도: GUIDE_RAIL, BOTTOM_FINISH, CASE, DOOR, BRACKET, GENERAL'); - } - - if (! Schema::hasColumn('products', 'installation_type')) { - $table->string('installation_type', 20) - ->nullable() - ->after('part_usage') - ->comment('설치 타입'); - } - - if (! Schema::hasColumn('products', 'assembly_type')) { - $table->string('assembly_type', 20) - ->nullable() - ->after('installation_type') - ->comment('조립 타입'); - } - - if (! Schema::hasColumn('products', 'side_spec_width')) { - $table->string('side_spec_width', 20) - ->nullable() - ->after('assembly_type') - ->comment('측면 규격 폭'); - } - - if (! Schema::hasColumn('products', 'side_spec_height')) { - $table->string('side_spec_height', 20) - ->nullable() - ->after('side_spec_width') - ->comment('측면 규격 높이'); - } - - if (! Schema::hasColumn('products', 'assembly_length')) { - $table->string('assembly_length', 20) - ->nullable() - ->after('side_spec_height') - ->comment('조립 길이'); - } - - if (! Schema::hasColumn('products', 'guide_rail_model_type')) { - $table->string('guide_rail_model_type', 50) - ->nullable() - ->after('assembly_length') - ->comment('가이드레일 모델 타입'); - } - - if (! Schema::hasColumn('products', 'guide_rail_model')) { - $table->string('guide_rail_model', 50) - ->nullable() - ->after('guide_rail_model_type') - ->comment('가이드레일 모델'); - } - - // ================================================ - // 절곡품 필드 (5개) - // ================================================ - if (! Schema::hasColumn('products', 'bending_diagram')) { - $table->string('bending_diagram', 255) - ->nullable() - ->after('guide_rail_model') - ->comment('절곡 전개도 이미지 URL'); - } - - if (! Schema::hasColumn('products', 'bending_details')) { - $table->json('bending_details') - ->nullable() - ->after('bending_diagram') - ->comment('절곡 전개도 상세 데이터 (BendingDetail[])'); - } - - if (! Schema::hasColumn('products', 'material')) { - $table->string('material', 50) - ->nullable() - ->after('bending_details') - ->comment('재질'); - } - - if (! Schema::hasColumn('products', 'length')) { - $table->string('length', 20) - ->nullable() - ->after('material') - ->comment('길이'); - } - - if (! Schema::hasColumn('products', 'bending_length')) { - $table->string('bending_length', 20) - ->nullable() - ->after('length') - ->comment('절곡 길이'); - } - - // ================================================ - // 인증 정보 필드 (7개) - // ================================================ - if (! Schema::hasColumn('products', 'certification_number')) { - $table->string('certification_number', 50) - ->nullable() - ->after('bending_length') - ->comment('인증 번호'); - } - - if (! Schema::hasColumn('products', 'certification_start_date')) { - $table->date('certification_start_date') - ->nullable() - ->after('certification_number') - ->comment('인증 시작일'); - } - - if (! Schema::hasColumn('products', 'certification_end_date')) { - $table->date('certification_end_date') - ->nullable() - ->after('certification_start_date') - ->comment('인증 종료일'); - } - - if (! Schema::hasColumn('products', 'specification_file')) { - $table->string('specification_file', 255) - ->nullable() - ->after('certification_end_date') - ->comment('시방서 파일 경로'); - } - - if (! Schema::hasColumn('products', 'specification_file_name')) { - $table->string('specification_file_name', 255) - ->nullable() - ->after('specification_file') - ->comment('시방서 파일명'); - } - - if (! Schema::hasColumn('products', 'certification_file')) { - $table->string('certification_file', 255) - ->nullable() - ->after('specification_file_name') - ->comment('인증서 파일 경로'); - } - - if (! Schema::hasColumn('products', 'certification_file_name')) { - $table->string('certification_file_name', 255) - ->nullable() - ->after('certification_file') - ->comment('인증서 파일명'); - } - - // ================================================ - // 동적 확장 필드 (1개) - // ================================================ - if (! Schema::hasColumn('products', 'options')) { - $table->json('options') - ->nullable() - ->after('certification_file_name') - ->comment('동적 옵션 데이터 (JSON)'); - } - - // ================================================ - // 인덱스 추가 (조회 최적화) - // ================================================ - // is_active 인덱스 - $hasIsActiveIndex = collect(\DB::select('SHOW INDEX FROM `products`')) - ->contains(fn ($r) => $r->Key_name === 'idx_products_is_active'); - if (! $hasIsActiveIndex && Schema::hasColumn('products', 'is_active')) { - $table->index('is_active', 'idx_products_is_active'); - } - - // product_category 인덱스 - $hasProductCategoryIndex = collect(\DB::select('SHOW INDEX FROM `products`')) - ->contains(fn ($r) => $r->Key_name === 'idx_products_product_category'); - if (! $hasProductCategoryIndex && Schema::hasColumn('products', 'product_category')) { - $table->index('product_category', 'idx_products_product_category'); - } - - // part_type 인덱스 - $hasPartTypeIndex = collect(\DB::select('SHOW INDEX FROM `products`')) - ->contains(fn ($r) => $r->Key_name === 'idx_products_part_type'); - if (! $hasPartTypeIndex && Schema::hasColumn('products', 'part_type')) { - $table->index('part_type', 'idx_products_part_type'); - } - - // part_usage 인덱스 - $hasPartUsageIndex = collect(\DB::select('SHOW INDEX FROM `products`')) - ->contains(fn ($r) => $r->Key_name === 'idx_products_part_usage'); - if (! $hasPartUsageIndex && Schema::hasColumn('products', 'part_usage')) { - $table->index('part_usage', 'idx_products_part_usage'); - } - }); - } - - public function down(): void - { - Schema::table('products', function (Blueprint $table) { - // 인덱스 제거 - $indexes = ['idx_products_is_active', 'idx_products_product_category', 'idx_products_part_type', 'idx_products_part_usage']; - foreach ($indexes as $indexName) { - $hasIndex = collect(\DB::select('SHOW INDEX FROM `products`')) - ->contains(fn ($r) => $r->Key_name === $indexName); - if ($hasIndex) { - $table->dropIndex($indexName); - } - } - - // 컬럼 제거 (역순) - $columns = [ - 'options', - 'certification_file_name', - 'certification_file', - 'specification_file_name', - 'specification_file', - 'certification_end_date', - 'certification_start_date', - 'certification_number', - 'bending_length', - 'length', - 'material', - 'bending_details', - 'bending_diagram', - 'guide_rail_model', - 'guide_rail_model_type', - 'assembly_length', - 'side_spec_height', - 'side_spec_width', - 'assembly_type', - 'installation_type', - 'part_usage', - 'part_type', - 'note', - 'lot_abbreviation', - 'product_category', - 'is_variable_size', - 'lead_time', - 'safety_stock', - 'install_cost', - 'labor_cost', - 'processing_cost', - 'margin_rate', - 'is_active', - ]; - - foreach ($columns as $column) { - if (Schema::hasColumn('products', $column)) { - $table->dropColumn($column); - } - } - }); - } -}; diff --git a/database/migrations/2025_11_13_120001_extend_product_components_table_for_bp_mes.php b/database/migrations/2025_11_13_120001_extend_product_components_table_for_bp_mes.php deleted file mode 100644 index afcf6b2..0000000 --- a/database/migrations/2025_11_13_120001_extend_product_components_table_for_bp_mes.php +++ /dev/null @@ -1,104 +0,0 @@ -text('quantity_formula') - ->nullable() - ->after('quantity') - ->comment('수량 계산 공식 (예: "W * 2", "H + 100", "G/1000*1.02")'); - } - - if (! Schema::hasColumn('product_components', 'condition')) { - $table->text('condition') - ->nullable() - ->after('quantity_formula') - ->comment('조건부 BOM 조건식 (예: "MOTOR=\'Y\'", "WIDTH > 3000")'); - } - - // ================================================ - // 절곡품 관련 필드 (3개) - // ================================================ - if (! Schema::hasColumn('product_components', 'is_bending')) { - $table->boolean('is_bending') - ->default(false) - ->after('condition') - ->comment('절곡품 여부'); - } - - if (! Schema::hasColumn('product_components', 'bending_diagram')) { - $table->string('bending_diagram', 255) - ->nullable() - ->after('is_bending') - ->comment('절곡 전개도 이미지 URL'); - } - - if (! Schema::hasColumn('product_components', 'bending_details')) { - $table->json('bending_details') - ->nullable() - ->after('bending_diagram') - ->comment('절곡 전개도 상세 데이터 (BendingDetail[])'); - } - - // ================================================ - // 인덱스 추가 (조회 최적화) - // ================================================ - // is_bending 인덱스 - $hasIsBendingIndex = collect(\DB::select('SHOW INDEX FROM `product_components`')) - ->contains(fn ($r) => $r->Key_name === 'idx_product_components_is_bending'); - if (! $hasIsBendingIndex && Schema::hasColumn('product_components', 'is_bending')) { - $table->index('is_bending', 'idx_product_components_is_bending'); - } - }); - } - - public function down(): void - { - Schema::table('product_components', function (Blueprint $table) { - // 인덱스 제거 - $hasIsBendingIndex = collect(\DB::select('SHOW INDEX FROM `product_components`')) - ->contains(fn ($r) => $r->Key_name === 'idx_product_components_is_bending'); - if ($hasIsBendingIndex) { - $table->dropIndex('idx_product_components_is_bending'); - } - - // 컬럼 제거 (역순) - $columns = [ - 'bending_details', - 'bending_diagram', - 'is_bending', - 'condition', - 'quantity_formula', - ]; - - foreach ($columns as $column) { - if (Schema::hasColumn('product_components', $column)) { - $table->dropColumn($column); - } - } - }); - } -}; diff --git a/database/migrations/2025_11_14_000001_add_hybrid_fields_to_products_table.php b/database/migrations/2025_11_14_000001_add_hybrid_fields_to_products_table.php new file mode 100644 index 0000000..19a12df --- /dev/null +++ b/database/migrations/2025_11_14_000001_add_hybrid_fields_to_products_table.php @@ -0,0 +1,138 @@ +integer('safety_stock') + ->nullable() + ->after('is_producible') + ->comment('안전 재고 수량'); + } + + if (! Schema::hasColumn('products', 'lead_time')) { + $table->integer('lead_time') + ->nullable() + ->after('safety_stock') + ->comment('리드타임 (일)'); + } + + if (! Schema::hasColumn('products', 'is_variable_size')) { + $table->boolean('is_variable_size') + ->default(false) + ->after('lead_time') + ->comment('가변 치수 여부 (맞춤형 제품)'); + } + + // ================================================ + // 통계용 분류 필드 (2개) + // - 테넌트 공통으로 자주 사용하는 분류 + // - 인덱스 지원으로 빠른 통계 조회 + // ================================================ + if (! Schema::hasColumn('products', 'product_category')) { + $table->string('product_category', 20) + ->nullable() + ->after('is_variable_size') + ->comment('제품 분류 (SCREEN, STEEL 등) - 통계용'); + } + + if (! Schema::hasColumn('products', 'part_type')) { + $table->string('part_type', 20) + ->nullable() + ->after('product_category') + ->comment('부품 타입 (ASSEMBLY, BENDING, PURCHASED) - 통계용'); + } + + // ================================================ + // 데이터 보존 필드 (1개) + // ================================================ + if (! Schema::hasColumn('products', 'attributes_archive')) { + $table->json('attributes_archive') + ->nullable() + ->after('attributes') + ->comment('카테고리 변경 시 이전 attributes 백업'); + } + + // ================================================ + // 인덱스 추가 (통계 조회 최적화) + // ================================================ + $indexes = collect(\DB::select('SHOW INDEX FROM `products`')) + ->pluck('Key_name') + ->toArray(); + + if (! in_array('idx_products_product_category', $indexes)) { + $table->index(['tenant_id', 'product_category'], 'idx_products_product_category'); + } + + if (! in_array('idx_products_part_type', $indexes)) { + $table->index(['tenant_id', 'part_type'], 'idx_products_part_type'); + } + + if (! in_array('idx_products_is_variable_size', $indexes)) { + $table->index(['tenant_id', 'is_variable_size'], 'idx_products_is_variable_size'); + } + }); + } + + public function down(): void + { + Schema::table('products', function (Blueprint $table) { + // 인덱스 제거 + $indexes = collect(\DB::select('SHOW INDEX FROM `products`')) + ->pluck('Key_name') + ->toArray(); + + if (in_array('idx_products_product_category', $indexes)) { + $table->dropIndex('idx_products_product_category'); + } + + if (in_array('idx_products_part_type', $indexes)) { + $table->dropIndex('idx_products_part_type'); + } + + if (in_array('idx_products_is_variable_size', $indexes)) { + $table->dropIndex('idx_products_is_variable_size'); + } + + // 컬럼 제거 + $columns = [ + 'attributes_archive', + 'part_type', + 'product_category', + 'is_variable_size', + 'lead_time', + 'safety_stock', + ]; + + foreach ($columns as $column) { + if (Schema::hasColumn('products', $column)) { + $table->dropColumn($column); + } + } + }); + } +}; diff --git a/database/migrations/2025_11_14_000002_add_attributes_to_product_components_table.php b/database/migrations/2025_11_14_000002_add_attributes_to_product_components_table.php new file mode 100644 index 0000000..1e8659b --- /dev/null +++ b/database/migrations/2025_11_14_000002_add_attributes_to_product_components_table.php @@ -0,0 +1,72 @@ +string('quantity_formula', 500) + ->nullable() + ->after('quantity') + ->comment('수량 계산 수식 (예: W0*H0/1000000)'); + } + + if (! Schema::hasColumn('product_components', 'condition')) { + $table->text('condition') + ->nullable() + ->after('quantity_formula') + ->comment('조건부 BOM 적용 조건 (예: installation_type=CEILING)'); + } + + // ================================================ + // 동적 확장 필드 (1개) + // - 테넌트별 커스텀 필드 저장 + // - category_fields로 스키마 관리 + // ================================================ + if (! Schema::hasColumn('product_components', 'attributes')) { + $table->json('attributes') + ->nullable() + ->after('condition') + ->comment('동적 필드 (절곡품, 인증 정보 등 테넌트별 커스텀)'); + } + }); + } + + public function down(): void + { + Schema::table('product_components', function (Blueprint $table) { + $columns = [ + 'attributes', + 'condition', + 'quantity_formula', + ]; + + foreach ($columns as $column) { + if (Schema::hasColumn('product_components', $column)) { + $table->dropColumn($column); + } + } + }); + } +}; diff --git a/database/migrations/2025_11_14_000003_create_tenant_stat_fields_table.php b/database/migrations/2025_11_14_000003_create_tenant_stat_fields_table.php new file mode 100644 index 0000000..acf1817 --- /dev/null +++ b/database/migrations/2025_11_14_000003_create_tenant_stat_fields_table.php @@ -0,0 +1,80 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + + // ================================================ + // 통계 대상 정의 + // ================================================ + $table->string('target_table', 50)->comment('통계 대상 테이블 (products, materials 등)'); + $table->string('field_key', 100)->comment('필드 키 (attributes JSON 내 키값)'); + $table->string('field_name', 100)->comment('필드 한글명 (마진율, 가공비 등)'); + $table->string('field_type', 20)->comment('필드 타입 (decimal, varchar, integer 등)'); + + // ================================================ + // 통계 방법 정의 + // ================================================ + $table->json('aggregation_types') + ->nullable() + ->comment('집계 함수 배열 (["avg", "sum", "min", "max", "count"])'); + + $table->boolean('is_critical') + ->default(false) + ->comment('중요 필드 여부 (true면 일일 스냅샷 생성)'); + + $table->integer('display_order') + ->default(0) + ->comment('통계 화면 표시 순서'); + + // ================================================ + // 감사 및 메타 정보 + // ================================================ + $table->text('description')->nullable()->comment('필드 설명 및 용도'); + $table->unsignedBigInteger('created_by')->nullable()->comment('생성자'); + $table->unsignedBigInteger('updated_by')->nullable()->comment('수정자'); + $table->timestamps(); + + // ================================================ + // 인덱스 + // ================================================ + $table->unique(['tenant_id', 'target_table', 'field_key'], 'idx_tenant_stat_fields_unique'); + $table->index(['tenant_id', 'is_critical'], 'idx_tenant_stat_fields_critical'); + $table->index(['target_table', 'field_key'], 'idx_tenant_stat_fields_lookup'); + + // ================================================ + // 외래 키 (개발 환경에서만 권장) + // ================================================ + // $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('tenant_stat_fields'); + } +}; diff --git a/database/migrations/2025_11_14_000004_create_stat_snapshots_table.php b/database/migrations/2025_11_14_000004_create_stat_snapshots_table.php new file mode 100644 index 0000000..4cabb88 --- /dev/null +++ b/database/migrations/2025_11_14_000004_create_stat_snapshots_table.php @@ -0,0 +1,97 @@ +id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + + // ================================================ + // 스냅샷 메타 정보 + // ================================================ + $table->string('snapshot_type', 50)->comment('스냅샷 타입 (daily, weekly, monthly)'); + $table->string('target_table', 50)->comment('통계 대상 테이블 (products, materials 등)'); + $table->date('snapshot_date')->comment('스냅샷 기준일'); + + // ================================================ + // 통계 대상 필드 + // ================================================ + $table->string('field_key', 100)->comment('통계 필드 키 (attributes JSON 내 키값)'); + $table->string('metric_type', 20)->comment('집계 함수 (avg, sum, min, max, count)'); + $table->decimal('metric_value', 18, 4)->nullable()->comment('계산된 통계 값'); + + // ================================================ + // 그룹화 기준 (선택적) + // ================================================ + $table->string('group_by_field', 100)->nullable()->comment('그룹화 기준 필드 (product_category, part_type 등)'); + $table->string('group_by_value', 100)->nullable()->comment('그룹화 기준 값 (SCREEN, STEEL 등)'); + + // ================================================ + // 신뢰성 정보 + // ================================================ + $table->integer('record_count')->nullable()->comment('계산에 사용된 레코드 수'); + $table->timestamp('calculated_at')->nullable()->comment('계산 실행 시각'); + + // ================================================ + // 감사 정보 + // ================================================ + $table->text('calculation_note')->nullable()->comment('계산 상세 정보 (에러 로그 등)'); + $table->timestamps(); + + // ================================================ + // 인덱스 - 조회 성능 최적화 + // ================================================ + $table->index(['tenant_id', 'snapshot_type', 'snapshot_date'], 'idx_stat_snapshots_tenant_type_date'); + $table->index(['tenant_id', 'target_table', 'field_key', 'snapshot_date'], 'idx_stat_snapshots_field_lookup'); + $table->index(['tenant_id', 'target_table', 'group_by_field', 'group_by_value'], 'idx_stat_snapshots_group_lookup'); + $table->index(['snapshot_date', 'calculated_at'], 'idx_stat_snapshots_freshness'); + + // ================================================ + // 유니크 제약 (중복 스냅샷 방지) + // ================================================ + $table->unique( + ['tenant_id', 'snapshot_type', 'snapshot_date', 'target_table', 'field_key', 'metric_type', 'group_by_field', 'group_by_value'], + 'idx_stat_snapshots_unique' + ); + + // ================================================ + // 외래 키 (개발 환경에서만 권장) + // ================================================ + // $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('stat_snapshots'); + } +}; diff --git a/database/seeders/BpMesCategoryFieldsSeeder.php b/database/seeders/BpMesCategoryFieldsSeeder.php new file mode 100644 index 0000000..bb91d70 --- /dev/null +++ b/database/seeders/BpMesCategoryFieldsSeeder.php @@ -0,0 +1,232 @@ +insertGetId([ + 'tenant_id' => self::TENANT_ID, + 'parent_id' => null, + 'code_group' => 'bp_mes', + 'code' => 'bp_mes_products', + 'name' => 'BP-MES 품목 관리', + 'description' => 'BP-MES 품목 기준관리 시스템 루트 카테고리', + 'sort_order' => 1, + 'profile_code' => 'bp_mes_root', + 'is_active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + // 2. 완제품(FG) 카테고리 생성 + $fgCategoryId = DB::table('categories')->insertGetId([ + 'tenant_id' => self::TENANT_ID, + 'parent_id' => $rootCategoryId, + 'code_group' => 'bp_mes', + 'code' => 'fg_products', + 'name' => '완제품(FG)', + 'description' => '판매 가능한 완제품 카테고리 (마진율, 비용 관리)', + 'sort_order' => 1, + 'profile_code' => 'fg_category', + 'is_active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + // 3. 부품(PT) 카테고리 생성 + $ptCategoryId = DB::table('categories')->insertGetId([ + 'tenant_id' => self::TENANT_ID, + 'parent_id' => $rootCategoryId, + 'code_group' => 'bp_mes', + 'code' => 'pt_products', + 'name' => '부품(PT)', + 'description' => '조립용 부품 카테고리 (용도, 설치방식, 규격)', + 'sort_order' => 2, + 'profile_code' => 'pt_category', + 'is_active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + // 4. 절곡품 카테고리 생성 + $bendingCategoryId = DB::table('categories')->insertGetId([ + 'tenant_id' => self::TENANT_ID, + 'parent_id' => $rootCategoryId, + 'code_group' => 'bp_mes', + 'code' => 'bending_products', + 'name' => '절곡품', + 'description' => '금속 절곡 가공품 카테고리 (절곡도, 재질, 길이)', + 'sort_order' => 3, + 'profile_code' => 'bending_category', + 'is_active' => 1, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + // 5. 완제품(FG) 동적 필드 정의 + $fgFields = [ + // 비용 관리 (통계 필요) + ['key' => 'margin_rate', 'name' => '마진율(%)', 'type' => 'decimal', 'required' => false, 'order' => 10, + 'desc' => '제품 마진율 (통계 대상)'], + + ['key' => 'processing_cost', 'name' => '가공비(원)', 'type' => 'decimal', 'required' => false, 'order' => 11, + 'desc' => '가공 비용 (통계 대상)'], + + ['key' => 'labor_cost', 'name' => '인건비(원)', 'type' => 'decimal', 'required' => false, 'order' => 12, + 'desc' => '인건비 (통계 대상)'], + + ['key' => 'install_cost', 'name' => '설치비(원)', 'type' => 'decimal', 'required' => false, 'order' => 13, + 'desc' => '설치 비용 (통계 대상)'], + + // LOT 관리 + ['key' => 'lot_abbreviation', 'name' => 'LOT 약어', 'type' => 'text', 'required' => false, 'order' => 20, + 'desc' => 'LOT 번호 생성용 약어'], + + ['key' => 'note', 'name' => '비고', 'type' => 'textarea', 'required' => false, 'order' => 21, + 'desc' => '제품 관련 메모 및 특이사항'], + + // 인증 정보 + ['key' => 'certification_number', 'name' => '인증번호', 'type' => 'text', 'required' => false, 'order' => 30, + 'desc' => '제품 인증번호'], + + ['key' => 'certification_start_date', 'name' => '인증시작일', 'type' => 'date', 'required' => false, 'order' => 31, + 'desc' => '인증 유효기간 시작일'], + + ['key' => 'certification_end_date', 'name' => '인증종료일', 'type' => 'date', 'required' => false, 'order' => 32, + 'desc' => '인증 유효기간 종료일'], + + // 파일 관리 + ['key' => 'specification_file', 'name' => '규격파일', 'type' => 'file', 'required' => false, 'order' => 40, + 'desc' => '제품 규격서 파일 경로'], + + ['key' => 'specification_file_name', 'name' => '규격파일명', 'type' => 'text', 'required' => false, 'order' => 41, + 'desc' => '규격서 파일 원본명'], + + ['key' => 'certification_file', 'name' => '인증파일', 'type' => 'file', 'required' => false, 'order' => 42, + 'desc' => '인증서 파일 경로'], + + ['key' => 'certification_file_name', 'name' => '인증파일명', 'type' => 'text', 'required' => false, 'order' => 43, + 'desc' => '인증서 파일 원본명'], + + // 확장 옵션 + ['key' => 'options', 'name' => '옵션정보', 'type' => 'json', 'required' => false, 'order' => 50, + 'desc' => '추가 옵션 정보 (JSON 형태)'], + ]; + + // 6. 부품(PT) 동적 필드 정의 + $ptFields = [ + // 부품 분류 및 용도 + ['key' => 'part_usage', 'name' => '용도', 'type' => 'text', 'required' => false, 'order' => 10, + 'desc' => '부품 사용 용도'], + + ['key' => 'installation_type', 'name' => '설치방식', 'type' => 'select', 'required' => false, 'order' => 11, + 'options' => ['CEILING', 'WALL', 'FLOOR', 'HANGING'], + 'desc' => '설치 방식 (천장형, 벽면형, 바닥형, 현가형)'], + + ['key' => 'assembly_type', 'name' => '조립타입', 'type' => 'select', 'required' => false, 'order' => 12, + 'options' => ['WELDING', 'BOLT', 'RIVET', 'ADHESIVE'], + 'desc' => '조립 방식 (용접, 볼트, 리벳, 접착)'], + + // 치수 정보 + ['key' => 'side_spec_width', 'name' => '측면규격_폭(mm)', 'type' => 'decimal', 'required' => false, 'order' => 20, + 'desc' => '측면 규격 폭'], + + ['key' => 'side_spec_height', 'name' => '측면규격_높이(mm)', 'type' => 'decimal', 'required' => false, 'order' => 21, + 'desc' => '측면 규격 높이'], + + ['key' => 'assembly_length', 'name' => '조립길이(mm)', 'type' => 'decimal', 'required' => false, 'order' => 22, + 'desc' => '조립 시 길이'], + + // 가이드레일 정보 + ['key' => 'guide_rail_model_type', 'name' => '가이드레일모델타입', 'type' => 'select', 'required' => false, 'order' => 30, + 'options' => ['TYPE_A', 'TYPE_B', 'TYPE_C', 'CUSTOM'], + 'desc' => '가이드레일 모델 타입'], + + ['key' => 'guide_rail_model', 'name' => '가이드레일모델', 'type' => 'text', 'required' => false, 'order' => 31, + 'desc' => '가이드레일 모델명'], + ]; + + // 7. 절곡품 동적 필드 정의 + $bendingFields = [ + // 절곡 정보 + ['key' => 'bending_diagram', 'name' => '절곡도', 'type' => 'file', 'required' => false, 'order' => 10, + 'desc' => '절곡 도면 파일 경로'], + + ['key' => 'bending_details', 'name' => '절곡상세', 'type' => 'json', 'required' => false, 'order' => 11, + 'desc' => '절곡 상세 정보 (각도, 위치 등 JSON)'], + + // 재질 및 치수 + ['key' => 'material', 'name' => '재질', 'type' => 'select', 'required' => true, 'order' => 20, + 'options' => ['STEEL', 'STAINLESS', 'ALUMINUM', 'GALVANIZED'], + 'desc' => '금속 재질 (강판, 스텐, 알루미늄, 아연도금)'], + + ['key' => 'length', 'name' => '길이(mm)', 'type' => 'decimal', 'required' => true, 'order' => 21, + 'desc' => '절곡품 길이'], + + ['key' => 'bending_length', 'name' => '절곡길이(mm)', 'type' => 'decimal', 'required' => false, 'order' => 22, + 'desc' => '절곡 부분 길이'], + ]; + + // 8. 완제품(FG) 카테고리 필드 생성 + foreach ($fgFields as $field) { + $this->insertCategoryField($fgCategoryId, $field); + } + + // 9. 부품(PT) 카테고리 필드 생성 + foreach ($ptFields as $field) { + $this->insertCategoryField($ptCategoryId, $field); + } + + // 10. 절곡품 카테고리 필드 생성 + foreach ($bendingFields as $field) { + $this->insertCategoryField($bendingCategoryId, $field); + } + } + + /** + * category_fields 테이블에 필드 삽입 + */ + private function insertCategoryField(int $categoryId, array $field): void + { + DB::table('category_fields')->insert([ + 'tenant_id' => self::TENANT_ID, + 'category_id' => $categoryId, + 'field_key' => $field['key'], + 'field_name' => $field['name'], + 'field_type' => $field['type'], + 'is_required' => $field['required'], + 'sort_order' => $field['order'], + 'default_value' => $field['default'] ?? null, + 'options' => isset($field['options']) ? json_encode($field['options']) : null, + 'description' => $field['desc'], + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/database/seeders/BpMesTenantStatFieldsSeeder.php b/database/seeders/BpMesTenantStatFieldsSeeder.php new file mode 100644 index 0000000..5382d05 --- /dev/null +++ b/database/seeders/BpMesTenantStatFieldsSeeder.php @@ -0,0 +1,174 @@ + 'products', + 'field_key' => 'margin_rate', + 'field_name' => '마진율(%)', + 'field_type' => 'decimal', + 'aggregation_types' => ['avg', 'min', 'max'], + 'is_critical' => true, + 'display_order' => 1, + 'description' => '제품별 마진율 통계 (평균, 최소, 최대)', + ], + + // 가공비 통계 + [ + 'target_table' => 'products', + 'field_key' => 'processing_cost', + 'field_name' => '가공비(원)', + 'field_type' => 'decimal', + 'aggregation_types' => ['avg', 'sum', 'min', 'max'], + 'is_critical' => true, + 'display_order' => 2, + 'description' => '가공비 통계 (평균, 합계, 최소, 최대)', + ], + + // 인건비 통계 + [ + 'target_table' => 'products', + 'field_key' => 'labor_cost', + 'field_name' => '인건비(원)', + 'field_type' => 'decimal', + 'aggregation_types' => ['avg', 'sum', 'min', 'max'], + 'is_critical' => true, + 'display_order' => 3, + 'description' => '인건비 통계 (평균, 합계, 최소, 최대)', + ], + + // 설치비 통계 + [ + 'target_table' => 'products', + 'field_key' => 'install_cost', + 'field_name' => '설치비(원)', + 'field_type' => 'decimal', + 'aggregation_types' => ['avg', 'sum', 'min', 'max'], + 'is_critical' => true, + 'display_order' => 4, + 'description' => '설치비 통계 (평균, 합계, 최소, 최대)', + ], + + // 절곡 길이 통계 (선택적) + [ + 'target_table' => 'products', + 'field_key' => 'bending_length', + 'field_name' => '절곡길이(mm)', + 'field_type' => 'decimal', + 'aggregation_types' => ['avg', 'min', 'max'], + 'is_critical' => false, + 'display_order' => 10, + 'description' => '절곡품 길이 통계 (선택적 집계)', + ], + + // 조립 길이 통계 (선택적) + [ + 'target_table' => 'products', + 'field_key' => 'assembly_length', + 'field_name' => '조립길이(mm)', + 'field_type' => 'decimal', + 'aggregation_types' => ['avg', 'min', 'max'], + 'is_critical' => false, + 'display_order' => 11, + 'description' => '부품 조립 길이 통계 (선택적 집계)', + ], + + // 측면 규격 통계 (선택적) + [ + 'target_table' => 'products', + 'field_key' => 'side_spec_width', + 'field_name' => '측면규격_폭(mm)', + 'field_type' => 'decimal', + 'aggregation_types' => ['avg', 'min', 'max'], + 'is_critical' => false, + 'display_order' => 12, + 'description' => '측면 규격 폭 통계 (선택적 집계)', + ], + + [ + 'target_table' => 'products', + 'field_key' => 'side_spec_height', + 'field_name' => '측면규격_높이(mm)', + 'field_type' => 'decimal', + 'aggregation_types' => ['avg', 'min', 'max'], + 'is_critical' => false, + 'display_order' => 13, + 'description' => '측면 규격 높이 통계 (선택적 집계)', + ], + ]; + + // 2. materials 테이블의 통계 필드 정의 (향후 확장용 예시) + $materialStatFields = [ + // 예시: 자재 단가 통계 + [ + 'target_table' => 'materials', + 'field_key' => 'unit_price', + 'field_name' => '단가(원)', + 'field_type' => 'decimal', + 'aggregation_types' => ['avg', 'min', 'max'], + 'is_critical' => false, + 'display_order' => 1, + 'description' => '자재 단가 통계 (향후 활용)', + ], + ]; + + // 3. products 통계 필드 생성 + foreach ($productStatFields as $field) { + $this->insertTenantStatField($field); + } + + // 4. materials 통계 필드 생성 (향후 확장) + foreach ($materialStatFields as $field) { + $this->insertTenantStatField($field); + } + } + + /** + * tenant_stat_fields 테이블에 필드 삽입 + */ + private function insertTenantStatField(array $field): void + { + DB::table('tenant_stat_fields')->insert([ + 'tenant_id' => self::TENANT_ID, + 'target_table' => $field['target_table'], + 'field_key' => $field['field_key'], + 'field_name' => $field['field_name'], + 'field_type' => $field['field_type'], + 'aggregation_types' => json_encode($field['aggregation_types']), + 'is_critical' => $field['is_critical'], + 'display_order' => $field['display_order'], + 'description' => $field['description'], + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +}