diff --git a/app/Console/Commands/StatBackfillCommand.php b/app/Console/Commands/StatBackfillCommand.php new file mode 100644 index 0000000..397401f --- /dev/null +++ b/app/Console/Commands/StatBackfillCommand.php @@ -0,0 +1,174 @@ +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; + } +} diff --git a/app/Console/Commands/StatVerifyCommand.php b/app/Console/Commands/StatVerifyCommand.php new file mode 100644 index 0000000..6efb93e --- /dev/null +++ b/app/Console/Commands/StatVerifyCommand.php @@ -0,0 +1,211 @@ +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(); + } +} diff --git a/app/Services/Stats/StatAggregatorService.php b/app/Services/Stats/StatAggregatorService.php index 7ca5515..57826b0 100644 --- a/app/Services/Stats/StatAggregatorService.php +++ b/app/Services/Stats/StatAggregatorService.php @@ -71,11 +71,19 @@ public function aggregateDaily(Carbon $date, ?string $domain = null, ?int $tenan $jobLog->markCompleted($recordCount); $domainsProcessed++; + + // 캐시 무효화 + StatQueryService::invalidateCache($tenant->id, $domainKey); } catch (\Throwable $e) { $errorMsg = "[{$domainKey}] tenant={$tenant->id}: {$e->getMessage()}"; $errors[] = $errorMsg; $jobLog->markFailed($e->getMessage()); + // 모니터링 알림 기록 + app(StatMonitorService::class)->recordAggregationFailure( + $tenant->id, $domainKey, "{$domainKey}_daily", $e->getMessage() + ); + Log::error('stat:aggregate-daily 실패', [ 'domain' => $domainKey, 'tenant_id' => $tenant->id, @@ -129,11 +137,19 @@ public function aggregateMonthly(int $year, int $month, ?string $domain = null, $jobLog->markCompleted($recordCount); $domainsProcessed++; + + // 캐시 무효화 + StatQueryService::invalidateCache($tenant->id, $domainKey); } catch (\Throwable $e) { $errorMsg = "[{$domainKey}] tenant={$tenant->id}: {$e->getMessage()}"; $errors[] = $errorMsg; $jobLog->markFailed($e->getMessage()); + // 모니터링 알림 기록 + app(StatMonitorService::class)->recordAggregationFailure( + $tenant->id, $domainKey, "{$domainKey}_monthly", $e->getMessage() + ); + Log::error('stat:aggregate-monthly 실패', [ 'domain' => $domainKey, 'tenant_id' => $tenant->id, diff --git a/app/Services/Stats/StatMonitorService.php b/app/Services/Stats/StatMonitorService.php new file mode 100644 index 0000000..292df47 --- /dev/null +++ b/app/Services/Stats/StatMonitorService.php @@ -0,0 +1,120 @@ + $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(), + ]); + } +} diff --git a/app/Services/Stats/StatQueryService.php b/app/Services/Stats/StatQueryService.php index 8b8a538..2fb4894 100644 --- a/app/Services/Stats/StatQueryService.php +++ b/app/Services/Stats/StatQueryService.php @@ -16,9 +16,12 @@ 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분 + /** * 도메인별 일간 통계 조회 */ @@ -28,16 +31,20 @@ public function getDailyStat(string $domain, array $params): array $startDate = $params['start_date']; $endDate = $params['end_date']; - $model = $this->getDailyModel($domain); - if (! $model) { - return []; - } + $cacheKey = "stat:daily:{$tenantId}:{$domain}:{$startDate}:{$endDate}"; - return $model::where('tenant_id', $tenantId) - ->whereBetween('stat_date', [$startDate, $endDate]) - ->orderBy('stat_date') - ->get() - ->toArray(); + 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(); + }); } /** @@ -49,21 +56,25 @@ public function getMonthlyStat(string $domain, array $params): array $year = (int) $params['year']; $month = isset($params['month']) ? (int) $params['month'] : null; - $model = $this->getMonthlyModel($domain); - if (! $model) { - return []; - } + $cacheKey = "stat:monthly:{$tenantId}:{$domain}:{$year}:".($month ?? 'all'); - $query = $model::where('tenant_id', $tenantId) - ->where('stat_year', $year); + return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($domain, $tenantId, $year, $month) { + $model = $this->getMonthlyModel($domain); + if (! $model) { + return []; + } - if ($month) { - $query->where('stat_month', $month); - } + $query = $model::where('tenant_id', $tenantId) + ->where('stat_year', $year); - return $query->orderBy('stat_month') - ->get() - ->toArray(); + if ($month) { + $query->where('stat_month', $month); + } + + return $query->orderBy('stat_month') + ->get() + ->toArray(); + }); } /** @@ -76,13 +87,17 @@ public function getDashboardSummary(): array $year = Carbon::now()->year; $month = Carbon::now()->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), - ]; + $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), + ]; + }); } /** @@ -104,6 +119,42 @@ public function getAlerts(array $params): array 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) diff --git a/database/migrations/stats/2026_01_29_300001_prepare_partitioning_daily_tables.php b/database/migrations/stats/2026_01_29_300001_prepare_partitioning_daily_tables.php new file mode 100644 index 0000000..c39e25d --- /dev/null +++ b/database/migrations/stats/2026_01_29_300001_prepare_partitioning_daily_tables.php @@ -0,0 +1,138 @@ + [ + '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`) + "); + } + } +};