merge: origin/develop 병합

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
pro
2026-01-30 11:55:50 +09:00
244 changed files with 14451 additions and 1699 deletions

View File

@@ -0,0 +1,117 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 문서양식 기본 테이블
Schema::create('document_templates', function (Blueprint $table) {
$table->id();
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
$table->string('name', 100)->comment('양식명');
$table->string('category', 50)->nullable()->comment('분류 (품질, 생산 등)');
$table->string('title', 200)->nullable()->comment('문서 제목');
$table->string('company_name', 100)->nullable()->comment('회사명');
$table->string('company_address', 255)->nullable()->comment('회사 주소');
$table->string('company_contact', 100)->nullable()->comment('회사 연락처');
$table->string('footer_remark_label', 50)->default('부적합 내용')->comment('비고 라벨');
$table->string('footer_judgement_label', 50)->default('종합판정')->comment('판정 라벨');
$table->json('footer_judgement_options')->nullable()->comment('판정 옵션 (적합/부적합 등)');
$table->boolean('is_active')->default(true)->comment('활성 여부');
$table->timestamps();
$table->softDeletes();
$table->index(['tenant_id', 'category']);
$table->index(['tenant_id', 'is_active']);
});
// 결재라인
Schema::create('document_template_approval_lines', function (Blueprint $table) {
$table->id();
$table->foreignId('template_id')->constrained('document_templates')->cascadeOnDelete();
$table->string('name', 50)->comment('결재 단계명 (작성, 검토, 승인)');
$table->string('dept', 50)->nullable()->comment('부서');
$table->string('role', 50)->nullable()->comment('직책/담당자');
$table->unsignedSmallInteger('sort_order')->default(0);
$table->timestamps();
$table->index(['template_id', 'sort_order']);
});
// 기본 필드 (품명, LOT NO 등)
Schema::create('document_template_basic_fields', function (Blueprint $table) {
$table->id();
$table->foreignId('template_id')->constrained('document_templates')->cascadeOnDelete();
$table->string('label', 50)->comment('필드 라벨');
$table->string('field_type', 20)->default('text')->comment('필드 타입 (text, date 등)');
$table->string('default_value', 255)->nullable()->comment('기본값');
$table->unsignedSmallInteger('sort_order')->default(0);
$table->timestamps();
$table->index(['template_id', 'sort_order']);
});
// 검사 기준서 섹션
Schema::create('document_template_sections', function (Blueprint $table) {
$table->id();
$table->foreignId('template_id')->constrained('document_templates')->cascadeOnDelete();
$table->string('title', 100)->comment('섹션 제목 (가이드레일, 연기차단재 등)');
$table->string('image_path', 255)->nullable()->comment('도해 이미지 경로');
$table->unsignedSmallInteger('sort_order')->default(0);
$table->timestamps();
$table->index(['template_id', 'sort_order']);
});
// 검사 기준서 섹션 항목
Schema::create('document_template_section_items', function (Blueprint $table) {
$table->id();
$table->foreignId('section_id')->constrained('document_template_sections')->cascadeOnDelete();
$table->string('category', 50)->nullable()->comment('구분 (겉모양, 치수 등)');
$table->string('item', 100)->comment('검사항목');
$table->string('standard', 255)->nullable()->comment('검사기준');
$table->string('method', 50)->nullable()->comment('검사방법');
$table->string('frequency', 50)->nullable()->comment('검사주기');
$table->string('regulation', 100)->nullable()->comment('관련규정');
$table->unsignedSmallInteger('sort_order')->default(0);
$table->timestamps();
$table->index(['section_id', 'sort_order']);
});
// 검사 데이터 테이블 컬럼 설정
Schema::create('document_template_columns', function (Blueprint $table) {
$table->id();
$table->foreignId('template_id')->constrained('document_templates')->cascadeOnDelete();
$table->string('label', 50)->comment('컬럼명');
$table->string('width', 20)->default('100px')->comment('컬럼 너비');
$table->string('column_type', 20)->default('text')->comment('타입 (text, check, measurement, select, complex)');
$table->string('group_name', 50)->nullable()->comment('그룹명 (상단 헤더 병합용)');
$table->json('sub_labels')->nullable()->comment('서브라벨 배열');
$table->unsignedSmallInteger('sort_order')->default(0);
$table->timestamps();
$table->index(['template_id', 'sort_order']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('document_template_columns');
Schema::dropIfExists('document_template_section_items');
Schema::dropIfExists('document_template_sections');
Schema::dropIfExists('document_template_basic_fields');
Schema::dropIfExists('document_template_approval_lines');
Schema::dropIfExists('document_templates');
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('attendance_settings', function (Blueprint $table) {
$table->boolean('use_auto')->default(false)->after('use_gps')->comment('자동 출퇴근 사용 여부');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('attendance_settings', function (Blueprint $table) {
$table->dropColumn('use_auto');
});
}
};

View File

@@ -0,0 +1,118 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('global_categories', function (Blueprint $table) {
$table->id()->comment('글로벌 카테고리 PK');
$table->unsignedBigInteger('parent_id')->nullable()->comment('상위 카테고리ID(NULL=최상위)');
$table->string('code_group', 30)->comment('코드 그룹');
$table->string('profile_code', 30)->nullable()->comment('역할 프로필 코드');
$table->string('code', 30)->comment('카테고리 코드(영문, 고유)');
$table->string('name', 100)->comment('카테고리명');
$table->boolean('is_active')->default(true)->comment('활성여부');
$table->integer('sort_order')->default(1)->comment('정렬순서');
$table->string('description', 255)->nullable()->comment('비고');
$table->unsignedBigInteger('created_by')->nullable()->comment('등록자');
$table->unsignedBigInteger('updated_by')->nullable()->comment('수정자');
$table->unsignedBigInteger('deleted_by')->nullable()->comment('삭제자');
$table->timestamps();
$table->softDeletes();
// 인덱스
$table->unique(['code_group', 'code'], 'uq_global_codegroup_code');
$table->index('parent_id', 'idx_global_parent_id');
$table->index('code_group', 'idx_global_code_group');
// 자기 참조 FK
$table->foreign('parent_id', 'fk_global_category_parent')
->references('id')
->on('global_categories')
->onDelete('set null');
});
// tenant 1의 카테고리를 글로벌 카테고리로 복사
$this->seedFromTenant1();
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('global_categories');
}
/**
* tenant 1의 카테고리를 글로벌로 복사
*/
private function seedFromTenant1(): void
{
$categories = \DB::table('categories')
->where('tenant_id', 1)
->whereNull('deleted_at')
->orderBy('parent_id')
->orderBy('sort_order')
->get();
if ($categories->isEmpty()) {
return;
}
$idMap = []; // old_id => new_id
// 1단계: parent_id가 NULL인 루트 카테고리
foreach ($categories->where('parent_id', null) as $cat) {
$newId = \DB::table('global_categories')->insertGetId([
'parent_id' => null,
'code_group' => $cat->code_group,
'profile_code' => $cat->profile_code,
'code' => $cat->code,
'name' => $cat->name,
'is_active' => $cat->is_active,
'sort_order' => $cat->sort_order,
'description' => $cat->description,
'created_at' => now(),
'updated_at' => now(),
]);
$idMap[$cat->id] = $newId;
}
// 2단계: 자식 카테고리 (재귀적으로 처리)
$remaining = $categories->whereNotNull('parent_id');
$maxIterations = 10;
$iteration = 0;
while ($remaining->isNotEmpty() && $iteration < $maxIterations) {
$processed = [];
foreach ($remaining as $cat) {
if (isset($idMap[$cat->parent_id])) {
$newId = \DB::table('global_categories')->insertGetId([
'parent_id' => $idMap[$cat->parent_id],
'code_group' => $cat->code_group,
'profile_code' => $cat->profile_code,
'code' => $cat->code,
'name' => $cat->name,
'is_active' => $cat->is_active,
'sort_order' => $cat->sort_order,
'description' => $cat->description,
'created_at' => now(),
'updated_at' => now(),
]);
$idMap[$cat->id] = $newId;
$processed[] = $cat->id;
}
}
$remaining = $remaining->whereNotIn('id', $processed);
$iteration++;
}
}
};

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('today_issues', function (Blueprint $table) {
$table->unsignedBigInteger('target_user_id')
->nullable()
->after('source_id')
->comment('특정 대상 사용자 ID (null이면 테넌트 전체)');
$table->foreign('target_user_id')
->references('id')
->on('users')
->onDelete('cascade');
$table->index(['tenant_id', 'target_user_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('today_issues', function (Blueprint $table) {
$table->dropForeign(['target_user_id']);
$table->dropIndex(['tenant_id', 'target_user_id']);
$table->dropColumn('target_user_id');
});
}
};

View File

@@ -0,0 +1,115 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 실제 문서
Schema::create('documents', function (Blueprint $table) {
$table->id()->comment('ID');
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete()->comment('테넌트 ID');
$table->foreignId('template_id')->constrained('document_templates')->comment('템플릿 ID');
// 문서 정보
$table->string('document_no', 50)->comment('문서번호');
$table->string('title', 255)->comment('문서 제목');
$table->enum('status', ['DRAFT', 'PENDING', 'APPROVED', 'REJECTED', 'CANCELLED'])
->default('DRAFT')->comment('상태 (DRAFT:임시저장, PENDING:결재중, APPROVED:승인, REJECTED:반려, CANCELLED:취소)');
// 연결 정보 (다형성)
$table->string('linkable_type', 100)->nullable()->comment('연결 모델 타입');
$table->unsignedBigInteger('linkable_id')->nullable()->comment('연결 모델 ID');
// 결재 정보
$table->timestamp('submitted_at')->nullable()->comment('결재 요청일');
$table->timestamp('completed_at')->nullable()->comment('결재 완료일');
// 감사 컬럼
$table->foreignId('created_by')->nullable()->constrained('users')->comment('생성자 ID');
$table->foreignId('updated_by')->nullable()->constrained('users')->comment('수정자 ID');
$table->foreignId('deleted_by')->nullable()->constrained('users')->comment('삭제자 ID');
$table->timestamps();
$table->softDeletes();
// 인덱스
$table->index(['tenant_id', 'status']);
$table->index('document_no');
$table->index(['linkable_type', 'linkable_id']);
});
// 문서 결재
Schema::create('document_approvals', function (Blueprint $table) {
$table->id()->comment('ID');
$table->foreignId('document_id')->constrained()->cascadeOnDelete()->comment('문서 ID');
$table->foreignId('user_id')->constrained()->comment('결재자 ID');
$table->unsignedTinyInteger('step')->default(1)->comment('결재 순서');
$table->string('role', 50)->comment('역할 (작성/검토/승인)');
$table->enum('status', ['PENDING', 'APPROVED', 'REJECTED'])
->default('PENDING')->comment('상태 (PENDING:대기, APPROVED:승인, REJECTED:반려)');
$table->text('comment')->nullable()->comment('결재 의견');
$table->timestamp('acted_at')->nullable()->comment('결재 처리일');
// 감사 컬럼
$table->foreignId('created_by')->nullable()->constrained('users')->comment('생성자 ID');
$table->foreignId('updated_by')->nullable()->constrained('users')->comment('수정자 ID');
$table->timestamps();
// 인덱스
$table->index(['document_id', 'step']);
$table->index(['user_id', 'status']);
});
// 문서 데이터 (EAV 패턴)
Schema::create('document_data', function (Blueprint $table) {
$table->id()->comment('ID');
$table->foreignId('document_id')->constrained()->cascadeOnDelete()->comment('문서 ID');
$table->unsignedBigInteger('section_id')->nullable()->comment('섹션 ID (document_template_sections 참조)');
$table->unsignedBigInteger('column_id')->nullable()->comment('컬럼 ID (document_template_columns 참조)');
$table->unsignedSmallInteger('row_index')->default(0)->comment('행 인덱스 (테이블 데이터용)');
$table->string('field_key', 100)->comment('필드 키');
$table->text('field_value')->nullable()->comment('필드 값');
$table->timestamps();
// 인덱스
$table->index(['document_id', 'section_id']);
$table->index(['document_id', 'field_key']);
});
// 문서 첨부파일
Schema::create('document_attachments', function (Blueprint $table) {
$table->id()->comment('ID');
$table->foreignId('document_id')->constrained()->cascadeOnDelete()->comment('문서 ID');
$table->foreignId('file_id')->constrained('files')->comment('파일 ID');
$table->string('attachment_type', 50)->default('general')->comment('첨부 유형 (general, signature, image 등)');
$table->string('description', 255)->nullable()->comment('설명');
// 감사 컬럼
$table->foreignId('created_by')->nullable()->constrained('users')->comment('생성자 ID');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('document_attachments');
Schema::dropIfExists('document_data');
Schema::dropIfExists('document_approvals');
Schema::dropIfExists('documents');
}
};

View File

@@ -0,0 +1,69 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 재고 입출고 거래 이력 테이블
*
* 모든 재고 변동을 스택으로 쌓아 이력 관리합니다.
* audit_logs와 별개로 재고 전용 거래 이력을 제공합니다.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('stock_transactions', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
// 재고 참조
$table->unsignedBigInteger('stock_id')->comment('재고 ID');
$table->unsignedBigInteger('stock_lot_id')->nullable()->comment('LOT ID (입고/출고 시)');
// 거래 유형
$table->string('type', 20)->comment('거래유형: IN(입고), OUT(출고), RESERVE(예약), RELEASE(예약해제)');
// 수량
$table->decimal('qty', 15, 3)->comment('변동 수량 (양수: 증가, 음수: 감소)');
$table->decimal('balance_qty', 15, 3)->comment('거래 후 재고 잔량');
// 참조 정보 (다형성)
$table->string('reference_type', 50)->nullable()->comment('참조 유형: receiving, work_order, shipment, order');
$table->unsignedBigInteger('reference_id')->nullable()->comment('참조 ID');
// 상세 정보
$table->string('lot_no', 50)->nullable()->comment('LOT번호');
$table->string('reason', 50)->nullable()->comment('사유: receiving, work_order_input, shipment, order_confirm, order_cancel');
$table->string('remark', 500)->nullable()->comment('비고');
// 품목 스냅샷 (조회 성능용)
$table->string('item_code', 50)->comment('품목코드');
$table->string('item_name', 200)->comment('품목명');
// 감사 정보
$table->unsignedBigInteger('created_by')->nullable()->comment('생성자');
$table->timestamp('created_at')->useCurrent()->comment('생성일시');
// 인덱스
$table->index('tenant_id');
$table->index('stock_id');
$table->index('stock_lot_id');
$table->index('type');
$table->index('reference_type');
$table->index(['stock_id', 'created_at']);
$table->index(['tenant_id', 'item_code']);
$table->index(['tenant_id', 'type', 'created_at']);
$table->index(['reference_type', 'reference_id']);
// 외래키
$table->foreign('stock_id')->references('id')->on('stocks')->onDelete('cascade');
});
}
public function down(): void
{
Schema::dropIfExists('stock_transactions');
}
};

