- 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>
175 lines
6.0 KiB
PHP
175 lines
6.0 KiB
PHP
<?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;
|
|
}
|
|
}
|