Files
sam-api/app/Services/Stats/StatQueryService.php
권혁성 ca51867cc2 feat: sam_stat 최적화 및 안정화 (Phase 5)
- StatBackfillCommand: 과거 데이터 일괄 백필 (일간+월간, 프로그레스바, 에러 리포트)
- StatVerifyCommand: 원본 DB vs sam_stat 정합성 교차 검증 (--fix 자동 재집계)
- 파티셔닝 준비: 7개 일간 테이블 RANGE COLUMNS(stat_date) 마이그레이션
- Redis 캐싱: StatQueryService Cache::remember TTL 5분 + invalidateCache()
- StatMonitorService: 집계 실패/누락/불일치 시 stat_alerts 알림 기록
- StatAggregatorService: 모니터링 알림 + 캐시 무효화 연동

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:17:11 +09:00

231 lines
7.2 KiB
PHP

<?php
namespace App\Services\Stats;
use App\Models\Stats\Daily\StatFinanceDaily;
use App\Models\Stats\Daily\StatHrAttendanceDaily;
use App\Models\Stats\Daily\StatInventoryDaily;
use App\Models\Stats\Daily\StatProductionDaily;
use App\Models\Stats\Daily\StatQuotePipelineDaily;
use App\Models\Stats\Daily\StatSalesDaily;
use App\Models\Stats\Daily\StatSystemDaily;
use App\Models\Stats\Monthly\StatFinanceMonthly;
use App\Models\Stats\Monthly\StatProductionMonthly;
use App\Models\Stats\Monthly\StatProjectMonthly;
use App\Models\Stats\Monthly\StatSalesMonthly;
use App\Models\Stats\StatAlert;
use App\Services\Service;
use Carbon\Carbon;
use Illuminate\Support\Facades\Cache;
class StatQueryService extends Service
{
private const CACHE_TTL = 300; // 5분
/**
* 도메인별 일간 통계 조회
*/
public function getDailyStat(string $domain, array $params): array
{
$tenantId = $this->tenantId();
$startDate = $params['start_date'];
$endDate = $params['end_date'];
$cacheKey = "stat:daily:{$tenantId}:{$domain}:{$startDate}:{$endDate}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($domain, $tenantId, $startDate, $endDate) {
$model = $this->getDailyModel($domain);
if (! $model) {
return [];
}
return $model::where('tenant_id', $tenantId)
->whereBetween('stat_date', [$startDate, $endDate])
->orderBy('stat_date')
->get()
->toArray();
});
}
/**
* 도메인별 월간 통계 조회
*/
public function getMonthlyStat(string $domain, array $params): array
{
$tenantId = $this->tenantId();
$year = (int) $params['year'];
$month = isset($params['month']) ? (int) $params['month'] : null;
$cacheKey = "stat:monthly:{$tenantId}:{$domain}:{$year}:".($month ?? 'all');
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($domain, $tenantId, $year, $month) {
$model = $this->getMonthlyModel($domain);
if (! $model) {
return [];
}
$query = $model::where('tenant_id', $tenantId)
->where('stat_year', $year);
if ($month) {
$query->where('stat_month', $month);
}
return $query->orderBy('stat_month')
->get()
->toArray();
});
}
/**
* 대시보드 요약 통계 (sam_stat 기반)
*/
public function getDashboardSummary(): array
{
$tenantId = $this->tenantId();
$today = Carbon::today()->format('Y-m-d');
$year = Carbon::now()->year;
$month = Carbon::now()->month;
$cacheKey = "stat:dashboard:{$tenantId}:{$today}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tenantId, $today, $year, $month) {
return [
'sales_today' => $this->getTodaySales($tenantId, $today),
'finance_today' => $this->getTodayFinance($tenantId, $today),
'production_today' => $this->getTodayProduction($tenantId, $today),
'sales_monthly' => $this->getMonthlySalesOverview($tenantId, $year, $month),
'alerts' => $this->getActiveAlerts($tenantId),
];
});
}
/**
* 알림 목록 조회
*/
public function getAlerts(array $params): array
{
$tenantId = $this->tenantId();
$limit = $params['limit'] ?? 20;
$unreadOnly = $params['unread_only'] ?? false;
$query = StatAlert::where('tenant_id', $tenantId)
->orderByDesc('created_at');
if ($unreadOnly) {
$query->where('is_read', false);
}
return $query->limit($limit)->get()->toArray();
}
/**
* 집계 완료 시 관련 캐시 무효화
*/
public static function invalidateCache(int $tenantId, ?string $domain = null): void
{
$patterns = [
"stat:dashboard:{$tenantId}:*",
];
if ($domain) {
$patterns[] = "stat:daily:{$tenantId}:{$domain}:*";
$patterns[] = "stat:monthly:{$tenantId}:{$domain}:*";
} else {
$patterns[] = "stat:daily:{$tenantId}:*";
$patterns[] = "stat:monthly:{$tenantId}:*";
}
// Redis 태그 기반 또는 키 패턴 삭제
$redis = Cache::getStore();
if (method_exists($redis, 'getRedis')) {
$connection = $redis->getRedis();
$prefix = config('cache.prefix', '').':';
foreach ($patterns as $pattern) {
$keys = $connection->keys($prefix.$pattern);
if (! empty($keys)) {
$connection->del($keys);
}
}
} else {
// Redis가 아닌 경우 개별 키 삭제 (대시보드만)
$today = Carbon::today()->format('Y-m-d');
Cache::forget("stat:dashboard:{$tenantId}:{$today}");
}
}
private function getTodaySales(int $tenantId, string $today): ?array
{
$stat = StatSalesDaily::where('tenant_id', $tenantId)
->where('stat_date', $today)
->first();
return $stat?->toArray();
}
private function getTodayFinance(int $tenantId, string $today): ?array
{
$stat = StatFinanceDaily::where('tenant_id', $tenantId)
->where('stat_date', $today)
->first();
return $stat?->toArray();
}
private function getTodayProduction(int $tenantId, string $today): ?array
{
$stat = StatProductionDaily::where('tenant_id', $tenantId)
->where('stat_date', $today)
->first();
return $stat?->toArray();
}
private function getMonthlySalesOverview(int $tenantId, int $year, int $month): ?array
{
$stat = StatSalesMonthly::where('tenant_id', $tenantId)
->where('stat_year', $year)
->where('stat_month', $month)
->first();
return $stat?->toArray();
}
private function getActiveAlerts(int $tenantId): array
{
return StatAlert::where('tenant_id', $tenantId)
->where('is_read', false)
->where('is_resolved', false)
->orderByDesc('created_at')
->limit(10)
->get()
->toArray();
}
private function getDailyModel(string $domain): ?string
{
return match ($domain) {
'sales' => StatSalesDaily::class,
'finance' => StatFinanceDaily::class,
'production' => StatProductionDaily::class,
'inventory' => StatInventoryDaily::class,
'quote' => StatQuotePipelineDaily::class,
'hr' => StatHrAttendanceDaily::class,
'system' => StatSystemDaily::class,
default => null,
};
}
private function getMonthlyModel(string $domain): ?string
{
return match ($domain) {
'sales' => StatSalesMonthly::class,
'finance' => StatFinanceMonthly::class,
'production' => StatProductionMonthly::class,
'project' => StatProjectMonthly::class,
default => null,
};
}
}