View File

@@ -0,0 +1,62 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 경동기업 전용 단가 테이블
*
* 5130 레거시 시스템의 price_* 테이블 데이터를 저장
* - price_motor: 모터/제어기 단가
* - price_shaft: 샤프트 계산 참조
* - price_pipe: 파이프 계산 참조
* - price_angle: 앵글 계산 참조
* - price_raw_materials: 원자재 단가
*/
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('kd_price_tables', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->default(287)->comment('경동기업 테넌트 ID');
$table->string('table_type', 50)->comment('테이블 유형: motor, shaft, pipe, angle, raw_material, bdmodels');
$table->string('item_code', 100)->nullable()->comment('품목 코드 (연동용)');
$table->string('item_name', 200)->nullable()->comment('품목명');
// 조회 조건 필드들
$table->string('category', 100)->nullable()->comment('분류 (모터용량, 재질, 타입 등)');
$table->string('spec1', 100)->nullable()->comment('규격1 (사이즈, 두께 등)');
$table->string('spec2', 100)->nullable()->comment('규격2 (길이, 브라켓크기 등)');
$table->string('spec3', 100)->nullable()->comment('규격3 (추가 조건)');
// 단가 정보
$table->decimal('unit_price', 15, 2)->default(0)->comment('단가');
$table->string('unit', 20)->default('EA')->comment('단위');
// 원본 JSON 데이터 (레거시 호환용)
$table->json('raw_data')->nullable()->comment('원본 JSON 데이터');
// 메타 정보
$table->boolean('is_active')->default(true)->comment('활성 여부');
$table->timestamps();
// 인덱스
$table->index(['tenant_id', 'table_type']);
$table->index(['table_type', 'category']);
$table->index(['table_type', 'spec1', 'spec2']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('kd_price_tables');
}
};

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
protected $connection = 'sam_stat';
public function up(): void
{
Schema::connection($this->connection)->create('stat_definitions', function (Blueprint $table) {
$table->id();
$table->string('code', 100)->unique()->comment('통계 코드 (sales_daily_revenue)');
$table->string('domain', 50)->index()->comment('도메인 (sales, finance, production)');
$table->string('name', 200)->comment('통계명 (일일 매출액)');
$table->text('description')->nullable();
$table->json('source_tables')->comment('원본 테이블 목록');
$table->string('aggregation', 20)->default('daily')->index()->comment('집계 주기');
$table->text('query_template')->nullable()->comment('집계 SQL 템플릿');
$table->boolean('is_active')->default(true)->index();
$table->json('config')->nullable()->comment('추가 설정 (임계값, 단위 등)');
$table->timestamps();
});
}
public function down(): void
{
Schema::connection($this->connection)->dropIfExists('stat_definitions');
}
};

View File

@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
protected $connection = 'sam_stat';
public function up(): void
{
Schema::connection($this->connection)->create('stat_job_logs', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->comment('테넌트 ID');
$table->string('job_type', 100)->comment('작업 유형 (sales_daily, finance_monthly)');
$table->date('target_date')->comment('집계 대상 날짜');
$table->enum('status', ['pending', 'running', 'completed', 'failed'])->default('pending');
$table->unsignedInteger('records_processed')->default(0);
$table->text('error_message')->nullable();
$table->timestamp('started_at')->nullable();
$table->timestamp('completed_at')->nullable();
$table->unsignedInteger('duration_ms')->nullable();
$table->timestamp('created_at')->nullable();
$table->index(['tenant_id', 'job_type']);
$table->index('status');
$table->index('target_date');
});
}
public function down(): void
{
Schema::connection($this->connection)->dropIfExists('stat_job_logs');
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
protected $connection = 'sam_stat';
public function up(): void
{
Schema::connection($this->connection)->create('dim_date', function (Blueprint $table) {
$table->date('date_key')->primary()->comment('날짜 키');
$table->smallInteger('year')->comment('연도');
$table->tinyInteger('quarter')->comment('분기 (1~4)');
$table->tinyInteger('month')->comment('월');
$table->tinyInteger('week')->comment('ISO 주차');
$table->tinyInteger('day_of_week')->comment('요일 (1:월~7:일)');
$table->tinyInteger('day_of_month')->comment('일');
$table->boolean('is_weekend')->comment('주말 여부');
$table->boolean('is_holiday')->default(false)->comment('공휴일 여부');
$table->string('holiday_name', 100)->nullable()->comment('공휴일명');
$table->smallInteger('fiscal_year')->nullable()->comment('회계연도');
$table->tinyInteger('fiscal_quarter')->nullable()->comment('회계분기');
});
}
public function down(): void
{
Schema::connection($this->connection)->dropIfExists('dim_date');
}
};

View File

@@ -0,0 +1,55 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
protected $connection = 'sam_stat';
public function up(): void
{
Schema::connection($this->connection)->create('stat_sales_daily', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id');
$table->date('stat_date');
// 수주
$table->unsignedInteger('order_count')->default(0)->comment('신규 수주 건수');
$table->decimal('order_amount', 18, 2)->default(0)->comment('수주 금액');
$table->unsignedInteger('order_item_count')->default(0)->comment('수주 품목 수');
// 매출
$table->unsignedInteger('sales_count')->default(0)->comment('매출 건수');
$table->decimal('sales_amount', 18, 2)->default(0)->comment('매출 금액');
$table->decimal('sales_tax_amount', 18, 2)->default(0)->comment('세액');
// 고객
$table->unsignedInteger('new_client_count')->default(0)->comment('신규 고객 수');
$table->unsignedInteger('active_client_count')->default(0)->comment('활성 고객 수');
// 수주 상태별 건수
$table->unsignedInteger('order_draft_count')->default(0);
$table->unsignedInteger('order_confirmed_count')->default(0);
$table->unsignedInteger('order_in_progress_count')->default(0);
$table->unsignedInteger('order_completed_count')->default(0);
$table->unsignedInteger('order_cancelled_count')->default(0);
// 출하
$table->unsignedInteger('shipment_count')->default(0);
$table->decimal('shipment_amount', 18, 2)->default(0);
$table->timestamps();
$table->unique(['tenant_id', 'stat_date'], 'uk_tenant_date');
$table->index('stat_date');
$table->index('tenant_id');
});
}
public function down(): void
{
Schema::connection($this->connection)->dropIfExists('stat_sales_daily');
}
};

View File

@@ -0,0 +1,59 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
protected $connection = 'sam_stat';
public function up(): void
{
Schema::connection($this->connection)->create('stat_finance_daily', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id');
$table->date('stat_date');
// 입출금
$table->unsignedInteger('deposit_count')->default(0);
$table->decimal('deposit_amount', 18, 2)->default(0);
$table->unsignedInteger('withdrawal_count')->default(0);
$table->decimal('withdrawal_amount', 18, 2)->default(0);
$table->decimal('net_cashflow', 18, 2)->default(0)->comment('입금 - 출금');
// 매입
$table->unsignedInteger('purchase_count')->default(0);
$table->decimal('purchase_amount', 18, 2)->default(0);
$table->decimal('purchase_tax_amount', 18, 2)->default(0);
// 미수/미지급
$table->decimal('receivable_balance', 18, 2)->default(0)->comment('미수금 잔액');
$table->decimal('payable_balance', 18, 2)->default(0)->comment('미지급 잔액');
$table->decimal('overdue_receivable', 18, 2)->default(0)->comment('연체 미수금');
// 어음
$table->unsignedInteger('bill_issued_count')->default(0);
$table->decimal('bill_issued_amount', 18, 2)->default(0);
$table->unsignedInteger('bill_matured_count')->default(0);
$table->decimal('bill_matured_amount', 18, 2)->default(0);
// 카드
$table->unsignedInteger('card_transaction_count')->default(0);
$table->decimal('card_transaction_amount', 18, 2)->default(0);
// 은행
$table->decimal('bank_balance_total', 18, 2)->default(0)->comment('전 계좌 잔액 합계');
$table->timestamps();
$table->unique(['tenant_id', 'stat_date'], 'uk_tenant_date');
$table->index('stat_date');
});
}
public function down(): void
{
Schema::connection($this->connection)->dropIfExists('stat_finance_daily');
}
};

View File

@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
protected $connection = 'sam_stat';
public function up(): void
{
Schema::connection($this->connection)->create('stat_finance_monthly', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id');
$table->smallInteger('stat_year');
$table->tinyInteger('stat_month');
$table->decimal('deposit_total', 18, 2)->default(0);
$table->decimal('withdrawal_total', 18, 2)->default(0);
$table->decimal('net_cashflow', 18, 2)->default(0);
$table->decimal('purchase_total', 18, 2)->default(0);
$table->decimal('card_total', 18, 2)->default(0);
$table->decimal('receivable_end', 18, 2)->default(0)->comment('월말 미수금');
$table->decimal('payable_end', 18, 2)->default(0)->comment('월말 미지급');
$table->decimal('bank_balance_end', 18, 2)->default(0)->comment('월말 잔액');
$table->decimal('mom_cashflow_change', 8, 2)->nullable()->comment('전월 대비 현금흐름 변화 (%)');
$table->timestamps();
$table->unique(['tenant_id', 'stat_year', 'stat_month'], 'uk_tenant_year_month');
$table->index(['stat_year', 'stat_month']);
});
}
public function down(): void
{
Schema::connection($this->connection)->dropIfExists('stat_finance_monthly');
}
};

View File

