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:
2026-01-29 22:17:11 +09:00
parent 3793e95662
commit ca51867cc2
6 changed files with 738 additions and 28 deletions

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

View 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();
}
}