[추가된 파일] - 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 최적화 가이드
10 KiB
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.phpdatabase/migrations/2025_11_14_000002_add_attributes_to_product_components_table.phpdatabase/migrations/2025_11_14_000003_create_tenant_stat_fields_table.phpdatabase/seeders/BpMesCategoryFieldsSeeder.phpdatabase/seeders/BpMesTenantStatFieldsSeeder.phpapp/Models/Products/Product.phpapp/Models/Products/ProductComponent.phpapp/Models/Tenants/TenantStatField.php