diff --git a/app/Console/Commands/StatCheckKpiAlertsCommand.php b/app/Console/Commands/StatCheckKpiAlertsCommand.php new file mode 100644 index 0000000..204175c --- /dev/null +++ b/app/Console/Commands/StatCheckKpiAlertsCommand.php @@ -0,0 +1,33 @@ +info('KPI 알림 체크 시작...'); + + $result = $service->checkKpiAlerts(); + + $this->info("알림 생성: {$result['alerts_created']}건"); + + if (! empty($result['errors'])) { + $this->warn('오류 발생:'); + foreach ($result['errors'] as $error) { + $this->error(" - {$error}"); + } + } + + $this->info('KPI 알림 체크 완료.'); + + return empty($result['errors']) ? self::SUCCESS : self::FAILURE; + } +} diff --git a/app/Models/Stats/Daily/StatHrAttendanceDaily.php b/app/Models/Stats/Daily/StatHrAttendanceDaily.php new file mode 100644 index 0000000..91b0444 --- /dev/null +++ b/app/Models/Stats/Daily/StatHrAttendanceDaily.php @@ -0,0 +1,17 @@ + 'date', + 'attendance_rate' => 'decimal:2', + 'overtime_hours' => 'decimal:2', + 'total_labor_cost' => 'decimal:2', + ]; +} diff --git a/app/Models/Stats/Daily/StatInventoryDaily.php b/app/Models/Stats/Daily/StatInventoryDaily.php new file mode 100644 index 0000000..ee9b227 --- /dev/null +++ b/app/Models/Stats/Daily/StatInventoryDaily.php @@ -0,0 +1,22 @@ + 'date', + 'total_stock_qty' => 'decimal:2', + 'total_stock_value' => 'decimal:2', + 'receipt_qty' => 'decimal:2', + 'receipt_amount' => 'decimal:2', + 'issue_qty' => 'decimal:2', + 'issue_amount' => 'decimal:2', + 'inspection_pass_rate' => 'decimal:2', + 'turnover_rate' => 'decimal:2', + ]; +} diff --git a/app/Models/Stats/Daily/StatQuotePipelineDaily.php b/app/Models/Stats/Daily/StatQuotePipelineDaily.php new file mode 100644 index 0000000..2449a83 --- /dev/null +++ b/app/Models/Stats/Daily/StatQuotePipelineDaily.php @@ -0,0 +1,18 @@ + 'date', + 'quote_amount' => 'decimal:2', + 'quote_conversion_rate' => 'decimal:2', + 'prospect_amount' => 'decimal:2', + 'bidding_amount' => 'decimal:2', + ]; +} diff --git a/app/Models/Stats/Dimensions/DimClient.php b/app/Models/Stats/Dimensions/DimClient.php new file mode 100644 index 0000000..9c7dbe6 --- /dev/null +++ b/app/Models/Stats/Dimensions/DimClient.php @@ -0,0 +1,18 @@ + 'date', + 'valid_to' => 'date', + 'is_current' => 'boolean', + ]; +} diff --git a/app/Models/Stats/Dimensions/DimProduct.php b/app/Models/Stats/Dimensions/DimProduct.php new file mode 100644 index 0000000..b8fc9da --- /dev/null +++ b/app/Models/Stats/Dimensions/DimProduct.php @@ -0,0 +1,18 @@ + 'date', + 'valid_to' => 'date', + 'is_current' => 'boolean', + ]; +} diff --git a/app/Models/Stats/StatAlert.php b/app/Models/Stats/StatAlert.php new file mode 100644 index 0000000..ad5fabe --- /dev/null +++ b/app/Models/Stats/StatAlert.php @@ -0,0 +1,19 @@ + 'decimal:2', + 'threshold_value' => 'decimal:2', + 'is_read' => 'boolean', + 'is_resolved' => 'boolean', + 'resolved_at' => 'datetime', + 'created_at' => 'datetime', + ]; +} diff --git a/app/Models/Stats/StatKpiTarget.php b/app/Models/Stats/StatKpiTarget.php new file mode 100644 index 0000000..14fa00f --- /dev/null +++ b/app/Models/Stats/StatKpiTarget.php @@ -0,0 +1,12 @@ + 'decimal:2', + ]; +} diff --git a/app/Services/Stats/DimensionSyncService.php b/app/Services/Stats/DimensionSyncService.php new file mode 100644 index 0000000..8f9ed63 --- /dev/null +++ b/app/Services/Stats/DimensionSyncService.php @@ -0,0 +1,163 @@ +format('Y-m-d'); + $synced = 0; + + $clients = DB::connection('mysql') + ->table('clients') + ->where('tenant_id', $tenantId) + ->select('id', 'tenant_id', 'name', 'client_group_id', 'client_type') + ->get(); + + foreach ($clients as $client) { + $groupName = null; + if ($client->client_group_id) { + $groupName = DB::connection('mysql') + ->table('client_groups') + ->where('id', $client->client_group_id) + ->value('group_name'); + } + + $current = DimClient::where('tenant_id', $tenantId) + ->where('client_id', $client->id) + ->where('is_current', true) + ->first(); + + if (! $current) { + DimClient::create([ + 'tenant_id' => $tenantId, + 'client_id' => $client->id, + 'client_name' => $client->name, + 'client_group_id' => $client->client_group_id, + 'client_group_name' => $groupName, + 'client_type' => $client->client_type, + 'region' => null, + 'valid_from' => $today, + 'valid_to' => null, + 'is_current' => true, + ]); + $synced++; + + continue; + } + + $changed = $current->client_name !== $client->name + || $current->client_group_id != $client->client_group_id + || $current->client_type !== $client->client_type; + + if ($changed) { + $current->update([ + 'valid_to' => $today, + 'is_current' => false, + ]); + + DimClient::create([ + 'tenant_id' => $tenantId, + 'client_id' => $client->id, + 'client_name' => $client->name, + 'client_group_id' => $client->client_group_id, + 'client_group_name' => $groupName, + 'client_type' => $client->client_type, + 'region' => null, + 'valid_from' => $today, + 'valid_to' => null, + 'is_current' => true, + ]); + $synced++; + } + } + + return $synced; + } + + /** + * 제품(품목) 차원 동기화 (SCD Type 2) + */ + public function syncProducts(int $tenantId): int + { + $today = Carbon::today()->format('Y-m-d'); + $synced = 0; + + $items = DB::connection('mysql') + ->table('items') + ->where('tenant_id', $tenantId) + ->where('is_active', true) + ->select('id', 'tenant_id', 'code', 'name', 'item_type', 'category_id') + ->get(); + + foreach ($items as $item) { + $categoryName = null; + if ($item->category_id) { + $categoryName = DB::connection('mysql') + ->table('categories') + ->where('id', $item->category_id) + ->value('name'); + } + + $current = DimProduct::where('tenant_id', $tenantId) + ->where('item_id', $item->id) + ->where('is_current', true) + ->first(); + + if (! $current) { + DimProduct::create([ + 'tenant_id' => $tenantId, + 'item_id' => $item->id, + 'item_code' => $item->code, + 'item_name' => $item->name, + 'item_type' => $item->item_type, + 'category_id' => $item->category_id, + 'category_name' => $categoryName, + 'valid_from' => $today, + 'valid_to' => null, + 'is_current' => true, + ]); + $synced++; + + continue; + } + + $changed = $current->item_name !== $item->name + || $current->item_code !== $item->code + || $current->item_type !== $item->item_type + || $current->category_id != $item->category_id; + + if ($changed) { + $current->update([ + 'valid_to' => $today, + 'is_current' => false, + ]); + + DimProduct::create([ + 'tenant_id' => $tenantId, + 'item_id' => $item->id, + 'item_code' => $item->code, + 'item_name' => $item->name, + 'item_type' => $item->item_type, + 'category_id' => $item->category_id, + 'category_name' => $categoryName, + 'valid_from' => $today, + 'valid_to' => null, + 'is_current' => true, + ]); + $synced++; + } + } + + return $synced; + } +} diff --git a/app/Services/Stats/HrStatService.php b/app/Services/Stats/HrStatService.php new file mode 100644 index 0000000..6b2b71e --- /dev/null +++ b/app/Services/Stats/HrStatService.php @@ -0,0 +1,87 @@ +format('Y-m-d'); + + // 전체 직원 수 (tenant_user_profiles 기준) + $totalEmployees = DB::connection('mysql') + ->table('user_tenants') + ->where('tenant_id', $tenantId) + ->where('is_active', true) + ->count(); + + // 근태 (attendances) + $attendanceStats = DB::connection('mysql') + ->table('attendances') + ->where('tenant_id', $tenantId) + ->where('base_date', $dateStr) + ->whereNull('deleted_at') + ->selectRaw(" + COUNT(*) as total_count, + SUM(CASE WHEN status = 'onTime' THEN 1 ELSE 0 END) as on_time_count, + SUM(CASE WHEN status = 'late' THEN 1 ELSE 0 END) as late_count, + SUM(CASE WHEN status = 'absent' THEN 1 ELSE 0 END) as absent_count, + SUM(CASE WHEN status = 'overtime' THEN 1 ELSE 0 END) as overtime_count + ") + ->first(); + + $attendanceCount = ($attendanceStats->on_time_count ?? 0) + + ($attendanceStats->late_count ?? 0) + + ($attendanceStats->overtime_count ?? 0); + $attendanceRate = $totalEmployees > 0 ? ($attendanceCount / $totalEmployees) * 100 : 0; + + // 휴가 (leaves) + $leaveStats = DB::connection('mysql') + ->table('leaves') + ->where('tenant_id', $tenantId) + ->where('start_date', '<=', $dateStr) + ->where('end_date', '>=', $dateStr) + ->where('status', 'approved') + ->whereNull('deleted_at') + ->selectRaw(" + COUNT(*) as total_count, + SUM(CASE WHEN leave_type = 'annual' THEN 1 ELSE 0 END) as annual_count, + SUM(CASE WHEN leave_type = 'sick' THEN 1 ELSE 0 END) as sick_count, + SUM(CASE WHEN leave_type NOT IN ('annual', 'sick') THEN 1 ELSE 0 END) as other_count + ") + ->first(); + + // 초과근무 (attendances status = 'overtime') + $overtimeCount = $attendanceStats->overtime_count ?? 0; + + StatHrAttendanceDaily::updateOrCreate( + ['tenant_id' => $tenantId, 'stat_date' => $dateStr], + [ + 'total_employees' => $totalEmployees, + 'attendance_count' => $attendanceCount, + 'late_count' => $attendanceStats->late_count ?? 0, + 'absent_count' => $attendanceStats->absent_count ?? 0, + 'attendance_rate' => $attendanceRate, + 'leave_count' => $leaveStats->total_count ?? 0, + 'leave_annual_count' => $leaveStats->annual_count ?? 0, + 'leave_sick_count' => $leaveStats->sick_count ?? 0, + 'leave_other_count' => $leaveStats->other_count ?? 0, + 'overtime_hours' => 0, // attendances에 시간 정보 없음 + 'overtime_employee_count' => $overtimeCount, + 'total_labor_cost' => 0, // 일간 인건비는 급여 정산 시 계산 + ] + ); + + return 1; + } + + public function aggregateMonthly(int $tenantId, int $year, int $month): int + { + // 인사 도메인은 일간 테이블만 운영 (월간은 Phase 4에서 필요시 추가) + return 0; + } +} diff --git a/app/Services/Stats/InventoryStatService.php b/app/Services/Stats/InventoryStatService.php new file mode 100644 index 0000000..4002b3c --- /dev/null +++ b/app/Services/Stats/InventoryStatService.php @@ -0,0 +1,97 @@ +format('Y-m-d'); + + // 재고 현황 (stocks 테이블 - 현재 스냅샷) + $stockSummary = DB::connection('mysql') + ->table('stocks') + ->where('tenant_id', $tenantId) + ->whereNull('deleted_at') + ->selectRaw(' + COUNT(*) as sku_count, + COALESCE(SUM(stock_qty), 0) as total_qty, + SUM(CASE WHEN stock_qty < safety_stock AND safety_stock > 0 THEN 1 ELSE 0 END) as below_safety, + SUM(CASE WHEN stock_qty = 0 THEN 1 ELSE 0 END) as zero_stock, + SUM(CASE WHEN stock_qty > safety_stock * 3 AND safety_stock > 0 THEN 1 ELSE 0 END) as excess_stock + ') + ->first(); + + // 입고 (stock_transactions type = 'receipt') + $receiptStats = DB::connection('mysql') + ->table('stock_transactions') + ->where('tenant_id', $tenantId) + ->whereDate('created_at', $dateStr) + ->where('type', 'receipt') + ->selectRaw('COUNT(*) as cnt, COALESCE(SUM(qty), 0) as total_qty') + ->first(); + + // 출고 (stock_transactions type = 'issue') + $issueStats = DB::connection('mysql') + ->table('stock_transactions') + ->where('tenant_id', $tenantId) + ->whereDate('created_at', $dateStr) + ->where('type', 'issue') + ->selectRaw('COUNT(*) as cnt, COALESCE(SUM(ABS(qty)), 0) as total_qty') + ->first(); + + // 품질검사 (inspections) + $inspectionStats = DB::connection('mysql') + ->table('inspections') + ->where('tenant_id', $tenantId) + ->where('inspection_date', $dateStr) + ->whereNull('deleted_at') + ->selectRaw(" + COUNT(*) as cnt, + SUM(CASE WHEN result = 'pass' THEN 1 ELSE 0 END) as pass_count, + SUM(CASE WHEN result = 'fail' THEN 1 ELSE 0 END) as fail_count + ") + ->first(); + + $inspectionCount = $inspectionStats->cnt ?? 0; + $passCount = $inspectionStats->pass_count ?? 0; + $failCount = $inspectionStats->fail_count ?? 0; + $passRate = $inspectionCount > 0 ? ($passCount / $inspectionCount) * 100 : 0; + + StatInventoryDaily::updateOrCreate( + ['tenant_id' => $tenantId, 'stat_date' => $dateStr], + [ + 'total_sku_count' => $stockSummary->sku_count ?? 0, + 'total_stock_qty' => $stockSummary->total_qty ?? 0, + 'total_stock_value' => 0, // 단가 정보 없어 Phase 4에서 보완 + 'receipt_count' => $receiptStats->cnt ?? 0, + 'receipt_qty' => $receiptStats->total_qty ?? 0, + 'receipt_amount' => 0, + 'issue_count' => $issueStats->cnt ?? 0, + 'issue_qty' => $issueStats->total_qty ?? 0, + 'issue_amount' => 0, + 'below_safety_count' => $stockSummary->below_safety ?? 0, + 'zero_stock_count' => $stockSummary->zero_stock ?? 0, + 'excess_stock_count' => $stockSummary->excess_stock ?? 0, + 'inspection_count' => $inspectionCount, + 'inspection_pass_count' => $passCount, + 'inspection_fail_count' => $failCount, + 'inspection_pass_rate' => $passRate, + 'turnover_rate' => 0, // 월간 집계에서 계산 + ] + ); + + return 1; + } + + public function aggregateMonthly(int $tenantId, int $year, int $month): int + { + // 재고 도메인은 일간 스냅샷 기반이므로 별도 월간 테이블 없음 + // 필요시 Phase 4에서 stat_inventory_monthly 추가 + return 0; + } +} diff --git a/app/Services/Stats/KpiAlertService.php b/app/Services/Stats/KpiAlertService.php new file mode 100644 index 0000000..d0119fb --- /dev/null +++ b/app/Services/Stats/KpiAlertService.php @@ -0,0 +1,148 @@ +get(); + + foreach ($tenants as $tenant) { + try { + $alertsCreated += $this->checkTenantKpi($tenant->id); + } catch (\Throwable $e) { + $errors[] = "tenant={$tenant->id}: {$e->getMessage()}"; + Log::error('stat:check-kpi-alerts 실패', [ + 'tenant_id' => $tenant->id, + 'error' => $e->getMessage(), + ]); + } + } + + return [ + 'alerts_created' => $alertsCreated, + 'errors' => $errors, + ]; + } + + private function checkTenantKpi(int $tenantId): int + { + $now = Carbon::now(); + $year = $now->year; + $month = $now->month; + $alertsCreated = 0; + + $targets = StatKpiTarget::where('tenant_id', $tenantId) + ->where('stat_year', $year) + ->where(function ($q) use ($month) { + $q->where('stat_month', $month)->orWhereNull('stat_month'); + }) + ->get(); + + foreach ($targets as $target) { + $currentValue = $this->getCurrentValue($tenantId, $target->domain, $target->metric_code, $year, $month); + + if ($currentValue === null) { + continue; + } + + $ratio = $target->target_value > 0 + ? ($currentValue / $target->target_value) * 100 + : 0; + + if ($ratio < 50) { + $severity = 'critical'; + } elseif ($ratio < 80) { + $severity = 'warning'; + } else { + continue; // 80% 이상이면 알림 불필요 + } + + StatAlert::create([ + 'tenant_id' => $tenantId, + 'domain' => $target->domain, + 'alert_type' => 'below_target', + 'severity' => $severity, + 'title' => "{$target->description} 목표 미달 (".number_format($ratio, 0).'%)', + 'message' => "KPI '{$target->metric_code}' 현재값: {$currentValue}, 목표값: {$target->target_value} ({$target->unit})", + 'metric_code' => $target->metric_code, + 'current_value' => $currentValue, + 'threshold_value' => $target->target_value, + 'created_at' => now(), + ]); + $alertsCreated++; + } + + return $alertsCreated; + } + + private function getCurrentValue(int $tenantId, string $domain, string $metricCode, int $year, int $month): ?float + { + return match ($domain) { + 'sales' => $this->getSalesMetric($tenantId, $metricCode, $year, $month), + 'finance' => $this->getFinanceMetric($tenantId, $metricCode, $year, $month), + default => null, + }; + } + + private function getSalesMetric(int $tenantId, string $metricCode, int $year, int $month): ?float + { + $monthly = StatSalesMonthly::where('tenant_id', $tenantId) + ->where('stat_year', $year) + ->where('stat_month', $month) + ->first(); + + if (! $monthly) { + // 월간 미집계 시 일간 합산 + $daily = StatSalesDaily::where('tenant_id', $tenantId) + ->whereYear('stat_date', $year) + ->whereMonth('stat_date', $month) + ->selectRaw('SUM(order_amount) as order_total, SUM(sales_amount) as sales_total') + ->first(); + + return match ($metricCode) { + 'monthly_order_amount' => (float) ($daily->order_total ?? 0), + 'monthly_sales_amount' => (float) ($daily->sales_total ?? 0), + default => null, + }; + } + + return match ($metricCode) { + 'monthly_order_amount' => (float) $monthly->order_amount, + 'monthly_sales_amount' => (float) $monthly->sales_amount, + 'monthly_order_count' => (float) $monthly->order_count, + default => null, + }; + } + + private function getFinanceMetric(int $tenantId, string $metricCode, int $year, int $month): ?float + { + $daily = StatFinanceDaily::where('tenant_id', $tenantId) + ->whereYear('stat_date', $year) + ->whereMonth('stat_date', $month) + ->selectRaw('SUM(deposit_amount) as deposit_total, SUM(withdrawal_amount) as withdrawal_total') + ->first(); + + return match ($metricCode) { + 'monthly_deposit_amount' => (float) ($daily->deposit_total ?? 0), + 'monthly_withdrawal_amount' => (float) ($daily->withdrawal_total ?? 0), + default => null, + }; + } +} diff --git a/app/Services/Stats/QuoteStatService.php b/app/Services/Stats/QuoteStatService.php new file mode 100644 index 0000000..5a09be9 --- /dev/null +++ b/app/Services/Stats/QuoteStatService.php @@ -0,0 +1,93 @@ +format('Y-m-d'); + + // 견적 (quotes) + $quoteStats = DB::connection('mysql') + ->table('quotes') + ->where('tenant_id', $tenantId) + ->where('registration_date', $dateStr) + ->whereNull('deleted_at') + ->selectRaw(" + COUNT(*) as created_count, + COALESCE(SUM(total_amount), 0) as total_amount, + SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) as approved_count, + SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) as rejected_count, + SUM(CASE WHEN status = 'converted' THEN 1 ELSE 0 END) as conversion_count + ") + ->first(); + + $createdCount = $quoteStats->created_count ?? 0; + $conversionCount = $quoteStats->conversion_count ?? 0; + $conversionRate = $createdCount > 0 ? ($conversionCount / $createdCount) * 100 : 0; + + // 입찰 (biddings) + $biddingStats = DB::connection('mysql') + ->table('biddings') + ->where('tenant_id', $tenantId) + ->where('bidding_date', $dateStr) + ->whereNull('deleted_at') + ->selectRaw(" + COUNT(*) as cnt, + SUM(CASE WHEN status = 'won' THEN 1 ELSE 0 END) as won_count, + COALESCE(SUM(bidding_amount), 0) as total_amount + ") + ->first(); + + // 상담 (sales_prospect_consultations) + $consultationCount = DB::connection('mysql') + ->table('sales_prospect_consultations') + ->whereDate('created_at', $dateStr) + ->count(); + + // 영업 기회 (sales_prospects - tenant_id 없음, created_at 기반) + $prospectStats = DB::connection('mysql') + ->table('sales_prospects') + ->whereDate('created_at', $dateStr) + ->whereNull('deleted_at') + ->selectRaw(" + COUNT(*) as created_count, + SUM(CASE WHEN status = 'contracted' THEN 1 ELSE 0 END) as won_count, + SUM(CASE WHEN status = 'lost' THEN 1 ELSE 0 END) as lost_count + ") + ->first(); + + StatQuotePipelineDaily::updateOrCreate( + ['tenant_id' => $tenantId, 'stat_date' => $dateStr], + [ + 'quote_created_count' => $createdCount, + 'quote_amount' => $quoteStats->total_amount ?? 0, + 'quote_approved_count' => $quoteStats->approved_count ?? 0, + 'quote_rejected_count' => $quoteStats->rejected_count ?? 0, + 'quote_conversion_count' => $conversionCount, + 'quote_conversion_rate' => $conversionRate, + 'prospect_created_count' => $prospectStats->created_count ?? 0, + 'prospect_won_count' => $prospectStats->won_count ?? 0, + 'prospect_lost_count' => $prospectStats->lost_count ?? 0, + 'prospect_amount' => 0, // sales_prospects에 금액 컬럼 없음 + 'bidding_count' => $biddingStats->cnt ?? 0, + 'bidding_won_count' => $biddingStats->won_count ?? 0, + 'bidding_amount' => $biddingStats->total_amount ?? 0, + 'consultation_count' => $consultationCount, + ] + ); + + return 1; + } + + public function aggregateMonthly(int $tenantId, int $year, int $month): int + { + // 견적 도메인은 일간 테이블만 운영 (월간은 Phase 4에서 필요시 추가) + return 0; + } +} diff --git a/app/Services/Stats/StatAggregatorService.php b/app/Services/Stats/StatAggregatorService.php index 03597fd..6dcc191 100644 --- a/app/Services/Stats/StatAggregatorService.php +++ b/app/Services/Stats/StatAggregatorService.php @@ -18,6 +18,9 @@ private function getDailyDomainServices(): array 'sales' => SalesStatService::class, 'finance' => FinanceStatService::class, 'production' => ProductionStatService::class, + 'inventory' => InventoryStatService::class, + 'quote' => QuoteStatService::class, + 'hr' => HrStatService::class, ]; } @@ -30,6 +33,9 @@ private function getMonthlyDomainServices(): array 'sales' => SalesStatService::class, 'finance' => FinanceStatService::class, 'production' => ProductionStatService::class, + 'inventory' => InventoryStatService::class, + 'quote' => QuoteStatService::class, + 'hr' => HrStatService::class, ]; } diff --git a/database/migrations/stats/2026_01_29_193546_create_dim_client_table.php b/database/migrations/stats/2026_01_29_193546_create_dim_client_table.php new file mode 100644 index 0000000..7c02b5d --- /dev/null +++ b/database/migrations/stats/2026_01_29_193546_create_dim_client_table.php @@ -0,0 +1,35 @@ +connection)->create('dim_client', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('client_id')->comment('원본 clients.id'); + $table->string('client_name', 200); + $table->unsignedBigInteger('client_group_id')->nullable(); + $table->string('client_group_name', 200)->nullable(); + $table->string('client_type', 50)->nullable()->comment('고객/공급업체/양쪽'); + $table->string('region', 100)->nullable(); + $table->date('valid_from'); + $table->date('valid_to')->nullable()->comment('NULL = 현재 유효'); + $table->boolean('is_current')->default(true); + + $table->index(['tenant_id', 'client_id'], 'idx_tenant_client'); + $table->index('is_current', 'idx_current'); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('dim_client'); + } +}; \ No newline at end of file diff --git a/database/migrations/stats/2026_01_29_193546_create_dim_product_table.php b/database/migrations/stats/2026_01_29_193546_create_dim_product_table.php new file mode 100644 index 0000000..60b29aa --- /dev/null +++ b/database/migrations/stats/2026_01_29_193546_create_dim_product_table.php @@ -0,0 +1,35 @@ +connection)->create('dim_product', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->unsignedBigInteger('item_id')->comment('원본 items.id'); + $table->string('item_code', 100); + $table->string('item_name', 300); + $table->string('item_type', 50)->nullable()->comment('item_type from items'); + $table->unsignedBigInteger('category_id')->nullable(); + $table->string('category_name', 200)->nullable(); + $table->date('valid_from'); + $table->date('valid_to')->nullable()->comment('NULL = 현재 유효'); + $table->boolean('is_current')->default(true); + + $table->index(['tenant_id', 'item_id'], 'idx_tenant_item'); + $table->index('is_current', 'idx_current'); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('dim_product'); + } +}; \ No newline at end of file diff --git a/database/migrations/stats/2026_01_29_193547_create_stat_inventory_daily_table.php b/database/migrations/stats/2026_01_29_193547_create_stat_inventory_daily_table.php new file mode 100644 index 0000000..fe87481 --- /dev/null +++ b/database/migrations/stats/2026_01_29_193547_create_stat_inventory_daily_table.php @@ -0,0 +1,56 @@ +connection)->create('stat_inventory_daily', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->date('stat_date'); + + // 재고 현황 + $table->unsignedInteger('total_sku_count')->default(0)->comment('총 SKU 수'); + $table->decimal('total_stock_qty', 18, 2)->default(0)->comment('총 재고 수량'); + $table->decimal('total_stock_value', 18, 2)->default(0)->comment('총 재고 금액'); + + // 입출고 + $table->unsignedInteger('receipt_count')->default(0)->comment('입고 건수'); + $table->decimal('receipt_qty', 18, 2)->default(0); + $table->decimal('receipt_amount', 18, 2)->default(0); + $table->unsignedInteger('issue_count')->default(0)->comment('출고 건수'); + $table->decimal('issue_qty', 18, 2)->default(0); + $table->decimal('issue_amount', 18, 2)->default(0); + + // 안전재고 + $table->unsignedInteger('below_safety_count')->default(0)->comment('안전재고 미달 품목 수'); + $table->unsignedInteger('zero_stock_count')->default(0)->comment('재고 0 품목 수'); + $table->unsignedInteger('excess_stock_count')->default(0)->comment('과잉 재고 품목 수'); + + // 품질검사 + $table->unsignedInteger('inspection_count')->default(0); + $table->unsignedInteger('inspection_pass_count')->default(0); + $table->unsignedInteger('inspection_fail_count')->default(0); + $table->decimal('inspection_pass_rate', 5, 2)->default(0)->comment('합격률 (%)'); + + // 재고회전 + $table->decimal('turnover_rate', 8, 2)->default(0)->comment('재고회전율'); + + $table->timestamps(); + + $table->unique(['tenant_id', 'stat_date'], 'uk_tenant_date'); + $table->index('stat_date'); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('stat_inventory_daily'); + } +}; \ No newline at end of file diff --git a/database/migrations/stats/2026_01_29_193547_create_stat_quote_pipeline_daily_table.php b/database/migrations/stats/2026_01_29_193547_create_stat_quote_pipeline_daily_table.php new file mode 100644 index 0000000..721af7d --- /dev/null +++ b/database/migrations/stats/2026_01_29_193547_create_stat_quote_pipeline_daily_table.php @@ -0,0 +1,51 @@ +connection)->create('stat_quote_pipeline_daily', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->date('stat_date'); + + // 견적 + $table->unsignedInteger('quote_created_count')->default(0); + $table->decimal('quote_amount', 18, 2)->default(0); + $table->unsignedInteger('quote_approved_count')->default(0); + $table->unsignedInteger('quote_rejected_count')->default(0); + $table->unsignedInteger('quote_conversion_count')->default(0)->comment('수주 전환 건수'); + $table->decimal('quote_conversion_rate', 5, 2)->default(0)->comment('전환율 (%)'); + + // 영업 기회 (sales_prospects - tenant_id 없어 manager_id로 연결) + $table->unsignedInteger('prospect_created_count')->default(0); + $table->unsignedInteger('prospect_won_count')->default(0); + $table->unsignedInteger('prospect_lost_count')->default(0); + $table->decimal('prospect_amount', 18, 2)->default(0)->comment('파이프라인 금액'); + + // 입찰 + $table->unsignedInteger('bidding_count')->default(0); + $table->unsignedInteger('bidding_won_count')->default(0); + $table->decimal('bidding_amount', 18, 2)->default(0); + + // 상담 + $table->unsignedInteger('consultation_count')->default(0); + + $table->timestamps(); + + $table->unique(['tenant_id', 'stat_date'], 'uk_tenant_date'); + $table->index('stat_date'); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('stat_quote_pipeline_daily'); + } +}; \ No newline at end of file diff --git a/database/migrations/stats/2026_01_29_193548_create_stat_hr_attendance_daily_table.php b/database/migrations/stats/2026_01_29_193548_create_stat_hr_attendance_daily_table.php new file mode 100644 index 0000000..e942c75 --- /dev/null +++ b/database/migrations/stats/2026_01_29_193548_create_stat_hr_attendance_daily_table.php @@ -0,0 +1,49 @@ +connection)->create('stat_hr_attendance_daily', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->date('stat_date'); + + // 근태 + $table->unsignedInteger('total_employees')->default(0)->comment('전체 직원 수'); + $table->unsignedInteger('attendance_count')->default(0)->comment('출근 인원'); + $table->unsignedInteger('late_count')->default(0)->comment('지각'); + $table->unsignedInteger('absent_count')->default(0)->comment('결근'); + $table->decimal('attendance_rate', 5, 2)->default(0)->comment('출근율 (%)'); + + // 휴가 + $table->unsignedInteger('leave_count')->default(0)->comment('휴가 사용'); + $table->unsignedInteger('leave_annual_count')->default(0)->comment('연차'); + $table->unsignedInteger('leave_sick_count')->default(0)->comment('병가'); + $table->unsignedInteger('leave_other_count')->default(0)->comment('기타'); + + // 초과근무 + $table->decimal('overtime_hours', 10, 2)->default(0); + $table->unsignedInteger('overtime_employee_count')->default(0); + + // 인건비 + $table->decimal('total_labor_cost', 18, 2)->default(0); + + $table->timestamps(); + + $table->unique(['tenant_id', 'stat_date'], 'uk_tenant_date'); + $table->index('stat_date'); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('stat_hr_attendance_daily'); + } +}; \ No newline at end of file diff --git a/database/migrations/stats/2026_01_29_193548_create_stat_kpi_targets_table.php b/database/migrations/stats/2026_01_29_193548_create_stat_kpi_targets_table.php new file mode 100644 index 0000000..a05695c --- /dev/null +++ b/database/migrations/stats/2026_01_29_193548_create_stat_kpi_targets_table.php @@ -0,0 +1,37 @@ +connection)->create('stat_kpi_targets', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->smallInteger('stat_year'); + $table->tinyInteger('stat_month')->nullable()->comment('NULL = 연간 목표'); + + $table->string('domain', 50)->comment('sales, production, finance 등'); + $table->string('metric_code', 100)->comment('monthly_sales_amount 등'); + $table->decimal('target_value', 18, 2); + $table->string('unit', 20)->default('KRW')->comment('KRW, %, count, hours'); + $table->string('description', 300)->nullable(); + + $table->unsignedBigInteger('created_by')->nullable(); + $table->timestamps(); + + $table->unique(['tenant_id', 'stat_year', 'stat_month', 'metric_code'], 'uk_tenant_metric'); + $table->index('domain', 'idx_domain'); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('stat_kpi_targets'); + } +}; \ No newline at end of file diff --git a/database/migrations/stats/2026_01_29_193549_create_stat_alerts_table.php b/database/migrations/stats/2026_01_29_193549_create_stat_alerts_table.php new file mode 100644 index 0000000..38972d3 --- /dev/null +++ b/database/migrations/stats/2026_01_29_193549_create_stat_alerts_table.php @@ -0,0 +1,40 @@ +connection)->create('stat_alerts', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->string('domain', 50); + $table->string('alert_type', 100)->comment('below_target, anomaly, threshold'); + $table->enum('severity', ['info', 'warning', 'critical'])->default('info'); + $table->string('title', 300); + $table->text('message'); + $table->string('metric_code', 100)->nullable(); + $table->decimal('current_value', 18, 2)->nullable(); + $table->decimal('threshold_value', 18, 2)->nullable(); + $table->boolean('is_read')->default(false); + $table->boolean('is_resolved')->default(false); + $table->timestamp('resolved_at')->nullable(); + $table->unsignedBigInteger('resolved_by')->nullable(); + $table->timestamp('created_at')->nullable(); + + $table->index(['tenant_id', 'is_read'], 'idx_tenant_unread'); + $table->index('severity', 'idx_severity'); + $table->index('domain', 'idx_domain'); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('stat_alerts'); + } +}; \ No newline at end of file diff --git a/routes/console.php b/routes/console.php index 0e9025a..190c612 100644 --- a/routes/console.php +++ b/routes/console.php @@ -119,3 +119,14 @@ ->onFailure(function () { \Illuminate\Support\Facades\Log::error('❌ stat:aggregate-monthly 스케줄러 실행 실패', ['time' => now()]); }); + +// 매일 오전 09:00에 KPI 목표 대비 알림 체크 +Schedule::command('stat:check-kpi-alerts') + ->dailyAt('09:00') + ->appendOutputTo(storage_path('logs/scheduler.log')) + ->onSuccess(function () { + \Illuminate\Support\Facades\Log::info('✅ stat:check-kpi-alerts 스케줄러 실행 성공', ['time' => now()]); + }) + ->onFailure(function () { + \Illuminate\Support\Facades\Log::error('❌ stat:check-kpi-alerts 스케줄러 실행 실패', ['time' => now()]); + });