@@ -0,0 +1,46 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
protected $connection = 'sam_stat';
public function up(): void
{
Schema::connection($this->connection)->create('stat_sales_monthly', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id');
$table->smallInteger('stat_year');
$table->tinyInteger('stat_month');
// 일일 합산
$table->unsignedInteger('order_count')->default(0);
$table->decimal('order_amount', 18, 2)->default(0);
$table->unsignedInteger('sales_count')->default(0);
$table->decimal('sales_amount', 18, 2)->default(0);
$table->unsignedInteger('shipment_count')->default(0);
$table->decimal('shipment_amount', 18, 2)->default(0);
// 월간 고유 지표
$table->unsignedInteger('unique_client_count')->default(0)->comment('거래 고객 수');
$table->decimal('avg_order_amount', 18, 2)->default(0)->comment('평균 수주 금액');
$table->unsignedBigInteger('top_client_id')->nullable()->comment('최다 거래 고객');
$table->decimal('top_client_amount', 18, 2)->default(0);
$table->decimal('mom_growth_rate', 8, 2)->nullable()->comment('전월 대비 성장률 (%)');
$table->decimal('yoy_growth_rate', 8, 2)->nullable()->comment('전년동월 대비 (%)');
$table->timestamps();
$table->unique(['tenant_id', 'stat_year', 'stat_month'], 'uk_tenant_year_month');
$table->index(['stat_year', 'stat_month']);
});
}
public function down(): void
{
Schema::connection($this->connection)->dropIfExists('stat_sales_monthly');
}
};

View File

@@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
protected $connection = 'sam_stat';
public function up(): void
{
Schema::connection($this->connection)->create('stat_production_daily', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id');
$table->date('stat_date');
// 작업지시
$table->unsignedInteger('wo_created_count')->default(0)->comment('신규 작업지시');
$table->unsignedInteger('wo_completed_count')->default(0)->comment('완료 작업지시');
$table->unsignedInteger('wo_in_progress_count')->default(0)->comment('진행중');
$table->unsignedInteger('wo_overdue_count')->default(0)->comment('납기 초과');
// 생산량
$table->decimal('production_qty', 18, 2)->default(0)->comment('생산 수량');
$table->decimal('defect_qty', 18, 2)->default(0)->comment('불량 수량');
$table->decimal('defect_rate', 5, 2)->default(0)->comment('불량률 (%)');
// 작업 효율
$table->decimal('planned_hours', 10, 2)->default(0)->comment('계획 공수');
$table->decimal('actual_hours', 10, 2)->default(0)->comment('실적 공수');
$table->decimal('efficiency_rate', 5, 2)->default(0)->comment('효율 (%)');
// 작업자
$table->unsignedInteger('active_worker_count')->default(0);
$table->unsignedInteger('issue_count')->default(0)->comment('발생 이슈 수');
// 납기
$table->unsignedInteger('on_time_delivery_count')->default(0);
$table->unsignedInteger('late_delivery_count')->default(0);
$table->decimal('delivery_rate', 5, 2)->default(0)->comment('납기준수율 (%)');
$table->timestamps();
$table->unique(['tenant_id', 'stat_date'], 'uk_tenant_date');
$table->index('stat_date');
});
}
public function down(): void
{
Schema::connection($this->connection)->dropIfExists('stat_production_daily');
}
};

View File

@@ -0,0 +1,41 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
protected $connection = 'sam_stat';
public function up(): void
{
Schema::connection($this->connection)->create('stat_production_monthly', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id');
$table->smallInteger('stat_year');
$table->tinyInteger('stat_month');
$table->unsignedInteger('wo_total_count')->default(0);
$table->unsignedInteger('wo_completed_count')->default(0);
$table->decimal('production_qty', 18, 2)->default(0);
$table->decimal('defect_qty', 18, 2)->default(0);
$table->decimal('avg_defect_rate', 5, 2)->default(0);
$table->decimal('avg_efficiency_rate', 5, 2)->default(0);
$table->decimal('avg_delivery_rate', 5, 2)->default(0);
$table->decimal('total_planned_hours', 10, 2)->default(0);
$table->decimal('total_actual_hours', 10, 2)->default(0);
$table->unsignedInteger('issue_total_count')->default(0);
$table->timestamps();
$table->unique(['tenant_id', 'stat_year', 'stat_month'], 'uk_tenant_year_month');
$table->index(['stat_year', 'stat_month']);
});
}
public function down(): void
{
Schema::connection($this->connection)->dropIfExists('stat_production_monthly');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
protected $connection = 'sam_stat';
public function up(): void
{
Schema::connection($this->connection)->create('dim_client', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id');
$table->unsignedBigInteger('client_id')->comment('원본 clients.id');
$table->string('client_name', 200);
$table->unsignedBigInteger('client_group_id')->nullable();
$table->string('client_group_name', 200)->nullable();
$table->string('client_type', 50)->nullable()->comment('고객/공급업체/양쪽');
$table->string('region', 100)->nullable();
$table->date('valid_from');
$table->date('valid_to')->nullable()->comment('NULL = 현재 유효');
$table->boolean('is_current')->default(true);
$table->index(['tenant_id', 'client_id'], 'idx_tenant_client');
$table->index('is_current', 'idx_current');
});
}
public function down(): void
{
Schema::connection($this->connection)->dropIfExists('dim_client');
}
};

View File

@@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
protected $connection = 'sam_stat';
public function up(): void
{
Schema::connection($this->connection)->create('dim_product', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id');
$table->unsignedBigInteger('item_id')->comment('원본 items.id');
$table->string('item_code', 100);
$table->string('item_name', 300);
$table->string('item_type', 50)->nullable()->comment('item_type from items');
$table->unsignedBigInteger('category_id')->nullable();
$table->string('category_name', 200)->nullable();
$table->date('valid_from');
$table->date('valid_to')->nullable()->comment('NULL = 현재 유효');
$table->boolean('is_current')->default(true);
$table->index(['tenant_id', 'item_id'], 'idx_tenant_item');
$table->index('is_current', 'idx_current');
});
}
public function down(): void
{
Schema::connection($this->connection)->dropIfExists('dim_product');
}
};

View File

@@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
protected $connection = 'sam_stat';
public function up(): void
{
Schema::connection($this->connection)->create('stat_inventory_daily', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id');
$table->date('stat_date');
// 재고 현황
$table->unsignedInteger('total_sku_count')->default(0)->comment('총 SKU 수');
$table->decimal('total_stock_qty', 18, 2)->default(0)->comment('총 재고 수량');
$table->decimal('total_stock_value', 18, 2)->default(0)->comment('총 재고 금액');
// 입출고
$table->unsignedInteger('receipt_count')->default(0)->comment('입고 건수');
$table->decimal('receipt_qty', 18, 2)->default(0);
$table->decimal('receipt_amount', 18, 2)->default(0);
$table->unsignedInteger('issue_count')->default(0)->comment('출고 건수');
$table->decimal('issue_qty', 18, 2)->default(0);
$table->decimal('issue_amount', 18, 2)->default(0);
// 안전재고
$table->unsignedInteger('below_safety_count')->default(0)->comment('안전재고 미달 품목 수');
$table->unsignedInteger('zero_stock_count')->default(0)->comment('재고 0 품목 수');
$table->unsignedInteger('excess_stock_count')->default(0)->comment('과잉 재고 품목 수');
// 품질검사
$table->unsignedInteger('inspection_count')->default(0);
$table->unsignedInteger('inspection_pass_count')->default(0);
$table->unsignedInteger('inspection_fail_count')->default(0);
$table->decimal('inspection_pass_rate', 5, 2)->default(0)->comment('합격률 (%)');
// 재고회전
$table->decimal('turnover_rate', 8, 2)->default(0)->comment('재고회전율');
$table->timestamps();
$table->unique(['tenant_id', 'stat_date'], 'uk_tenant_date');
$table->index('stat_date');
});
}
public function down(): void
{
Schema::connection($this->connection)->dropIfExists('stat_inventory_daily');
}
};

View File

@@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
protected $connection = 'sam_stat';
public function up(): void
{
Schema::connection($this->connection)->create('stat_quote_pipeline_daily', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id');
$table->date('stat_date');
// 견적
$table->unsignedInteger('quote_created_count')->default(0);
$table->decimal('quote_amount', 18, 2)->default(0);
$table->unsignedInteger('quote_approved_count')->default(0);
$table->unsignedInteger('quote_rejected_count')->default(0);
$table->unsignedInteger('quote_conversion_count')->default(0)->comment('수주 전환 건수');
$table->decimal('quote_conversion_rate', 5, 2)->default(0)->comment('전환율 (%)');
// 영업 기회 (sales_prospects - tenant_id 없어 manager_id로 연결)
$table->unsignedInteger('prospect_created_count')->default(0);
$table->unsignedInteger('prospect_won_count')->default(0);
$table->unsignedInteger('prospect_lost_count')->default(0);
$table->decimal('prospect_amount', 18, 2)->default(0)->comment('파이프라인 금액');
// 입찰
$table->unsignedInteger('bidding_count')->default(0);
$table->unsignedInteger('bidding_won_count')->default(0);
$table->decimal('bidding_amount', 18, 2)->default(0);
// 상담
$table->unsignedInteger('consultation_count')->default(0);
$table->timestamps();
$table->unique(['tenant_id', 'stat_date'], 'uk_tenant_date');
$table->index('stat_date');
});
}
public function down(): void
{
Schema::connection($this->connection)->dropIfExists('stat_quote_pipeline_daily');
}
};

View File

@@ -0,0 +1,49 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
protected $connection = 'sam_stat';
public function up(): void
{
Schema::connection($this->connection)->create('stat_hr_attendance_daily', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id');
$table->date('stat_date');
// 근태
$table->unsignedInteger('total_employees')->default(0)->comment('전체 직원 수');
$table->unsignedInteger('attendance_count')->default(0)->comment('출근 인원');
$table->unsignedInteger('late_count')->default(0)->comment('지각');
$table->unsignedInteger('absent_count')->default(0)->comment('결근');
$table->decimal('attendance_rate', 5, 2)->default(0)->comment('출근율 (%)');
// 휴가
$table->unsignedInteger('leave_count')->default(0)->comment('휴가 사용');
$table->unsignedInteger('leave_annual_count')->default(0)->comment('연차');
$table->unsignedInteger('leave_sick_count')->default(0)->comment('병가');
$table->unsignedInteger('leave_other_count')->default(0)->comment('기타');
// 초과근무
$table->decimal('overtime_hours', 10, 2)->default(0);
$table->unsignedInteger('overtime_employee_count')->default(0);
// 인건비
$table->decimal('total_labor_cost', 18, 2)->default(0);
$table->timestamps();
$table->unique(['tenant_id', 'stat_date'], 'uk_tenant_date');
$table->index('stat_date');
});
}
public function down(): void
{
Schema::connection($this->connection)->dropIfExists('stat_hr_attendance_daily');
}
};

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
protected $connection = 'sam_stat';
public function up(): void
{
Schema::connection($this->connection)->create('stat_kpi_targets', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id');
$table->smallInteger('stat_year');
$table->tinyInteger('stat_month')->nullable()->comment('NULL = 연간 목표');
$table->string('domain', 50)->comment('sales, production, finance 등');
$table->string('metric_code', 100)->comment('monthly_sales_amount 등');
$table->decimal('target_value', 18, 2);
$table->string('unit', 20)->default('KRW')->comment('KRW, %, count, hours');
$table->string('description', 300)->nullable();
$table->unsignedBigInteger('created_by')->nullable();
$table->timestamps();
$table->unique(['tenant_id', 'stat_year', 'stat_month', 'metric_code'], 'uk_tenant_metric');
$table->index('domain', 'idx_domain');
});
}
public function down(): void
{
Schema::connection($this->connection)->dropIfExists('stat_kpi_targets');
}
};

View File

@@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
protected $connection = 'sam_stat';
public function up(): void
{
Schema::connection($this->connection)->create('stat_alerts', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id');
$table->string('domain', 50);
$table->string('alert_type', 100)->comment('below_target, anomaly, threshold');
$table->enum('severity', ['info', 'warning', 'critical'])->default('info');
$table->string('title', 300);
$table->text('message');
$table->string('metric_code', 100)->nullable();
$table->decimal('current_value', 18, 2)->nullable();
$table->decimal('threshold_value', 18, 2)->nullable();
$table->boolean('is_read')->default(false);
$table->boolean('is_resolved')->default(false);
$table->timestamp('resolved_at')->nullable();
$table->unsignedBigInteger('resolved_by')->nullable();
$table->timestamp('created_at')->nullable();
$table->index(['tenant_id', 'is_read'], 'idx_tenant_unread');
$table->index('severity', 'idx_severity');
$table->index('domain', 'idx_domain');
});
}
public function down(): void
{
Schema::connection($this->connection)->dropIfExists('stat_alerts');
}
};

