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,33 @@
<?php
namespace App\Console\Commands;
use App\Services\Stats\KpiAlertService;
use Illuminate\Console\Command;
class StatCheckKpiAlertsCommand extends Command
{
protected $signature = 'stat:check-kpi-alerts';
protected $description = 'KPI 목표 대비 실적을 체크하고 미달 시 알림을 생성합니다';
public function handle(KpiAlertService $service): int
{
$this->info('KPI 알림 체크 시작...');
$result = $service->checkKpiAlerts();
$this->info("알림 생성: {$result['alerts_created']}");
if (! empty($result['errors'])) {
$this->warn('오류 발생:');
foreach ($result['errors'] as $error) {
$this->error(" - {$error}");
}
}
$this->info('KPI 알림 체크 완료.');
return empty($result['errors']) ? self::SUCCESS : self::FAILURE;
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Models\Stats\Daily;
use App\Models\Stats\BaseStatModel;
class StatHrAttendanceDaily extends BaseStatModel
{
protected $table = 'stat_hr_attendance_daily';
protected $casts = [
'stat_date' => 'date',
'attendance_rate' => 'decimal:2',
'overtime_hours' => 'decimal:2',
'total_labor_cost' => 'decimal:2',
];
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Models\Stats\Daily;
use App\Models\Stats\BaseStatModel;
class StatInventoryDaily extends BaseStatModel
{
protected $table = 'stat_inventory_daily';
protected $casts = [
'stat_date' => 'date',
'total_stock_qty' => 'decimal:2',
'total_stock_value' => 'decimal:2',
'receipt_qty' => 'decimal:2',
'receipt_amount' => 'decimal:2',
'issue_qty' => 'decimal:2',
'issue_amount' => 'decimal:2',
'inspection_pass_rate' => 'decimal:2',
'turnover_rate' => 'decimal:2',
];
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Models\Stats\Daily;
use App\Models\Stats\BaseStatModel;
class StatQuotePipelineDaily extends BaseStatModel
{
protected $table = 'stat_quote_pipeline_daily';
protected $casts = [
'stat_date' => 'date',
'quote_amount' => 'decimal:2',
'quote_conversion_rate' => 'decimal:2',
'prospect_amount' => 'decimal:2',
'bidding_amount' => 'decimal:2',
];
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Models\Stats\Dimensions;
use App\Models\Stats\BaseStatModel;
class DimClient extends BaseStatModel
{
protected $table = 'dim_client';
public $timestamps = false;
protected $casts = [
'valid_from' => 'date',
'valid_to' => 'date',
'is_current' => 'boolean',
];
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Models\Stats\Dimensions;
use App\Models\Stats\BaseStatModel;
class DimProduct extends BaseStatModel
{
protected $table = 'dim_product';
public $timestamps = false;
protected $casts = [
'valid_from' => 'date',
'valid_to' => 'date',
'is_current' => 'boolean',
];
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Models\Stats;
class StatAlert extends BaseStatModel
{
protected $table = 'stat_alerts';
public $timestamps = false;
protected $casts = [
'current_value' => 'decimal:2',
'threshold_value' => 'decimal:2',
'is_read' => 'boolean',
'is_resolved' => 'boolean',
'resolved_at' => 'datetime',
'created_at' => 'datetime',
];
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Models\Stats;
class StatKpiTarget extends BaseStatModel
{
protected $table = 'stat_kpi_targets';
protected $casts = [
'target_value' => 'decimal:2',
];
}

View File

@@ -0,0 +1,163 @@
<?php
namespace App\Services\Stats;
use App\Models\Stats\Dimensions\DimClient;
use App\Models\Stats\Dimensions\DimProduct;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class DimensionSyncService
{
/**
* 고객 차원 동기화 (SCD Type 2)
*/
public function syncClients(int $tenantId): int
{
$today = Carbon::today()->format('Y-m-d');
$synced = 0;
$clients = DB::connection('mysql')
->table('clients')
->where('tenant_id', $tenantId)
->select('id', 'tenant_id', 'name', 'client_group_id', 'client_type')
->get();
foreach ($clients as $client) {
$groupName = null;
if ($client->client_group_id) {
$groupName = DB::connection('mysql')
->table('client_groups')
->where('id', $client->client_group_id)
->value('group_name');
}
$current = DimClient::where('tenant_id', $tenantId)
->where('client_id', $client->id)
->where('is_current', true)
->first();
if (! $current) {
DimClient::create([
'tenant_id' => $tenantId,
'client_id' => $client->id,
'client_name' => $client->name,
'client_group_id' => $client->client_group_id,
'client_group_name' => $groupName,
'client_type' => $client->client_type,
'region' => null,
'valid_from' => $today,
'valid_to' => null,
'is_current' => true,
]);
$synced++;
continue;
}
$changed = $current->client_name !== $client->name
|| $current->client_group_id != $client->client_group_id
|| $current->client_type !== $client->client_type;
if ($changed) {
$current->update([
'valid_to' => $today,
'is_current' => false,
]);
DimClient::create([
'tenant_id' => $tenantId,
'client_id' => $client->id,
'client_name' => $client->name,
'client_group_id' => $client->client_group_id,
'client_group_name' => $groupName,
'client_type' => $client->client_type,
'region' => null,
'valid_from' => $today,
'valid_to' => null,
'is_current' => true,
]);
$synced++;
}
}
return $synced;
}
/**
* 제품(품목) 차원 동기화 (SCD Type 2)
*/
public function syncProducts(int $tenantId): int
{
$today = Carbon::today()->format('Y-m-d');
$synced = 0;
$items = DB::connection('mysql')
->table('items')
->where('tenant_id', $tenantId)
->where('is_active', true)
->select('id', 'tenant_id', 'code', 'name', 'item_type', 'category_id')
->get();
foreach ($items as $item) {
$categoryName = null;
if ($item->category_id) {
$categoryName = DB::connection('mysql')
->table('categories')
->where('id', $item->category_id)
->value('name');
}
$current = DimProduct::where('tenant_id', $tenantId)
->where('item_id', $item->id)
->where('is_current', true)
->first();
if (! $current) {
DimProduct::create([
'tenant_id' => $tenantId,
'item_id' => $item->id,
'item_code' => $item->code,
'item_name' => $item->name,
'item_type' => $item->item_type,
'category_id' => $item->category_id,
'category_name' => $categoryName,
'valid_from' => $today,
'valid_to' => null,
'is_current' => true,
]);
$synced++;
continue;
}
$changed = $current->item_name !== $item->name
|| $current->item_code !== $item->code
|| $current->item_type !== $item->item_type
|| $current->category_id != $item->category_id;
if ($changed) {
$current->update([
'valid_to' => $today,
'is_current' => false,
]);
DimProduct::create([
'tenant_id' => $tenantId,
'item_id' => $item->id,
'item_code' => $item->code,
'item_name' => $item->name,
'item_type' => $item->item_type,
'category_id' => $item->category_id,
'category_name' => $categoryName,
'valid_from' => $today,
'valid_to' => null,
'is_current' => true,
]);
$synced++;
}
}
return $synced;
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Services\Stats;
use App\Models\Stats\Daily\StatHrAttendanceDaily;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class HrStatService implements StatDomainServiceInterface
{
public function aggregateDaily(int $tenantId, Carbon $date): int
{
$dateStr = $date->format('Y-m-d');
// 전체 직원 수 (tenant_user_profiles 기준)
$totalEmployees = DB::connection('mysql')
->table('user_tenants')
->where('tenant_id', $tenantId)
->where('is_active', true)
->count();
// 근태 (attendances)
$attendanceStats = DB::connection('mysql')
->table('attendances')
->where('tenant_id', $tenantId)
->where('base_date', $dateStr)
->whereNull('deleted_at')
->selectRaw("
COUNT(*) as total_count,
SUM(CASE WHEN status = 'onTime' THEN 1 ELSE 0 END) as on_time_count,
SUM(CASE WHEN status = 'late' THEN 1 ELSE 0 END) as late_count,
SUM(CASE WHEN status = 'absent' THEN 1 ELSE 0 END) as absent_count,
SUM(CASE WHEN status = 'overtime' THEN 1 ELSE 0 END) as overtime_count
")
->first();
$attendanceCount = ($attendanceStats->on_time_count ?? 0)
+ ($attendanceStats->late_count ?? 0)
+ ($attendanceStats->overtime_count ?? 0);
$attendanceRate = $totalEmployees > 0 ? ($attendanceCount / $totalEmployees) * 100 : 0;
// 휴가 (leaves)
$leaveStats = DB::connection('mysql')
->table('leaves')
->where('tenant_id', $tenantId)
->where('start_date', '<=', $dateStr)
->where('end_date', '>=', $dateStr)
->where('status', 'approved')
->whereNull('deleted_at')
->selectRaw("
COUNT(*) as total_count,
SUM(CASE WHEN leave_type = 'annual' THEN 1 ELSE 0 END) as annual_count,
SUM(CASE WHEN leave_type = 'sick' THEN 1 ELSE 0 END) as sick_count,
SUM(CASE WHEN leave_type NOT IN ('annual', 'sick') THEN 1 ELSE 0 END) as other_count
")
->first();
// 초과근무 (attendances status = 'overtime')
$overtimeCount = $attendanceStats->overtime_count ?? 0;
StatHrAttendanceDaily::updateOrCreate(
['tenant_id' => $tenantId, 'stat_date' => $dateStr],
[
'total_employees' => $totalEmployees,
'attendance_count' => $attendanceCount,
'late_count' => $attendanceStats->late_count ?? 0,
'absent_count' => $attendanceStats->absent_count ?? 0,
'attendance_rate' => $attendanceRate,
'leave_count' => $leaveStats->total_count ?? 0,
'leave_annual_count' => $leaveStats->annual_count ?? 0,
'leave_sick_count' => $leaveStats->sick_count ?? 0,
'leave_other_count' => $leaveStats->other_count ?? 0,
'overtime_hours' => 0, // attendances에 시간 정보 없음
'overtime_employee_count' => $overtimeCount,
'total_labor_cost' => 0, // 일간 인건비는 급여 정산 시 계산
]
);
return 1;
}
public function aggregateMonthly(int $tenantId, int $year, int $month): int
{
// 인사 도메인은 일간 테이블만 운영 (월간은 Phase 4에서 필요시 추가)
return 0;
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Services\Stats;
use App\Models\Stats\Daily\StatInventoryDaily;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class InventoryStatService implements StatDomainServiceInterface
{
public function aggregateDaily(int $tenantId, Carbon $date): int
{
$dateStr = $date->format('Y-m-d');
// 재고 현황 (stocks 테이블 - 현재 스냅샷)
$stockSummary = DB::connection('mysql')
->table('stocks')
->where('tenant_id', $tenantId)
->whereNull('deleted_at')
->selectRaw('
COUNT(*) as sku_count,
COALESCE(SUM(stock_qty), 0) as total_qty,
SUM(CASE WHEN stock_qty < safety_stock AND safety_stock > 0 THEN 1 ELSE 0 END) as below_safety,
SUM(CASE WHEN stock_qty = 0 THEN 1 ELSE 0 END) as zero_stock,
SUM(CASE WHEN stock_qty > safety_stock * 3 AND safety_stock > 0 THEN 1 ELSE 0 END) as excess_stock
')
->first();
// 입고 (stock_transactions type = 'receipt')
$receiptStats = DB::connection('mysql')
->table('stock_transactions')
->where('tenant_id', $tenantId)
->whereDate('created_at', $dateStr)
->where('type', 'receipt')
->selectRaw('COUNT(*) as cnt, COALESCE(SUM(qty), 0) as total_qty')
->first();
// 출고 (stock_transactions type = 'issue')
$issueStats = DB::connection('mysql')
->table('stock_transactions')
->where('tenant_id', $tenantId)
->whereDate('created_at', $dateStr)
->where('type', 'issue')
->selectRaw('COUNT(*) as cnt, COALESCE(SUM(ABS(qty)), 0) as total_qty')
->first();
// 품질검사 (inspections)
$inspectionStats = DB::connection('mysql')
->table('inspections')
->where('tenant_id', $tenantId)
->where('inspection_date', $dateStr)
->whereNull('deleted_at')
->selectRaw("
COUNT(*) as cnt,
SUM(CASE WHEN result = 'pass' THEN 1 ELSE 0 END) as pass_count,
SUM(CASE WHEN result = 'fail' THEN 1 ELSE 0 END) as fail_count
")
->first();
$inspectionCount = $inspectionStats->cnt ?? 0;
$passCount = $inspectionStats->pass_count ?? 0;
$failCount = $inspectionStats->fail_count ?? 0;
$passRate = $inspectionCount > 0 ? ($passCount / $inspectionCount) * 100 : 0;
StatInventoryDaily::updateOrCreate(
['tenant_id' => $tenantId, 'stat_date' => $dateStr],
[
'total_sku_count' => $stockSummary->sku_count ?? 0,
'total_stock_qty' => $stockSummary->total_qty ?? 0,
'total_stock_value' => 0, // 단가 정보 없어 Phase 4에서 보완
'receipt_count' => $receiptStats->cnt ?? 0,
'receipt_qty' => $receiptStats->total_qty ?? 0,
'receipt_amount' => 0,
'issue_count' => $issueStats->cnt ?? 0,
'issue_qty' => $issueStats->total_qty ?? 0,
'issue_amount' => 0,
'below_safety_count' => $stockSummary->below_safety ?? 0,
'zero_stock_count' => $stockSummary->zero_stock ?? 0,
'excess_stock_count' => $stockSummary->excess_stock ?? 0,
'inspection_count' => $inspectionCount,
'inspection_pass_count' => $passCount,
'inspection_fail_count' => $failCount,
'inspection_pass_rate' => $passRate,
'turnover_rate' => 0, // 월간 집계에서 계산
]
);
return 1;
}
public function aggregateMonthly(int $tenantId, int $year, int $month): int
{
// 재고 도메인은 일간 스냅샷 기반이므로 별도 월간 테이블 없음
// 필요시 Phase 4에서 stat_inventory_monthly 추가
return 0;
}
}

View File

@@ -0,0 +1,148 @@
<?php
namespace App\Services\Stats;
use App\Models\Stats\Daily\StatFinanceDaily;
use App\Models\Stats\Daily\StatSalesDaily;
use App\Models\Stats\Monthly\StatSalesMonthly;
use App\Models\Stats\StatAlert;
use App\Models\Stats\StatKpiTarget;
use App\Models\Tenants\Tenant;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
class KpiAlertService
{
/**
* KPI 목표 대비 실적 체크 → 알림 생성
*/
public function checkKpiAlerts(): array
{
$errors = [];
$alertsCreated = 0;
$tenants = Tenant::where('tenant_st_code', '!=', 'none')->get();
foreach ($tenants as $tenant) {
try {
$alertsCreated += $this->checkTenantKpi($tenant->id);
} catch (\Throwable $e) {
$errors[] = "tenant={$tenant->id}: {$e->getMessage()}";
Log::error('stat:check-kpi-alerts 실패', [
'tenant_id' => $tenant->id,
'error' => $e->getMessage(),
]);
}
}
return [
'alerts_created' => $alertsCreated,
'errors' => $errors,
];
}
private function checkTenantKpi(int $tenantId): int
{
$now = Carbon::now();
$year = $now->year;
$month = $now->month;
$alertsCreated = 0;
$targets = StatKpiTarget::where('tenant_id', $tenantId)
->where('stat_year', $year)
->where(function ($q) use ($month) {
$q->where('stat_month', $month)->orWhereNull('stat_month');
})
->get();
foreach ($targets as $target) {
$currentValue = $this->getCurrentValue($tenantId, $target->domain, $target->metric_code, $year, $month);
if ($currentValue === null) {
continue;
}
$ratio = $target->target_value > 0
? ($currentValue / $target->target_value) * 100
: 0;
if ($ratio < 50) {
$severity = 'critical';
} elseif ($ratio < 80) {
$severity = 'warning';
} else {
continue; // 80% 이상이면 알림 불필요
}
StatAlert::create([
'tenant_id' => $tenantId,
'domain' => $target->domain,
'alert_type' => 'below_target',
'severity' => $severity,
'title' => "{$target->description} 목표 미달 (".number_format($ratio, 0).'%)',
'message' => "KPI '{$target->metric_code}' 현재값: {$currentValue}, 목표값: {$target->target_value} ({$target->unit})",
'metric_code' => $target->metric_code,
'current_value' => $currentValue,
'threshold_value' => $target->target_value,
'created_at' => now(),
]);
$alertsCreated++;
}
return $alertsCreated;
}
private function getCurrentValue(int $tenantId, string $domain, string $metricCode, int $year, int $month): ?float
{
return match ($domain) {
'sales' => $this->getSalesMetric($tenantId, $metricCode, $year, $month),
'finance' => $this->getFinanceMetric($tenantId, $metricCode, $year, $month),
default => null,
};
}
private function getSalesMetric(int $tenantId, string $metricCode, int $year, int $month): ?float
{
$monthly = StatSalesMonthly::where('tenant_id', $tenantId)
->where('stat_year', $year)
->where('stat_month', $month)
->first();
if (! $monthly) {
// 월간 미집계 시 일간 합산
$daily = StatSalesDaily::where('tenant_id', $tenantId)
->whereYear('stat_date', $year)
->whereMonth('stat_date', $month)
->selectRaw('SUM(order_amount) as order_total, SUM(sales_amount) as sales_total')
->first();
return match ($metricCode) {
'monthly_order_amount' => (float) ($daily->order_total ?? 0),
'monthly_sales_amount' => (float) ($daily->sales_total ?? 0),
default => null,
};
}
return match ($metricCode) {
'monthly_order_amount' => (float) $monthly->order_amount,
'monthly_sales_amount' => (float) $monthly->sales_amount,
'monthly_order_count' => (float) $monthly->order_count,
default => null,
};
}
private function getFinanceMetric(int $tenantId, string $metricCode, int $year, int $month): ?float
{
$daily = StatFinanceDaily::where('tenant_id', $tenantId)
->whereYear('stat_date', $year)
->whereMonth('stat_date', $month)
->selectRaw('SUM(deposit_amount) as deposit_total, SUM(withdrawal_amount) as withdrawal_total')
->first();
return match ($metricCode) {
'monthly_deposit_amount' => (float) ($daily->deposit_total ?? 0),
'monthly_withdrawal_amount' => (float) ($daily->withdrawal_total ?? 0),
default => null,
};
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Services\Stats;
use App\Models\Stats\Daily\StatQuotePipelineDaily;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
class QuoteStatService implements StatDomainServiceInterface
{
public function aggregateDaily(int $tenantId, Carbon $date): int
{
$dateStr = $date->format('Y-m-d');
// 견적 (quotes)
$quoteStats = DB::connection('mysql')
->table('quotes')
->where('tenant_id', $tenantId)
->where('registration_date', $dateStr)
->whereNull('deleted_at')
->selectRaw("
COUNT(*) as created_count,
COALESCE(SUM(total_amount), 0) as total_amount,
SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) as approved_count,
SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) as rejected_count,
SUM(CASE WHEN status = 'converted' THEN 1 ELSE 0 END) as conversion_count
")
->first();
$createdCount = $quoteStats->created_count ?? 0;
$conversionCount = $quoteStats->conversion_count ?? 0;
$conversionRate = $createdCount > 0 ? ($conversionCount / $createdCount) * 100 : 0;
// 입찰 (biddings)
$biddingStats = DB::connection('mysql')
->table('biddings')
->where('tenant_id', $tenantId)
->where('bidding_date', $dateStr)
->whereNull('deleted_at')
->selectRaw("
COUNT(*) as cnt,
SUM(CASE WHEN status = 'won' THEN 1 ELSE 0 END) as won_count,
COALESCE(SUM(bidding_amount), 0) as total_amount
")
->first();
// 상담 (sales_prospect_consultations)
$consultationCount = DB::connection('mysql')
->table('sales_prospect_consultations')
->whereDate('created_at', $dateStr)
->count();
// 영업 기회 (sales_prospects - tenant_id 없음, created_at 기반)
$prospectStats = DB::connection('mysql')
->table('sales_prospects')
->whereDate('created_at', $dateStr)
->whereNull('deleted_at')
->selectRaw("
COUNT(*) as created_count,
SUM(CASE WHEN status = 'contracted' THEN 1 ELSE 0 END) as won_count,
SUM(CASE WHEN status = 'lost' THEN 1 ELSE 0 END) as lost_count
")
->first();
StatQuotePipelineDaily::updateOrCreate(
['tenant_id' => $tenantId, 'stat_date' => $dateStr],
[
'quote_created_count' => $createdCount,
'quote_amount' => $quoteStats->total_amount ?? 0,
'quote_approved_count' => $quoteStats->approved_count ?? 0,
'quote_rejected_count' => $quoteStats->rejected_count ?? 0,
'quote_conversion_count' => $conversionCount,
'quote_conversion_rate' => $conversionRate,
'prospect_created_count' => $prospectStats->created_count ?? 0,
'prospect_won_count' => $prospectStats->won_count ?? 0,
'prospect_lost_count' => $prospectStats->lost_count ?? 0,
'prospect_amount' => 0, // sales_prospects에 금액 컬럼 없음
'bidding_count' => $biddingStats->cnt ?? 0,
'bidding_won_count' => $biddingStats->won_count ?? 0,
'bidding_amount' => $biddingStats->total_amount ?? 0,
'consultation_count' => $consultationCount,
]
);
return 1;
}
public function aggregateMonthly(int $tenantId, int $year, int $month): int
{
// 견적 도메인은 일간 테이블만 운영 (월간은 Phase 4에서 필요시 추가)
return 0;
}
}

View File

@@ -18,6 +18,9 @@ private function getDailyDomainServices(): array
'sales' => SalesStatService::class,
'finance' => FinanceStatService::class,
'production' => ProductionStatService::class,
'inventory' => InventoryStatService::class,
'quote' => QuoteStatService::class,
'hr' => HrStatService::class,
];
}
@@ -30,6 +33,9 @@ private function getMonthlyDomainServices(): array
'sales' => SalesStatService::class,
'finance' => FinanceStatService::class,
'production' => ProductionStatService::class,
'inventory' => InventoryStatService::class,
'quote' => QuoteStatService::class,
'hr' => HrStatService::class,
];
}

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

@@ -119,3 +119,14 @@
->onFailure(function () {
\Illuminate\Support\Facades\Log::error('❌ stat:aggregate-monthly 스케줄러 실행 실패', ['time' => now()]);
});
// 매일 오전 09:00에 KPI 목표 대비 알림 체크
Schedule::command('stat:check-kpi-alerts')
->dailyAt('09:00')
->appendOutputTo(storage_path('logs/scheduler.log'))
->onSuccess(function () {
\Illuminate\Support\Facades\Log::info('✅ stat:check-kpi-alerts 스케줄러 실행 성공', ['time' => now()]);
})
->onFailure(function () {
\Illuminate\Support\Facades\Log::error('❌ stat:check-kpi-alerts 스케줄러 실행 실패', ['time' => now()]);
});