refactor: BP-MES Phase 1 하이브리드 구조 전환

- products 테이블: 6개 고정 필드 + attributes JSON
- product_components 테이블: 수식/조건 + attributes JSON
- tenant_stat_fields 테이블: 테넌트별 통계 필드 설정
- stat_snapshots 테이블: 통계 캐싱
- BP-MES CategoryFields Seeder: 제품/부품/절곡품 카테고리 필드
- BP-MES TenantStatFields Seeder: 통계 필드 설정

[변경 사항]
삭제:
- 2025_11_13_120000_extend_products_table_for_bp_mes.php
- 2025_11_13_120001_extend_product_components_table_for_bp_mes.php

추가:
- 2025_11_14_000001_add_hybrid_fields_to_products_table.php
- 2025_11_14_000002_add_attributes_to_product_components_table.php
- 2025_11_14_000003_create_tenant_stat_fields_table.php
- 2025_11_14_000004_create_stat_snapshots_table.php
- BpMesCategoryFieldsSeeder.php
- BpMesTenantStatFieldsSeeder.php

[배경]
멀티테넌트 시스템의 유연성 확보를 위해 고정 필드를 최소화하고
동적 필드 시스템(category_fields + attributes JSON)으로 전환.
통계 성능을 위해 자주 조회하는 분류 필드(product_category, part_type)는
고정 컬럼으로 유지하고 인덱싱.
This commit is contained in:
2025-11-14 10:57:02 +09:00
parent 900452753a
commit 342d15196e
9 changed files with 794 additions and 472 deletions

View File

