From 131c0fc5dcc3c9e3b71d2b7e13413da73d828ae0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=98=81=EC=84=B1?= Date: Fri, 23 Jan 2026 15:38:32 +0900 Subject: [PATCH] =?UTF-8?q?feat(api):=20=EC=98=88=EC=83=81=EB=B9=84?= =?UTF-8?q?=EC=9A=A9=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EC=BB=A4=EB=A7=A8?= =?UTF-8?q?=EB=93=9C=20=EB=B0=8F=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SyncExpectedExpensesCommand 추가 - ExpectedExpenseService 로직 개선 - LOGICAL_RELATIONSHIPS 문서 업데이트 Co-Authored-By: Claude Opus 4.5 --- LOGICAL_RELATIONSHIPS.md | 3 +- .../Commands/SyncExpectedExpensesCommand.php | 310 ++++++++++++++++++ app/Services/ExpectedExpenseService.php | 115 ++++++- 3 files changed, 425 insertions(+), 3 deletions(-) create mode 100644 app/Console/Commands/SyncExpectedExpensesCommand.php diff --git a/LOGICAL_RELATIONSHIPS.md b/LOGICAL_RELATIONSHIPS.md index e545bf6..e03c910 100644 --- a/LOGICAL_RELATIONSHIPS.md +++ b/LOGICAL_RELATIONSHIPS.md @@ -1,6 +1,6 @@ # 논리적 데이터베이스 관계 문서 -> **자동 생성**: 2026-01-22 22:44:22 +> **자동 생성**: 2026-01-23 10:19:57 > **소스**: Eloquent 모델 관계 분석 ## 📊 모델별 관계 현황 @@ -731,6 +731,7 @@ ### expected_expenses - **client()**: belongsTo → `clients` - **bankAccount()**: belongsTo → `bank_accounts` - **creator()**: belongsTo → `users` +- **source()**: morphTo → `(Polymorphic)` ### expense_accounts **모델**: `App\Models\Tenants\ExpenseAccount` diff --git a/app/Console/Commands/SyncExpectedExpensesCommand.php b/app/Console/Commands/SyncExpectedExpensesCommand.php new file mode 100644 index 0000000..11f376a --- /dev/null +++ b/app/Console/Commands/SyncExpectedExpensesCommand.php @@ -0,0 +1,310 @@ +option('type') ?: 'all'; + $tenantId = $this->option('tenant'); + $dryRun = $this->option('dry-run'); + + $this->info('=== Expected Expenses 동기화 시작 ==='); + $this->info("유형: {$type}, 테넌트: ".($tenantId ?: '전체').', 모드: '.($dryRun ? 'DRY-RUN' : '실행')); + $this->newLine(); + + if ($dryRun) { + $this->warn('⚠️ DRY-RUN 모드: 실제 데이터는 저장되지 않습니다.'); + $this->newLine(); + } + + DB::beginTransaction(); + + try { + if ($type === 'all' || $type === 'purchase') { + $this->syncPurchases($tenantId, $dryRun); + } + + if ($type === 'all' || $type === 'card') { + $this->syncCardWithdrawals($tenantId, $dryRun); + } + + if ($type === 'all' || $type === 'bill') { + $this->syncBills($tenantId, $dryRun); + } + + if ($dryRun) { + DB::rollBack(); + $this->warn('DRY-RUN 완료: 롤백됨'); + } else { + DB::commit(); + $this->info('✅ 커밋 완료'); + } + } catch (\Exception $e) { + DB::rollBack(); + $this->error('❌ 오류 발생: '.$e->getMessage()); + + return Command::FAILURE; + } + + $this->newLine(); + $this->info('=== 동기화 결과 ==='); + $this->table( + ['항목', '건수'], + [ + ['생성', $this->created], + ['업데이트', $this->updated], + ['스킵 (이미 존재)', $this->skipped], + ['합계', $this->created + $this->updated + $this->skipped], + ] + ); + + return Command::SUCCESS; + } + + /** + * 매입 → expected_expenses 동기화 + */ + private function syncPurchases(?int $tenantId, bool $dryRun): void + { + $this->info('📦 매입(purchases) 동기화 중...'); + + $query = Purchase::withoutGlobalScopes()->whereNull('deleted_at'); + + if ($tenantId) { + $query->where('tenant_id', $tenantId); + } + + $purchases = $query->get(); + $bar = $this->output->createProgressBar($purchases->count()); + + foreach ($purchases as $purchase) { + $this->syncPurchaseToExpense($purchase, $dryRun); + $bar->advance(); + } + + $bar->finish(); + $this->newLine(); + } + + /** + * 카드 출금 → expected_expenses 동기화 + */ + private function syncCardWithdrawals(?int $tenantId, bool $dryRun): void + { + $this->info('💳 카드(withdrawals) 동기화 중...'); + + $query = Withdrawal::withoutGlobalScopes() + ->whereNull('deleted_at') + ->where('payment_method', 'card'); + + if ($tenantId) { + $query->where('tenant_id', $tenantId); + } + + $withdrawals = $query->get(); + $bar = $this->output->createProgressBar($withdrawals->count()); + + foreach ($withdrawals as $withdrawal) { + $this->syncWithdrawalToExpense($withdrawal, $dryRun); + $bar->advance(); + } + + $bar->finish(); + $this->newLine(); + } + + /** + * 발행어음 → expected_expenses 동기화 + */ + private function syncBills(?int $tenantId, bool $dryRun): void + { + $this->info('📄 발행어음(bills) 동기화 중...'); + + $query = Bill::withoutGlobalScopes() + ->whereNull('deleted_at') + ->where('bill_type', 'issued'); // 발행어음만 (수령어음 제외) + + if ($tenantId) { + $query->where('tenant_id', $tenantId); + } + + $bills = $query->get(); + $bar = $this->output->createProgressBar($bills->count()); + + foreach ($bills as $bill) { + $this->syncBillToExpense($bill, $dryRun); + $bar->advance(); + } + + $bar->finish(); + $this->newLine(); + } + + /** + * 매입 레코드 → expected_expense 동기화 + */ + private function syncPurchaseToExpense(Purchase $purchase, bool $dryRun): void + { + $existing = ExpectedExpense::withoutGlobalScopes() + ->where('source_type', 'purchases') + ->where('source_id', $purchase->id) + ->first(); + + if ($existing) { + // 이미 존재하면 업데이트 + if (! $dryRun) { + $existing->update([ + 'expected_payment_date' => $purchase->purchase_date, + 'amount' => $purchase->total_amount, + 'client_id' => $purchase->client_id, + 'description' => $purchase->description ?? "매입번호: {$purchase->purchase_number}", + 'payment_status' => $purchase->withdrawal_id ? 'paid' : 'pending', + 'updated_by' => $purchase->updated_by, + ]); + } + $this->updated++; + } else { + // 새로 생성 + if (! $dryRun) { + ExpectedExpense::create([ + 'tenant_id' => $purchase->tenant_id, + 'expected_payment_date' => $purchase->purchase_date, + 'transaction_type' => 'purchase', + 'amount' => $purchase->total_amount, + 'client_id' => $purchase->client_id, + 'description' => $purchase->description ?? "매입번호: {$purchase->purchase_number}", + 'payment_status' => $purchase->withdrawal_id ? 'paid' : 'pending', + 'approval_status' => 'none', + 'source_type' => 'purchases', + 'source_id' => $purchase->id, + 'created_by' => $purchase->created_by, + 'updated_by' => $purchase->updated_by, + ]); + } + $this->created++; + } + } + + /** + * 카드 출금 레코드 → expected_expense 동기화 + */ + private function syncWithdrawalToExpense(Withdrawal $withdrawal, bool $dryRun): void + { + $existing = ExpectedExpense::withoutGlobalScopes() + ->where('source_type', 'withdrawals') + ->where('source_id', $withdrawal->id) + ->first(); + + $clientName = $withdrawal->client_name ?: $withdrawal->merchant_name ?: '미지정'; + + if ($existing) { + if (! $dryRun) { + $existing->update([ + 'expected_payment_date' => $withdrawal->withdrawal_date ?? $withdrawal->used_at, + 'amount' => $withdrawal->amount, + 'client_id' => $withdrawal->client_id, + 'description' => $withdrawal->description ?? "카드결제: {$clientName}", + 'payment_status' => 'paid', // 카드는 이미 결제됨 + 'updated_by' => $withdrawal->updated_by, + ]); + } + $this->updated++; + } else { + if (! $dryRun) { + ExpectedExpense::create([ + 'tenant_id' => $withdrawal->tenant_id, + 'expected_payment_date' => $withdrawal->withdrawal_date ?? $withdrawal->used_at, + 'transaction_type' => 'card', + 'amount' => $withdrawal->amount, + 'client_id' => $withdrawal->client_id, + 'description' => $withdrawal->description ?? "카드결제: {$clientName}", + 'payment_status' => 'paid', + 'approval_status' => 'none', + 'source_type' => 'withdrawals', + 'source_id' => $withdrawal->id, + 'created_by' => $withdrawal->created_by, + 'updated_by' => $withdrawal->updated_by, + ]); + } + $this->created++; + } + } + + /** + * 발행어음 레코드 → expected_expense 동기화 + */ + private function syncBillToExpense(Bill $bill, bool $dryRun): void + { + $existing = ExpectedExpense::withoutGlobalScopes() + ->where('source_type', 'bills') + ->where('source_id', $bill->id) + ->first(); + + $clientName = $bill->client_name ?: '미지정'; + + if ($existing) { + if (! $dryRun) { + $existing->update([ + 'expected_payment_date' => $bill->maturity_date, // 만기일 기준 + 'amount' => $bill->amount, + 'client_id' => $bill->client_id, + 'description' => $bill->note ?? "발행어음: {$bill->bill_number}", + 'payment_status' => $bill->status === 'settled' ? 'paid' : 'pending', + 'updated_by' => $bill->updated_by, + ]); + } + $this->updated++; + } else { + if (! $dryRun) { + ExpectedExpense::create([ + 'tenant_id' => $bill->tenant_id, + 'expected_payment_date' => $bill->maturity_date, + 'transaction_type' => 'bill', + 'amount' => $bill->amount, + 'client_id' => $bill->client_id, + 'description' => $bill->note ?? "발행어음: {$bill->bill_number}", + 'payment_status' => $bill->status === 'settled' ? 'paid' : 'pending', + 'approval_status' => 'none', + 'source_type' => 'bills', + 'source_id' => $bill->id, + 'created_by' => $bill->created_by, + 'updated_by' => $bill->updated_by, + ]); + } + $this->created++; + } + } +} diff --git a/app/Services/ExpectedExpenseService.php b/app/Services/ExpectedExpenseService.php index 6569fef..8d76048 100644 --- a/app/Services/ExpectedExpenseService.php +++ b/app/Services/ExpectedExpenseService.php @@ -312,6 +312,8 @@ public function summary(array $params): array * remaining_balance: float, * item_count: int * }, + * monthly_trend: array, + * vendor_distribution: array, * items: array, * footer_summary: array * } @@ -356,7 +358,13 @@ public function dashboardDetail(?string $transactionType = null): array ->where('payment_status', 'pending') ->sum('amount'); - // 2. 지출예상 목록 (당월, 지급일 순) + // 2. 월별 추이 (최근 7개월) + $monthlyTrend = $this->getMonthlyTrend($tenantId, $transactionType); + + // 3. 거래처별 분포 (당월, 상위 5개) + $vendorDistribution = $this->getVendorDistribution($tenantId, $transactionType, $currentMonthStart, $currentMonthEnd); + + // 4. 지출예상 목록 (당월, 지급일 순) $itemsQuery = ExpectedExpense::query() ->select([ 'expected_expenses.id', @@ -394,7 +402,7 @@ public function dashboardDetail(?string $transactionType = null): array }) ->toArray(); - // 3. 푸터 합계 + // 5. 푸터 합계 $footerSummary = [ 'total_amount' => (float) $currentMonthTotal, 'item_count' => count($items), @@ -407,8 +415,111 @@ public function dashboardDetail(?string $transactionType = null): array 'change_rate' => $changeRate, 'remaining_balance' => (float) $pendingBalance, ], + 'monthly_trend' => $monthlyTrend, + 'vendor_distribution' => $vendorDistribution, 'items' => $items, 'footer_summary' => $footerSummary, ]; } + + /** + * 월별 추이 데이터 조회 (최근 7개월) + */ + private function getMonthlyTrend(int $tenantId, ?string $transactionType = null): array + { + $months = []; + for ($i = 6; $i >= 0; $i--) { + $date = now()->subMonths($i); + $months[] = [ + 'month' => $date->format('Y-m'), + 'label' => $date->format('n') . '월', + 'start' => $date->startOfMonth()->toDateString(), + 'end' => $date->endOfMonth()->toDateString(), + ]; + } + + $result = []; + foreach ($months as $month) { + $query = ExpectedExpense::query() + ->where('tenant_id', $tenantId) + ->whereBetween('expected_payment_date', [$month['start'], $month['end']]); + + if ($transactionType) { + $query->where('transaction_type', $transactionType); + } + + $amount = $query->sum('amount'); + + $result[] = [ + 'month' => $month['month'], + 'label' => $month['label'], + 'amount' => (float) $amount, + ]; + } + + return $result; + } + + /** + * 거래처별 분포 데이터 조회 (상위 N개 + 기타) + */ + private function getVendorDistribution(int $tenantId, ?string $transactionType, string $startDate, string $endDate, int $limit = 5): array + { + $query = ExpectedExpense::query() + ->select( + DB::raw("COALESCE(client_name, '미지정') as vendor_name"), + DB::raw('SUM(amount) as total_amount'), + DB::raw('COUNT(*) as count') + ) + ->where('tenant_id', $tenantId) + ->whereBetween('expected_payment_date', [$startDate, $endDate]) + ->groupBy('client_name') + ->orderByDesc('total_amount'); + + if ($transactionType) { + $query->where('transaction_type', $transactionType); + } + + $all = $query->get(); + + // 전체 합계 + $totalSum = $all->sum('total_amount'); + if ($totalSum <= 0) { + return []; + } + + // 상위 N개 + $top = $all->take($limit); + $topSum = $top->sum('total_amount'); + + // 색상 팔레트 + $colors = ['#60A5FA', '#34D399', '#FBBF24', '#F87171', '#A78BFA', '#94A3B8']; + + $result = []; + foreach ($top as $index => $item) { + $percentage = round(($item->total_amount / $totalSum) * 100, 1); + $result[] = [ + 'name' => $item->vendor_name, + 'value' => (float) $item->total_amount, + 'count' => (int) $item->count, + 'percentage' => $percentage, + 'color' => $colors[$index] ?? '#94A3B8', + ]; + } + + // 기타 (나머지 합산) + $othersSum = $totalSum - $topSum; + if ($othersSum > 0 && $all->count() > $limit) { + $othersCount = $all->skip($limit)->sum('count'); + $result[] = [ + 'name' => '기타', + 'value' => (float) $othersSum, + 'count' => (int) $othersCount, + 'percentage' => round(($othersSum / $totalSum) * 100, 1), + 'color' => '#94A3B8', + ]; + } + + return $result; + } }