From 4b04d6db87606dd78b620fb19d8479ac1836ccdf Mon Sep 17 00:00:00 2001 From: hskwon Date: Fri, 14 Nov 2025 11:47:28 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20TenantStatField=20=EB=AA=A8=EB=8D=B8=20?= =?UTF-8?q?=EB=B0=8F=20=ED=95=98=EC=9D=B4=EB=B8=8C=EB=A6=AC=EB=93=9C=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EC=82=AC=EC=9A=A9=20=EA=B0=80=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [추가된 파일] - app/Models/Tenants/TenantStatField.php - 테넌트별 통계 필드 설정 모델 - Scopes: forTable, critical, withAggregation, ordered - 통계 시스템의 메타 정보 제공 - claudedocs/mes/HYBRID_STRUCTURE_GUIDE.md - 하이브리드 구조 사용 가이드 문서 - 데이터베이스 구조 설명 - 코드 예제 (Product, ProductComponent 생성) - 통계 쿼리 예제 - 성능 고려사항 및 주의사항 [모델 기능] - BelongsToTenant, ModelTrait 적용 - aggregation_types JSON 자동 캐스팅 - tenant, target_table, field_key 조합으로 통계 필드 관리 [문서 내용] - 고정 필드 vs 동적 필드 선택 기준 - attributes JSON 사용법 - 통계 쿼리 예제 (JSON_EXTRACT) - CategoryField와 연동 방법 - 향후 Virtual Column 최적화 가이드 --- app/Models/Tenants/TenantStatField.php | 109 ++++++ claudedocs/mes/HYBRID_STRUCTURE_GUIDE.md | 411 +++++++++++++++++++++++ 2 files changed, 520 insertions(+) create mode 100644 app/Models/Tenants/TenantStatField.php create mode 100644 claudedocs/mes/HYBRID_STRUCTURE_GUIDE.md diff --git a/app/Models/Tenants/TenantStatField.php b/app/Models/Tenants/TenantStatField.php new file mode 100644 index 0000000..2544499 --- /dev/null +++ b/app/Models/Tenants/TenantStatField.php @@ -0,0 +1,109 @@ + 'array', + 'is_critical' => 'boolean', + 'display_order' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + // ============================================ + // Relationships + // ============================================ + + /** + * 소속 테넌트 + */ + public function tenant() + { + return $this->belongsTo(Tenant::class, 'tenant_id'); + } + + // ============================================ + // Query Scopes + // ============================================ + + /** + * 특정 테이블의 통계 필드만 + */ + public function scopeForTable($query, string $targetTable) + { + return $query->where('target_table', $targetTable); + } + + /** + * 중요 필드만 (일일 통계 대상) + */ + public function scopeCritical($query) + { + return $query->where('is_critical', true); + } + + /** + * 특정 집계 함수를 포함하는 필드 + */ + public function scopeWithAggregation($query, string $aggregationType) + { + return $query->whereJsonContains('aggregation_types', $aggregationType); + } + + /** + * 표시 순서대로 정렬 + */ + public function scopeOrdered($query) + { + return $query->orderBy('display_order')->orderBy('field_name'); + } +} diff --git a/claudedocs/mes/HYBRID_STRUCTURE_GUIDE.md b/claudedocs/mes/HYBRID_STRUCTURE_GUIDE.md new file mode 100644 index 0000000..1660768 --- /dev/null +++ b/claudedocs/mes/HYBRID_STRUCTURE_GUIDE.md @@ -0,0 +1,411 @@ +# BP-MES 하이브리드 구조 사용 가이드 + +## 📖 개요 + +BP-MES Phase 1에서 도입한 하이브리드 구조는 **멀티테넌트 유연성**과 **통계 성능**을 동시에 달성하기 위한 설계입니다. + +### 핵심 개념 + +- **고정 필드**: 자주 조회하고 통계를 내는 필드 (인덱싱) +- **동적 필드**: 테넌트별로 다를 수 있는 필드 (JSON attributes) + +--- + +## 🗂️ 데이터베이스 구조 + +### 1. products 테이블 + +```sql +-- 고정 필드 (6개) +safety_stock INT NULL -- 안전 재고 수량 +lead_time INT NULL -- 리드타임 (일) +is_variable_size BOOLEAN -- 가변 치수 여부 +product_category VARCHAR(20) -- 통계용 분류 (SCREEN, STEEL 등) +part_type VARCHAR(20) -- 통계용 분류 (ASSEMBLY, BENDING, PURCHASED) +attributes_archive JSON NULL -- 카테고리 변경 시 백업 + +-- 동적 필드 +attributes JSON NULL -- 테넌트별 커스텀 필드 + +-- 인덱스 +INDEX idx_products_product_category (tenant_id, product_category) +INDEX idx_products_part_type (tenant_id, part_type) +INDEX idx_products_is_variable_size (tenant_id, is_variable_size) +``` + +### 2. product_components 테이블 + +```sql +-- 고정 필드 (2개) +quantity_formula VARCHAR(500) -- BOM 수식 계산 +condition TEXT -- 조건부 BOM + +-- 동적 필드 +attributes JSON -- 테넌트별 커스텀 필드 +``` + +### 3. 메타 정보 테이블 + +```sql +-- category_fields: 동적 필드 스키마 정의 +category_id, field_key, field_name, field_type, is_required, options... + +-- tenant_stat_fields: 통계 필드 설정 +tenant_id, target_table, field_key, aggregation_types, is_critical... +``` + +--- + +## 💻 코드 예제 + +### Product 생성 (완제품 - FG) + +```php +use App\Models\Products\Product; + +$product = Product::create([ + 'tenant_id' => 1, + 'code' => 'FG-001', + 'name' => '방화셔터 KSS01', + 'unit' => 'EA', + 'category_id' => $fgCategoryId, // 완제품 카테고리 + + // 고정 필드 + 'safety_stock' => 10, + 'lead_time' => 5, + 'is_variable_size' => false, + 'product_category' => 'SCREEN', + + // 동적 필드 (attributes JSON) + 'attributes' => [ + // 비용 관리 + 'margin_rate' => 25.5, + 'processing_cost' => 50000, + 'labor_cost' => 30000, + 'install_cost' => 20000, + + // LOT 관리 + 'lot_abbreviation' => 'KSS', + 'note' => '스크린 제품', + + // 인증 정보 + 'certification_number' => 'CERT-2025-001', + 'certification_start_date' => '2025-01-01', + 'certification_end_date' => '2027-12-31', + + // 파일 + 'specification_file' => '/files/spec_001.pdf', + 'specification_file_name' => '규격서.pdf', + ], +]); +``` + +### Product 생성 (부품 - PT) + +```php +$part = Product::create([ + 'tenant_id' => 1, + 'code' => 'PT-001', + 'name' => '가이드레일 A형', + 'unit' => 'M', + 'category_id' => $ptCategoryId, // 부품 카테고리 + + // 고정 필드 + 'safety_stock' => 100, + 'lead_time' => 3, + 'is_variable_size' => false, + 'part_type' => 'PURCHASED', + + // 동적 필드 + 'attributes' => [ + 'part_usage' => '셔터 가이드', + 'installation_type' => 'WALL', + 'assembly_type' => 'BOLT', + 'side_spec_width' => 70, + 'side_spec_height' => 50, + 'assembly_length' => 3000, + 'guide_rail_model_type' => 'TYPE_A', + 'guide_rail_model' => 'GR-A-2025', + ], +]); +``` + +### Product 생성 (절곡품) + +```php +$bending = Product::create([ + 'tenant_id' => 1, + 'code' => 'BD-001', + 'name' => '절곡 브라켓 L형', + 'unit' => 'EA', + 'category_id' => $bendingCategoryId, // 절곡품 카테고리 + + // 고정 필드 + 'safety_stock' => 50, + 'lead_time' => 2, + 'is_variable_size' => true, // 맞춤형 절곡 + + // 동적 필드 + 'attributes' => [ + 'bending_diagram' => '/files/bending_001.dwg', + 'bending_details' => [ + ['angle' => 90, 'position' => 100], + ['angle' => 45, 'position' => 250], + ], + 'material' => 'STEEL', + 'length' => 500, + 'bending_length' => 350, + ], +]); +``` + +### ProductComponent 생성 (BOM) + +```php +use App\Models\Products\ProductComponent; + +// 일반 BOM +$component = ProductComponent::create([ + 'tenant_id' => 1, + 'parent_product_id' => $productId, + 'ref_type' => 'MATERIAL', + 'ref_id' => $materialId, + 'quantity' => 5.0, + 'sort_order' => 1, +]); + +// 수식 계산 BOM +$formulaComponent = ProductComponent::create([ + 'tenant_id' => 1, + 'parent_product_id' => $productId, + 'ref_type' => 'PRODUCT', + 'ref_id' => $partId, + 'quantity' => 1.0, // 기본값 + 'quantity_formula' => 'W0 * H0 / 1000000', // 면적 기반 계산 + 'sort_order' => 2, +]); + +// 조건부 BOM +$conditionalComponent = ProductComponent::create([ + 'tenant_id' => 1, + 'parent_product_id' => $productId, + 'ref_type' => 'PRODUCT', + 'ref_id' => $partId, + 'quantity' => 2.0, + 'condition' => 'installation_type=CEILING', // 천장형일 때만 + 'sort_order' => 3, +]); + +// 절곡품 BOM +$bendingComponent = ProductComponent::create([ + 'tenant_id' => 1, + 'parent_product_id' => $productId, + 'ref_type' => 'PRODUCT', + 'ref_id' => $bendingProductId, + 'quantity' => 4.0, + 'attributes' => [ + 'is_bending' => true, + 'bending_diagram' => '/files/bending_diagram_001.dwg', + 'bending_details' => [ + ['angle' => 90, 'position' => 100], + ], + ], + 'sort_order' => 4, +]); +``` + +### attributes 조회 + +```php +// 단순 조회 +$product = Product::find($id); +$marginRate = $product->attributes['margin_rate'] ?? null; + +// 배열 접근 +$certNumber = $product->attributes['certification_number'] ?? '없음'; + +// JSON 중첩 구조 +$bendingDetails = $product->attributes['bending_details'] ?? []; +``` + +### 통계 필드 조회 + +```php +use App\Models\Tenants\TenantStatField; + +// 특정 테넌트의 products 통계 필드 +$statFields = TenantStatField::where('tenant_id', $tenantId) + ->forTable('products') + ->critical() // 중요 필드만 + ->ordered() + ->get(); + +// 평균 집계가 필요한 필드 +$avgFields = TenantStatField::where('tenant_id', $tenantId) + ->withAggregation('avg') + ->get(); + +// 통계 계산 예시 +foreach ($statFields as $field) { + $fieldKey = $field->field_key; + $aggregations = $field->aggregation_types; + + foreach ($aggregations as $agg) { + // DB::raw("JSON_EXTRACT(attributes, '$.{$fieldKey}')") + // 를 사용하여 통계 계산 + } +} +``` + +--- + +## 🔍 통계 쿼리 예제 + +### JSON 필드 통계 + +```php +use Illuminate\Support\Facades\DB; + +// 평균 마진율 (SCREEN 카테고리) +$avgMargin = DB::table('products') + ->where('tenant_id', $tenantId) + ->where('product_category', 'SCREEN') + ->whereNotNull('attributes') + ->selectRaw("AVG(JSON_UNQUOTE(JSON_EXTRACT(attributes, '$.margin_rate'))) as avg_margin") + ->value('avg_margin'); + +// 가공비 합계 (부품 타입별) +$costByType = DB::table('products') + ->where('tenant_id', $tenantId) + ->whereNotNull('part_type') + ->selectRaw(" + part_type, + SUM(JSON_UNQUOTE(JSON_EXTRACT(attributes, '$.processing_cost'))) as total_cost + ") + ->groupBy('part_type') + ->get(); +``` + +--- + +## 📝 카테고리 필드 스키마 관리 + +### CategoryField 조회 + +```php +use App\Models\Commons\CategoryField; + +// 특정 카테고리의 필드 스키마 +$fields = CategoryField::where('category_id', $categoryId) + ->orderBy('sort_order') + ->get(); + +// 동적 폼 생성 +foreach ($fields as $field) { + echo "Field: {$field->field_name} ({$field->field_key})\n"; + echo "Type: {$field->field_type}\n"; + echo "Required: {$field->is_required}\n"; + + if ($field->field_type === 'select') { + $options = $field->options; // JSON 자동 파싱 + echo "Options: " . implode(', ', $options) . "\n"; + } +} +``` + +--- + +## ⚠️ 주의사항 + +### 1. attributes JSON 구조 + +```php +// ✅ 올바른 사용 +$product->attributes = [ + 'margin_rate' => 25.5, + 'processing_cost' => 50000, +]; + +// ❌ 잘못된 사용 (중첩 깊이 제한) +$product->attributes = [ + 'level1' => [ + 'level2' => [ + 'level3' => [ + 'level4' => 'too deep' // 피하세요 + ] + ] + ] +]; +``` + +### 2. 고정 필드 vs 동적 필드 선택 기준 + +``` +고정 필드로 만들어야 하는 경우: +✅ 모든 테넌트가 공통으로 사용 +✅ 인덱스가 필요한 필드 (통계, 검색) +✅ WHERE 절에서 자주 사용 + +동적 필드로 만들어야 하는 경우: +✅ 테넌트마다 다를 수 있는 필드 +✅ 선택적으로 사용하는 필드 +✅ 자주 변경되는 비즈니스 로직 +``` + +### 3. 성능 고려사항 + +```php +// ✅ 고정 필드로 필터링 (빠름) +Product::where('product_category', 'SCREEN') + ->where('is_variable_size', false) + ->get(); + +// ⚠️ JSON 필드로 필터링 (느림 - 필요시에만) +Product::whereRaw("JSON_EXTRACT(attributes, '$.margin_rate') > ?", [20]) + ->get(); +``` + +--- + +## 🚀 향후 확장 + +### 통계 시스템 연동 + +```php +// tenant_stat_fields를 기반으로 통계 생성 +$statFields = TenantStatField::where('tenant_id', $tenantId) + ->critical() + ->get(); + +foreach ($statFields as $field) { + // 통계 시스템에서 처리 + // - aggregation_types에 따라 avg, sum, min, max 계산 + // - 리포트에 표시 +} +``` + +### Virtual Column 최적화 (MySQL 8.0+) + +자주 조회하는 JSON 필드를 Virtual Column으로 변환하여 성능 향상: + +```sql +-- 예시: margin_rate를 Virtual Column으로 +ALTER TABLE products +ADD COLUMN margin_rate_virtual DECIMAL(5,2) +AS (JSON_UNQUOTE(JSON_EXTRACT(attributes, '$.margin_rate'))) VIRTUAL; + +CREATE INDEX idx_margin_rate ON products(margin_rate_virtual); +``` + +--- + +## 📚 참고 문서 + +- `database/migrations/2025_11_14_000001_add_hybrid_fields_to_products_table.php` +- `database/migrations/2025_11_14_000002_add_attributes_to_product_components_table.php` +- `database/migrations/2025_11_14_000003_create_tenant_stat_fields_table.php` +- `database/seeders/BpMesCategoryFieldsSeeder.php` +- `database/seeders/BpMesTenantStatFieldsSeeder.php` +- `app/Models/Products/Product.php` +- `app/Models/Products/ProductComponent.php` +- `app/Models/Tenants/TenantStatField.php`