@@ -1,367 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* BP-MES (품목 기준관리 중심 MES/ERP) Phase 1
* products 테이블 확장: ItemMaster 구조 지원
*
* 추가 필드:
* - 공통: 가격/비용/재고/리드타임 (8개)
* - FG(제품) 전용: 제품 분류/로트/노트 (3개)
* - PT(부품) 전용: 부품 타입/용도/조립 정보/가이드레일 (9개)
* - 절곡품: 전개도/치수 정보 (5개)
* - 인증: 인증서/시방서 파일 관리 (7개)
* - 동적 확장: JSON 옵션 (1개)
*
* 총 33개 필드 추가
*
* @see /claudedocs/mes/00_baseline/BACKEND_DEVELOPMENT_ROADMAP_V2.md
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
// ================================================
// 공통 필드 (8개)
// ================================================
if (! Schema::hasColumn('products', 'is_active')) {
$table->boolean('is_active')
->default(true)
->after('product_type')
->comment('활성 상태');
}
if (! Schema::hasColumn('products', 'margin_rate')) {
$table->decimal('margin_rate', 5, 2)
->nullable()
->after('is_active')
->comment('마진율 (%)');
}
if (! Schema::hasColumn('products', 'processing_cost')) {
$table->decimal('processing_cost', 10, 2)
->nullable()
->after('margin_rate')
->comment('가공비');
}
if (! Schema::hasColumn('products', 'labor_cost')) {
$table->decimal('labor_cost', 10, 2)
->nullable()
->after('processing_cost')
->comment('인건비');
}
if (! Schema::hasColumn('products', 'install_cost')) {
$table->decimal('install_cost', 10, 2)
->nullable()
->after('labor_cost')
->comment('설치비');
}
if (! Schema::hasColumn('products', 'safety_stock')) {
$table->integer('safety_stock')
->nullable()
->after('install_cost')
->comment('안전 재고');
}
if (! Schema::hasColumn('products', 'lead_time')) {
$table->integer('lead_time')
->nullable()
->after('safety_stock')
->comment('리드타임 (일)');
}
if (! Schema::hasColumn('products', 'is_variable_size')) {
$table->boolean('is_variable_size')
->default(false)
->after('lead_time')
->comment('가변 치수 여부');
}
// ================================================
// FG (제품) 전용 필드 (3개)
// ================================================
if (! Schema::hasColumn('products', 'product_category')) {
$table->string('product_category', 20)
->nullable()
->after('is_variable_size')
->comment('제품 분류: SCREEN, STEEL 등');
}
if (! Schema::hasColumn('products', 'lot_abbreviation')) {
$table->string('lot_abbreviation', 10)
->nullable()
->after('product_category')
->comment('로트 약어 (예: KD)');
}
if (! Schema::hasColumn('products', 'note')) {
$table->text('note')
->nullable()
->after('lot_abbreviation')
->comment('비고');
}
// ================================================
// PT (부품) 전용 필드 (9개)
// ================================================
if (! Schema::hasColumn('products', 'part_type')) {
$table->string('part_type', 20)
->nullable()
->after('note')
->comment('부품 타입: ASSEMBLY, BENDING, PURCHASED');
}
if (! Schema::hasColumn('products', 'part_usage')) {
$table->string('part_usage', 30)
->nullable()
->after('part_type')
->comment('부품 용도: GUIDE_RAIL, BOTTOM_FINISH, CASE, DOOR, BRACKET, GENERAL');
}
if (! Schema::hasColumn('products', 'installation_type')) {
$table->string('installation_type', 20)
->nullable()
->after('part_usage')
->comment('설치 타입');
}
if (! Schema::hasColumn('products', 'assembly_type')) {
$table->string('assembly_type', 20)
->nullable()
->after('installation_type')
->comment('조립 타입');
}
if (! Schema::hasColumn('products', 'side_spec_width')) {
$table->string('side_spec_width', 20)
->nullable()
->after('assembly_type')
->comment('측면 규격 폭');
}
if (! Schema::hasColumn('products', 'side_spec_height')) {
$table->string('side_spec_height', 20)
->nullable()
->after('side_spec_width')
->comment('측면 규격 높이');
}
if (! Schema::hasColumn('products', 'assembly_length')) {
$table->string('assembly_length', 20)
->nullable()
->after('side_spec_height')
->comment('조립 길이');
}
if (! Schema::hasColumn('products', 'guide_rail_model_type')) {
$table->string('guide_rail_model_type', 50)
->nullable()
->after('assembly_length')
->comment('가이드레일 모델 타입');
}
if (! Schema::hasColumn('products', 'guide_rail_model')) {
$table->string('guide_rail_model', 50)
->nullable()
->after('guide_rail_model_type')
->comment('가이드레일 모델');
}
// ================================================
// 절곡품 필드 (5개)
// ================================================
if (! Schema::hasColumn('products', 'bending_diagram')) {
$table->string('bending_diagram', 255)
->nullable()
->after('guide_rail_model')
->comment('절곡 전개도 이미지 URL');
}
if (! Schema::hasColumn('products', 'bending_details')) {
$table->json('bending_details')
->nullable()
->after('bending_diagram')
->comment('절곡 전개도 상세 데이터 (BendingDetail[])');
}
if (! Schema::hasColumn('products', 'material')) {
$table->string('material', 50)
->nullable()
->after('bending_details')
->comment('재질');
}
if (! Schema::hasColumn('products', 'length')) {
$table->string('length', 20)
->nullable()
->after('material')
->comment('길이');
}
if (! Schema::hasColumn('products', 'bending_length')) {
$table->string('bending_length', 20)
->nullable()
->after('length')
->comment('절곡 길이');
}
// ================================================
// 인증 정보 필드 (7개)
// ================================================
if (! Schema::hasColumn('products', 'certification_number')) {
$table->string('certification_number', 50)
->nullable()
->after('bending_length')
->comment('인증 번호');
}
if (! Schema::hasColumn('products', 'certification_start_date')) {
$table->date('certification_start_date')
->nullable()
->after('certification_number')
->comment('인증 시작일');
}
if (! Schema::hasColumn('products', 'certification_end_date')) {
$table->date('certification_end_date')
->nullable()
->after('certification_start_date')
->comment('인증 종료일');
}
if (! Schema::hasColumn('products', 'specification_file')) {
$table->string('specification_file', 255)
->nullable()
->after('certification_end_date')
->comment('시방서 파일 경로');
}
if (! Schema::hasColumn('products', 'specification_file_name')) {
$table->string('specification_file_name', 255)
->nullable()
->after('specification_file')
->comment('시방서 파일명');
}
if (! Schema::hasColumn('products', 'certification_file')) {
$table->string('certification_file', 255)
->nullable()
->after('specification_file_name')
->comment('인증서 파일 경로');
}
if (! Schema::hasColumn('products', 'certification_file_name')) {
$table->string('certification_file_name', 255)
->nullable()
->after('certification_file')
->comment('인증서 파일명');
}
// ================================================
// 동적 확장 필드 (1개)
// ================================================
if (! Schema::hasColumn('products', 'options')) {
$table->json('options')
->nullable()
->after('certification_file_name')
->comment('동적 옵션 데이터 (JSON)');
}
// ================================================
// 인덱스 추가 (조회 최적화)
// ================================================
// is_active 인덱스
$hasIsActiveIndex = collect(\DB::select('SHOW INDEX FROM `products`'))
->contains(fn ($r) => $r->Key_name === 'idx_products_is_active');
if (! $hasIsActiveIndex && Schema::hasColumn('products', 'is_active')) {
$table->index('is_active', 'idx_products_is_active');
}
// product_category 인덱스
$hasProductCategoryIndex = collect(\DB::select('SHOW INDEX FROM `products`'))
->contains(fn ($r) => $r->Key_name === 'idx_products_product_category');
if (! $hasProductCategoryIndex && Schema::hasColumn('products', 'product_category')) {
$table->index('product_category', 'idx_products_product_category');
}
// part_type 인덱스
$hasPartTypeIndex = collect(\DB::select('SHOW INDEX FROM `products`'))
->contains(fn ($r) => $r->Key_name === 'idx_products_part_type');
if (! $hasPartTypeIndex && Schema::hasColumn('products', 'part_type')) {
$table->index('part_type', 'idx_products_part_type');
}
// part_usage 인덱스
$hasPartUsageIndex = collect(\DB::select('SHOW INDEX FROM `products`'))
->contains(fn ($r) => $r->Key_name === 'idx_products_part_usage');
if (! $hasPartUsageIndex && Schema::hasColumn('products', 'part_usage')) {
$table->index('part_usage', 'idx_products_part_usage');
}
});
}
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
// 인덱스 제거
$indexes = ['idx_products_is_active', 'idx_products_product_category', 'idx_products_part_type', 'idx_products_part_usage'];
foreach ($indexes as $indexName) {
$hasIndex = collect(\DB::select('SHOW INDEX FROM `products`'))
->contains(fn ($r) => $r->Key_name === $indexName);
if ($hasIndex) {
$table->dropIndex($indexName);
}
}
// 컬럼 제거 (역순)
$columns = [
'options',
'certification_file_name',
'certification_file',
'specification_file_name',
'specification_file',
'certification_end_date',
'certification_start_date',
'certification_number',
'bending_length',
'length',
'material',
'bending_details',
'bending_diagram',
'guide_rail_model',
'guide_rail_model_type',
'assembly_length',
'side_spec_height',
'side_spec_width',
'assembly_type',
'installation_type',
'part_usage',
'part_type',
'note',
'lot_abbreviation',
'product_category',
'is_variable_size',
'lead_time',
'safety_stock',
'install_cost',
'labor_cost',
'processing_cost',
'margin_rate',
'is_active',
];
foreach ($columns as $column) {
if (Schema::hasColumn('products', $column)) {
$table->dropColumn($column);
}
}
});
}
};

