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>
This commit is contained in:
174
app/Console/Commands/StatBackfillCommand.php
Normal file
174
app/Console/Commands/StatBackfillCommand.php
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Stats\DimensionSyncService;
|
||||||
|
use App\Services\Stats\StatAggregatorService;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Carbon\CarbonPeriod;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class StatBackfillCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'stat:backfill
|
||||||
|
{--from= : 시작 날짜 (YYYY-MM-DD, 필수)}
|
||||||
|
{--to= : 종료 날짜 (YYYY-MM-DD, 기본: 어제)}
|
||||||
|
{--domain= : 특정 도메인만 집계}
|
||||||
|
{--tenant= : 특정 테넌트만 집계}
|
||||||
|
{--skip-monthly : 월간 집계 건너뛰기}
|
||||||
|
{--skip-dimensions : 차원 동기화 건너뛰기}';
|
||||||
|
|
||||||
|
protected $description = '과거 데이터 일괄 통계 집계 (백필)';
|
||||||
|
|
||||||
|
public function handle(StatAggregatorService $aggregator, DimensionSyncService $dimensionSync): int
|
||||||
|
{
|
||||||
|
$from = $this->option('from');
|
||||||
|
if (! $from) {
|
||||||
|
$this->error('--from 옵션은 필수입니다. 예: --from=2024-01-01');
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$startDate = Carbon::parse($from);
|
||||||
|
$endDate = $this->option('to')
|
||||||
|
? Carbon::parse($this->option('to'))
|
||||||
|
: Carbon::yesterday();
|
||||||
|
|
||||||
|
$domain = $this->option('domain');
|
||||||
|
$tenantId = $this->option('tenant') ? (int) $this->option('tenant') : null;
|
||||||
|
|
||||||
|
$totalDays = $startDate->diffInDays($endDate) + 1;
|
||||||
|
|
||||||
|
$this->info("📊 백필 시작: {$startDate->format('Y-m-d')} ~ {$endDate->format('Y-m-d')} ({$totalDays}일)");
|
||||||
|
if ($domain) {
|
||||||
|
$this->info(" 도메인 필터: {$domain}");
|
||||||
|
}
|
||||||
|
if ($tenantId) {
|
||||||
|
$this->info(" 테넌트 필터: {$tenantId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalErrors = [];
|
||||||
|
$totalDomainsProcessed = 0;
|
||||||
|
$startTime = microtime(true);
|
||||||
|
|
||||||
|
// 1. 차원 테이블 동기화 (최초 1회)
|
||||||
|
if (! $this->option('skip-dimensions')) {
|
||||||
|
$this->info('');
|
||||||
|
$this->info('🔄 차원 테이블 동기화...');
|
||||||
|
try {
|
||||||
|
$tenants = $this->getTargetTenants($tenantId);
|
||||||
|
foreach ($tenants as $tenant) {
|
||||||
|
$clients = $dimensionSync->syncClients($tenant->id);
|
||||||
|
$products = $dimensionSync->syncProducts($tenant->id);
|
||||||
|
$this->line(" tenant={$tenant->id}: 고객 {$clients}건, 제품 {$products}건");
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->warn(" 차원 동기화 실패: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 일간 집계
|
||||||
|
$this->info('');
|
||||||
|
$this->info('📅 일간 집계 시작...');
|
||||||
|
$bar = $this->output->createProgressBar($totalDays);
|
||||||
|
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %message%');
|
||||||
|
$bar->setMessage('');
|
||||||
|
|
||||||
|
$period = CarbonPeriod::create($startDate, $endDate);
|
||||||
|
|
||||||
|
foreach ($period as $date) {
|
||||||
|
$bar->setMessage($date->format('Y-m-d'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $aggregator->aggregateDaily($date, $domain, $tenantId);
|
||||||
|
$totalDomainsProcessed += $result['domains_processed'];
|
||||||
|
|
||||||
|
if (! empty($result['errors'])) {
|
||||||
|
$totalErrors = array_merge($totalErrors, $result['errors']);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$totalErrors[] = "daily {$date->format('Y-m-d')}: {$e->getMessage()}";
|
||||||
|
}
|
||||||
|
|
||||||
|
$bar->advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
$bar->finish();
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// 3. 월간 집계
|
||||||
|
if (! $this->option('skip-monthly')) {
|
||||||
|
$this->info('');
|
||||||
|
$this->info('📆 월간 집계 시작...');
|
||||||
|
|
||||||
|
$months = $this->getMonthRange($startDate, $endDate);
|
||||||
|
$monthBar = $this->output->createProgressBar(count($months));
|
||||||
|
|
||||||
|
foreach ($months as [$year, $month]) {
|
||||||
|
$monthBar->setMessage("{$year}-".str_pad($month, 2, '0', STR_PAD_LEFT));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $aggregator->aggregateMonthly($year, $month, $domain, $tenantId);
|
||||||
|
$totalDomainsProcessed += $result['domains_processed'];
|
||||||
|
|
||||||
|
if (! empty($result['errors'])) {
|
||||||
|
$totalErrors = array_merge($totalErrors, $result['errors']);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$totalErrors[] = "monthly {$year}-{$month}: {$e->getMessage()}";
|
||||||
|
}
|
||||||
|
|
||||||
|
$monthBar->advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
$monthBar->finish();
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
$durationSec = round(microtime(true) - $startTime, 1);
|
||||||
|
|
||||||
|
$this->info('');
|
||||||
|
$this->info('✅ 백필 완료:');
|
||||||
|
$this->info(" - 기간: {$startDate->format('Y-m-d')} ~ {$endDate->format('Y-m-d')} ({$totalDays}일)");
|
||||||
|
$this->info(" - 처리 도메인-테넌트: {$totalDomainsProcessed}건");
|
||||||
|
$this->info(" - 소요 시간: {$durationSec}초");
|
||||||
|
|
||||||
|
if (! empty($totalErrors)) {
|
||||||
|
$this->warn(' - 에러: '.count($totalErrors).'건');
|
||||||
|
foreach (array_slice($totalErrors, 0, 20) as $error) {
|
||||||
|
$this->error(" - {$error}");
|
||||||
|
}
|
||||||
|
if (count($totalErrors) > 20) {
|
||||||
|
$this->warn(' ... 외 '.(count($totalErrors) - 20).'건');
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getTargetTenants(?int $tenantId): \Illuminate\Database\Eloquent\Collection
|
||||||
|
{
|
||||||
|
$query = \App\Models\Tenants\Tenant::where('tenant_st_code', '!=', 'none');
|
||||||
|
if ($tenantId) {
|
||||||
|
$query->where('id', $tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getMonthRange(Carbon $start, Carbon $end): array
|
||||||
|
{
|
||||||
|
$months = [];
|
||||||
|
$current = $start->copy()->startOfMonth();
|
||||||
|
$endMonth = $end->copy()->startOfMonth();
|
||||||
|
|
||||||
|
while ($current->lte($endMonth)) {
|
||||||
|
$months[] = [$current->year, $current->month];
|
||||||
|
$current->addMonth();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $months;
|
||||||
|
}
|
||||||
|
}
|
||||||
211
app/Console/Commands/StatVerifyCommand.php
Normal file
211
app/Console/Commands/StatVerifyCommand.php
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Stats\Daily\StatFinanceDaily;
|
||||||
|
use App\Models\Stats\Daily\StatSalesDaily;
|
||||||
|
use App\Models\Stats\Daily\StatSystemDaily;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class StatVerifyCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'stat:verify
|
||||||
|
{--date= : 검증 날짜 (YYYY-MM-DD, 기본: 어제)}
|
||||||
|
{--tenant= : 특정 테넌트만 검증}
|
||||||
|
{--domain= : 특정 도메인만 검증 (sales,finance,system)}
|
||||||
|
{--fix : 불일치 시 자동 재집계}';
|
||||||
|
|
||||||
|
protected $description = '원본 DB와 sam_stat 통계 정합성 교차 검증';
|
||||||
|
|
||||||
|
private int $totalChecks = 0;
|
||||||
|
|
||||||
|
private int $passedChecks = 0;
|
||||||
|
|
||||||
|
private int $failedChecks = 0;
|
||||||
|
|
||||||
|
private array $mismatches = [];
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$date = $this->option('date')
|
||||||
|
? Carbon::parse($this->option('date'))
|
||||||
|
: Carbon::yesterday();
|
||||||
|
$tenantId = $this->option('tenant') ? (int) $this->option('tenant') : null;
|
||||||
|
$domain = $this->option('domain');
|
||||||
|
$dateStr = $date->format('Y-m-d');
|
||||||
|
|
||||||
|
$this->info("🔍 정합성 검증: {$dateStr}");
|
||||||
|
|
||||||
|
$tenants = $this->getTargetTenants($tenantId);
|
||||||
|
|
||||||
|
$domains = $domain
|
||||||
|
? [$domain]
|
||||||
|
: ['sales', 'finance', 'system'];
|
||||||
|
|
||||||
|
foreach ($tenants as $tenant) {
|
||||||
|
$this->info('');
|
||||||
|
$this->info("── tenant={$tenant->id} ──");
|
||||||
|
|
||||||
|
foreach ($domains as $d) {
|
||||||
|
match ($d) {
|
||||||
|
'sales' => $this->verifySales($tenant->id, $dateStr),
|
||||||
|
'finance' => $this->verifyFinance($tenant->id, $dateStr),
|
||||||
|
'system' => $this->verifySystem($tenant->id, $dateStr),
|
||||||
|
default => $this->warn(" 미지원 도메인: {$d}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->printSummary();
|
||||||
|
|
||||||
|
if ($this->failedChecks > 0 && $this->option('fix')) {
|
||||||
|
$this->info('');
|
||||||
|
$this->info('🔧 불일치 항목 재집계...');
|
||||||
|
$this->reAggregate($date, $tenantId, $domains);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->failedChecks > 0 ? self::FAILURE : self::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function verifySales(int $tenantId, string $dateStr): void
|
||||||
|
{
|
||||||
|
$this->line(' [sales]');
|
||||||
|
|
||||||
|
$originOrderCount = DB::connection('mysql')
|
||||||
|
->table('orders')
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->whereDate('created_at', $dateStr)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$originSalesAmount = (float) DB::connection('mysql')
|
||||||
|
->table('sales')
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('sale_date', $dateStr)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->sum('supply_amount');
|
||||||
|
|
||||||
|
$stat = StatSalesDaily::where('tenant_id', $tenantId)
|
||||||
|
->where('stat_date', $dateStr)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$this->check('수주건수', $originOrderCount, $stat?->order_count ?? 0, $tenantId, 'sales');
|
||||||
|
$this->check('매출금액', $originSalesAmount, (float) ($stat?->sales_amount ?? 0), $tenantId, 'sales');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function verifyFinance(int $tenantId, string $dateStr): void
|
||||||
|
{
|
||||||
|
$this->line(' [finance]');
|
||||||
|
|
||||||
|
$originDepositAmount = (float) DB::connection('mysql')
|
||||||
|
->table('deposits')
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('deposit_date', $dateStr)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->sum('amount');
|
||||||
|
|
||||||
|
$originWithdrawalAmount = (float) DB::connection('mysql')
|
||||||
|
->table('withdrawals')
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->where('withdrawal_date', $dateStr)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->sum('amount');
|
||||||
|
|
||||||
|
$stat = StatFinanceDaily::where('tenant_id', $tenantId)
|
||||||
|
->where('stat_date', $dateStr)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$this->check('입금액', $originDepositAmount, (float) ($stat?->deposit_amount ?? 0), $tenantId, 'finance');
|
||||||
|
$this->check('출금액', $originWithdrawalAmount, (float) ($stat?->withdrawal_amount ?? 0), $tenantId, 'finance');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function verifySystem(int $tenantId, string $dateStr): void
|
||||||
|
{
|
||||||
|
$this->line(' [system]');
|
||||||
|
|
||||||
|
$originApiCount = DB::connection('mysql')
|
||||||
|
->table('api_request_logs')
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->whereDate('created_at', $dateStr)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$originAuditCount = DB::connection('mysql')
|
||||||
|
->table('audit_logs')
|
||||||
|
->where('tenant_id', $tenantId)
|
||||||
|
->whereDate('created_at', $dateStr)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
$stat = StatSystemDaily::where('tenant_id', $tenantId)
|
||||||
|
->where('stat_date', $dateStr)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$this->check('API요청수', $originApiCount, $stat?->api_request_count ?? 0, $tenantId, 'system');
|
||||||
|
|
||||||
|
$statAuditTotal = ($stat?->audit_create_count ?? 0)
|
||||||
|
+ ($stat?->audit_update_count ?? 0)
|
||||||
|
+ ($stat?->audit_delete_count ?? 0);
|
||||||
|
$this->check('감사로그수', $originAuditCount, $statAuditTotal, $tenantId, 'system');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function check(string $label, float|int $expected, float|int $actual, int $tenantId, string $domain): void
|
||||||
|
{
|
||||||
|
$this->totalChecks++;
|
||||||
|
|
||||||
|
$tolerance = is_float($expected) ? 0.01 : 0;
|
||||||
|
$match = abs($expected - $actual) <= $tolerance;
|
||||||
|
|
||||||
|
if ($match) {
|
||||||
|
$this->passedChecks++;
|
||||||
|
$this->line(" ✅ {$label}: {$actual}");
|
||||||
|
} else {
|
||||||
|
$this->failedChecks++;
|
||||||
|
$this->error(" ❌ {$label}: 원본={$expected} / 통계={$actual} (차이=".($actual - $expected).')');
|
||||||
|
$this->mismatches[] = compact('tenantId', 'domain', 'label', 'expected', 'actual');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function printSummary(): void
|
||||||
|
{
|
||||||
|
$this->info('');
|
||||||
|
$this->info('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||||
|
$this->info("📊 검증 결과: {$this->totalChecks}건 검사, ✅ {$this->passedChecks}건 일치, ❌ {$this->failedChecks}건 불일치");
|
||||||
|
|
||||||
|
if ($this->failedChecks > 0) {
|
||||||
|
$this->warn('');
|
||||||
|
$this->warn('불일치 목록:');
|
||||||
|
foreach ($this->mismatches as $m) {
|
||||||
|
$this->warn(" - tenant={$m['tenantId']} [{$m['domain']}] {$m['label']}: 원본={$m['expected']} / 통계={$m['actual']}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function reAggregate(Carbon $date, ?int $tenantId, array $domains): void
|
||||||
|
{
|
||||||
|
$aggregator = app(\App\Services\Stats\StatAggregatorService::class);
|
||||||
|
|
||||||
|
foreach ($domains as $d) {
|
||||||
|
$result = $aggregator->aggregateDaily($date, $d, $tenantId);
|
||||||
|
$this->line(" {$d}: 재집계 완료 ({$result['domains_processed']}건)");
|
||||||
|
|
||||||
|
if (! empty($result['errors'])) {
|
||||||
|
foreach ($result['errors'] as $error) {
|
||||||
|
$this->error(" {$error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('✅ 재집계 완료. stat:verify를 다시 실행하여 확인하세요.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getTargetTenants(?int $tenantId): \Illuminate\Database\Eloquent\Collection
|
||||||
|
{
|
||||||
|
$query = \App\Models\Tenants\Tenant::where('tenant_st_code', '!=', 'none');
|
||||||
|
if ($tenantId) {
|
||||||
|
$query->where('id', $tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->get();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,11 +71,19 @@ public function aggregateDaily(Carbon $date, ?string $domain = null, ?int $tenan
|
|||||||
|
|
||||||
$jobLog->markCompleted($recordCount);
|
$jobLog->markCompleted($recordCount);
|
||||||
$domainsProcessed++;
|
$domainsProcessed++;
|
||||||
|
|
||||||
|
// 캐시 무효화
|
||||||
|
StatQueryService::invalidateCache($tenant->id, $domainKey);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$errorMsg = "[{$domainKey}] tenant={$tenant->id}: {$e->getMessage()}";
|
$errorMsg = "[{$domainKey}] tenant={$tenant->id}: {$e->getMessage()}";
|
||||||
$errors[] = $errorMsg;
|
$errors[] = $errorMsg;
|
||||||
$jobLog->markFailed($e->getMessage());
|
$jobLog->markFailed($e->getMessage());
|
||||||
|
|
||||||
|
// 모니터링 알림 기록
|
||||||
|
app(StatMonitorService::class)->recordAggregationFailure(
|
||||||
|
$tenant->id, $domainKey, "{$domainKey}_daily", $e->getMessage()
|
||||||
|
);
|
||||||
|
|
||||||
Log::error('stat:aggregate-daily 실패', [
|
Log::error('stat:aggregate-daily 실패', [
|
||||||
'domain' => $domainKey,
|
'domain' => $domainKey,
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
@@ -129,11 +137,19 @@ public function aggregateMonthly(int $year, int $month, ?string $domain = null,
|
|||||||
|
|
||||||
$jobLog->markCompleted($recordCount);
|
$jobLog->markCompleted($recordCount);
|
||||||
$domainsProcessed++;
|
$domainsProcessed++;
|
||||||
|
|
||||||
|
// 캐시 무효화
|
||||||
|
StatQueryService::invalidateCache($tenant->id, $domainKey);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$errorMsg = "[{$domainKey}] tenant={$tenant->id}: {$e->getMessage()}";
|
$errorMsg = "[{$domainKey}] tenant={$tenant->id}: {$e->getMessage()}";
|
||||||
$errors[] = $errorMsg;
|
$errors[] = $errorMsg;
|
||||||
$jobLog->markFailed($e->getMessage());
|
$jobLog->markFailed($e->getMessage());
|
||||||
|
|
||||||
|
// 모니터링 알림 기록
|
||||||
|
app(StatMonitorService::class)->recordAggregationFailure(
|
||||||
|
$tenant->id, $domainKey, "{$domainKey}_monthly", $e->getMessage()
|
||||||
|
);
|
||||||
|
|
||||||
Log::error('stat:aggregate-monthly 실패', [
|
Log::error('stat:aggregate-monthly 실패', [
|
||||||
'domain' => $domainKey,
|
'domain' => $domainKey,
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
|
|||||||
120
app/Services/Stats/StatMonitorService.php
Normal file
120
app/Services/Stats/StatMonitorService.php
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Stats;
|
||||||
|
|
||||||
|
use App\Models\Stats\StatAlert;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class StatMonitorService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 집계 실패 알림 기록
|
||||||
|
*/
|
||||||
|
public function recordAggregationFailure(int $tenantId, string $domain, string $jobType, string $errorMessage): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
StatAlert::create([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'alert_type' => 'aggregation_failure',
|
||||||
|
'domain' => $domain,
|
||||||
|
'severity' => 'critical',
|
||||||
|
'title' => "[{$jobType}] 집계 실패",
|
||||||
|
'message' => mb_substr($errorMessage, 0, 500),
|
||||||
|
'current_value' => 0,
|
||||||
|
'threshold_value' => 0,
|
||||||
|
'is_read' => false,
|
||||||
|
'is_resolved' => false,
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('stat_alert 기록 실패', [
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'domain' => $domain,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 누락 알림 (특정 날짜에 통계 데이터 없음)
|
||||||
|
*/
|
||||||
|
public function recordMissingData(int $tenantId, string $domain, string $date): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// 동일 알림 중복 방지
|
||||||
|
$exists = StatAlert::where('tenant_id', $tenantId)
|
||||||
|
->where('alert_type', 'missing_data')
|
||||||
|
->where('domain', $domain)
|
||||||
|
->where('title', 'LIKE', "%{$date}%")
|
||||||
|
->where('is_resolved', false)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($exists) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
StatAlert::create([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'alert_type' => 'missing_data',
|
||||||
|
'domain' => $domain,
|
||||||
|
'severity' => 'warning',
|
||||||
|
'title' => "[{$domain}] {$date} 데이터 누락",
|
||||||
|
'message' => "{$date} 날짜의 {$domain} 통계 데이터가 없습니다. stat:backfill 실행을 권장합니다.",
|
||||||
|
'current_value' => 0,
|
||||||
|
'threshold_value' => 1,
|
||||||
|
'is_read' => false,
|
||||||
|
'is_resolved' => false,
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('stat_alert 기록 실패 (missing_data)', [
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'domain' => $domain,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정합성 불일치 알림
|
||||||
|
*/
|
||||||
|
public function recordMismatch(int $tenantId, string $domain, string $label, float|int $expected, float|int $actual): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
StatAlert::create([
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'alert_type' => 'data_mismatch',
|
||||||
|
'domain' => $domain,
|
||||||
|
'severity' => 'critical',
|
||||||
|
'title' => "[{$domain}] {$label} 정합성 불일치",
|
||||||
|
'message' => "원본={$expected}, 통계={$actual}, 차이=".($actual - $expected),
|
||||||
|
'current_value' => $actual,
|
||||||
|
'threshold_value' => $expected,
|
||||||
|
'is_read' => false,
|
||||||
|
'is_resolved' => false,
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('stat_alert 기록 실패 (mismatch)', [
|
||||||
|
'tenant_id' => $tenantId,
|
||||||
|
'domain' => $domain,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 알림 해결 처리
|
||||||
|
*/
|
||||||
|
public function resolveAlerts(int $tenantId, string $domain, string $alertType): int
|
||||||
|
{
|
||||||
|
return StatAlert::where('tenant_id', $tenantId)
|
||||||
|
->where('domain', $domain)
|
||||||
|
->where('alert_type', $alertType)
|
||||||
|
->where('is_resolved', false)
|
||||||
|
->update([
|
||||||
|
'is_resolved' => true,
|
||||||
|
'resolved_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,9 +16,12 @@
|
|||||||
use App\Models\Stats\StatAlert;
|
use App\Models\Stats\StatAlert;
|
||||||
use App\Services\Service;
|
use App\Services\Service;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
class StatQueryService extends Service
|
class StatQueryService extends Service
|
||||||
{
|
{
|
||||||
|
private const CACHE_TTL = 300; // 5분
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 도메인별 일간 통계 조회
|
* 도메인별 일간 통계 조회
|
||||||
*/
|
*/
|
||||||
@@ -28,16 +31,20 @@ public function getDailyStat(string $domain, array $params): array
|
|||||||
$startDate = $params['start_date'];
|
$startDate = $params['start_date'];
|
||||||
$endDate = $params['end_date'];
|
$endDate = $params['end_date'];
|
||||||
|
|
||||||
$model = $this->getDailyModel($domain);
|
$cacheKey = "stat:daily:{$tenantId}:{$domain}:{$startDate}:{$endDate}";
|
||||||
if (! $model) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $model::where('tenant_id', $tenantId)
|
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($domain, $tenantId, $startDate, $endDate) {
|
||||||
->whereBetween('stat_date', [$startDate, $endDate])
|
$model = $this->getDailyModel($domain);
|
||||||
->orderBy('stat_date')
|
if (! $model) {
|
||||||
->get()
|
return [];
|
||||||
->toArray();
|
}
|
||||||
|
|
||||||
|
return $model::where('tenant_id', $tenantId)
|
||||||
|
->whereBetween('stat_date', [$startDate, $endDate])
|
||||||
|
->orderBy('stat_date')
|
||||||
|
->get()
|
||||||
|
->toArray();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,21 +56,25 @@ public function getMonthlyStat(string $domain, array $params): array
|
|||||||
$year = (int) $params['year'];
|
$year = (int) $params['year'];
|
||||||
$month = isset($params['month']) ? (int) $params['month'] : null;
|
$month = isset($params['month']) ? (int) $params['month'] : null;
|
||||||
|
|
||||||
$model = $this->getMonthlyModel($domain);
|
$cacheKey = "stat:monthly:{$tenantId}:{$domain}:{$year}:".($month ?? 'all');
|
||||||
if (! $model) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$query = $model::where('tenant_id', $tenantId)
|
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($domain, $tenantId, $year, $month) {
|
||||||
->where('stat_year', $year);
|
$model = $this->getMonthlyModel($domain);
|
||||||
|
if (! $model) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
if ($month) {
|
$query = $model::where('tenant_id', $tenantId)
|
||||||
$query->where('stat_month', $month);
|
->where('stat_year', $year);
|
||||||
}
|
|
||||||
|
|
||||||
return $query->orderBy('stat_month')
|
if ($month) {
|
||||||
->get()
|
$query->where('stat_month', $month);
|
||||||
->toArray();
|
}
|
||||||
|
|
||||||
|
return $query->orderBy('stat_month')
|
||||||
|
->get()
|
||||||
|
->toArray();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,13 +87,17 @@ public function getDashboardSummary(): array
|
|||||||
$year = Carbon::now()->year;
|
$year = Carbon::now()->year;
|
||||||
$month = Carbon::now()->month;
|
$month = Carbon::now()->month;
|
||||||
|
|
||||||
return [
|
$cacheKey = "stat:dashboard:{$tenantId}:{$today}";
|
||||||
'sales_today' => $this->getTodaySales($tenantId, $today),
|
|
||||||
'finance_today' => $this->getTodayFinance($tenantId, $today),
|
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tenantId, $today, $year, $month) {
|
||||||
'production_today' => $this->getTodayProduction($tenantId, $today),
|
return [
|
||||||
'sales_monthly' => $this->getMonthlySalesOverview($tenantId, $year, $month),
|
'sales_today' => $this->getTodaySales($tenantId, $today),
|
||||||
'alerts' => $this->getActiveAlerts($tenantId),
|
'finance_today' => $this->getTodayFinance($tenantId, $today),
|
||||||
];
|
'production_today' => $this->getTodayProduction($tenantId, $today),
|
||||||
|
'sales_monthly' => $this->getMonthlySalesOverview($tenantId, $year, $month),
|
||||||
|
'alerts' => $this->getActiveAlerts($tenantId),
|
||||||
|
];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,6 +119,42 @@ public function getAlerts(array $params): array
|
|||||||
return $query->limit($limit)->get()->toArray();
|
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
|
private function getTodaySales(int $tenantId, string $today): ?array
|
||||||
{
|
{
|
||||||
$stat = StatSalesDaily::where('tenant_id', $tenantId)
|
$stat = StatSalesDaily::where('tenant_id', $tenantId)
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일간 통계 테이블 RANGE 파티셔닝 준비
|
||||||
|
*
|
||||||
|
* 실행 방법: php artisan migrate --path=database/migrations/stats/2026_01_29_300001_prepare_partitioning_daily_tables.php
|
||||||
|
*
|
||||||
|
* 주의: MySQL에서 파티셔닝 적용 시 UNIQUE KEY에 파티션 컬럼(stat_date)이 포함되어야 합니다.
|
||||||
|
* 이 마이그레이션은 기존 데이터를 보존하며 파티셔닝을 적용합니다.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
protected $connection = 'sam_stat';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 파티셔닝 대상 테이블과 고유 키 정의
|
||||||
|
*/
|
||||||
|
private function getTargetTables(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'stat_sales_daily' => [
|
||||||
|
'unique_columns' => 'tenant_id, stat_date',
|
||||||
|
'unique_name' => 'uk_tenant_date',
|
||||||
|
],
|
||||||
|
'stat_finance_daily' => [
|
||||||
|
'unique_columns' => 'tenant_id, stat_date',
|
||||||
|
'unique_name' => 'uk_tenant_date',
|
||||||
|
],
|
||||||
|
'stat_production_daily' => [
|
||||||
|
'unique_columns' => 'tenant_id, stat_date',
|
||||||
|
'unique_name' => 'uk_tenant_date',
|
||||||
|
],
|
||||||
|
'stat_inventory_daily' => [
|
||||||
|
'unique_columns' => 'tenant_id, stat_date',
|
||||||
|
'unique_name' => 'uk_tenant_date',
|
||||||
|
],
|
||||||
|
'stat_quote_pipeline_daily' => [
|
||||||
|
'unique_columns' => 'tenant_id, stat_date',
|
||||||
|
'unique_name' => 'uk_tenant_date',
|
||||||
|
],
|
||||||
|
'stat_hr_attendance_daily' => [
|
||||||
|
'unique_columns' => 'tenant_id, stat_date',
|
||||||
|
'unique_name' => 'uk_tenant_date',
|
||||||
|
],
|
||||||
|
'stat_system_daily' => [
|
||||||
|
'unique_columns' => 'tenant_id, stat_date',
|
||||||
|
'unique_name' => 'uk_tenant_date',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연도별 파티션 정의 생성 (2024~2028 + MAXVALUE)
|
||||||
|
*/
|
||||||
|
private function getPartitionDefinitions(): string
|
||||||
|
{
|
||||||
|
$partitions = [];
|
||||||
|
for ($year = 2024; $year <= 2028; $year++) {
|
||||||
|
$partitions[] = "PARTITION p{$year} VALUES LESS THAN ('{$year}-01-01')";
|
||||||
|
}
|
||||||
|
$partitions[] = 'PARTITION p_future VALUES LESS THAN MAXVALUE';
|
||||||
|
|
||||||
|
return implode(",\n ", $partitions);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$tables = $this->getTargetTables();
|
||||||
|
$partitionDefs = $this->getPartitionDefinitions();
|
||||||
|
|
||||||
|
foreach ($tables as $table => $config) {
|
||||||
|
// 테이블 존재 여부 확인
|
||||||
|
$exists = DB::connection('sam_stat')
|
||||||
|
->select("SHOW TABLES LIKE '{$table}'");
|
||||||
|
|
||||||
|
if (empty($exists)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 파티셔닝되어 있는지 확인
|
||||||
|
$partitionInfo = DB::connection('sam_stat')
|
||||||
|
->select('SELECT PARTITION_NAME FROM INFORMATION_SCHEMA.PARTITIONS
|
||||||
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND PARTITION_NAME IS NOT NULL',
|
||||||
|
[$table]);
|
||||||
|
|
||||||
|
if (! empty($partitionInfo)) {
|
||||||
|
continue; // 이미 파티셔닝됨
|
||||||
|
}
|
||||||
|
|
||||||
|
// AUTO_INCREMENT PK를 일반 PK로 변경 (파티션 키 포함)
|
||||||
|
// MySQL 파티셔닝 제약: UNIQUE KEY에 파티션 컬럼 포함 필수
|
||||||
|
DB::connection('sam_stat')->statement("
|
||||||
|
ALTER TABLE `{$table}`
|
||||||
|
DROP PRIMARY KEY,
|
||||||
|
ADD PRIMARY KEY (`id`, `stat_date`),
|
||||||
|
DROP INDEX `{$config['unique_name']}`,
|
||||||
|
ADD UNIQUE KEY `{$config['unique_name']}` ({$config['unique_columns']})
|
||||||
|
");
|
||||||
|
|
||||||
|
// RANGE 파티셔닝 적용
|
||||||
|
DB::connection('sam_stat')->statement("
|
||||||
|
ALTER TABLE `{$table}`
|
||||||
|
PARTITION BY RANGE COLUMNS(`stat_date`) (
|
||||||
|
{$partitionDefs}
|
||||||
|
)
|
||||||
|
");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
$tables = $this->getTargetTables();
|
||||||
|
|
||||||
|
foreach ($tables as $table => $config) {
|
||||||
|
$exists = DB::connection('sam_stat')
|
||||||
|
->select("SHOW TABLES LIKE '{$table}'");
|
||||||
|
|
||||||
|
if (empty($exists)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 파티션 제거
|
||||||
|
DB::connection('sam_stat')->statement("
|
||||||
|
ALTER TABLE `{$table}` REMOVE PARTITIONING
|
||||||
|
");
|
||||||
|
|
||||||
|
// PK 원복
|
||||||
|
DB::connection('sam_stat')->statement("
|
||||||
|
ALTER TABLE `{$table}`
|
||||||
|
DROP PRIMARY KEY,
|
||||||
|
ADD PRIMARY KEY (`id`)
|
||||||
|
");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user