Files
sam-api/claudedocs/mes/HYBRID_STRUCTURE_GUIDE.md
hskwon 4b04d6db87 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 최적화 가이드
2025-11-14 11:47:28 +09:00

10 KiB

BP-MES 하이브리드 구조 사용 가이드

📖 개요

BP-MES Phase 1에서 도입한 하이브리드 구조는 멀티테넌트 유연성통계 성능을 동시에 달성하기 위한 설계입니다.

핵심 개념

  • 고정 필드: 자주 조회하고 통계를 내는 필드 (인덱싱)
  • 동적 필드: 테넌트별로 다를 수 있는 필드 (JSON attributes)

🗂️ 데이터베이스 구조

1. products 테이블

-- 고정 필드 (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 테이블

-- 고정 필드 (2개)
quantity_formula VARCHAR(500)      -- BOM 수식 계산
condition TEXT                     -- 조건부 BOM

-- 동적 필드
attributes JSON                    -- 테넌트별 커스텀 필드

3. 메타 정보 테이블

-- 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)

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)

$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 생성 (절곡품)

$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)

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 조회

// 단순 조회
$product = Product::find($id);
$marginRate = $product->attributes['margin_rate'] ?? null;

// 배열 접근
$certNumber = $product->attributes['certification_number'] ?? '없음';

// JSON 중첩 구조
$bendingDetails = $product->attributes['bending_details'] ?? [];

통계 필드 조회

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 필드 통계

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 조회

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 구조

// ✅ 올바른 사용
$product->attributes = [
    'margin_rate' => 25.5,
    'processing_cost' => 50000,
];

// ❌ 잘못된 사용 (중첩 깊이 제한)
$product->attributes = [
    'level1' => [
        'level2' => [
            'level3' => [
                'level4' => 'too deep'  // 피하세요
            ]
        ]
    ]
];

2. 고정 필드 vs 동적 필드 선택 기준

고정 필드로 만들어야 하는 경우:
✅ 모든 테넌트가 공통으로 사용
✅ 인덱스가 필요한 필드 (통계, 검색)
✅ WHERE 절에서 자주 사용

동적 필드로 만들어야 하는 경우:
✅ 테넌트마다 다를 수 있는 필드
✅ 선택적으로 사용하는 필드
✅ 자주 변경되는 비즈니스 로직

3. 성능 고려사항

// ✅ 고정 필드로 필터링 (빠름)
Product::where('product_category', 'SCREEN')
    ->where('is_variable_size', false)
    ->get();

// ⚠️ JSON 필드로 필터링 (느림 - 필요시에만)
Product::whereRaw("JSON_EXTRACT(attributes, '$.margin_rate') > ?", [20])
    ->get();

🚀 향후 확장

통계 시스템 연동

// 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으로 변환하여 성능 향상:

-- 예시: 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