View File

@@ -1,104 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* BP-MES (품목 기준관리 중심 MES/ERP) Phase 1
* product_components 테이블 확장: BOMLine 수식 계산 및 조건부 BOM 지원
*
* 추가 필드:
* - 수식 계산: quantity_formula (핵심 기능)
* - 조건부 BOM: condition
* - 절곡품: is_bending, bending_diagram, bending_details (3개)
*
* 총 5개 필드 추가
*
* @see /claudedocs/mes/00_baseline/BACKEND_DEVELOPMENT_ROADMAP_V2.md
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('product_components', function (Blueprint $table) {
// ================================================
// 핵심 필드: 수식 계산 및 조건부 BOM
// ================================================
if (! Schema::hasColumn('product_components', 'quantity_formula')) {
$table->text('quantity_formula')
->nullable()
->after('quantity')
->comment('수량 계산 공식 (예: "W * 2", "H + 100", "G/1000*1.02")');
}
if (! Schema::hasColumn('product_components', 'condition')) {
$table->text('condition')
->nullable()
->after('quantity_formula')
->comment('조건부 BOM 조건식 (예: "MOTOR=\'Y\'", "WIDTH > 3000")');
}
// ================================================
// 절곡품 관련 필드 (3개)
// ================================================
if (! Schema::hasColumn('product_components', 'is_bending')) {
$table->boolean('is_bending')
->default(false)
->after('condition')
->comment('절곡품 여부');
}
if (! Schema::hasColumn('product_components', 'bending_diagram')) {
$table->string('bending_diagram', 255)
->nullable()
->after('is_bending')
->comment('절곡 전개도 이미지 URL');
}
if (! Schema::hasColumn('product_components', 'bending_details')) {
$table->json('bending_details')
->nullable()
->after('bending_diagram')
->comment('절곡 전개도 상세 데이터 (BendingDetail[])');
}
// ================================================
// 인덱스 추가 (조회 최적화)
// ================================================
// is_bending 인덱스
$hasIsBendingIndex = collect(\DB::select('SHOW INDEX FROM `product_components`'))
->contains(fn ($r) => $r->Key_name === 'idx_product_components_is_bending');
if (! $hasIsBendingIndex && Schema::hasColumn('product_components', 'is_bending')) {
$table->index('is_bending', 'idx_product_components_is_bending');
}
});
}
public function down(): void
{
Schema::table('product_components', function (Blueprint $table) {
// 인덱스 제거
$hasIsBendingIndex = collect(\DB::select('SHOW INDEX FROM `product_components`'))
->contains(fn ($r) => $r->Key_name === 'idx_product_components_is_bending');
if ($hasIsBendingIndex) {
$table->dropIndex('idx_product_components_is_bending');
}
// 컬럼 제거 (역순)
$columns = [
'bending_details',
'bending_diagram',
'is_bending',
'condition',
'quantity_formula',
];
foreach ($columns as $column) {
if (Schema::hasColumn('product_components', $column)) {
$table->dropColumn($column);
}
}
});
}
};

