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:
148
app/Services/Stats/KpiAlertService.php
Normal file
148
app/Services/Stats/KpiAlertService.php
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user