- 차원 테이블: 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>
149 lines
5.1 KiB
PHP
149 lines
5.1 KiB
PHP
<?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,
|
|
};
|
|
}
|
|
}
|