View File

@@ -0,0 +1,138 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 하이브리드 구조: 최소 고정 필드 + 동적 attributes
*
* 고정 필드 (6개):
* - safety_stock, lead_time: 재고/조달 관리 (범용)
* - is_variable_size: 맞춤형 vs 표준품 구분
* - product_category, part_type: 통계용 분류 필드
* - attributes_archive: 카테고리 변경 시 데이터 백업
*
* 가변 필드 (attributes JSON):
* - 테넌트별 커스텀 필드 (margin_rate, costs, bending_*, certification_* 등)
* - category_fields + tenant_stat_fields로 관리
*
* @see /claudedocs/mes/ITEM_MANAGEMENT_MIGRATION_GUIDE.md
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('products', function (Blueprint $table) {
// ================================================
// 범용 관리 필드 (3개)
// ================================================
if (! Schema::hasColumn('products', 'safety_stock')) {
$table->integer('safety_stock')
->nullable()
->after('is_producible')
->comment('안전 재고 수량');
}
if (! Schema::hasColumn('products', 'lead_time')) {
$table->integer('lead_time')
->nullable()
->after('safety_stock')
->comment('리드타임 (일)');
}
if (! Schema::hasColumn('products', 'is_variable_size')) {
$table->boolean('is_variable_size')
->default(false)
->after('lead_time')
->comment('가변 치수 여부 (맞춤형 제품)');
}
// ================================================
// 통계용 분류 필드 (2개)
// - 테넌트 공통으로 자주 사용하는 분류
// - 인덱스 지원으로 빠른 통계 조회
// ================================================
if (! Schema::hasColumn('products', 'product_category')) {
$table->string('product_category', 20)
->nullable()
->after('is_variable_size')
->comment('제품 분류 (SCREEN, STEEL 등) - 통계용');
}
if (! Schema::hasColumn('products', 'part_type')) {
$table->string('part_type', 20)
->nullable()
->after('product_category')
->comment('부품 타입 (ASSEMBLY, BENDING, PURCHASED) - 통계용');
}
// ================================================
// 데이터 보존 필드 (1개)
// ================================================
if (! Schema::hasColumn('products', 'attributes_archive')) {
$table->json('attributes_archive')
->nullable()
->after('attributes')
->comment('카테고리 변경 시 이전 attributes 백업');
}
// ================================================
// 인덱스 추가 (통계 조회 최적화)
// ================================================
$indexes = collect(\DB::select('SHOW INDEX FROM `products`'))
->pluck('Key_name')
->toArray();
if (! in_array('idx_products_product_category', $indexes)) {
$table->index(['tenant_id', 'product_category'], 'idx_products_product_category');
}
if (! in_array('idx_products_part_type', $indexes)) {
$table->index(['tenant_id', 'part_type'], 'idx_products_part_type');
}
if (! in_array('idx_products_is_variable_size', $indexes)) {
$table->index(['tenant_id', 'is_variable_size'], 'idx_products_is_variable_size');
}
});
}
public function down(): void
{
Schema::table('products', function (Blueprint $table) {
// 인덱스 제거
$indexes = collect(\DB::select('SHOW INDEX FROM `products`'))
->pluck('Key_name')
->toArray();
if (in_array('idx_products_product_category', $indexes)) {
$table->dropIndex('idx_products_product_category');
}
if (in_array('idx_products_part_type', $indexes)) {
$table->dropIndex('idx_products_part_type');
}
if (in_array('idx_products_is_variable_size', $indexes)) {
$table->dropIndex('idx_products_is_variable_size');
}
// 컬럼 제거
$columns = [
'attributes_archive',
'part_type',
'product_category',
'is_variable_size',
'lead_time',
'safety_stock',
];
foreach ($columns as $column) {
if (Schema::hasColumn('products', $column)) {
$table->dropColumn($column);
}
}
});
}
};

