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,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,
};
}
}