[추가된 파일] - 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 최적화 가이드
412 lines
10 KiB
Markdown
412 lines
10 KiB
Markdown
# 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`
|