View File

@@ -0,0 +1,72 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 하이브리드 구조: BOM 수식/조건 + 동적 attributes
*
* 고정 필드 (2개):
* - quantity_formula: BOM 수식 계산 (BP-MES 및 범용)
* - condition: 조건부 BOM (BP-MES 및 범용)
*
* 가변 필드 (attributes JSON):
* - 테넌트별 커스텀 필드 (is_bending, bending_diagram, bending_details 등)
* - category_fields로 관리
*
* @see /claudedocs/mes/ITEM_MANAGEMENT_MIGRATION_GUIDE.md
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('product_components', function (Blueprint $table) {
// ================================================
// 범용 BOM 계산 필드 (2개)
// ================================================
if (! Schema::hasColumn('product_components', 'quantity_formula')) {
$table->string('quantity_formula', 500)
->nullable()
->after('quantity')
->comment('수량 계산 수식 (예: W0*H0/1000000)');
}
if (! Schema::hasColumn('product_components', 'condition')) {
$table->text('condition')
->nullable()
->after('quantity_formula')
->comment('조건부 BOM 적용 조건 (예: installation_type=CEILING)');
}
// ================================================
// 동적 확장 필드 (1개)
// - 테넌트별 커스텀 필드 저장
// - category_fields로 스키마 관리
// ================================================
if (! Schema::hasColumn('product_components', 'attributes')) {
$table->json('attributes')
->nullable()
->after('condition')
->comment('동적 필드 (절곡품, 인증 정보 등 테넌트별 커스텀)');
}
});
}
public function down(): void
{
Schema::table('product_components', function (Blueprint $table) {
$columns = [
'attributes',
'condition',
'quantity_formula',
];
foreach ($columns as $column) {
if (Schema::hasColumn('product_components', $column)) {
$table->dropColumn($column);
}
}
});
}
};

View File

@@ -0,0 +1,80 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 테넌트별 통계 필드 설정 테이블
*
* 목적: 각 테넌트가 통계를 내고 싶은 필드를 선언하는 메타 테이블
*
* 예시:
* - BP-MES 테넌트: margin_rate, processing_cost 등 통계 필요
* - 다른 테넌트: 다른 필드 조합으로 통계 필요
*
* 효과:
* - 테넌트마다 DDL 변경 없이 통계 필드 구성 가능
* - stat_snapshots 테이블에서 캐싱 대상 식별
* - 잠재적 Virtual Column 최적화 대상 식별
*
* @see /claudedocs/mes/ITEM_MANAGEMENT_MIGRATION_GUIDE.md
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('tenant_stat_fields', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
// ================================================
// 통계 대상 정의
// ================================================
$table->string('target_table', 50)->comment('통계 대상 테이블 (products, materials 등)');
$table->string('field_key', 100)->comment('필드 키 (attributes JSON 내 키값)');
$table->string('field_name', 100)->comment('필드 한글명 (마진율, 가공비 등)');
$table->string('field_type', 20)->comment('필드 타입 (decimal, varchar, integer 등)');
// ================================================
// 통계 방법 정의
// ================================================
$table->json('aggregation_types')
->nullable()
->comment('집계 함수 배열 (["avg", "sum", "min", "max", "count"])');
$table->boolean('is_critical')
->default(false)
->comment('중요 필드 여부 (true면 일일 스냅샷 생성)');
$table->integer('display_order')
->default(0)
->comment('통계 화면 표시 순서');
// ================================================
// 감사 및 메타 정보
// ================================================
$table->text('description')->nullable()->comment('필드 설명 및 용도');
$table->unsignedBigInteger('created_by')->nullable()->comment('생성자');
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자');
$table->timestamps();
// ================================================
// 인덱스
// ================================================
$table->unique(['tenant_id', 'target_table', 'field_key'], 'idx_tenant_stat_fields_unique');
$table->index(['tenant_id', 'is_critical'], 'idx_tenant_stat_fields_critical');
$table->index(['target_table', 'field_key'], 'idx_tenant_stat_fields_lookup');
// ================================================
// 외래 키 (개발 환경에서만 권장)
// ================================================
// $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade');
});
}
public function down(): void
{
Schema::dropIfExists('tenant_stat_fields');
}
};

