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