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:
109
app/Models/Tenants/TenantStatField.php
Normal file
109
app/Models/Tenants/TenantStatField.php
Normal 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');
|
||||
}
|
||||
}
|
||||
411
claudedocs/mes/HYBRID_STRUCTURE_GUIDE.md
Normal file
411
claudedocs/mes/HYBRID_STRUCTURE_GUIDE.md
Normal 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`
|
||||
Reference in New Issue
Block a user