View File

@@ -0,0 +1,97 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 통계 스냅샷 캐싱 테이블
*
* 목적: 자주 조회하는 통계를 사전 계산하여 저장
*
* 동작 방식:
* 1. tenant_stat_fields에서 is_critical=true 필드 식별
* 2. 일일/월별 스케줄러가 해당 필드의 통계 계산
* 3. 이 테이블에 결과 저장
* 4. 통계 조회 시 실시간 계산 대신 이 테이블 참조
*
* 예시:
* - snapshot_type: 'daily'
* - target_table: 'products'
* - field_key: 'margin_rate'
* - metric_type: 'avg'
* - metric_value: 25.50
* - group_by_field: 'product_category'
* - group_by_value: 'SCREEN'
* → "SCREEN 카테고리 제품의 평균 마진율: 25.50%"
*
* @see /claudedocs/mes/ITEM_MANAGEMENT_MIGRATION_GUIDE.md
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('stat_snapshots', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
// ================================================
// 스냅샷 메타 정보
// ================================================
$table->string('snapshot_type', 50)->comment('스냅샷 타입 (daily, weekly, monthly)');
$table->string('target_table', 50)->comment('통계 대상 테이블 (products, materials 등)');
$table->date('snapshot_date')->comment('스냅샷 기준일');
// ================================================
// 통계 대상 필드
// ================================================
$table->string('field_key', 100)->comment('통계 필드 키 (attributes JSON 내 키값)');
$table->string('metric_type', 20)->comment('집계 함수 (avg, sum, min, max, count)');
$table->decimal('metric_value', 18, 4)->nullable()->comment('계산된 통계 값');
// ================================================
// 그룹화 기준 (선택적)
// ================================================
$table->string('group_by_field', 100)->nullable()->comment('그룹화 기준 필드 (product_category, part_type 등)');
$table->string('group_by_value', 100)->nullable()->comment('그룹화 기준 값 (SCREEN, STEEL 등)');
// ================================================
// 신뢰성 정보
// ================================================
$table->integer('record_count')->nullable()->comment('계산에 사용된 레코드 수');
$table->timestamp('calculated_at')->nullable()->comment('계산 실행 시각');
// ================================================
// 감사 정보
// ================================================
$table->text('calculation_note')->nullable()->comment('계산 상세 정보 (에러 로그 등)');
$table->timestamps();
// ================================================
// 인덱스 - 조회 성능 최적화
// ================================================
$table->index(['tenant_id', 'snapshot_type', 'snapshot_date'], 'idx_stat_snapshots_tenant_type_date');
$table->index(['tenant_id', 'target_table', 'field_key', 'snapshot_date'], 'idx_stat_snapshots_field_lookup');
$table->index(['tenant_id', 'target_table', 'group_by_field', 'group_by_value'], 'idx_stat_snapshots_group_lookup');
$table->index(['snapshot_date', 'calculated_at'], 'idx_stat_snapshots_freshness');
// ================================================
// 유니크 제약 (중복 스냅샷 방지)
// ================================================
$table->unique(
['tenant_id', 'snapshot_type', 'snapshot_date', 'target_table', 'field_key', 'metric_type', 'group_by_field', 'group_by_value'],
'idx_stat_snapshots_unique'
);
// ================================================
// 외래 키 (개발 환경에서만 권장)
// ================================================
// $table->foreign('tenant_id')->references('id')->on('tenants')->onDelete('cascade');
});
}
public function down(): void
{
Schema::dropIfExists('stat_snapshots');
}
};