View File

@@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
protected $connection = 'sam_stat';
public function up(): void
{
Schema::connection('sam_stat')->create('stat_project_monthly', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id');
$table->smallInteger('stat_year');
$table->tinyInteger('stat_month');
// 프로젝트 현황
$table->unsignedInteger('active_site_count')->default(0);
$table->unsignedInteger('completed_site_count')->default(0);
$table->unsignedInteger('new_contract_count')->default(0);
$table->decimal('contract_total_amount', 18, 2)->default(0);
// 원가
$table->decimal('expected_expense_total', 18, 2)->default(0);
$table->decimal('actual_expense_total', 18, 2)->default(0);
$table->decimal('labor_cost_total', 18, 2)->default(0);
$table->decimal('material_cost_total', 18, 2)->default(0);
// 수익률
$table->decimal('gross_profit', 18, 2)->default(0);
$table->decimal('gross_profit_rate', 5, 2)->default(0);
// 이슈
$table->unsignedInteger('handover_report_count')->default(0);
$table->unsignedInteger('structure_review_count')->default(0);
$table->timestamps();
$table->unique(['tenant_id', 'stat_year', 'stat_month'], 'uk_tenant_year_month');
$table->index(['stat_year', 'stat_month'], 'idx_year_month');
});
}
public function down(): void
{
Schema::connection('sam_stat')->dropIfExists('stat_project_monthly');
}
};

View File

@@ -0,0 +1,56 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
protected $connection = 'sam_stat';
public function up(): void
{
Schema::connection('sam_stat')->create('stat_system_daily', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id');
$table->date('stat_date');
// API 사용량
$table->unsignedInteger('api_request_count')->default(0);
$table->unsignedInteger('api_error_count')->default(0);
$table->unsignedInteger('api_avg_response_ms')->default(0);
// 사용자 활동
$table->unsignedInteger('active_user_count')->default(0);
$table->unsignedInteger('login_count')->default(0);
// 감사
$table->unsignedInteger('audit_create_count')->default(0);
$table->unsignedInteger('audit_update_count')->default(0);
$table->unsignedInteger('audit_delete_count')->default(0);
// 알림
$table->unsignedInteger('fcm_sent_count')->default(0);
$table->unsignedInteger('fcm_failed_count')->default(0);
// 파일
$table->unsignedInteger('file_upload_count')->default(0);
$table->decimal('file_upload_size_mb', 10, 2)->default(0);
// 결재
$table->unsignedInteger('approval_submitted_count')->default(0);
$table->unsignedInteger('approval_completed_count')->default(0);
$table->decimal('approval_avg_hours', 8, 2)->default(0);
$table->timestamps();
$table->unique(['tenant_id', 'stat_date'], 'uk_tenant_date');
$table->index('stat_date', 'idx_date');
});
}
public function down(): void
{
Schema::connection('sam_stat')->dropIfExists('stat_system_daily');
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
protected $connection = 'sam_stat';
public function up(): void
{
Schema::connection('sam_stat')->create('stat_events', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id');
$table->string('domain', 50);
$table->string('event_type', 100);
$table->string('entity_type', 100);
$table->unsignedBigInteger('entity_id');
$table->json('payload')->nullable();
$table->timestamp('occurred_at');
$table->index(['tenant_id', 'domain'], 'idx_tenant_domain');
$table->index('occurred_at', 'idx_occurred');
$table->index(['entity_type', 'entity_id'], 'idx_entity');
});
}
public function down(): void
{
Schema::connection('sam_stat')->dropIfExists('stat_events');
}
};

View File

@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
protected $connection = 'sam_stat';
public function up(): void
{
Schema::connection('sam_stat')->create('stat_snapshots', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id');
$table->date('snapshot_date');
$table->string('domain', 50);
$table->string('snapshot_type', 50)->default('daily');
$table->json('data');
$table->timestamp('created_at')->nullable();
$table->unique(['tenant_id', 'snapshot_date', 'domain', 'snapshot_type'], 'uk_tenant_date_domain');
$table->index('snapshot_date', 'idx_date');
});
}
public function down(): void
{
Schema::connection('sam_stat')->dropIfExists('stat_snapshots');
}
};

View File

