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');
}
};

View File

@@ -0,0 +1,232 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
/**
* BP-MES 품목 관리 시스템 카테고리 필드 시더
*
* 목적: BP-MES 테넌트를 위한 제품/부품/절곡품 카테고리 및 동적 필드 생성
*
* 구조:
* - bp_mes_products (루트)
* - fg_products (완제품/FG) - 마진율, 비용, LOT 관리
* - pt_products (부품/PT) - 용도, 설치방식, 조립 정보
* - bending_products (절곡품) - 절곡 상세, 재질, 길이
*
* 실행: php artisan db:seed --class=BpMesCategoryFieldsSeeder
*
* @see /claudedocs/mes/ITEM_MANAGEMENT_MIGRATION_GUIDE.md
*/
class BpMesCategoryFieldsSeeder extends Seeder
{
/**
* BP-MES 테넌트 ID (실제 환경에 맞게 수정 필요)
*/
private const TENANT_ID = 1;
public function run(): void
{
// 1. BP-MES 루트 카테고리 생성
$rootCategoryId = DB::table('categories')->insertGetId([
'tenant_id' => self::TENANT_ID,
'parent_id' => null,
'code_group' => 'bp_mes',
'code' => 'bp_mes_products',
'name' => 'BP-MES 품목 관리',
'description' => 'BP-MES 품목 기준관리 시스템 루트 카테고리',
'sort_order' => 1,
'profile_code' => 'bp_mes_root',
'is_active' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
// 2. 완제품(FG) 카테고리 생성
$fgCategoryId = DB::table('categories')->insertGetId([
'tenant_id' => self::TENANT_ID,
'parent_id' => $rootCategoryId,
'code_group' => 'bp_mes',
'code' => 'fg_products',
'name' => '완제품(FG)',
'description' => '판매 가능한 완제품 카테고리 (마진율, 비용 관리)',
'sort_order' => 1,
'profile_code' => 'fg_category',
'is_active' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
// 3. 부품(PT) 카테고리 생성
$ptCategoryId = DB::table('categories')->insertGetId([
'tenant_id' => self::TENANT_ID,
'parent_id' => $rootCategoryId,
'code_group' => 'bp_mes',
'code' => 'pt_products',
'name' => '부품(PT)',
'description' => '조립용 부품 카테고리 (용도, 설치방식, 규격)',
'sort_order' => 2,
'profile_code' => 'pt_category',
'is_active' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
// 4. 절곡품 카테고리 생성
$bendingCategoryId = DB::table('categories')->insertGetId([
'tenant_id' => self::TENANT_ID,
'parent_id' => $rootCategoryId,
'code_group' => 'bp_mes',
'code' => 'bending_products',
'name' => '절곡품',
'description' => '금속 절곡 가공품 카테고리 (절곡도, 재질, 길이)',
'sort_order' => 3,
'profile_code' => 'bending_category',
'is_active' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
// 5. 완제품(FG) 동적 필드 정의
$fgFields = [
// 비용 관리 (통계 필요)
['key' => 'margin_rate', 'name' => '마진율(%)', 'type' => 'decimal', 'required' => false, 'order' => 10,
'desc' => '제품 마진율 (통계 대상)'],
['key' => 'processing_cost', 'name' => '가공비(원)', 'type' => 'decimal', 'required' => false, 'order' => 11,
'desc' => '가공 비용 (통계 대상)'],
['key' => 'labor_cost', 'name' => '인건비(원)', 'type' => 'decimal', 'required' => false, 'order' => 12,
'desc' => '인건비 (통계 대상)'],
['key' => 'install_cost', 'name' => '설치비(원)', 'type' => 'decimal', 'required' => false, 'order' => 13,
'desc' => '설치 비용 (통계 대상)'],
// LOT 관리
['key' => 'lot_abbreviation', 'name' => 'LOT 약어', 'type' => 'text', 'required' => false, 'order' => 20,
'desc' => 'LOT 번호 생성용 약어'],
['key' => 'note', 'name' => '비고', 'type' => 'textarea', 'required' => false, 'order' => 21,
'desc' => '제품 관련 메모 및 특이사항'],
// 인증 정보
['key' => 'certification_number', 'name' => '인증번호', 'type' => 'text', 'required' => false, 'order' => 30,
'desc' => '제품 인증번호'],
['key' => 'certification_start_date', 'name' => '인증시작일', 'type' => 'date', 'required' => false, 'order' => 31,
'desc' => '인증 유효기간 시작일'],
['key' => 'certification_end_date', 'name' => '인증종료일', 'type' => 'date', 'required' => false, 'order' => 32,
'desc' => '인증 유효기간 종료일'],
// 파일 관리
['key' => 'specification_file', 'name' => '규격파일', 'type' => 'file', 'required' => false, 'order' => 40,
'desc' => '제품 규격서 파일 경로'],
['key' => 'specification_file_name', 'name' => '규격파일명', 'type' => 'text', 'required' => false, 'order' => 41,
'desc' => '규격서 파일 원본명'],
['key' => 'certification_file', 'name' => '인증파일', 'type' => 'file', 'required' => false, 'order' => 42,
'desc' => '인증서 파일 경로'],
['key' => 'certification_file_name', 'name' => '인증파일명', 'type' => 'text', 'required' => false, 'order' => 43,
'desc' => '인증서 파일 원본명'],
// 확장 옵션
['key' => 'options', 'name' => '옵션정보', 'type' => 'json', 'required' => false, 'order' => 50,
'desc' => '추가 옵션 정보 (JSON 형태)'],
];
// 6. 부품(PT) 동적 필드 정의
$ptFields = [
// 부품 분류 및 용도
['key' => 'part_usage', 'name' => '용도', 'type' => 'text', 'required' => false, 'order' => 10,
'desc' => '부품 사용 용도'],
['key' => 'installation_type', 'name' => '설치방식', 'type' => 'select', 'required' => false, 'order' => 11,
'options' => ['CEILING', 'WALL', 'FLOOR', 'HANGING'],
'desc' => '설치 방식 (천장형, 벽면형, 바닥형, 현가형)'],
['key' => 'assembly_type', 'name' => '조립타입', 'type' => 'select', 'required' => false, 'order' => 12,
'options' => ['WELDING', 'BOLT', 'RIVET', 'ADHESIVE'],
'desc' => '조립 방식 (용접, 볼트, 리벳, 접착)'],
// 치수 정보
['key' => 'side_spec_width', 'name' => '측면규격_폭(mm)', 'type' => 'decimal', 'required' => false, 'order' => 20,
'desc' => '측면 규격 폭'],
['key' => 'side_spec_height', 'name' => '측면규격_높이(mm)', 'type' => 'decimal', 'required' => false, 'order' => 21,
'desc' => '측면 규격 높이'],
['key' => 'assembly_length', 'name' => '조립길이(mm)', 'type' => 'decimal', 'required' => false, 'order' => 22,
'desc' => '조립 시 길이'],
// 가이드레일 정보
['key' => 'guide_rail_model_type', 'name' => '가이드레일모델타입', 'type' => 'select', 'required' => false, 'order' => 30,
'options' => ['TYPE_A', 'TYPE_B', 'TYPE_C', 'CUSTOM'],
'desc' => '가이드레일 모델 타입'],
['key' => 'guide_rail_model', 'name' => '가이드레일모델', 'type' => 'text', 'required' => false, 'order' => 31,
'desc' => '가이드레일 모델명'],
];
// 7. 절곡품 동적 필드 정의
$bendingFields = [
// 절곡 정보
['key' => 'bending_diagram', 'name' => '절곡도', 'type' => 'file', 'required' => false, 'order' => 10,
'desc' => '절곡 도면 파일 경로'],
['key' => 'bending_details', 'name' => '절곡상세', 'type' => 'json', 'required' => false, 'order' => 11,
'desc' => '절곡 상세 정보 (각도, 위치 등 JSON)'],
// 재질 및 치수
['key' => 'material', 'name' => '재질', 'type' => 'select', 'required' => true, 'order' => 20,
'options' => ['STEEL', 'STAINLESS', 'ALUMINUM', 'GALVANIZED'],
'desc' => '금속 재질 (강판, 스텐, 알루미늄, 아연도금)'],
['key' => 'length', 'name' => '길이(mm)', 'type' => 'decimal', 'required' => true, 'order' => 21,
'desc' => '절곡품 길이'],
['key' => 'bending_length', 'name' => '절곡길이(mm)', 'type' => 'decimal', 'required' => false, 'order' => 22,
'desc' => '절곡 부분 길이'],
];
// 8. 완제품(FG) 카테고리 필드 생성
foreach ($fgFields as $field) {
$this->insertCategoryField($fgCategoryId, $field);
}
// 9. 부품(PT) 카테고리 필드 생성
foreach ($ptFields as $field) {
$this->insertCategoryField($ptCategoryId, $field);
}
// 10. 절곡품 카테고리 필드 생성
foreach ($bendingFields as $field) {
$this->insertCategoryField($bendingCategoryId, $field);
}
}
/**
* category_fields 테이블에 필드 삽입
*/
private function insertCategoryField(int $categoryId, array $field): void
{
DB::table('category_fields')->insert([
'tenant_id' => self::TENANT_ID,
'category_id' => $categoryId,
'field_key' => $field['key'],
'field_name' => $field['name'],
'field_type' => $field['type'],
'is_required' => $field['required'],
'sort_order' => $field['order'],
'default_value' => $field['default'] ?? null,
'options' => isset($field['options']) ? json_encode($field['options']) : null,
'description' => $field['desc'],
'created_at' => now(),
'updated_at' => now(),
]);
}
}

View File

@@ -0,0 +1,174 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
/**
* BP-MES 테넌트 통계 필드 설정 시더
*
* 목적: BP-MES 테넌트가 통계를 내고 싶은 필드를 tenant_stat_fields 테이블에 선언
*
* 효과:
* - 일일 스케줄러가 이 필드들의 통계를 계산하여 stat_snapshots에 저장
* - 대시보드 및 통계 화면에서 빠른 조회 가능
* - 잠재적으로 MySQL Virtual Column 최적화 대상 식별
*
* 실행: php artisan db:seed --class=BpMesTenantStatFieldsSeeder
*
* @see /claudedocs/mes/ITEM_MANAGEMENT_MIGRATION_GUIDE.md
*/
class BpMesTenantStatFieldsSeeder extends Seeder
{
/**
* BP-MES 테넌트 ID (실제 환경에 맞게 수정 필요)
*/
private const TENANT_ID = 1;
public function run(): void
{
// 1. products 테이블의 통계 필드 정의
$productStatFields = [
// 마진율 통계 (중요 지표)
[
'target_table' => 'products',
'field_key' => 'margin_rate',
'field_name' => '마진율(%)',
'field_type' => 'decimal',
'aggregation_types' => ['avg', 'min', 'max'],
'is_critical' => true,
'display_order' => 1,
'description' => '제품별 마진율 통계 (평균, 최소, 최대)',
],
// 가공비 통계
[
'target_table' => 'products',
'field_key' => 'processing_cost',
'field_name' => '가공비(원)',
'field_type' => 'decimal',
'aggregation_types' => ['avg', 'sum', 'min', 'max'],
'is_critical' => true,
'display_order' => 2,
'description' => '가공비 통계 (평균, 합계, 최소, 최대)',
],
// 인건비 통계
[
'target_table' => 'products',
'field_key' => 'labor_cost',
'field_name' => '인건비(원)',
'field_type' => 'decimal',
'aggregation_types' => ['avg', 'sum', 'min', 'max'],
'is_critical' => true,
'display_order' => 3,
'description' => '인건비 통계 (평균, 합계, 최소, 최대)',
],
// 설치비 통계
[
'target_table' => 'products',
'field_key' => 'install_cost',
'field_name' => '설치비(원)',
'field_type' => 'decimal',
'aggregation_types' => ['avg', 'sum', 'min', 'max'],
'is_critical' => true,
'display_order' => 4,
'description' => '설치비 통계 (평균, 합계, 최소, 최대)',
],
// 절곡 길이 통계 (선택적)
[
'target_table' => 'products',
'field_key' => 'bending_length',
'field_name' => '절곡길이(mm)',
'field_type' => 'decimal',
'aggregation_types' => ['avg', 'min', 'max'],
'is_critical' => false,
'display_order' => 10,
'description' => '절곡품 길이 통계 (선택적 집계)',
],
// 조립 길이 통계 (선택적)
[
'target_table' => 'products',
'field_key' => 'assembly_length',
'field_name' => '조립길이(mm)',
'field_type' => 'decimal',
'aggregation_types' => ['avg', 'min', 'max'],
'is_critical' => false,
'display_order' => 11,
'description' => '부품 조립 길이 통계 (선택적 집계)',
],
// 측면 규격 통계 (선택적)
[
'target_table' => 'products',
'field_key' => 'side_spec_width',
'field_name' => '측면규격_폭(mm)',
'field_type' => 'decimal',
'aggregation_types' => ['avg', 'min', 'max'],
'is_critical' => false,
'display_order' => 12,
'description' => '측면 규격 폭 통계 (선택적 집계)',
],
[
'target_table' => 'products',
'field_key' => 'side_spec_height',
'field_name' => '측면규격_높이(mm)',
'field_type' => 'decimal',
'aggregation_types' => ['avg', 'min', 'max'],
'is_critical' => false,
'display_order' => 13,
'description' => '측면 규격 높이 통계 (선택적 집계)',
],
];
// 2. materials 테이블의 통계 필드 정의 (향후 확장용 예시)
$materialStatFields = [
// 예시: 자재 단가 통계
[
'target_table' => 'materials',
'field_key' => 'unit_price',
'field_name' => '단가(원)',
'field_type' => 'decimal',
'aggregation_types' => ['avg', 'min', 'max'],
'is_critical' => false,
'display_order' => 1,
'description' => '자재 단가 통계 (향후 활용)',
],
];
// 3. products 통계 필드 생성
foreach ($productStatFields as $field) {
$this->insertTenantStatField($field);
}
// 4. materials 통계 필드 생성 (향후 확장)
foreach ($materialStatFields as $field) {
$this->insertTenantStatField($field);
}
}
/**
* tenant_stat_fields 테이블에 필드 삽입
*/
private function insertTenantStatField(array $field): void
{
DB::table('tenant_stat_fields')->insert([
'tenant_id' => self::TENANT_ID,
'target_table' => $field['target_table'],
'field_key' => $field['field_key'],
'field_name' => $field['field_name'],
'field_type' => $field['field_type'],
'aggregation_types' => json_encode($field['aggregation_types']),
'is_critical' => $field['is_critical'],
'display_order' => $field['display_order'],
'description' => $field['description'],
'created_at' => now(),
'updated_at' => now(),
]);
}
}