feat: TenantStatField 모델 및 하이브리드 구조 사용 가이드 추가

[추가된 파일]
- 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 최적화 가이드
This commit is contained in:
2025-11-14 11:47:28 +09:00
parent 62d671edcf
commit 4b04d6db87
2 changed files with 520 additions and 0 deletions

View File

@@ -0,0 +1,109 @@
<?php
namespace App\Models\Tenants;
use App\Traits\BelongsToTenant;
use App\Traits\ModelTrait;
use Illuminate\Database\Eloquent\Model;
/**
* 테넌트별 통계 필드 설정 모델
*
* 각 테넌트가 통계를 원하는 필드를 선언하는 메타 정보 테이블
*
* 사용 용도:
* - 통계 시스템에서 어떤 필드의 통계를 계산할지 결정
* - aggregation_types에 따라 평균, 합계, 최소, 최대 등 집계
* - is_critical 플래그로 중요 통계 식별
*
* @property int $id
* @property int $tenant_id
* @property string $target_table
* @property string $field_key
* @property string $field_name
* @property string $field_type
* @property array|null $aggregation_types
* @property bool $is_critical
* @property int $display_order
* @property string|null $description
* @property int|null $created_by
* @property int|null $updated_by
* @property \Illuminate\Support\Carbon|null $created_at
* @property \Illuminate\Support\Carbon|null $updated_at
*/
class TenantStatField extends Model
{
use BelongsToTenant, ModelTrait;
protected $table = 'tenant_stat_fields';
protected $fillable = [
'tenant_id',
'target_table',
'field_key',
'field_name',
'field_type',
'aggregation_types',
'is_critical',
'display_order',
'description',
'created_by',
'updated_by',
];
protected $casts = [
'aggregation_types' => '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');
}
}

View File

@@ -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`