feat(api): 예상비용 동기화 커맨드 및 서비스 개선
- SyncExpectedExpensesCommand 추가 - ExpectedExpenseService 로직 개선 - LOGICAL_RELATIONSHIPS 문서 업데이트 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# 논리적 데이터베이스 관계 문서
|
# 논리적 데이터베이스 관계 문서
|
||||||
|
|
||||||
> **자동 생성**: 2026-01-22 22:44:22
|
> **자동 생성**: 2026-01-23 10:19:57
|
||||||
> **소스**: Eloquent 모델 관계 분석
|
> **소스**: Eloquent 모델 관계 분석
|
||||||
|
|
||||||
## 📊 모델별 관계 현황
|
## 📊 모델별 관계 현황
|
||||||
@@ -731,6 +731,7 @@ ### expected_expenses
|
|||||||
- **client()**: belongsTo → `clients`
|
- **client()**: belongsTo → `clients`
|
||||||
- **bankAccount()**: belongsTo → `bank_accounts`
|
- **bankAccount()**: belongsTo → `bank_accounts`
|
||||||
- **creator()**: belongsTo → `users`
|
- **creator()**: belongsTo → `users`
|
||||||
|
- **source()**: morphTo → `(Polymorphic)`
|
||||||
|
|
||||||
### expense_accounts
|
### expense_accounts
|
||||||
**모델**: `App\Models\Tenants\ExpenseAccount`
|
**모델**: `App\Models\Tenants\ExpenseAccount`
|
||||||
|
|||||||
310
app/Console/Commands/SyncExpectedExpensesCommand.php
Normal file
310
app/Console/Commands/SyncExpectedExpensesCommand.php
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use App\Models\Tenants\Bill;
|
||||||
|
use App\Models\Tenants\ExpectedExpense;
|
||||||
|
use App\Models\Tenants\Purchase;
|
||||||
|
use App\Models\Tenants\Withdrawal;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기존 원장 데이터를 expected_expenses로 일괄 동기화
|
||||||
|
*
|
||||||
|
* 사용법:
|
||||||
|
* php artisan expense:sync # 전체 동기화
|
||||||
|
* php artisan expense:sync --type=purchase # 매입만
|
||||||
|
* php artisan expense:sync --type=card # 카드만
|
||||||
|
* php artisan expense:sync --type=bill # 발행어음만
|
||||||
|
* php artisan expense:sync --tenant=287 # 특정 테넌트만
|
||||||
|
* php artisan expense:sync --dry-run # 시뮬레이션 (실제 저장 안함)
|
||||||
|
*/
|
||||||
|
class SyncExpectedExpensesCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'expense:sync
|
||||||
|
{--type= : 동기화 유형 (purchase, card, bill, all)}
|
||||||
|
{--tenant= : 특정 테넌트 ID만 처리}
|
||||||
|
{--dry-run : 시뮬레이션 모드 (실제 저장 안함)}';
|
||||||
|
|
||||||
|
protected $description = '기존 원장 데이터(매입/카드/발행어음)를 expected_expenses로 일괄 동기화';
|
||||||
|
|
||||||
|
private int $created = 0;
|
||||||
|
|
||||||
|
private int $updated = 0;
|
||||||
|
|
||||||
|
private int $skipped = 0;
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$type = $this->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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -312,6 +312,8 @@ public function summary(array $params): array
|
|||||||
* remaining_balance: float,
|
* remaining_balance: float,
|
||||||
* item_count: int
|
* item_count: int
|
||||||
* },
|
* },
|
||||||
|
* monthly_trend: array,
|
||||||
|
* vendor_distribution: array,
|
||||||
* items: array,
|
* items: array,
|
||||||
* footer_summary: array
|
* footer_summary: array
|
||||||
* }
|
* }
|
||||||
@@ -356,7 +358,13 @@ public function dashboardDetail(?string $transactionType = null): array
|
|||||||
->where('payment_status', 'pending')
|
->where('payment_status', 'pending')
|
||||||
->sum('amount');
|
->sum('amount');
|
||||||
|
|
||||||
// 2. 지출예상 목록 (당월, 지급일 순)
|
// 2. 월별 추이 (최근 7개월)
|
||||||
|
$monthlyTrend = $this->getMonthlyTrend($tenantId, $transactionType);
|
||||||
|
|
||||||
|
// 3. 거래처별 분포 (당월, 상위 5개)
|
||||||
|
$vendorDistribution = $this->getVendorDistribution($tenantId, $transactionType, $currentMonthStart, $currentMonthEnd);
|
||||||
|
|
||||||
|
// 4. 지출예상 목록 (당월, 지급일 순)
|
||||||
$itemsQuery = ExpectedExpense::query()
|
$itemsQuery = ExpectedExpense::query()
|
||||||
->select([
|
->select([
|
||||||
'expected_expenses.id',
|
'expected_expenses.id',
|
||||||
@@ -394,7 +402,7 @@ public function dashboardDetail(?string $transactionType = null): array
|
|||||||
})
|
})
|
||||||
->toArray();
|
->toArray();
|
||||||
|
|
||||||
// 3. 푸터 합계
|
// 5. 푸터 합계
|
||||||
$footerSummary = [
|
$footerSummary = [
|
||||||
'total_amount' => (float) $currentMonthTotal,
|
'total_amount' => (float) $currentMonthTotal,
|
||||||
'item_count' => count($items),
|
'item_count' => count($items),
|
||||||
@@ -407,8 +415,111 @@ public function dashboardDetail(?string $transactionType = null): array
|
|||||||
'change_rate' => $changeRate,
|
'change_rate' => $changeRate,
|
||||||
'remaining_balance' => (float) $pendingBalance,
|
'remaining_balance' => (float) $pendingBalance,
|
||||||
],
|
],
|
||||||
|
'monthly_trend' => $monthlyTrend,
|
||||||
|
'vendor_distribution' => $vendorDistribution,
|
||||||
'items' => $items,
|
'items' => $items,
|
||||||
'footer_summary' => $footerSummary,
|
'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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user