@@ -0,0 +1,138 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* 일간 통계 테이블 RANGE 파티셔닝 준비
*
* 실행 방법: php artisan migrate --path=database/migrations/stats/2026_01_29_300001_prepare_partitioning_daily_tables.php
*
* 주의: MySQL에서 파티셔닝 적용 시 UNIQUE KEY에 파티션 컬럼(stat_date)이 포함되어야 합니다.
* 이 마이그레이션은 기존 데이터를 보존하며 파티셔닝을 적용합니다.
*/
return new class extends Migration
{
protected $connection = 'sam_stat';
/**
* 파티셔닝 대상 테이블과 고유 키 정의
*/
private function getTargetTables(): array
{
return [
'stat_sales_daily' => [
'unique_columns' => 'tenant_id, stat_date',
'unique_name' => 'uk_tenant_date',
],
'stat_finance_daily' => [
'unique_columns' => 'tenant_id, stat_date',
'unique_name' => 'uk_tenant_date',
],
'stat_production_daily' => [
'unique_columns' => 'tenant_id, stat_date',
'unique_name' => 'uk_tenant_date',
],
'stat_inventory_daily' => [
'unique_columns' => 'tenant_id, stat_date',
'unique_name' => 'uk_tenant_date',
],
'stat_quote_pipeline_daily' => [
'unique_columns' => 'tenant_id, stat_date',
'unique_name' => 'uk_tenant_date',
],
'stat_hr_attendance_daily' => [
'unique_columns' => 'tenant_id, stat_date',
'unique_name' => 'uk_tenant_date',
],
'stat_system_daily' => [
'unique_columns' => 'tenant_id, stat_date',
'unique_name' => 'uk_tenant_date',
],
];
}
/**
* 연도별 파티션 정의 생성 (2024~2028 + MAXVALUE)
*/
private function getPartitionDefinitions(): string
{
$partitions = [];
for ($year = 2024; $year <= 2028; $year++) {
$partitions[] = "PARTITION p{$year} VALUES LESS THAN ('{$year}-01-01')";
}
$partitions[] = 'PARTITION p_future VALUES LESS THAN MAXVALUE';
return implode(",\n ", $partitions);
}
public function up(): void
{
$tables = $this->getTargetTables();
$partitionDefs = $this->getPartitionDefinitions();
foreach ($tables as $table => $config) {
// 테이블 존재 여부 확인
$exists = DB::connection('sam_stat')
->select("SHOW TABLES LIKE '{$table}'");
if (empty($exists)) {
continue;
}
// 이미 파티셔닝되어 있는지 확인
$partitionInfo = DB::connection('sam_stat')
->select('SELECT PARTITION_NAME FROM INFORMATION_SCHEMA.PARTITIONS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND PARTITION_NAME IS NOT NULL',
[$table]);
if (! empty($partitionInfo)) {
continue; // 이미 파티셔닝됨
}
// AUTO_INCREMENT PK를 일반 PK로 변경 (파티션 키 포함)
// MySQL 파티셔닝 제약: UNIQUE KEY에 파티션 컬럼 포함 필수
DB::connection('sam_stat')->statement("
ALTER TABLE `{$table}`
DROP PRIMARY KEY,
ADD PRIMARY KEY (`id`, `stat_date`),
DROP INDEX `{$config['unique_name']}`,
ADD UNIQUE KEY `{$config['unique_name']}` ({$config['unique_columns']})
");
// RANGE 파티셔닝 적용
DB::connection('sam_stat')->statement("
ALTER TABLE `{$table}`
PARTITION BY RANGE COLUMNS(`stat_date`) (
{$partitionDefs}
)
");
}
}
public function down(): void
{
$tables = $this->getTargetTables();
foreach ($tables as $table => $config) {
$exists = DB::connection('sam_stat')
->select("SHOW TABLES LIKE '{$table}'");
if (empty($exists)) {
continue;
}
// 파티션 제거
DB::connection('sam_stat')->statement("
ALTER TABLE `{$table}` REMOVE PARTITIONING
");
// PK 원복
DB::connection('sam_stat')->statement("
ALTER TABLE `{$table}`
DROP PRIMARY KEY,
ADD PRIMARY KEY (`id`)
");
}
}
};

View File

@@ -0,0 +1,81 @@
<?php
namespace Database\Seeders;
use Carbon\Carbon;
use Carbon\CarbonPeriod;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
class DimDateSeeder extends Seeder
{
public function run(): void
{
$start = Carbon::create(2020, 1, 1);
$end = Carbon::create(2030, 12, 31);
$period = CarbonPeriod::create($start, $end);
$holidays = $this->getKoreanHolidays();
$batch = [];
$batchSize = 500;
foreach ($period as $date) {
$dateStr = $date->format('Y-m-d');
$holiday = $holidays[$dateStr] ?? null;
$batch[] = [
'date_key' => $dateStr,
'year' => $date->year,
'quarter' => $date->quarter,
'month' => $date->month,
'week' => (int) $date->isoWeek(),
'day_of_week' => $date->dayOfWeekIso,
'day_of_month' => $date->day,
'is_weekend' => $date->isWeekend(),
'is_holiday' => $holiday !== null,
'holiday_name' => $holiday,
'fiscal_year' => $date->year,
'fiscal_quarter' => $date->quarter,
];
if (count($batch) >= $batchSize) {
DB::connection('sam_stat')->table('dim_date')->insert($batch);
$batch = [];
}
}
if (! empty($batch)) {
DB::connection('sam_stat')->table('dim_date')->insert($batch);
}
$totalCount = DB::connection('sam_stat')->table('dim_date')->count();
$this->command->info("dim_date 시딩 완료: {$totalCount}건 (2020-01-01 ~ 2030-12-31)");
}
/**
* 한국 공휴일 목록 (고정 공휴일만, 음력 공휴일은 수동 추가 필요)
*/
private function getKoreanHolidays(): array
{
$holidays = [];
for ($year = 2020; $year <= 2030; $year++) {
// 고정 공휴일
$fixed = [
"{$year}-01-01" => '신정',
"{$year}-03-01" => '삼일절',
"{$year}-05-05" => '어린이날',
"{$year}-06-06" => '현충일',
"{$year}-08-15" => '광복절',
"{$year}-10-03" => '개천절',
"{$year}-10-09" => '한글날',
"{$year}-12-25" => '크리스마스',
];
$holidays = array_merge($holidays, $fixed);
}
return $holidays;
}
}

View File

@@ -0,0 +1,565 @@
<?php
namespace Database\Seeders\Kyungdong;
use App\Models\Kyungdong\KdPriceTable;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
/**
* 경동기업 전용 단가 테이블 Seeder
*
* 5130 레거시 시스템의 price_* 테이블 데이터를 kd_price_tables로 마이그레이션
*
* 실행: php artisan db:seed --class="Database\Seeders\Kyungdong\KdPriceTableSeeder"
*/
class KdPriceTableSeeder extends Seeder
{
private const TENANT_ID = 287;
/**
* Run the database seeds.
*/
public function run(): void
{
$this->command->info('');
$this->command->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
$this->command->info('🔧 경동기업 단가 테이블 마이그레이션 (kd_price_tables)');
$this->command->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
// 기존 데이터 삭제
DB::table('kd_price_tables')->where('tenant_id', self::TENANT_ID)->delete();
$this->command->info(' → 기존 데이터 삭제 완료');
// chandj 연결 가능 여부 확인
$chandjAvailable = $this->checkChandjConnection();
if ($chandjAvailable) {
$this->command->info(' → chandj 데이터베이스 연결됨');
$this->migrateFromChandj();
} else {
$this->command->warn(' → chandj 데이터베이스 연결 불가 - 샘플 데이터 사용');
$this->insertSampleData();
}
$count = DB::table('kd_price_tables')->where('tenant_id', self::TENANT_ID)->count();
$this->command->info('');
$this->command->info("✅ 완료: kd_price_tables {$count}");
}
/**
* chandj 데이터베이스 연결 확인
*/
private function checkChandjConnection(): bool
{
try {
DB::connection('chandj')->getPdo();
return true;
} catch (\Exception $e) {
return false;
}
}
/**
* chandj 데이터베이스에서 마이그레이션
*/
private function migrateFromChandj(): void
{
$this->migrateMotorPrices();
$this->migrateShaftPrices();
$this->migratePipePrices();
$this->migrateAnglePrices();
$this->migrateRawMaterialPrices();
}
/**
* price_motor → kd_price_tables
*/
private function migrateMotorPrices(): void
{
$this->command->info('');
$this->command->info('📦 [1/5] price_motor 마이그레이션...');
$priceMotor = DB::connection('chandj')
->table('price_motor')
->where(function ($q) {
$q->where('is_deleted', 0)->orWhereNull('is_deleted');
})
->orderByDesc('registedate')
->first();
if (! $priceMotor || empty($priceMotor->itemList)) {
$this->command->info(' → 데이터 없음');
return;
}
$itemList = json_decode($priceMotor->itemList, true);
if (! is_array($itemList)) {
$this->command->info(' → JSON 파싱 실패');
return;
}
$count = 0;
$now = now();
foreach ($itemList as $item) {
$col1 = $item['col1'] ?? ''; // 전압/카테고리 (220, 380, 제어기 등)
$col2 = $item['col2'] ?? ''; // 용량/품목명
$salesPrice = (float) str_replace(',', '', $item['col13'] ?? '0');
if (empty($col2) || $salesPrice <= 0) {
continue;
}
// 카테고리 결정
$category = match ($col1) {
'220', '380' => $col2, // 모터 용량 (150K, 300K 등)
default => $col1, // 제어기, 방화, 방범 등
};
KdPriceTable::create([
'tenant_id' => self::TENANT_ID,
'table_type' => KdPriceTable::TYPE_MOTOR,
'item_name' => trim("{$col1} {$col2}"),
'category' => $category,
'spec1' => $col1,
'spec2' => $col2,
'unit_price' => $salesPrice,
'unit' => 'EA',
'raw_data' => $item,
'is_active' => true,
]);
$count++;
}
$this->command->info("{$count}건 완료");
}
/**
* price_shaft → kd_price_tables
*/
private function migrateShaftPrices(): void
{
$this->command->info('');
$this->command->info('📦 [2/5] price_shaft 마이그레이션...');
$priceShaft = DB::connection('chandj')
->table('price_shaft')
->where(function ($q) {
$q->where('is_deleted', 0)->orWhereNull('is_deleted');
})
->orderByDesc('registedate')
->first();
if (! $priceShaft || empty($priceShaft->itemList)) {
$this->command->info(' → 데이터 없음');
return;
}
$itemList = json_decode($priceShaft->itemList, true);
if (! is_array($itemList)) {
$this->command->info(' → JSON 파싱 실패');
return;
}
$count = 0;
foreach ($itemList as $item) {
$size = $item['col4'] ?? ''; // 사이즈 (3, 4, 5인치)
$length = $item['col10'] ?? ''; // 길이 (m 단위)
$salesPrice = (float) str_replace(',', '', $item['col19'] ?? '0');
if (empty($size) || $salesPrice <= 0) {
continue;
}
KdPriceTable::create([
'tenant_id' => self::TENANT_ID,
'table_type' => KdPriceTable::TYPE_SHAFT,
'item_name' => "감기샤프트 {$size}인치 {$length}m",
'category' => '감기샤프트',
'spec1' => $size,
'spec2' => $length,
'unit_price' => $salesPrice,
'unit' => 'EA',
'raw_data' => $item,
'is_active' => true,
]);
$count++;
}
$this->command->info("{$count}건 완료");
}
/**
* price_pipe → kd_price_tables
*/
private function migratePipePrices(): void
{
$this->command->info('');
$this->command->info('📦 [3/5] price_pipe 마이그레이션...');
$pricePipe = DB::connection('chandj')
->table('price_pipe')
->where(function ($q) {
$q->where('is_deleted', 0)->orWhereNull('is_deleted');
})
->orderByDesc('registedate')
->first();
if (! $pricePipe || empty($pricePipe->itemList)) {
$this->command->info(' → 데이터 없음');
return;
}
$itemList = json_decode($pricePipe->itemList, true);
if (! is_array($itemList)) {
$this->command->info(' → JSON 파싱 실패');
return;
}
$count = 0;
foreach ($itemList as $item) {
$length = $item['col2'] ?? ''; // 길이 (3000, 6000)
$thickness = $item['col4'] ?? ''; // 두께 (1.4)
$salesPrice = (float) str_replace(',', '', $item['col8'] ?? '0');
if (empty($thickness) || $salesPrice <= 0) {
continue;
}
KdPriceTable::create([
'tenant_id' => self::TENANT_ID,
'table_type' => KdPriceTable::TYPE_PIPE,
'item_name' => "각파이프 {$thickness}T {$length}mm",
'category' => '각파이프',
'spec1' => $thickness,
'spec2' => $length,
'unit_price' => $salesPrice,
'unit' => 'EA',
'raw_data' => $item,
'is_active' => true,
]);
$count++;
}
$this->command->info("{$count}건 완료");
}
/**
* price_angle → kd_price_tables
*/
private function migrateAnglePrices(): void
{
$this->command->info('');
$this->command->info('📦 [4/5] price_angle 마이그레이션...');
$priceAngle = DB::connection('chandj')
->table('price_angle')
->where(function ($q) {
$q->where('is_deleted', 0)->orWhereNull('is_deleted');
})
->orderByDesc('registedate')
->first();
if (! $priceAngle || empty($priceAngle->itemList)) {
$this->command->info(' → 데이터 없음');
return;
}
$itemList = json_decode($priceAngle->itemList, true);
if (! is_array($itemList)) {
$this->command->info(' → JSON 파싱 실패');
return;
}
$count = 0;
foreach ($itemList as $item) {
$type = $item['col2'] ?? ''; // 타입 (스크린용, 철재용)
$bracketSize = $item['col3'] ?? ''; // 브라켓크기
$angleType = $item['col4'] ?? ''; // 앵글타입 (앵글3T, 앵글4T)
$thickness = $item['col10'] ?? ''; // 두께
$salesPrice = (float) str_replace(',', '', $item['col19'] ?? '0');
if (empty($type) || $salesPrice <= 0) {
continue;
}
KdPriceTable::create([
'tenant_id' => self::TENANT_ID,
'table_type' => KdPriceTable::TYPE_ANGLE,
'item_name' => "앵글 {$type} {$bracketSize} {$angleType}",
'category' => $type,
'spec1' => $bracketSize,
'spec2' => $angleType,
'spec3' => $thickness,
'unit_price' => $salesPrice,
'unit' => 'EA',
'raw_data' => $item,
'is_active' => true,
]);
$count++;
}
$this->command->info("{$count}건 완료");
}
/**
* price_raw_materials → kd_price_tables
*/
private function migrateRawMaterialPrices(): void
{
$this->command->info('');
$this->command->info('📦 [5/5] price_raw_materials 마이그레이션...');
$priceRaw = DB::connection('chandj')
->table('price_raw_materials')
->where(function ($q) {
$q->where('is_deleted', 0)->orWhereNull('is_deleted');
})
->orderByDesc('registedate')
->first();
if (! $priceRaw || empty($priceRaw->itemList)) {
$this->command->info(' → 데이터 없음');
return;
}
$itemList = json_decode($priceRaw->itemList, true);
if (! is_array($itemList)) {
$this->command->info(' → JSON 파싱 실패');
return;
}
$count = 0;
foreach ($itemList as $item) {
$name = $item['col2'] ?? '';
$spec = $item['col3'] ?? '';
$salesPrice = (float) str_replace(',', '', $item['col19'] ?? $item['col13'] ?? '0');
if (empty($name) || $salesPrice <= 0) {
continue;
}
KdPriceTable::create([
'tenant_id' => self::TENANT_ID,
'table_type' => KdPriceTable::TYPE_RAW_MATERIAL,
'item_name' => $name,
'category' => '원자재',
'spec1' => $spec,
'unit_price' => $salesPrice,
'unit' => '㎡',
'raw_data' => $item,
'is_active' => true,
]);
$count++;
}
$this->command->info("{$count}건 완료");
}
/**
* chandj 연결 불가 시 샘플 데이터 삽입
*/
private function insertSampleData(): void
{
$this->command->info('');
$this->command->info('📦 샘플 데이터 삽입 중...');
// 모터 샘플 데이터 (5130 분석 결과 기반)
$motorData = [
['category' => '150K', 'spec1' => '220', 'spec2' => '150K', 'unit_price' => 85000],
['category' => '300K', 'spec1' => '220', 'spec2' => '300K', 'unit_price' => 120000],
['category' => '400K', 'spec1' => '220', 'spec2' => '400K', 'unit_price' => 150000],
['category' => '500K', 'spec1' => '220', 'spec2' => '500K', 'unit_price' => 180000],
['category' => '600K', 'spec1' => '220', 'spec2' => '600K', 'unit_price' => 220000],
['category' => '800K', 'spec1' => '220', 'spec2' => '800K', 'unit_price' => 280000],
['category' => '1000K', 'spec1' => '220', 'spec2' => '1000K', 'unit_price' => 350000],
['category' => '매립형', 'spec1' => '제어기', 'spec2' => '매립형', 'unit_price' => 45000],
['category' => '노출형', 'spec1' => '제어기', 'spec2' => '노출형', 'unit_price' => 55000],
['category' => '뒷박스', 'spec1' => '제어기', 'spec2' => '뒷박스', 'unit_price' => 35000],
];
foreach ($motorData as $data) {
KdPriceTable::create(array_merge($data, [
'tenant_id' => self::TENANT_ID,
'table_type' => KdPriceTable::TYPE_MOTOR,
'item_name' => "모터/제어기 {$data['category']}",
'unit' => 'EA',
'is_active' => true,
]));
}
$this->command->info(' → 모터/제어기 '.count($motorData).'건');
// 샤프트 샘플 데이터
$shaftData = [
['spec1' => '3', 'spec2' => '3.0', 'unit_price' => 45000],
['spec1' => '4', 'spec2' => '3.0', 'unit_price' => 55000],
['spec1' => '5', 'spec2' => '3.0', 'unit_price' => 65000],
['spec1' => '3', 'spec2' => '4.0', 'unit_price' => 60000],
['spec1' => '4', 'spec2' => '4.0', 'unit_price' => 75000],
['spec1' => '5', 'spec2' => '4.0', 'unit_price' => 90000],
];
foreach ($shaftData as $data) {
KdPriceTable::create(array_merge($data, [
'tenant_id' => self::TENANT_ID,
'table_type' => KdPriceTable::TYPE_SHAFT,
'item_name' => "감기샤프트 {$data['spec1']}인치 {$data['spec2']}m",
'category' => '감기샤프트',
'unit' => 'EA',
'is_active' => true,
]));
}
$this->command->info(' → 샤프트 '.count($shaftData).'건');
// 파이프 샘플 데이터
$pipeData = [
['spec1' => '1.4', 'spec2' => '3000', 'unit_price' => 12000],
['spec1' => '1.4', 'spec2' => '6000', 'unit_price' => 24000],
];
foreach ($pipeData as $data) {
KdPriceTable::create(array_merge($data, [
'tenant_id' => self::TENANT_ID,
'table_type' => KdPriceTable::TYPE_PIPE,
'item_name' => "각파이프 {$data['spec1']}T {$data['spec2']}mm",
'category' => '각파이프',
'unit' => 'EA',
'is_active' => true,
]));
}
$this->command->info(' → 파이프 '.count($pipeData).'건');
// 앵글 샘플 데이터
$angleData = [
['category' => '스크린용', 'spec1' => '530*320', 'spec2' => '앵글3T', 'unit_price' => 8000],
['category' => '스크린용', 'spec1' => '600*350', 'spec2' => '앵글3T', 'unit_price' => 10000],
['category' => '스크린용', 'spec1' => '690*390', 'spec2' => '앵글4T', 'unit_price' => 12000],
['category' => '철재용', 'spec1' => '530*320', 'spec2' => '앵글3T', 'unit_price' => 9000],
['category' => '철재용', 'spec1' => '600*350', 'spec2' => '앵글3T', 'unit_price' => 11000],
['category' => '철재용', 'spec1' => '690*390', 'spec2' => '앵글4T', 'unit_price' => 14000],
];
foreach ($angleData as $data) {
KdPriceTable::create(array_merge($data, [
'tenant_id' => self::TENANT_ID,
'table_type' => KdPriceTable::TYPE_ANGLE,
'item_name' => "앵글 {$data['category']} {$data['spec1']} {$data['spec2']}",
'unit' => 'EA',
'is_active' => true,
]));
}
$this->command->info(' → 앵글 '.count($angleData).'건');
// 원자재 샘플 데이터
$rawData = [
['item_name' => '실리카', 'spec1' => '스크린용', 'unit_price' => 25000],
['item_name' => '불투명', 'spec1' => '스크린용', 'unit_price' => 22000],
['item_name' => '화이바원단', 'spec1' => '스크린용', 'unit_price' => 28000],
];
foreach ($rawData as $data) {
KdPriceTable::create(array_merge($data, [
'tenant_id' => self::TENANT_ID,
'table_type' => KdPriceTable::TYPE_RAW_MATERIAL,
'category' => '원자재',
'unit' => '㎡',
'is_active' => true,
]));
}
$this->command->info(' → 원자재 '.count($rawData).'건');
// BDmodels 샘플 데이터 (절곡품)
$this->insertBDModelsSampleData();
}
/**
* BDmodels 샘플 데이터 삽입 (절곡품)
*/
private function insertBDModelsSampleData(): void
{
$bdmodelsData = [
// 케이스 (규격별 단가 - 원/m)
['category' => '케이스', 'spec2' => '500*380', 'unit_price' => 15000, 'unit' => 'm'],
['category' => '케이스', 'spec2' => '550*430', 'unit_price' => 18000, 'unit' => 'm'],
['category' => '케이스', 'spec2' => '650*550', 'unit_price' => 22000, 'unit' => 'm'],
// 케이스 마구리 (규격별 단가 - 원/개)
['category' => '마구리', 'spec2' => '500*380', 'unit_price' => 5000, 'unit' => 'EA'],
['category' => '마구리', 'spec2' => '550*430', 'unit_price' => 6000, 'unit' => 'EA'],
['category' => '마구리', 'spec2' => '650*550', 'unit_price' => 7500, 'unit' => 'EA'],
// 케이스용 연기차단재 (공통 단가 - 원/m)
['category' => '케이스용 연기차단재', 'unit_price' => 3500, 'unit' => 'm'],
// 가이드레일 (모델+마감+규격별 단가 - 원/m)
['category' => '가이드레일', 'item_code' => 'KSS01', 'spec1' => 'SUS', 'spec2' => '120*70', 'unit_price' => 12000, 'unit' => 'm'],
['category' => '가이드레일', 'item_code' => 'KSS01', 'spec1' => 'SUS', 'spec2' => '120*100', 'unit_price' => 15000, 'unit' => 'm'],
['category' => '가이드레일', 'item_code' => 'KSS01', 'spec1' => 'EGI', 'spec2' => '120*70', 'unit_price' => 10000, 'unit' => 'm'],
['category' => '가이드레일', 'item_code' => 'KSS01', 'spec1' => 'EGI', 'spec2' => '120*100', 'unit_price' => 13000, 'unit' => 'm'],
['category' => '가이드레일', 'item_code' => 'KWS01', 'spec1' => 'SUS', 'spec2' => '120*70', 'unit_price' => 13000, 'unit' => 'm'],
['category' => '가이드레일', 'item_code' => 'KWS01', 'spec1' => 'SUS', 'spec2' => '120*100', 'unit_price' => 16000, 'unit' => 'm'],
// 가이드레일용 연기차단재 (공통 단가 - 원/m)
['category' => '가이드레일용 연기차단재', 'unit_price' => 2500, 'unit' => 'm'],
// 하단마감재/하장바 (모델+마감별 단가 - 원/m)
['category' => '하단마감재', 'item_code' => 'KSS01', 'spec1' => 'SUS', 'unit_price' => 8000, 'unit' => 'm'],
['category' => '하단마감재', 'item_code' => 'KSS01', 'spec1' => 'EGI', 'unit_price' => 6500, 'unit' => 'm'],
['category' => '하단마감재', 'item_code' => 'KWS01', 'spec1' => 'SUS', 'unit_price' => 8500, 'unit' => 'm'],
// L-BAR (모델별 단가 - 원/m)
['category' => 'L-BAR', 'item_code' => 'KSS01', 'unit_price' => 4500, 'unit' => 'm'],
['category' => 'L-BAR', 'item_code' => 'KWS01', 'unit_price' => 5000, 'unit' => 'm'],
// 보강평철 (공통 단가 - 원/m)
['category' => '보강평철', 'unit_price' => 3000, 'unit' => 'm'],
];
foreach ($bdmodelsData as $data) {
$itemCode = $data['item_code'] ?? null;
$spec1 = $data['spec1'] ?? null;
$spec2 = $data['spec2'] ?? null;
$itemName = $data['category'];
if ($itemCode) {
$itemName .= " {$itemCode}";
}
if ($spec1) {
$itemName .= " {$spec1}";
}
if ($spec2) {
$itemName .= " {$spec2}";
}
KdPriceTable::create([
'tenant_id' => self::TENANT_ID,
'table_type' => KdPriceTable::TYPE_BDMODELS,
'item_code' => $itemCode,
'item_name' => $itemName,
'category' => $data['category'],
'spec1' => $spec1,
'spec2' => $spec2,
'unit_price' => $data['unit_price'],
'unit' => $data['unit'],
'is_active' => true,
]);
}
$this->command->info(' → BDmodels(절곡품) '.count($bdmodelsData).'건');
}
}

View File

@@ -0,0 +1,911 @@
<?php
namespace Database\Seeders\Kyungdong;
use Database\Seeders\DummyDataSeeder;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
/**
* 경동기업 품목/단가 마이그레이션 Seeder
*
* Phase 1.0: chandj.KDunitprice (601건) → items, prices
* Phase 1.1: chandj.models (18건) → items (FG), prices
* Phase 1.2: chandj.item_list (9건) → items (PT), prices
* Phase 2.1: chandj.BDmodels.seconditem → items (PT) 누락 부품 추가
* Phase 2.2: chandj.BDmodels → items.bom JSON (FG ↔ PT 연결)
* Phase 3.1: chandj.price_motor → items (SM) + prices 누락 품목
* Phase 3.2: chandj.price_raw_materials → items (RM) + prices 누락 품목
*
* @see docs/plans/kd-items-migration-plan.md
*/
class KyungdongItemSeeder extends Seeder
{
/**
* item_div → item_type 매핑
*/
private const ITEM_TYPE_MAP = [
'[제품]' => 'FG',
'[상품]' => 'FG',
'[반제품]' => 'PT',
'[부재료]' => 'SM',
'[원재료]' => 'RM',
'[무형상품]' => 'CS',
];
/**
* finishing_type 약어 매핑
*/
private const FINISHING_MAP = [
'SUS마감' => 'SUS',
'EGI마감' => 'EGI',
];
/**
* 경동기업 품목/단가 마이그레이션 실행
*/
public function run(): void
{
$tenantId = DummyDataSeeder::TENANT_ID;
$userId = DummyDataSeeder::USER_ID;
$this->command->info('🚀 경동기업 품목/단가 마이그레이션 시작...');
$this->command->info(" 대상 테넌트: ID {$tenantId}");
// 1. 기존 데이터 삭제
$this->cleanupExistingData($tenantId);
// Phase 1.0: KDunitprice → items
$itemCount = $this->migrateItems($tenantId, $userId);
// Phase 1.1: models → items (FG)
$modelCount = $this->migrateModels($tenantId, $userId);
// Phase 1.2: item_list → items (PT)
$itemListCount = $this->migrateItemList($tenantId, $userId);
// Phase 2.1: BDmodels.seconditem → items (PT) 누락 부품
$bdPartsCount = $this->migrateBDmodelsParts($tenantId, $userId);
// prices 생성 (모든 items 기반)
$priceCount = $this->migratePrices($tenantId, $userId);
// Phase 2.2: BDmodels → items.bom JSON
$bomCount = $this->migrateBom($tenantId);
// Phase 3.1: price_motor → items (SM) + prices
$motorResult = $this->migratePriceMotor($tenantId, $userId);
// Phase 3.2: price_raw_materials → items (RM) + prices
$rawMatResult = $this->migratePriceRawMaterials($tenantId, $userId);
$totalItems = $itemCount + $modelCount + $itemListCount + $bdPartsCount + $motorResult['items'] + $rawMatResult['items'];
$totalPrices = $priceCount + $motorResult['prices'] + $rawMatResult['prices'];
$this->command->info('');
$this->command->info('✅ 마이그레이션 완료:');
$this->command->info(" → items: {$totalItems}");
$this->command->info(" - KDunitprice: {$itemCount}");
$this->command->info(" - models: {$modelCount}");
$this->command->info(" - item_list: {$itemListCount}");
$this->command->info(" - BDmodels부품: {$bdPartsCount}");
$this->command->info(" - price_motor: {$motorResult['items']}");
$this->command->info(" - price_raw_materials: {$rawMatResult['items']}");
$this->command->info(" → prices: {$totalPrices}");
$this->command->info(" → BOM 연결: {$bomCount}");
}
/**
* 기존 데이터 삭제 (tenant_id 기준)
*/
private function cleanupExistingData(int $tenantId): void
{
$this->command->info('');
$this->command->info('🧹 기존 데이터 삭제 중...');
// prices 먼저 삭제 (FK 관계)
$priceCount = DB::table('prices')->where('tenant_id', $tenantId)->count();
DB::table('prices')->where('tenant_id', $tenantId)->delete();
$this->command->info(" → prices: {$priceCount}건 삭제");
// items 삭제
$itemCount = DB::table('items')->where('tenant_id', $tenantId)->count();
DB::table('items')->where('tenant_id', $tenantId)->delete();
$this->command->info(" → items: {$itemCount}건 삭제");
}
/**
* KDunitprice → items 마이그레이션
*/
private function migrateItems(int $tenantId, int $userId): int
{
$this->command->info('');
$this->command->info('📦 KDunitprice → items 마이그레이션...');
// chandj.KDunitprice에서 데이터 조회 (is_deleted=NULL이 활성 상태)
$kdItems = DB::connection('chandj')
->table('KDunitprice')
->whereNull('is_deleted')
->whereNotNull('prodcode')
->where('prodcode', '!=', '')
->get();
$this->command->info(" → 소스 데이터: {$kdItems->count()}");
$items = [];
$now = now();
$batchCount = 0;
foreach ($kdItems as $kd) {
$items[] = [
'tenant_id' => $tenantId,
'item_type' => $this->mapItemType($kd->item_div),
'code' => $kd->prodcode,
'name' => $kd->item_name,
'unit' => $kd->unit,
'category_id' => null,
'process_type' => null,
'item_category' => null,
'bom' => null,
'attributes' => json_encode([
'spec' => $kd->spec,
'item_div' => $kd->item_div,
'legacy_source' => 'KDunitprice',
'legacy_num' => $kd->num,
]),
'attributes_archive' => null,
'options' => null,
'description' => null,
'is_active' => true,
'created_by' => $userId,
'updated_by' => $userId,
'created_at' => $now,
'updated_at' => $now,
];
// 500건씩 배치 INSERT
if (count($items) >= 500) {
DB::table('items')->insert($items);
$batchCount += count($items);
$this->command->info("{$batchCount}건 완료...");
$items = [];
}
}
// 남은 데이터 INSERT
if (! empty($items)) {
DB::table('items')->insert($items);
$batchCount += count($items);
}
$this->command->info(" ✓ items: {$batchCount}건 생성 완료");
return $batchCount;
}
/**
* items 기반 → prices 마이그레이션
*/
private function migratePrices(int $tenantId, int $userId): int
{
$this->command->info('');
$this->command->info('💰 items → prices 마이그레이션...');
// 생성된 items 조회
$items = DB::table('items')
->where('tenant_id', $tenantId)
->get(['id', 'code', 'item_type', 'attributes']);
// KDunitprice 단가 (code → unitprice)
$kdPrices = DB::connection('chandj')
->table('KDunitprice')
->whereNull('is_deleted')
->whereNotNull('prodcode')
->where('prodcode', '!=', '')
->pluck('unitprice', 'prodcode');
// item_list 단가 (item_name → col13)
$itemListPrices = DB::connection('chandj')
->table('item_list')
->pluck('col13', 'item_name');
$prices = [];
$now = now();
$batchCount = 0;
foreach ($items as $item) {
$attributes = json_decode($item->attributes, true) ?? [];
$legacySource = $attributes['legacy_source'] ?? '';
// 소스별 단가 결정
$unitPrice = match ($legacySource) {
'KDunitprice' => $kdPrices[$item->code] ?? 0,
'item_list' => $itemListPrices[$attributes['legacy_num'] ? $this->getItemListName($item->code) : ''] ?? $attributes['base_price'] ?? 0,
'models' => 0, // models는 단가 없음
default => 0,
};
// item_list의 경우 attributes에 저장된 base_price 사용
if ($legacySource === 'item_list' && isset($attributes['base_price'])) {
$unitPrice = $attributes['base_price'];
}
$prices[] = [
'tenant_id' => $tenantId,
'item_type_code' => $item->item_type,
'item_id' => $item->id,
'client_group_id' => null,
'purchase_price' => 0,
'processing_cost' => null,
'loss_rate' => null,
'margin_rate' => null,
'sales_price' => $unitPrice,
'rounding_rule' => 'round',
'rounding_unit' => 1,
'supplier' => null,
'effective_from' => now()->toDateString(),
'effective_to' => null,
'note' => "{$legacySource} 마이그레이션",
'status' => 'active',
'is_final' => false,
'created_by' => $userId,
'updated_by' => $userId,
'created_at' => $now,
'updated_at' => $now,
];
// 500건씩 배치 INSERT
if (count($prices) >= 500) {
DB::table('prices')->insert($prices);
$batchCount += count($prices);
$this->command->info("{$batchCount}건 완료...");
$prices = [];
}
}
// 남은 데이터 INSERT
if (! empty($prices)) {
DB::table('prices')->insert($prices);
$batchCount += count($prices);
}
$this->command->info(" ✓ prices: {$batchCount}건 생성 완료");
return $batchCount;
}
/**
* PT-{name} 코드에서 name 추출
*/
private function getItemListName(string $code): string
{
return str_starts_with($code, 'PT-') ? substr($code, 3) : '';
}
/**
* Phase 1.1: models → items (FG) 마이그레이션
*/
private function migrateModels(int $tenantId, int $userId): int
{
$this->command->info('');
$this->command->info('📦 [Phase 1.1] models → items (FG) 마이그레이션...');
$models = DB::connection('chandj')
->table('models')
->where(function ($q) {
$q->where('is_deleted', 0)->orWhereNull('is_deleted');
})
->get();
$this->command->info(" → 소스 데이터: {$models->count()}");
$items = [];
$now = now();
foreach ($models as $model) {
$finishingShort = self::FINISHING_MAP[$model->finishing_type] ?? 'STD';
$code = "FG-{$model->model_name}-{$model->guiderail_type}-{$finishingShort}";
$name = "{$model->model_name} {$model->major_category} {$model->finishing_type} {$model->guiderail_type}";
$items[] = [
'tenant_id' => $tenantId,
'item_type' => 'FG',
'code' => $code,
'name' => trim($name),
'unit' => 'EA',
'category_id' => null,
'process_type' => null,
'item_category' => $model->major_category,
'bom' => null,
'attributes' => json_encode([
'model_name' => $model->model_name,
'major_category' => $model->major_category,
'finishing_type' => $model->finishing_type,
'guiderail_type' => $model->guiderail_type,
'legacy_source' => 'models',
'legacy_model_id' => $model->model_id,
]),
'attributes_archive' => null,
'options' => null,
'description' => null,
'is_active' => true,
'created_by' => $userId,
'updated_by' => $userId,
'created_at' => $now,
'updated_at' => $now,
];
}
if (! empty($items)) {
DB::table('items')->insert($items);
}
$this->command->info(" ✓ items (FG): {$models->count()}건 생성 완료");
return $models->count();
}
/**
* Phase 1.2: item_list → items (PT) 마이그레이션
*/
private function migrateItemList(int $tenantId, int $userId): int
{
$this->command->info('');
$this->command->info('📦 [Phase 1.2] item_list → items (PT) 마이그레이션...');
$itemList = DB::connection('chandj')
->table('item_list')
->get();
$this->command->info(" → 소스 데이터: {$itemList->count()}");
$items = [];
$now = now();
foreach ($itemList as $item) {
$code = "PT-{$item->item_name}";
$items[] = [
'tenant_id' => $tenantId,
'item_type' => 'PT',
'code' => $code,
'name' => $item->item_name,
'unit' => 'EA',
'category_id' => null,
'process_type' => null,
'item_category' => null,
'bom' => null,
'attributes' => json_encode([
'base_price' => $item->col13,
'legacy_source' => 'item_list',
'legacy_num' => $item->num,
]),
'attributes_archive' => null,
'options' => null,
'description' => null,
'is_active' => true,
'created_by' => $userId,
'updated_by' => $userId,
'created_at' => $now,
'updated_at' => $now,
];
}
if (! empty($items)) {
DB::table('items')->insert($items);
}
$this->command->info(" ✓ items (PT): {$itemList->count()}건 생성 완료");
return $itemList->count();
}
/**
* item_div → item_type 매핑
*/
private function mapItemType(?string $itemDiv): string
{
return self::ITEM_TYPE_MAP[$itemDiv] ?? 'SM';
}
/**
* Phase 2.1: BDmodels.seconditem → items (PT) 누락 부품 추가
*
* item_list에 없는 BDmodels.seconditem을 PT items로 생성
*/
private function migrateBDmodelsParts(int $tenantId, int $userId): int
{
$this->command->info('');
$this->command->info('📦 [Phase 2.1] BDmodels.seconditem → items (PT) 누락 부품...');
// BDmodels에서 고유한 seconditem 목록 조회
$bdSecondItems = DB::connection('chandj')
->table('BDmodels')
->where(function ($q) {
$q->where('is_deleted', 0)->orWhereNull('is_deleted');
})
->whereNotNull('seconditem')
->where('seconditem', '!=', '')
->distinct()
->pluck('seconditem');
// 이미 존재하는 PT items 코드 조회
$existingPtCodes = DB::table('items')
->where('tenant_id', $tenantId)
->where('item_type', 'PT')
->pluck('code')
->map(fn ($code) => str_starts_with($code, 'PT-') ? substr($code, 3) : $code)
->toArray();
$items = [];
$now = now();
foreach ($bdSecondItems as $secondItem) {
// 이미 PT items에 있으면 스킵
if (in_array($secondItem, $existingPtCodes)) {
continue;
}
$code = "PT-{$secondItem}";
$items[] = [
'tenant_id' => $tenantId,
'item_type' => 'PT',
'code' => $code,
'name' => $secondItem,
'unit' => 'EA',
'category_id' => null,
'process_type' => null,
'item_category' => null,
'bom' => null,
'attributes' => json_encode([
'legacy_source' => 'BDmodels_seconditem',
]),
'attributes_archive' => null,
'options' => null,
'description' => null,
'is_active' => true,
'created_by' => $userId,
'updated_by' => $userId,
'created_at' => $now,
'updated_at' => $now,
];
}
if (! empty($items)) {
DB::table('items')->insert($items);
}
$this->command->info(" → 소스 데이터: {$bdSecondItems->count()}건 (중복 제외 ".count($items).'건 신규)');
$this->command->info(' ✓ items (PT): '.count($items).'건 생성 완료');
return count($items);
}
/**
* Phase 2.2: BDmodels → items.bom JSON (FG ↔ PT 연결)
*
* models 기반 FG items에 BOM 연결
* bom: [{child_item_id: X, quantity: Y}, ...]
*/
private function migrateBom(int $tenantId): int
{
$this->command->info('');
$this->command->info('🔗 [Phase 2.2] BDmodels → items.bom JSON 연결...');
// PT items 조회 (code → id 매핑)
$ptItems = DB::table('items')
->where('tenant_id', $tenantId)
->where('item_type', 'PT')
->pluck('id', 'code')
->toArray();
// PT- prefix 없는 버전도 매핑 추가
$ptItemsByName = [];
foreach ($ptItems as $code => $id) {
$name = str_starts_with($code, 'PT-') ? substr($code, 3) : $code;
$ptItemsByName[$name] = $id;
}
// FG items 조회 (models 기반)
$fgItems = DB::table('items')
->where('tenant_id', $tenantId)
->where('item_type', 'FG')
->whereNotNull('attributes')
->get(['id', 'code', 'attributes']);
// BDmodels 데이터 조회
$bdModels = DB::connection('chandj')
->table('BDmodels')
->where(function ($q) {
$q->where('is_deleted', 0)->orWhereNull('is_deleted');
})
->whereNotNull('model_name')
->where('model_name', '!=', '')
->get(['model_name', 'seconditem', 'savejson']);
// model_name → seconditems 그룹핑
$modelBomMap = [];
foreach ($bdModels as $bd) {
if (empty($bd->seconditem)) {
continue;
}
$modelName = $bd->model_name;
if (! isset($modelBomMap[$modelName])) {
$modelBomMap[$modelName] = [];
}
// savejson에서 수량 파싱 (col8이 수량)
$quantity = 1;
if (! empty($bd->savejson)) {
$json = json_decode($bd->savejson, true);
if (is_array($json) && ! empty($json)) {
// 첫 번째 항목의 col8(수량) 사용
$quantity = (int) ($json[0]['col8'] ?? 1);
}
}
// 중복 체크 후 추가
$found = false;
foreach ($modelBomMap[$modelName] as &$existing) {
if ($existing['seconditem'] === $bd->seconditem) {
$found = true;
break;
}
}
if (! $found) {
$modelBomMap[$modelName][] = [
'seconditem' => $bd->seconditem,
'quantity' => $quantity,
];
}
}
$updatedCount = 0;
foreach ($fgItems as $fgItem) {
$attributes = json_decode($fgItem->attributes, true) ?? [];
$modelName = $attributes['model_name'] ?? null;
if (empty($modelName) || ! isset($modelBomMap[$modelName])) {
continue;
}
$bomArray = [];
foreach ($modelBomMap[$modelName] as $bomItem) {
$childItemId = $ptItemsByName[$bomItem['seconditem']] ?? null;
if ($childItemId) {
$bomArray[] = [
'child_item_id' => $childItemId,
'quantity' => $bomItem['quantity'],
];
}
}
if (! empty($bomArray)) {
DB::table('items')
->where('id', $fgItem->id)
->update(['bom' => json_encode($bomArray)]);
$updatedCount++;
}
}
$this->command->info(' → BDmodels 모델: '.count($modelBomMap).'개');
$this->command->info(" ✓ items.bom 연결: {$updatedCount}건 완료");
return $updatedCount;
}
/**
* Phase 3.1: price_motor → items (SM) + prices
*
* price_motor JSON에서 누락된 품목만 추가
* - 제어기, 방화/방범 콘트롤박스, 스위치, 리모콘 등
*
* @return array{items: int, prices: int}
*/
private function migratePriceMotor(int $tenantId, int $userId): array
{
$this->command->info('');
$this->command->info('📦 [Phase 3.1] price_motor → items (SM) 누락 품목...');
// 최신 price_motor 데이터 조회
$priceMotor = DB::connection('chandj')
->table('price_motor')
->where(function ($q) {
$q->where('is_deleted', 0)->orWhereNull('is_deleted');
})
->orderByDesc('registedate')
->first();
if (! $priceMotor || empty($priceMotor->itemList)) {
$this->command->info(' → 소스 데이터 없음');
return ['items' => 0, 'prices' => 0];
}
$itemList = json_decode($priceMotor->itemList, true);
if (! is_array($itemList)) {
$this->command->info(' → JSON 파싱 실패');
return ['items' => 0, 'prices' => 0];
}
// 기존 items 이름 조회 (중복 체크용)
$existingNames = DB::table('items')
->where('tenant_id', $tenantId)
->pluck('name')
->map(fn ($n) => mb_strtolower($n))
->toArray();
$items = [];
$now = now();
$newItemCodes = [];
foreach ($itemList as $idx => $item) {
$col1 = $item['col1'] ?? ''; // 전압/카테고리 (220, 380, 제어기, 방화, 방범)
$col2 = $item['col2'] ?? ''; // 용량/품목명
$salesPrice = (float) str_replace(',', '', $item['col13'] ?? '0');
// 모터 품목은 KDunitprice에 이미 있으므로 스킵
if (in_array($col1, ['220', '380'])) {
continue;
}
// 품목명 생성
$name = trim("{$col1} {$col2}");
if (empty($name) || $name === ' ') {
continue;
}
// 이미 존재하는 품목 스킵 (유사 이름 체크)
$nameLower = mb_strtolower($name);
$exists = false;
foreach ($existingNames as $existingName) {
if (str_contains($existingName, $nameLower) || str_contains($nameLower, $existingName)) {
$exists = true;
break;
}
}
if ($exists) {
continue;
}
// 코드 생성
$code = 'PM-'.str_pad($idx + 1, 3, '0', STR_PAD_LEFT);
$items[] = [
'tenant_id' => $tenantId,
'item_type' => 'SM',
'code' => $code,
'name' => $name,
'unit' => 'EA',
'category_id' => null,
'process_type' => null,
'item_category' => null,
'bom' => null,
'attributes' => json_encode([
'price_category' => $col1,
'price_spec' => $col2,
'legacy_source' => 'price_motor',
]),
'attributes_archive' => null,
'options' => null,
'description' => null,
'is_active' => true,
'created_by' => $userId,
'updated_by' => $userId,
'created_at' => $now,
'updated_at' => $now,
];
$newItemCodes[$code] = $salesPrice;
$existingNames[] = $nameLower; // 중복 방지
}
if (! empty($items)) {
DB::table('items')->insert($items);
}
// prices 생성
$priceCount = 0;
if (! empty($newItemCodes)) {
$newItems = DB::table('items')
->where('tenant_id', $tenantId)
->whereIn('code', array_keys($newItemCodes))
->get(['id', 'code', 'item_type']);
$prices = [];
foreach ($newItems as $item) {
$prices[] = [
'tenant_id' => $tenantId,
'item_type_code' => $item->item_type,
'item_id' => $item->id,
'client_group_id' => null,
'purchase_price' => 0,
'processing_cost' => null,
'loss_rate' => null,
'margin_rate' => null,
'sales_price' => $newItemCodes[$item->code],
'rounding_rule' => 'round',
'rounding_unit' => 1,
'supplier' => null,
'effective_from' => $priceMotor->registedate ?? now()->toDateString(),
'effective_to' => null,
'note' => 'price_motor 마이그레이션',
'status' => 'active',
'is_final' => false,
'created_by' => $userId,
'updated_by' => $userId,
'created_at' => $now,
'updated_at' => $now,
];
}
if (! empty($prices)) {
DB::table('prices')->insert($prices);
$priceCount = count($prices);
}
}
$this->command->info(' → 소스 데이터: '.count($itemList).'건 (누락 '.count($items).'건 추가)');
$this->command->info(' ✓ items: '.count($items).'건, prices: '.$priceCount.'건 생성 완료');
return ['items' => count($items), 'prices' => $priceCount];
}
/**
* Phase 3.2: price_raw_materials → items (RM) + prices
*
* price_raw_materials JSON에서 누락된 원자재 품목 추가
*
* @return array{items: int, prices: int}
*/
private function migratePriceRawMaterials(int $tenantId, int $userId): array
{
$this->command->info('');
$this->command->info('📦 [Phase 3.2] price_raw_materials → items (RM) 누락 품목...');
// 최신 price_raw_materials 데이터 조회
$priceRaw = DB::connection('chandj')
->table('price_raw_materials')
->where(function ($q) {
$q->where('is_deleted', 0)->orWhereNull('is_deleted');
})
->orderByDesc('registedate')
->first();
if (! $priceRaw || empty($priceRaw->itemList)) {
$this->command->info(' → 소스 데이터 없음');
return ['items' => 0, 'prices' => 0];
}
$itemList = json_decode($priceRaw->itemList, true);
if (! is_array($itemList)) {
$this->command->info(' → JSON 파싱 실패');
return ['items' => 0, 'prices' => 0];
}
// 기존 items 이름 조회 (중복 체크용)
$existingNames = DB::table('items')
->where('tenant_id', $tenantId)
->pluck('name')
->map(fn ($n) => mb_strtolower($n))
->toArray();
$items = [];
$now = now();
$newItemCodes = [];
foreach ($itemList as $idx => $item) {
$col1 = $item['col1'] ?? ''; // 카테고리 (슬랫, 스크린)
$col2 = $item['col2'] ?? ''; // 품목명 (방화, 실리카, 화이바)
$salesPrice = (float) str_replace(',', '', $item['col13'] ?? '0');
// 품목명 생성
$name = trim("{$col1} {$col2}");
if (empty($name) || $name === ' ') {
continue;
}
// 이미 존재하는 품목 스킵
$nameLower = mb_strtolower($name);
$exists = false;
foreach ($existingNames as $existingName) {
// 정확히 일치하거나 유사한 이름 체크
$col2Lower = mb_strtolower($col2);
if (str_contains($existingName, $col2Lower) || $existingName === $nameLower) {
$exists = true;
break;
}
}
if ($exists) {
continue;
}
// 코드 생성
$code = 'RM-'.str_pad($idx + 1, 3, '0', STR_PAD_LEFT);
$items[] = [
'tenant_id' => $tenantId,
'item_type' => 'RM',
'code' => $code,
'name' => $name,
'unit' => 'EA',
'category_id' => null,
'process_type' => null,
'item_category' => $col1,
'bom' => null,
'attributes' => json_encode([
'raw_category' => $col1,
'raw_name' => $col2,
'legacy_source' => 'price_raw_materials',
]),
'attributes_archive' => null,
'options' => null,
'description' => null,
'is_active' => true,
'created_by' => $userId,
'updated_by' => $userId,
'created_at' => $now,
'updated_at' => $now,
];
$newItemCodes[$code] = $salesPrice;
$existingNames[] = $nameLower;
}
if (! empty($items)) {
DB::table('items')->insert($items);
}
// prices 생성
$priceCount = 0;
if (! empty($newItemCodes)) {
$newItems = DB::table('items')
->where('tenant_id', $tenantId)
->whereIn('code', array_keys($newItemCodes))
->get(['id', 'code', 'item_type']);
$prices = [];
foreach ($newItems as $item) {
$prices[] = [
'tenant_id' => $tenantId,
'item_type_code' => $item->item_type,
'item_id' => $item->id,
'client_group_id' => null,
'purchase_price' => 0,
'processing_cost' => null,
'loss_rate' => null,
'margin_rate' => null,
'sales_price' => $newItemCodes[$item->code],
'rounding_rule' => 'round',
'rounding_unit' => 1,
'supplier' => null,
'effective_from' => $priceRaw->registedate ?? now()->toDateString(),
'effective_to' => null,
'note' => 'price_raw_materials 마이그레이션',
'status' => 'active',
'is_final' => false,
'created_by' => $userId,
'updated_by' => $userId,
'created_at' => $now,
'updated_at' => $now,
];
}
if (! empty($prices)) {
DB::table('prices')->insert($prices);
$priceCount = count($prices);
}
}
$this->command->info(' → 소스 데이터: '.count($itemList).'건 (누락 '.count($items).'건 추가)');
$this->command->info(' ✓ items: '.count($items).'건, prices: '.$priceCount.'건 생성 완료');
return ['items' => count($items), 'prices' => $priceCount];
}
}

View File

@@ -0,0 +1,131 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
/**
* 견적 품목 카테고리 시더
*
* 상위 카테고리 추가 및 기존 세부 항목 연결
* - 본체, 절곡품(>가이드레일,케이스,하단마감재), 모터&제어기, 부자재
*/
class QuoteItemCategorySeeder extends Seeder
{
public function run(): void
{
$tenantId = 1;
$codeGroup = 'item_category';
$now = now();
// 1. 상위 카테고리 추가
$parentCategories = [
['code' => 'BODY', 'name' => '본체', 'sort_order' => 1],
['code' => 'BENDING', 'name' => '절곡품', 'sort_order' => 2],
['code' => 'MOTOR_CTRL', 'name' => '모터 & 제어기', 'sort_order' => 3],
['code' => 'ACCESSORY', 'name' => '부자재', 'sort_order' => 4],
];
$parentIds = [];
foreach ($parentCategories as $cat) {
// 이미 존재하면 업데이트, 없으면 생성
DB::table('categories')->updateOrInsert(
[
'tenant_id' => $tenantId,
'code_group' => $codeGroup,
'code' => $cat['code'],
],
[
'parent_id' => null,
'name' => $cat['name'],
'sort_order' => $cat['sort_order'],
'is_active' => true,
'updated_at' => $now,
'created_at' => $now,
]
);
// ID 조회
$parentIds[$cat['code']] = DB::table('categories')
->where('tenant_id', $tenantId)
->where('code_group', $codeGroup)
->where('code', $cat['code'])
->value('id');
}
// 2. 절곡품 하위 3개 중간 카테고리 추가
$bendingSubCategories = [
['code' => 'BENDING_GUIDE', 'name' => '가이드레일', 'sort_order' => 1],
['code' => 'BENDING_CASE', 'name' => '케이스', 'sort_order' => 2],
['code' => 'BENDING_BOTTOM', 'name' => '하단마감재', 'sort_order' => 3],
];
$bendingSubIds = [];
foreach ($bendingSubCategories as $cat) {
// 이미 존재하면 업데이트, 없으면 생성
DB::table('categories')->updateOrInsert(
[
'tenant_id' => $tenantId,
'code_group' => $codeGroup,
'code' => $cat['code'],
],
[
'parent_id' => $parentIds['BENDING'],
'name' => $cat['name'],
'sort_order' => $cat['sort_order'],
'is_active' => true,
'updated_at' => $now,
'created_at' => $now,
]
);
// ID 조회
$bendingSubIds[$cat['code']] = DB::table('categories')
->where('tenant_id', $tenantId)
->where('code_group', $codeGroup)
->where('code', $cat['code'])
->value('id');
}
// 3. 기존 세부 항목들의 parent_id 업데이트
$mappings = [
// 본체
'BODY' => ['SILICA_BODY', 'WIRE_BODY', 'FIBER_BODY', 'COLUMNLESS_BODY', 'SLAT_BODY'],
// 절곡품 > 가이드레일
'BENDING_GUIDE' => ['GUIDE_RAIL', 'SMOKE_SEAL'],
// 절곡품 > 케이스
'BENDING_CASE' => ['SHUTTER_BOX', 'TOP_COVER', 'END_PLATE'],
// 절곡품 > 하단마감재
'BENDING_BOTTOM' => ['BOTTOM_TRIM', 'HAJANG_BAR', 'SPECIAL_TRIM', 'FLOOR_CUT_PLATE'],
// 모터 & 제어기
'MOTOR_CTRL' => ['MOTOR_SET', 'INTERLOCK_CTRL', 'EMBED_BACK_BOX'],
// 부자재
'ACCESSORY' => ['JOINT_BAR', 'SQUARE_PIPE', 'WINDING_SHAFT', 'ANGLE', 'ROUND_BAR', 'L_BAR', 'REINF_FLAT_BAR', 'WEIGHT_FLAT_BAR'],
];
foreach ($mappings as $parentCode => $childCodes) {
// 상위 카테고리 ID 찾기
$parentId = $parentIds[$parentCode] ?? $bendingSubIds[$parentCode] ?? null;
if ($parentId) {
DB::table('categories')
->where('tenant_id', $tenantId)
->where('code_group', $codeGroup)
->whereIn('code', $childCodes)
->whereNull('deleted_at')
->update(['parent_id' => $parentId, 'updated_at' => $now]);
}
}
$this->command->info('견적 품목 카테고리 시딩 완료!');
$this->command->info('- 상위 카테고리 4개 추가');
$this->command->info('- 절곡품 하위 카테고리 3개 추가');
$this->command->info('- 기존 세부 항목 parent_id 연결 완료');
}
}