diff --git a/app/Console/Commands/StatAggregateDailyCommand.php b/app/Console/Commands/StatAggregateDailyCommand.php new file mode 100644 index 0000000..0acaf10 --- /dev/null +++ b/app/Console/Commands/StatAggregateDailyCommand.php @@ -0,0 +1,60 @@ +option('date') + ? Carbon::parse($this->option('date')) + : Carbon::yesterday(); + + $domain = $this->option('domain'); + $tenantId = $this->option('tenant') ? (int) $this->option('tenant') : null; + + $this->info("📊 일간 통계 집계 시작: {$date->format('Y-m-d')}"); + + if ($domain) { + $this->info(" 도메인 필터: {$domain}"); + } + if ($tenantId) { + $this->info(" 테넌트 필터: {$tenantId}"); + } + + try { + $result = $aggregator->aggregateDaily($date, $domain, $tenantId); + + $this->info('✅ 일간 집계 완료:'); + $this->info(" - 처리 테넌트: {$result['tenants_processed']}"); + $this->info(" - 처리 도메인: {$result['domains_processed']}"); + $this->info(" - 소요 시간: {$result['duration_ms']}ms"); + + if (! empty($result['errors'])) { + $this->warn(' ⚠️ 에러 발생: '.count($result['errors']).'건'); + foreach ($result['errors'] as $error) { + $this->error(" - {$error}"); + } + + return self::FAILURE; + } + + return self::SUCCESS; + } catch (\Throwable $e) { + $this->error("❌ 집계 실패: {$e->getMessage()}"); + + return self::FAILURE; + } + } +} diff --git a/app/Console/Commands/StatAggregateMonthlyCommand.php b/app/Console/Commands/StatAggregateMonthlyCommand.php new file mode 100644 index 0000000..e942213 --- /dev/null +++ b/app/Console/Commands/StatAggregateMonthlyCommand.php @@ -0,0 +1,54 @@ +subMonth(); + $year = $this->option('year') ? (int) $this->option('year') : $lastMonth->year; + $month = $this->option('month') ? (int) $this->option('month') : $lastMonth->month; + + $domain = $this->option('domain'); + $tenantId = $this->option('tenant') ? (int) $this->option('tenant') : null; + + $this->info("📊 월간 통계 집계 시작: {$year}-".str_pad($month, 2, '0', STR_PAD_LEFT)); + + try { + $result = $aggregator->aggregateMonthly($year, $month, $domain, $tenantId); + + $this->info('✅ 월간 집계 완료:'); + $this->info(" - 처리 테넌트: {$result['tenants_processed']}"); + $this->info(" - 처리 도메인: {$result['domains_processed']}"); + $this->info(" - 소요 시간: {$result['duration_ms']}ms"); + + if (! empty($result['errors'])) { + $this->warn(' ⚠️ 에러 발생: '.count($result['errors']).'건'); + foreach ($result['errors'] as $error) { + $this->error(" - {$error}"); + } + + return self::FAILURE; + } + + return self::SUCCESS; + } catch (\Throwable $e) { + $this->error("❌ 집계 실패: {$e->getMessage()}"); + + return self::FAILURE; + } + } +} diff --git a/app/Models/Stats/BaseStatModel.php b/app/Models/Stats/BaseStatModel.php new file mode 100644 index 0000000..ddaa464 --- /dev/null +++ b/app/Models/Stats/BaseStatModel.php @@ -0,0 +1,12 @@ + 'date', + 'is_weekend' => 'boolean', + 'is_holiday' => 'boolean', + ]; +} diff --git a/app/Models/Stats/StatDefinition.php b/app/Models/Stats/StatDefinition.php new file mode 100644 index 0000000..fa290c0 --- /dev/null +++ b/app/Models/Stats/StatDefinition.php @@ -0,0 +1,14 @@ + 'array', + 'config' => 'array', + 'is_active' => 'boolean', + ]; +} diff --git a/app/Models/Stats/StatJobLog.php b/app/Models/Stats/StatJobLog.php new file mode 100644 index 0000000..c4d350a --- /dev/null +++ b/app/Models/Stats/StatJobLog.php @@ -0,0 +1,53 @@ + 'date', + 'started_at' => 'datetime', + 'completed_at' => 'datetime', + 'created_at' => 'datetime', + ]; + + public function markRunning(): void + { + $this->update([ + 'status' => 'running', + 'started_at' => now(), + ]); + } + + public function markCompleted(int $recordsProcessed = 0): void + { + $durationMs = $this->started_at + ? (int) now()->diffInMilliseconds($this->started_at) + : null; + + $this->update([ + 'status' => 'completed', + 'records_processed' => $recordsProcessed, + 'completed_at' => now(), + 'duration_ms' => $durationMs, + ]); + } + + public function markFailed(string $errorMessage): void + { + $durationMs = $this->started_at + ? (int) now()->diffInMilliseconds($this->started_at) + : null; + + $this->update([ + 'status' => 'failed', + 'error_message' => $errorMessage, + 'completed_at' => now(), + 'duration_ms' => $durationMs, + ]); + } +} diff --git a/app/Services/Stats/StatAggregatorService.php b/app/Services/Stats/StatAggregatorService.php new file mode 100644 index 0000000..f87fd08 --- /dev/null +++ b/app/Services/Stats/StatAggregatorService.php @@ -0,0 +1,187 @@ + SalesStatService::class, + // 'finance' => FinanceStatService::class, + // 'production' => ProductionStatService::class, + ]; + } + + /** + * 월간 도메인 서비스 매핑 + */ + private function getMonthlyDomainServices(): array + { + return [ + // 'sales' => SalesStatService::class, + // 'finance' => FinanceStatService::class, + // 'production' => ProductionStatService::class, + ]; + } + + /** + * 일간 통계 집계 + */ + public function aggregateDaily(Carbon $date, ?string $domain = null, ?int $tenantId = null): array + { + $startTime = microtime(true); + $errors = []; + $tenantsProcessed = 0; + $domainsProcessed = 0; + + $tenants = $this->getTargetTenants($tenantId); + $services = $this->getDailyDomainServices(); + + if ($domain) { + $services = array_intersect_key($services, [$domain => true]); + } + + foreach ($tenants as $tenant) { + foreach ($services as $domainKey => $serviceClass) { + $jobLog = $this->createJobLog($tenant->id, "{$domainKey}_daily", $date); + + try { + $jobLog->markRunning(); + + /** @var StatDomainServiceInterface $service */ + $service = app($serviceClass); + $recordCount = $service->aggregateDaily($tenant->id, $date); + + $jobLog->markCompleted($recordCount); + $domainsProcessed++; + } catch (\Throwable $e) { + $errorMsg = "[{$domainKey}] tenant={$tenant->id}: {$e->getMessage()}"; + $errors[] = $errorMsg; + $jobLog->markFailed($e->getMessage()); + + Log::error('stat:aggregate-daily 실패', [ + 'domain' => $domainKey, + 'tenant_id' => $tenant->id, + 'date' => $date->format('Y-m-d'), + 'error' => $e->getMessage(), + ]); + } + } + $tenantsProcessed++; + } + + $durationMs = (int) ((microtime(true) - $startTime) * 1000); + + return [ + 'tenants_processed' => $tenantsProcessed, + 'domains_processed' => $domainsProcessed, + 'errors' => $errors, + 'duration_ms' => $durationMs, + ]; + } + + /** + * 월간 통계 집계 + */ + public function aggregateMonthly(int $year, int $month, ?string $domain = null, ?int $tenantId = null): array + { + $startTime = microtime(true); + $errors = []; + $tenantsProcessed = 0; + $domainsProcessed = 0; + + $tenants = $this->getTargetTenants($tenantId); + $services = $this->getMonthlyDomainServices(); + + if ($domain) { + $services = array_intersect_key($services, [$domain => true]); + } + + $targetDate = Carbon::create($year, $month, 1); + + foreach ($tenants as $tenant) { + foreach ($services as $domainKey => $serviceClass) { + $jobLog = $this->createJobLog($tenant->id, "{$domainKey}_monthly", $targetDate); + + try { + $jobLog->markRunning(); + + /** @var StatDomainServiceInterface $service */ + $service = app($serviceClass); + $recordCount = $service->aggregateMonthly($tenant->id, $year, $month); + + $jobLog->markCompleted($recordCount); + $domainsProcessed++; + } catch (\Throwable $e) { + $errorMsg = "[{$domainKey}] tenant={$tenant->id}: {$e->getMessage()}"; + $errors[] = $errorMsg; + $jobLog->markFailed($e->getMessage()); + + Log::error('stat:aggregate-monthly 실패', [ + 'domain' => $domainKey, + 'tenant_id' => $tenant->id, + 'year' => $year, + 'month' => $month, + 'error' => $e->getMessage(), + ]); + } + } + $tenantsProcessed++; + } + + $durationMs = (int) ((microtime(true) - $startTime) * 1000); + + return [ + 'tenants_processed' => $tenantsProcessed, + 'domains_processed' => $domainsProcessed, + 'errors' => $errors, + 'duration_ms' => $durationMs, + ]; + } + + /** + * 대상 테넌트 목록 조회 + */ + private function getTargetTenants(?int $tenantId): \Illuminate\Database\Eloquent\Collection + { + $query = Tenant::query()->where('tenant_st_code', '!=', 'none'); + + if ($tenantId) { + $query->where('id', $tenantId); + } + + return $query->get(); + } + + /** + * 집계 작업 로그 생성 (멱등: 동일 조건이면 기존 레코드 재사용) + */ + private function createJobLog(int $tenantId, string $jobType, Carbon $targetDate): StatJobLog + { + return StatJobLog::updateOrCreate( + [ + 'tenant_id' => $tenantId, + 'job_type' => $jobType, + 'target_date' => $targetDate->format('Y-m-d'), + ], + [ + 'status' => 'pending', + 'records_processed' => 0, + 'error_message' => null, + 'started_at' => null, + 'completed_at' => null, + 'duration_ms' => null, + 'created_at' => now(), + ] + ); + } +} diff --git a/app/Services/Stats/StatDomainServiceInterface.php b/app/Services/Stats/StatDomainServiceInterface.php new file mode 100644 index 0000000..9af1449 --- /dev/null +++ b/app/Services/Stats/StatDomainServiceInterface.php @@ -0,0 +1,22 @@ + [ + 'driver' => 'mysql', + 'host' => env('STAT_DB_HOST', env('DB_HOST', '127.0.0.1')), + 'port' => env('STAT_DB_PORT', env('DB_PORT', '3306')), + 'database' => env('STAT_DB_DATABASE', 'sam_stat'), + 'username' => env('STAT_DB_USERNAME', env('DB_USERNAME', 'root')), + 'password' => env('STAT_DB_PASSWORD', env('DB_PASSWORD', '')), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + // 5130 레거시 DB (chandj) 'chandj' => [ 'driver' => 'mysql', diff --git a/database/migrations/stats/2026_01_29_164541_create_stat_definitions_table.php b/database/migrations/stats/2026_01_29_164541_create_stat_definitions_table.php new file mode 100644 index 0000000..d08a621 --- /dev/null +++ b/database/migrations/stats/2026_01_29_164541_create_stat_definitions_table.php @@ -0,0 +1,32 @@ +connection)->create('stat_definitions', function (Blueprint $table) { + $table->id(); + $table->string('code', 100)->unique()->comment('통계 코드 (sales_daily_revenue)'); + $table->string('domain', 50)->index()->comment('도메인 (sales, finance, production)'); + $table->string('name', 200)->comment('통계명 (일일 매출액)'); + $table->text('description')->nullable(); + $table->json('source_tables')->comment('원본 테이블 목록'); + $table->string('aggregation', 20)->default('daily')->index()->comment('집계 주기'); + $table->text('query_template')->nullable()->comment('집계 SQL 템플릿'); + $table->boolean('is_active')->default(true)->index(); + $table->json('config')->nullable()->comment('추가 설정 (임계값, 단위 등)'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('stat_definitions'); + } +}; diff --git a/database/migrations/stats/2026_01_29_164542_create_stat_job_logs_table.php b/database/migrations/stats/2026_01_29_164542_create_stat_job_logs_table.php new file mode 100644 index 0000000..aa6040f --- /dev/null +++ b/database/migrations/stats/2026_01_29_164542_create_stat_job_logs_table.php @@ -0,0 +1,36 @@ +connection)->create('stat_job_logs', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id')->comment('테넌트 ID'); + $table->string('job_type', 100)->comment('작업 유형 (sales_daily, finance_monthly)'); + $table->date('target_date')->comment('집계 대상 날짜'); + $table->enum('status', ['pending', 'running', 'completed', 'failed'])->default('pending'); + $table->unsignedInteger('records_processed')->default(0); + $table->text('error_message')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('completed_at')->nullable(); + $table->unsignedInteger('duration_ms')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index(['tenant_id', 'job_type']); + $table->index('status'); + $table->index('target_date'); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('stat_job_logs'); + } +}; diff --git a/database/migrations/stats/2026_01_29_164742_create_dim_date_table.php b/database/migrations/stats/2026_01_29_164742_create_dim_date_table.php new file mode 100644 index 0000000..2da3326 --- /dev/null +++ b/database/migrations/stats/2026_01_29_164742_create_dim_date_table.php @@ -0,0 +1,33 @@ +connection)->create('dim_date', function (Blueprint $table) { + $table->date('date_key')->primary()->comment('날짜 키'); + $table->smallInteger('year')->comment('연도'); + $table->tinyInteger('quarter')->comment('분기 (1~4)'); + $table->tinyInteger('month')->comment('월'); + $table->tinyInteger('week')->comment('ISO 주차'); + $table->tinyInteger('day_of_week')->comment('요일 (1:월~7:일)'); + $table->tinyInteger('day_of_month')->comment('일'); + $table->boolean('is_weekend')->comment('주말 여부'); + $table->boolean('is_holiday')->default(false)->comment('공휴일 여부'); + $table->string('holiday_name', 100)->nullable()->comment('공휴일명'); + $table->smallInteger('fiscal_year')->nullable()->comment('회계연도'); + $table->tinyInteger('fiscal_quarter')->nullable()->comment('회계분기'); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('dim_date'); + } +}; diff --git a/database/seeders/DimDateSeeder.php b/database/seeders/DimDateSeeder.php new file mode 100644 index 0000000..036b033 --- /dev/null +++ b/database/seeders/DimDateSeeder.php @@ -0,0 +1,81 @@ +getKoreanHolidays(); + + $batch = []; + $batchSize = 500; + + foreach ($period as $date) { + $dateStr = $date->format('Y-m-d'); + $holiday = $holidays[$dateStr] ?? null; + + $batch[] = [ + 'date_key' => $dateStr, + 'year' => $date->year, + 'quarter' => $date->quarter, + 'month' => $date->month, + 'week' => (int) $date->isoWeek(), + 'day_of_week' => $date->dayOfWeekIso, + 'day_of_month' => $date->day, + 'is_weekend' => $date->isWeekend(), + 'is_holiday' => $holiday !== null, + 'holiday_name' => $holiday, + 'fiscal_year' => $date->year, + 'fiscal_quarter' => $date->quarter, + ]; + + if (count($batch) >= $batchSize) { + DB::connection('sam_stat')->table('dim_date')->insert($batch); + $batch = []; + } + } + + if (! empty($batch)) { + DB::connection('sam_stat')->table('dim_date')->insert($batch); + } + + $totalCount = DB::connection('sam_stat')->table('dim_date')->count(); + $this->command->info("dim_date 시딩 완료: {$totalCount}건 (2020-01-01 ~ 2030-12-31)"); + } + + /** + * 한국 공휴일 목록 (고정 공휴일만, 음력 공휴일은 수동 추가 필요) + */ + private function getKoreanHolidays(): array + { + $holidays = []; + + for ($year = 2020; $year <= 2030; $year++) { + // 고정 공휴일 + $fixed = [ + "{$year}-01-01" => '신정', + "{$year}-03-01" => '삼일절', + "{$year}-05-05" => '어린이날', + "{$year}-06-06" => '현충일', + "{$year}-08-15" => '광복절', + "{$year}-10-03" => '개천절', + "{$year}-10-09" => '한글날', + "{$year}-12-25" => '크리스마스', + ]; + + $holidays = array_merge($holidays, $fixed); + } + + return $holidays; + } +}