feat: sam_stat 통계 DB 인프라 구축 (Phase 1)
- sam_stat DB 연결 설정 (config/database.php, .env) - 메타 테이블 마이그레이션 (stat_definitions, stat_job_logs) - dim_date 차원 테이블 + DimDateSeeder (2020~2030, 4018건) - 기반 모델: BaseStatModel, StatDefinition, StatJobLog, DimDate - 집계 커맨드: stat:aggregate-daily, stat:aggregate-monthly - StatAggregatorService + StatDomainServiceInterface 골격 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
60
app/Console/Commands/StatAggregateDailyCommand.php
Normal file
60
app/Console/Commands/StatAggregateDailyCommand.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Stats\StatAggregatorService;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class StatAggregateDailyCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'stat:aggregate-daily
|
||||||
|
{--date= : 집계 대상 날짜 (YYYY-MM-DD, 기본: 전일)}
|
||||||
|
{--domain= : 특정 도메인만 집계 (sales, finance, production)}
|
||||||
|
{--tenant= : 특정 테넌트만 집계}';
|
||||||
|
|
||||||
|
protected $description = '일간 통계 집계 (sam_stat DB)';
|
||||||
|
|
||||||
|
public function handle(StatAggregatorService $aggregator): int
|
||||||
|
{
|
||||||
|
$date = $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Console/Commands/StatAggregateMonthlyCommand.php
Normal file
54
app/Console/Commands/StatAggregateMonthlyCommand.php
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Services\Stats\StatAggregatorService;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class StatAggregateMonthlyCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'stat:aggregate-monthly
|
||||||
|
{--year= : 집계 대상 연도 (기본: 전월 기준)}
|
||||||
|
{--month= : 집계 대상 월 (기본: 전월)}
|
||||||
|
{--domain= : 특정 도메인만 집계}
|
||||||
|
{--tenant= : 특정 테넌트만 집계}';
|
||||||
|
|
||||||
|
protected $description = '월간 통계 집계 (sam_stat DB)';
|
||||||
|
|
||||||
|
public function handle(StatAggregatorService $aggregator): int
|
||||||
|
{
|
||||||
|
$lastMonth = Carbon::now()->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
app/Models/Stats/BaseStatModel.php
Normal file
12
app/Models/Stats/BaseStatModel.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Stats;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
abstract class BaseStatModel extends Model
|
||||||
|
{
|
||||||
|
protected $connection = 'sam_stat';
|
||||||
|
|
||||||
|
protected $guarded = ['id'];
|
||||||
|
}
|
||||||
24
app/Models/Stats/Dimensions/DimDate.php
Normal file
24
app/Models/Stats/Dimensions/DimDate.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Stats\Dimensions;
|
||||||
|
|
||||||
|
use App\Models\Stats\BaseStatModel;
|
||||||
|
|
||||||
|
class DimDate extends BaseStatModel
|
||||||
|
{
|
||||||
|
protected $table = 'dim_date';
|
||||||
|
|
||||||
|
protected $primaryKey = 'date_key';
|
||||||
|
|
||||||
|
public $incrementing = false;
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $keyType = 'string';
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'date_key' => 'date',
|
||||||
|
'is_weekend' => 'boolean',
|
||||||
|
'is_holiday' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
14
app/Models/Stats/StatDefinition.php
Normal file
14
app/Models/Stats/StatDefinition.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Stats;
|
||||||
|
|
||||||
|
class StatDefinition extends BaseStatModel
|
||||||
|
{
|
||||||
|
protected $table = 'stat_definitions';
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'source_tables' => 'array',
|
||||||
|
'config' => 'array',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
53
app/Models/Stats/StatJobLog.php
Normal file
53
app/Models/Stats/StatJobLog.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models\Stats;
|
||||||
|
|
||||||
|
class StatJobLog extends BaseStatModel
|
||||||
|
{
|
||||||
|
protected $table = 'stat_job_logs';
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'target_date' => '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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
187
app/Services/Stats/StatAggregatorService.php
Normal file
187
app/Services/Stats/StatAggregatorService.php
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Stats;
|
||||||
|
|
||||||
|
use App\Models\Stats\StatJobLog;
|
||||||
|
use App\Models\Tenants\Tenant;
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class StatAggregatorService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 일간 도메인 서비스 매핑 (Phase 2에서 구현체 추가)
|
||||||
|
*/
|
||||||
|
private function getDailyDomainServices(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// 'sales' => 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(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Services/Stats/StatDomainServiceInterface.php
Normal file
22
app/Services/Stats/StatDomainServiceInterface.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Stats;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
interface StatDomainServiceInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 일간 집계
|
||||||
|
*
|
||||||
|
* @return int 처리된 레코드 수
|
||||||
|
*/
|
||||||
|
public function aggregateDaily(int $tenantId, Carbon $date): int;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 월간 집계
|
||||||
|
*
|
||||||
|
* @return int 처리된 레코드 수
|
||||||
|
*/
|
||||||
|
public function aggregateMonthly(int $tenantId, int $year, int $month): int;
|
||||||
|
}
|
||||||
@@ -62,6 +62,26 @@
|
|||||||
]) : [],
|
]) : [],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// 통계 전용 DB (sam_stat)
|
||||||
|
'sam_stat' => [
|
||||||
|
'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)
|
// 5130 레거시 DB (chandj)
|
||||||
'chandj' => [
|
'chandj' => [
|
||||||
'driver' => 'mysql',
|
'driver' => 'mysql',
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
protected $connection = 'sam_stat';
|
||||||
|
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::connection($this->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
protected $connection = 'sam_stat';
|
||||||
|
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::connection($this->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
protected $connection = 'sam_stat';
|
||||||
|
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::connection($this->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
81
database/seeders/DimDateSeeder.php
Normal file
81
database/seeders/DimDateSeeder.php
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Carbon\CarbonPeriod;
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class DimDateSeeder extends Seeder
|
||||||
|
{
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
$start = Carbon::create(2020, 1, 1);
|
||||||
|
$end = Carbon::create(2030, 12, 31);
|
||||||
|
$period = CarbonPeriod::create($start, $end);
|
||||||
|
|
||||||
|
$holidays = $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user