diff --git a/app/Models/Stats/Daily/StatFinanceDaily.php b/app/Models/Stats/Daily/StatFinanceDaily.php new file mode 100644 index 0000000..033df9e --- /dev/null +++ b/app/Models/Stats/Daily/StatFinanceDaily.php @@ -0,0 +1,26 @@ + 'date', + 'deposit_amount' => 'decimal:2', + 'withdrawal_amount' => 'decimal:2', + 'net_cashflow' => 'decimal:2', + 'purchase_amount' => 'decimal:2', + 'purchase_tax_amount' => 'decimal:2', + 'receivable_balance' => 'decimal:2', + 'payable_balance' => 'decimal:2', + 'overdue_receivable' => 'decimal:2', + 'bill_issued_amount' => 'decimal:2', + 'bill_matured_amount' => 'decimal:2', + 'card_transaction_amount' => 'decimal:2', + 'bank_balance_total' => 'decimal:2', + ]; +} diff --git a/app/Models/Stats/Daily/StatProductionDaily.php b/app/Models/Stats/Daily/StatProductionDaily.php new file mode 100644 index 0000000..9674a4c --- /dev/null +++ b/app/Models/Stats/Daily/StatProductionDaily.php @@ -0,0 +1,21 @@ + 'date', + 'production_qty' => 'decimal:2', + 'defect_qty' => 'decimal:2', + 'defect_rate' => 'decimal:2', + 'planned_hours' => 'decimal:2', + 'actual_hours' => 'decimal:2', + 'efficiency_rate' => 'decimal:2', + 'delivery_rate' => 'decimal:2', + ]; +} diff --git a/app/Models/Stats/Daily/StatSalesDaily.php b/app/Models/Stats/Daily/StatSalesDaily.php new file mode 100644 index 0000000..6fe01bd --- /dev/null +++ b/app/Models/Stats/Daily/StatSalesDaily.php @@ -0,0 +1,18 @@ + 'date', + 'order_amount' => 'decimal:2', + 'sales_amount' => 'decimal:2', + 'sales_tax_amount' => 'decimal:2', + 'shipment_amount' => 'decimal:2', + ]; +} diff --git a/app/Models/Stats/Monthly/StatFinanceMonthly.php b/app/Models/Stats/Monthly/StatFinanceMonthly.php new file mode 100644 index 0000000..031085b --- /dev/null +++ b/app/Models/Stats/Monthly/StatFinanceMonthly.php @@ -0,0 +1,22 @@ + 'decimal:2', + 'withdrawal_total' => 'decimal:2', + 'net_cashflow' => 'decimal:2', + 'purchase_total' => 'decimal:2', + 'card_total' => 'decimal:2', + 'receivable_end' => 'decimal:2', + 'payable_end' => 'decimal:2', + 'bank_balance_end' => 'decimal:2', + 'mom_cashflow_change' => 'decimal:2', + ]; +} diff --git a/app/Models/Stats/Monthly/StatProductionMonthly.php b/app/Models/Stats/Monthly/StatProductionMonthly.php new file mode 100644 index 0000000..86c33eb --- /dev/null +++ b/app/Models/Stats/Monthly/StatProductionMonthly.php @@ -0,0 +1,20 @@ + 'decimal:2', + 'defect_qty' => 'decimal:2', + 'avg_defect_rate' => 'decimal:2', + 'avg_efficiency_rate' => 'decimal:2', + 'avg_delivery_rate' => 'decimal:2', + 'total_planned_hours' => 'decimal:2', + 'total_actual_hours' => 'decimal:2', + ]; +} diff --git a/app/Models/Stats/Monthly/StatSalesMonthly.php b/app/Models/Stats/Monthly/StatSalesMonthly.php new file mode 100644 index 0000000..49a846e --- /dev/null +++ b/app/Models/Stats/Monthly/StatSalesMonthly.php @@ -0,0 +1,20 @@ + 'decimal:2', + 'sales_amount' => 'decimal:2', + 'shipment_amount' => 'decimal:2', + 'avg_order_amount' => 'decimal:2', + 'top_client_amount' => 'decimal:2', + 'mom_growth_rate' => 'decimal:2', + 'yoy_growth_rate' => 'decimal:2', + ]; +} diff --git a/app/Models/Stats/StatJobLog.php b/app/Models/Stats/StatJobLog.php index c4d350a..b0f79b4 100644 --- a/app/Models/Stats/StatJobLog.php +++ b/app/Models/Stats/StatJobLog.php @@ -26,7 +26,7 @@ public function markRunning(): void public function markCompleted(int $recordsProcessed = 0): void { $durationMs = $this->started_at - ? (int) now()->diffInMilliseconds($this->started_at) + ? abs((int) now()->diffInMilliseconds($this->started_at)) : null; $this->update([ @@ -40,7 +40,7 @@ public function markCompleted(int $recordsProcessed = 0): void public function markFailed(string $errorMessage): void { $durationMs = $this->started_at - ? (int) now()->diffInMilliseconds($this->started_at) + ? abs((int) now()->diffInMilliseconds($this->started_at)) : null; $this->update([ diff --git a/app/Services/Stats/FinanceStatService.php b/app/Services/Stats/FinanceStatService.php new file mode 100644 index 0000000..376f959 --- /dev/null +++ b/app/Services/Stats/FinanceStatService.php @@ -0,0 +1,172 @@ +format('Y-m-d'); + + // 입금 (deposits) + $depositStats = DB::connection('mysql') + ->table('deposits') + ->where('tenant_id', $tenantId) + ->where('deposit_date', $dateStr) + ->whereNull('deleted_at') + ->selectRaw('COUNT(*) as cnt, COALESCE(SUM(amount), 0) as total') + ->first(); + + // 출금 (withdrawals) + $withdrawalStats = DB::connection('mysql') + ->table('withdrawals') + ->where('tenant_id', $tenantId) + ->where('withdrawal_date', $dateStr) + ->whereNull('deleted_at') + ->selectRaw('COUNT(*) as cnt, COALESCE(SUM(amount), 0) as total') + ->first(); + + // 매입 (purchases) + $purchaseStats = DB::connection('mysql') + ->table('purchases') + ->where('tenant_id', $tenantId) + ->where('purchase_date', $dateStr) + ->whereNull('deleted_at') + ->selectRaw(' + COUNT(*) as cnt, + COALESCE(SUM(supply_amount), 0) as supply_total, + COALESCE(SUM(tax_amount), 0) as tax_total + ') + ->first(); + + // 어음 발행 (bills - issued on this date) + $billIssuedStats = DB::connection('mysql') + ->table('bills') + ->where('tenant_id', $tenantId) + ->where('issue_date', $dateStr) + ->whereNull('deleted_at') + ->selectRaw('COUNT(*) as cnt, COALESCE(SUM(amount), 0) as total') + ->first(); + + // 어음 만기 (bills - matured on this date) + $billMaturedStats = DB::connection('mysql') + ->table('bills') + ->where('tenant_id', $tenantId) + ->where('maturity_date', $dateStr) + ->whereNull('deleted_at') + ->selectRaw('COUNT(*) as cnt, COALESCE(SUM(amount), 0) as total') + ->first(); + + // 카드 거래 (withdrawals with card_id) + $cardStats = DB::connection('mysql') + ->table('withdrawals') + ->where('tenant_id', $tenantId) + ->where('withdrawal_date', $dateStr) + ->whereNotNull('card_id') + ->whereNull('deleted_at') + ->selectRaw('COUNT(*) as cnt, COALESCE(SUM(amount), 0) as total') + ->first(); + + // 은행 잔액 합계 (bank_transactions - 계좌별 최신 잔액) + $bankBalance = DB::connection('mysql') + ->query() + ->fromSub(function ($query) use ($tenantId, $dateStr) { + $query->from('bank_transactions') + ->where('tenant_id', $tenantId) + ->where('transaction_date', '<=', $dateStr) + ->whereNull('deleted_at') + ->selectRaw('bank_account_id, balance_after as latest_balance, + ROW_NUMBER() OVER(PARTITION BY bank_account_id ORDER BY transaction_date DESC, id DESC) as rn'); + }, 'sub') + ->where('rn', 1) + ->selectRaw('COALESCE(SUM(latest_balance), 0) as total_balance') + ->first(); + + $depositAmount = $depositStats->total ?? 0; + $withdrawalAmount = $withdrawalStats->total ?? 0; + + StatFinanceDaily::updateOrCreate( + ['tenant_id' => $tenantId, 'stat_date' => $dateStr], + [ + 'deposit_count' => $depositStats->cnt ?? 0, + 'deposit_amount' => $depositAmount, + 'withdrawal_count' => $withdrawalStats->cnt ?? 0, + 'withdrawal_amount' => $withdrawalAmount, + 'net_cashflow' => $depositAmount - $withdrawalAmount, + 'purchase_count' => $purchaseStats->cnt ?? 0, + 'purchase_amount' => $purchaseStats->supply_total ?? 0, + 'purchase_tax_amount' => $purchaseStats->tax_total ?? 0, + 'receivable_balance' => 0, // Phase 3에서 미수금 로직 추가 + 'payable_balance' => 0, + 'overdue_receivable' => 0, + 'bill_issued_count' => $billIssuedStats->cnt ?? 0, + 'bill_issued_amount' => $billIssuedStats->total ?? 0, + 'bill_matured_count' => $billMaturedStats->cnt ?? 0, + 'bill_matured_amount' => $billMaturedStats->total ?? 0, + 'card_transaction_count' => $cardStats->cnt ?? 0, + 'card_transaction_amount' => $cardStats->total ?? 0, + 'bank_balance_total' => $bankBalance->total_balance ?? 0, + ] + ); + + return 1; + } + + public function aggregateMonthly(int $tenantId, int $year, int $month): int + { + $dailySum = 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, + SUM(net_cashflow) as net_cashflow, + SUM(purchase_amount) as purchase_total, + SUM(card_transaction_amount) as card_total + ') + ->first(); + + // 월말 데이터 (해당 월의 마지막 일간 레코드) + $lastDay = StatFinanceDaily::where('tenant_id', $tenantId) + ->whereYear('stat_date', $year) + ->whereMonth('stat_date', $month) + ->orderByDesc('stat_date') + ->first(); + + // 전월 대비 현금흐름 변화 + $prevMonth = StatFinanceMonthly::where('tenant_id', $tenantId) + ->where(function ($q) use ($year, $month) { + $prev = Carbon::create($year, $month, 1)->subMonth(); + $q->where('stat_year', $prev->year)->where('stat_month', $prev->month); + }) + ->first(); + + $netCashflow = $dailySum->net_cashflow ?? 0; + $momChange = null; + if ($prevMonth && $prevMonth->net_cashflow != 0) { + $momChange = (($netCashflow - $prevMonth->net_cashflow) / abs($prevMonth->net_cashflow)) * 100; + } + + StatFinanceMonthly::updateOrCreate( + ['tenant_id' => $tenantId, 'stat_year' => $year, 'stat_month' => $month], + [ + 'deposit_total' => $dailySum->deposit_total ?? 0, + 'withdrawal_total' => $dailySum->withdrawal_total ?? 0, + 'net_cashflow' => $netCashflow, + 'purchase_total' => $dailySum->purchase_total ?? 0, + 'card_total' => $dailySum->card_total ?? 0, + 'receivable_end' => $lastDay->receivable_balance ?? 0, + 'payable_end' => $lastDay->payable_balance ?? 0, + 'bank_balance_end' => $lastDay->bank_balance_total ?? 0, + 'mom_cashflow_change' => $momChange, + ] + ); + + return 1; + } +} diff --git a/app/Services/Stats/ProductionStatService.php b/app/Services/Stats/ProductionStatService.php new file mode 100644 index 0000000..eb48b84 --- /dev/null +++ b/app/Services/Stats/ProductionStatService.php @@ -0,0 +1,149 @@ +format('Y-m-d'); + + // 작업지시 (work_orders) + $woCreated = DB::connection('mysql') + ->table('work_orders') + ->where('tenant_id', $tenantId) + ->whereDate('created_at', $dateStr) + ->whereNull('deleted_at') + ->count(); + + $woCompleted = DB::connection('mysql') + ->table('work_orders') + ->where('tenant_id', $tenantId) + ->whereDate('completed_at', $dateStr) + ->whereNull('deleted_at') + ->count(); + + $woInProgress = DB::connection('mysql') + ->table('work_orders') + ->where('tenant_id', $tenantId) + ->where('status', 'in_progress') + ->whereNull('deleted_at') + ->count(); + + // 납기 초과 (scheduled_date < today && status not completed/shipped) + $woOverdue = DB::connection('mysql') + ->table('work_orders') + ->where('tenant_id', $tenantId) + ->where('scheduled_date', '<', $dateStr) + ->whereNotIn('status', ['completed', 'shipped', 'cancelled']) + ->whereNull('deleted_at') + ->count(); + + // 생산 실적 (work_results) + $productionStats = DB::connection('mysql') + ->table('work_results') + ->where('tenant_id', $tenantId) + ->where('work_date', $dateStr) + ->whereNull('deleted_at') + ->selectRaw(' + COALESCE(SUM(production_qty), 0) as production_qty, + COALESCE(SUM(defect_qty), 0) as defect_qty, + COUNT(DISTINCT worker_id) as active_worker_count + ') + ->first(); + + $productionQty = $productionStats->production_qty ?? 0; + $defectQty = $productionStats->defect_qty ?? 0; + $defectRate = $productionQty > 0 ? ($defectQty / $productionQty) * 100 : 0; + + // 납기 준수 (당일 완료된 작업지시 중 scheduled_date >= completed_at인 것) + $onTimeCount = DB::connection('mysql') + ->table('work_orders') + ->where('tenant_id', $tenantId) + ->whereDate('completed_at', $dateStr) + ->whereNull('deleted_at') + ->whereRaw('DATE(completed_at) <= scheduled_date') + ->count(); + + $lateCount = $woCompleted - $onTimeCount; + $deliveryRate = $woCompleted > 0 ? ($onTimeCount / $woCompleted) * 100 : 0; + + StatProductionDaily::updateOrCreate( + ['tenant_id' => $tenantId, 'stat_date' => $dateStr], + [ + 'wo_created_count' => $woCreated, + 'wo_completed_count' => $woCompleted, + 'wo_in_progress_count' => $woInProgress, + 'wo_overdue_count' => $woOverdue, + 'production_qty' => $productionQty, + 'defect_qty' => $defectQty, + 'defect_rate' => round($defectRate, 2), + 'planned_hours' => 0, // 계획 공수 필드 없음 - 추후 확장 + 'actual_hours' => 0, + 'efficiency_rate' => 0, + 'active_worker_count' => $productionStats->active_worker_count ?? 0, + 'issue_count' => 0, // work_order_issues 테이블 확인 필요 + 'on_time_delivery_count' => $onTimeCount, + 'late_delivery_count' => max(0, $lateCount), + 'delivery_rate' => round($deliveryRate, 2), + ] + ); + + return 1; + } + + public function aggregateMonthly(int $tenantId, int $year, int $month): int + { + $dailyData = StatProductionDaily::where('tenant_id', $tenantId) + ->whereYear('stat_date', $year) + ->whereMonth('stat_date', $month) + ->selectRaw(' + SUM(wo_created_count) as wo_total_count, + SUM(wo_completed_count) as wo_completed_count, + SUM(production_qty) as production_qty, + SUM(defect_qty) as defect_qty, + SUM(planned_hours) as total_planned_hours, + SUM(actual_hours) as total_actual_hours, + SUM(issue_count) as issue_total_count + ') + ->first(); + + $productionQty = $dailyData->production_qty ?? 0; + $defectQty = $dailyData->defect_qty ?? 0; + $avgDefectRate = $productionQty > 0 ? ($defectQty / $productionQty) * 100 : 0; + + // 월평균 효율/납기 (일간 데이터의 평균) + $avgRates = StatProductionDaily::where('tenant_id', $tenantId) + ->whereYear('stat_date', $year) + ->whereMonth('stat_date', $month) + ->where('wo_completed_count', '>', 0) + ->selectRaw(' + AVG(efficiency_rate) as avg_efficiency_rate, + AVG(delivery_rate) as avg_delivery_rate + ') + ->first(); + + StatProductionMonthly::updateOrCreate( + ['tenant_id' => $tenantId, 'stat_year' => $year, 'stat_month' => $month], + [ + 'wo_total_count' => $dailyData->wo_total_count ?? 0, + 'wo_completed_count' => $dailyData->wo_completed_count ?? 0, + 'production_qty' => $productionQty, + 'defect_qty' => $defectQty, + 'avg_defect_rate' => round($avgDefectRate, 2), + 'avg_efficiency_rate' => round($avgRates->avg_efficiency_rate ?? 0, 2), + 'avg_delivery_rate' => round($avgRates->avg_delivery_rate ?? 0, 2), + 'total_planned_hours' => $dailyData->total_planned_hours ?? 0, + 'total_actual_hours' => $dailyData->total_actual_hours ?? 0, + 'issue_total_count' => $dailyData->issue_total_count ?? 0, + ] + ); + + return 1; + } +} diff --git a/app/Services/Stats/SalesStatService.php b/app/Services/Stats/SalesStatService.php new file mode 100644 index 0000000..e3654a1 --- /dev/null +++ b/app/Services/Stats/SalesStatService.php @@ -0,0 +1,192 @@ +format('Y-m-d'); + + // 수주 집계 (orders) + $orderStats = DB::connection('mysql') + ->table('orders') + ->where('tenant_id', $tenantId) + ->whereDate('created_at', $dateStr) + ->whereNull('deleted_at') + ->selectRaw(' + COUNT(*) as order_count, + COALESCE(SUM(total_amount), 0) as order_amount, + COALESCE(SUM(quantity), 0) as order_item_count, + SUM(CASE WHEN status_code = ? THEN 1 ELSE 0 END) as order_draft_count, + SUM(CASE WHEN status_code = ? THEN 1 ELSE 0 END) as order_confirmed_count, + SUM(CASE WHEN status_code = ? THEN 1 ELSE 0 END) as order_in_progress_count, + SUM(CASE WHEN status_code = ? THEN 1 ELSE 0 END) as order_completed_count, + SUM(CASE WHEN status_code = ? THEN 1 ELSE 0 END) as order_cancelled_count + ', ['draft', 'confirmed', 'in_progress', 'completed', 'cancelled']) + ->first(); + + // 매출 집계 (sales) + $salesStats = DB::connection('mysql') + ->table('sales') + ->where('tenant_id', $tenantId) + ->where('sale_date', $dateStr) + ->whereNull('deleted_at') + ->selectRaw(' + COUNT(*) as sales_count, + COALESCE(SUM(supply_amount), 0) as sales_amount, + COALESCE(SUM(tax_amount), 0) as sales_tax_amount + ') + ->first(); + + // 신규 고객 (당일 생성된 고객) + $newClientCount = DB::connection('mysql') + ->table('clients') + ->where('tenant_id', $tenantId) + ->whereDate('created_at', $dateStr) + ->count(); + + // 활성 고객 (당일 수주/매출에 연결된 고유 고객) + $activeClientCount = DB::connection('mysql') + ->table('orders') + ->where('tenant_id', $tenantId) + ->whereDate('created_at', $dateStr) + ->whereNull('deleted_at') + ->whereNotNull('client_id') + ->distinct('client_id') + ->count('client_id'); + + // 출하 집계 (shipments) + $shipmentStats = DB::connection('mysql') + ->table('shipments') + ->where('tenant_id', $tenantId) + ->where('scheduled_date', $dateStr) + ->where('status', 'completed') + ->whereNull('deleted_at') + ->selectRaw(' + COUNT(*) as shipment_count, + COALESCE(SUM(shipping_cost), 0) as shipment_amount + ') + ->first(); + + StatSalesDaily::updateOrCreate( + ['tenant_id' => $tenantId, 'stat_date' => $dateStr], + [ + 'order_count' => $orderStats->order_count ?? 0, + 'order_amount' => $orderStats->order_amount ?? 0, + 'order_item_count' => $orderStats->order_item_count ?? 0, + 'sales_count' => $salesStats->sales_count ?? 0, + 'sales_amount' => $salesStats->sales_amount ?? 0, + 'sales_tax_amount' => $salesStats->sales_tax_amount ?? 0, + 'new_client_count' => $newClientCount, + 'active_client_count' => $activeClientCount, + 'order_draft_count' => $orderStats->order_draft_count ?? 0, + 'order_confirmed_count' => $orderStats->order_confirmed_count ?? 0, + 'order_in_progress_count' => $orderStats->order_in_progress_count ?? 0, + 'order_completed_count' => $orderStats->order_completed_count ?? 0, + 'order_cancelled_count' => $orderStats->order_cancelled_count ?? 0, + 'shipment_count' => $shipmentStats->shipment_count ?? 0, + 'shipment_amount' => $shipmentStats->shipment_amount ?? 0, + ] + ); + + return 1; + } + + public function aggregateMonthly(int $tenantId, int $year, int $month): int + { + // 일간 데이터를 합산하여 월간 통계 생성 + $dailySum = StatSalesDaily::where('tenant_id', $tenantId) + ->whereYear('stat_date', $year) + ->whereMonth('stat_date', $month) + ->selectRaw(' + SUM(order_count) as order_count, + SUM(order_amount) as order_amount, + SUM(sales_count) as sales_count, + SUM(sales_amount) as sales_amount, + SUM(shipment_count) as shipment_count, + SUM(shipment_amount) as shipment_amount + ') + ->first(); + + // 월간 고유 거래 고객 수 + $startDate = Carbon::create($year, $month, 1)->format('Y-m-d'); + $endDate = Carbon::create($year, $month, 1)->endOfMonth()->format('Y-m-d'); + + $uniqueClientCount = DB::connection('mysql') + ->table('orders') + ->where('tenant_id', $tenantId) + ->whereBetween(DB::raw('DATE(created_at)'), [$startDate, $endDate]) + ->whereNull('deleted_at') + ->whereNotNull('client_id') + ->distinct('client_id') + ->count('client_id'); + + // 평균 수주 금액 + $orderCount = $dailySum->order_count ?? 0; + $orderAmount = $dailySum->order_amount ?? 0; + $avgOrderAmount = $orderCount > 0 ? $orderAmount / $orderCount : 0; + + // 최다 거래 고객 + $topClient = DB::connection('mysql') + ->table('orders') + ->where('tenant_id', $tenantId) + ->whereBetween(DB::raw('DATE(created_at)'), [$startDate, $endDate]) + ->whereNull('deleted_at') + ->whereNotNull('client_id') + ->groupBy('client_id') + ->orderByRaw('SUM(total_amount) DESC') + ->selectRaw('client_id, SUM(total_amount) as total') + ->first(); + + // 전월 대비 성장률 + $prevMonth = StatSalesMonthly::where('tenant_id', $tenantId) + ->where(function ($q) use ($year, $month) { + $prev = Carbon::create($year, $month, 1)->subMonth(); + $q->where('stat_year', $prev->year)->where('stat_month', $prev->month); + }) + ->first(); + + $salesAmount = $dailySum->sales_amount ?? 0; + $momGrowthRate = null; + if ($prevMonth && $prevMonth->sales_amount > 0) { + $momGrowthRate = (($salesAmount - $prevMonth->sales_amount) / $prevMonth->sales_amount) * 100; + } + + // 전년동월 대비 + $prevYear = StatSalesMonthly::where('tenant_id', $tenantId) + ->where('stat_year', $year - 1) + ->where('stat_month', $month) + ->first(); + + $yoyGrowthRate = null; + if ($prevYear && $prevYear->sales_amount > 0) { + $yoyGrowthRate = (($salesAmount - $prevYear->sales_amount) / $prevYear->sales_amount) * 100; + } + + StatSalesMonthly::updateOrCreate( + ['tenant_id' => $tenantId, 'stat_year' => $year, 'stat_month' => $month], + [ + 'order_count' => $dailySum->order_count ?? 0, + 'order_amount' => $orderAmount, + 'sales_count' => $dailySum->sales_count ?? 0, + 'sales_amount' => $salesAmount, + 'shipment_count' => $dailySum->shipment_count ?? 0, + 'shipment_amount' => $dailySum->shipment_amount ?? 0, + 'unique_client_count' => $uniqueClientCount, + 'avg_order_amount' => $avgOrderAmount, + 'top_client_id' => $topClient->client_id ?? null, + 'top_client_amount' => $topClient->total ?? 0, + 'mom_growth_rate' => $momGrowthRate, + 'yoy_growth_rate' => $yoyGrowthRate, + ] + ); + + return 1; + } +} diff --git a/app/Services/Stats/StatAggregatorService.php b/app/Services/Stats/StatAggregatorService.php index f87fd08..03597fd 100644 --- a/app/Services/Stats/StatAggregatorService.php +++ b/app/Services/Stats/StatAggregatorService.php @@ -10,14 +10,14 @@ class StatAggregatorService { /** - * 일간 도메인 서비스 매핑 (Phase 2에서 구현체 추가) + * 일간 도메인 서비스 매핑 */ private function getDailyDomainServices(): array { return [ - // 'sales' => SalesStatService::class, - // 'finance' => FinanceStatService::class, - // 'production' => ProductionStatService::class, + 'sales' => SalesStatService::class, + 'finance' => FinanceStatService::class, + 'production' => ProductionStatService::class, ]; } @@ -27,9 +27,9 @@ private function getDailyDomainServices(): array private function getMonthlyDomainServices(): array { return [ - // 'sales' => SalesStatService::class, - // 'finance' => FinanceStatService::class, - // 'production' => ProductionStatService::class, + 'sales' => SalesStatService::class, + 'finance' => FinanceStatService::class, + 'production' => ProductionStatService::class, ]; } diff --git a/database/migrations/stats/2026_01_29_174339_create_stat_sales_daily_table.php b/database/migrations/stats/2026_01_29_174339_create_stat_sales_daily_table.php new file mode 100644 index 0000000..97374cd --- /dev/null +++ b/database/migrations/stats/2026_01_29_174339_create_stat_sales_daily_table.php @@ -0,0 +1,55 @@ +connection)->create('stat_sales_daily', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->date('stat_date'); + + // 수주 + $table->unsignedInteger('order_count')->default(0)->comment('신규 수주 건수'); + $table->decimal('order_amount', 18, 2)->default(0)->comment('수주 금액'); + $table->unsignedInteger('order_item_count')->default(0)->comment('수주 품목 수'); + + // 매출 + $table->unsignedInteger('sales_count')->default(0)->comment('매출 건수'); + $table->decimal('sales_amount', 18, 2)->default(0)->comment('매출 금액'); + $table->decimal('sales_tax_amount', 18, 2)->default(0)->comment('세액'); + + // 고객 + $table->unsignedInteger('new_client_count')->default(0)->comment('신규 고객 수'); + $table->unsignedInteger('active_client_count')->default(0)->comment('활성 고객 수'); + + // 수주 상태별 건수 + $table->unsignedInteger('order_draft_count')->default(0); + $table->unsignedInteger('order_confirmed_count')->default(0); + $table->unsignedInteger('order_in_progress_count')->default(0); + $table->unsignedInteger('order_completed_count')->default(0); + $table->unsignedInteger('order_cancelled_count')->default(0); + + // 출하 + $table->unsignedInteger('shipment_count')->default(0); + $table->decimal('shipment_amount', 18, 2)->default(0); + + $table->timestamps(); + + $table->unique(['tenant_id', 'stat_date'], 'uk_tenant_date'); + $table->index('stat_date'); + $table->index('tenant_id'); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('stat_sales_daily'); + } +}; diff --git a/database/migrations/stats/2026_01_29_174340_create_stat_finance_daily_table.php b/database/migrations/stats/2026_01_29_174340_create_stat_finance_daily_table.php new file mode 100644 index 0000000..6568949 --- /dev/null +++ b/database/migrations/stats/2026_01_29_174340_create_stat_finance_daily_table.php @@ -0,0 +1,59 @@ +connection)->create('stat_finance_daily', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->date('stat_date'); + + // 입출금 + $table->unsignedInteger('deposit_count')->default(0); + $table->decimal('deposit_amount', 18, 2)->default(0); + $table->unsignedInteger('withdrawal_count')->default(0); + $table->decimal('withdrawal_amount', 18, 2)->default(0); + $table->decimal('net_cashflow', 18, 2)->default(0)->comment('입금 - 출금'); + + // 매입 + $table->unsignedInteger('purchase_count')->default(0); + $table->decimal('purchase_amount', 18, 2)->default(0); + $table->decimal('purchase_tax_amount', 18, 2)->default(0); + + // 미수/미지급 + $table->decimal('receivable_balance', 18, 2)->default(0)->comment('미수금 잔액'); + $table->decimal('payable_balance', 18, 2)->default(0)->comment('미지급 잔액'); + $table->decimal('overdue_receivable', 18, 2)->default(0)->comment('연체 미수금'); + + // 어음 + $table->unsignedInteger('bill_issued_count')->default(0); + $table->decimal('bill_issued_amount', 18, 2)->default(0); + $table->unsignedInteger('bill_matured_count')->default(0); + $table->decimal('bill_matured_amount', 18, 2)->default(0); + + // 카드 + $table->unsignedInteger('card_transaction_count')->default(0); + $table->decimal('card_transaction_amount', 18, 2)->default(0); + + // 은행 + $table->decimal('bank_balance_total', 18, 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_finance_daily'); + } +}; diff --git a/database/migrations/stats/2026_01_29_174340_create_stat_finance_monthly_table.php b/database/migrations/stats/2026_01_29_174340_create_stat_finance_monthly_table.php new file mode 100644 index 0000000..fe90203 --- /dev/null +++ b/database/migrations/stats/2026_01_29_174340_create_stat_finance_monthly_table.php @@ -0,0 +1,42 @@ +connection)->create('stat_finance_monthly', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->smallInteger('stat_year'); + $table->tinyInteger('stat_month'); + + $table->decimal('deposit_total', 18, 2)->default(0); + $table->decimal('withdrawal_total', 18, 2)->default(0); + $table->decimal('net_cashflow', 18, 2)->default(0); + $table->decimal('purchase_total', 18, 2)->default(0); + $table->decimal('card_total', 18, 2)->default(0); + + $table->decimal('receivable_end', 18, 2)->default(0)->comment('월말 미수금'); + $table->decimal('payable_end', 18, 2)->default(0)->comment('월말 미지급'); + $table->decimal('bank_balance_end', 18, 2)->default(0)->comment('월말 잔액'); + + $table->decimal('mom_cashflow_change', 8, 2)->nullable()->comment('전월 대비 현금흐름 변화 (%)'); + + $table->timestamps(); + + $table->unique(['tenant_id', 'stat_year', 'stat_month'], 'uk_tenant_year_month'); + $table->index(['stat_year', 'stat_month']); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('stat_finance_monthly'); + } +}; diff --git a/database/migrations/stats/2026_01_29_174340_create_stat_sales_monthly_table.php b/database/migrations/stats/2026_01_29_174340_create_stat_sales_monthly_table.php new file mode 100644 index 0000000..1669368 --- /dev/null +++ b/database/migrations/stats/2026_01_29_174340_create_stat_sales_monthly_table.php @@ -0,0 +1,46 @@ +connection)->create('stat_sales_monthly', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->smallInteger('stat_year'); + $table->tinyInteger('stat_month'); + + // 일일 합산 + $table->unsignedInteger('order_count')->default(0); + $table->decimal('order_amount', 18, 2)->default(0); + $table->unsignedInteger('sales_count')->default(0); + $table->decimal('sales_amount', 18, 2)->default(0); + $table->unsignedInteger('shipment_count')->default(0); + $table->decimal('shipment_amount', 18, 2)->default(0); + + // 월간 고유 지표 + $table->unsignedInteger('unique_client_count')->default(0)->comment('거래 고객 수'); + $table->decimal('avg_order_amount', 18, 2)->default(0)->comment('평균 수주 금액'); + $table->unsignedBigInteger('top_client_id')->nullable()->comment('최다 거래 고객'); + $table->decimal('top_client_amount', 18, 2)->default(0); + $table->decimal('mom_growth_rate', 8, 2)->nullable()->comment('전월 대비 성장률 (%)'); + $table->decimal('yoy_growth_rate', 8, 2)->nullable()->comment('전년동월 대비 (%)'); + + $table->timestamps(); + + $table->unique(['tenant_id', 'stat_year', 'stat_month'], 'uk_tenant_year_month'); + $table->index(['stat_year', 'stat_month']); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('stat_sales_monthly'); + } +}; diff --git a/database/migrations/stats/2026_01_29_174341_create_stat_production_daily_table.php b/database/migrations/stats/2026_01_29_174341_create_stat_production_daily_table.php new file mode 100644 index 0000000..b376fa4 --- /dev/null +++ b/database/migrations/stats/2026_01_29_174341_create_stat_production_daily_table.php @@ -0,0 +1,54 @@ +connection)->create('stat_production_daily', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->date('stat_date'); + + // 작업지시 + $table->unsignedInteger('wo_created_count')->default(0)->comment('신규 작업지시'); + $table->unsignedInteger('wo_completed_count')->default(0)->comment('완료 작업지시'); + $table->unsignedInteger('wo_in_progress_count')->default(0)->comment('진행중'); + $table->unsignedInteger('wo_overdue_count')->default(0)->comment('납기 초과'); + + // 생산량 + $table->decimal('production_qty', 18, 2)->default(0)->comment('생산 수량'); + $table->decimal('defect_qty', 18, 2)->default(0)->comment('불량 수량'); + $table->decimal('defect_rate', 5, 2)->default(0)->comment('불량률 (%)'); + + // 작업 효율 + $table->decimal('planned_hours', 10, 2)->default(0)->comment('계획 공수'); + $table->decimal('actual_hours', 10, 2)->default(0)->comment('실적 공수'); + $table->decimal('efficiency_rate', 5, 2)->default(0)->comment('효율 (%)'); + + // 작업자 + $table->unsignedInteger('active_worker_count')->default(0); + $table->unsignedInteger('issue_count')->default(0)->comment('발생 이슈 수'); + + // 납기 + $table->unsignedInteger('on_time_delivery_count')->default(0); + $table->unsignedInteger('late_delivery_count')->default(0); + $table->decimal('delivery_rate', 5, 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_production_daily'); + } +}; diff --git a/database/migrations/stats/2026_01_29_174341_create_stat_production_monthly_table.php b/database/migrations/stats/2026_01_29_174341_create_stat_production_monthly_table.php new file mode 100644 index 0000000..1ed7aa5 --- /dev/null +++ b/database/migrations/stats/2026_01_29_174341_create_stat_production_monthly_table.php @@ -0,0 +1,41 @@ +connection)->create('stat_production_monthly', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('tenant_id'); + $table->smallInteger('stat_year'); + $table->tinyInteger('stat_month'); + + $table->unsignedInteger('wo_total_count')->default(0); + $table->unsignedInteger('wo_completed_count')->default(0); + $table->decimal('production_qty', 18, 2)->default(0); + $table->decimal('defect_qty', 18, 2)->default(0); + $table->decimal('avg_defect_rate', 5, 2)->default(0); + $table->decimal('avg_efficiency_rate', 5, 2)->default(0); + $table->decimal('avg_delivery_rate', 5, 2)->default(0); + $table->decimal('total_planned_hours', 10, 2)->default(0); + $table->decimal('total_actual_hours', 10, 2)->default(0); + $table->unsignedInteger('issue_total_count')->default(0); + + $table->timestamps(); + + $table->unique(['tenant_id', 'stat_year', 'stat_month'], 'uk_tenant_year_month'); + $table->index(['stat_year', 'stat_month']); + }); + } + + public function down(): void + { + Schema::connection($this->connection)->dropIfExists('stat_production_monthly'); + } +}; diff --git a/routes/console.php b/routes/console.php index adc567c..0e9025a 100644 --- a/routes/console.php +++ b/routes/console.php @@ -93,3 +93,29 @@ ->onFailure(function () { \Illuminate\Support\Facades\Log::error('❌ storage:record-usage 스케줄러 실행 실패', ['time' => now()]); }); + +// ─── 통계 집계 (sam_stat DB) ─── + +// 매일 새벽 02:00에 일간 통계 집계 (전일 데이터) +Schedule::command('stat:aggregate-daily') + ->dailyAt('02:00') + ->withoutOverlapping() + ->appendOutputTo(storage_path('logs/scheduler.log')) + ->onSuccess(function () { + \Illuminate\Support\Facades\Log::info('✅ stat:aggregate-daily 스케줄러 실행 성공', ['time' => now()]); + }) + ->onFailure(function () { + \Illuminate\Support\Facades\Log::error('❌ stat:aggregate-daily 스케줄러 실행 실패', ['time' => now()]); + }); + +// 매월 1일 새벽 03:00에 월간 통계 집계 (전월 데이터) +Schedule::command('stat:aggregate-monthly') + ->monthlyOn(1, '03:00') + ->withoutOverlapping() + ->appendOutputTo(storage_path('logs/scheduler.log')) + ->onSuccess(function () { + \Illuminate\Support\Facades\Log::info('✅ stat:aggregate-monthly 스케줄러 실행 성공', ['time' => now()]); + }) + ->onFailure(function () { + \Illuminate\Support\Facades\Log::error('❌ stat:aggregate-monthly 스케줄러 실행 실패', ['time' => now()]); + });