feat: sam_stat P1 도메인 확장 (Phase 3)

- 차원 테이블: dim_client, dim_product 마이그레이션 + SCD Type 2 동기화 (DimensionSyncService)
- 재고 통계: stat_inventory_daily + InventoryStatService (stocks, stock_transactions, inspections)
- 견적/영업 통계: stat_quote_pipeline_daily + QuoteStatService (quotes, biddings, sales_prospects)
- 인사/근태 통계: stat_hr_attendance_daily + HrStatService (attendances, leaves, user_tenants)
- KPI/알림: stat_kpi_targets, stat_alerts + KpiAlertService + StatCheckKpiAlertsCommand
- StatAggregatorService에 inventory, quote, hr 도메인 추가 (총 6개 도메인)
- 스케줄러: stat:check-kpi-alerts 매일 09:00 등록

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-29 20:19:50 +09:00
parent 6c9735581d
commit 595e3d59b4
22 changed files with 1065 additions and 0 deletions

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