feat: [expense,loan] 대시보드 상세 필터 및 가지급금 카테고리 분류

- ExpectedExpenseController/Service: dashboardDetail에 start_date/end_date/search 파라미터 추가
- Loan 모델: category 상수 및 라벨 정의 (카드/경조사/상품권/접대비)
- LoanService: dashboard에 category_breakdown 집계 추가
- 마이그레이션: loans 테이블 category 컬럼 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-03-04 10:42:53 +09:00
parent 4f3467c3b0
commit 1deeafc4de
5 changed files with 167 additions and 28 deletions

View File

@@ -128,13 +128,16 @@ public function summary(Request $request)
/**
* 대시보드 상세 조회 (CEO 대시보드 당월 예상 지출내역 모달용)
*
* @param Request $request transaction_type 쿼리 파라미터 (purchase, card, bill, null=전체)
* @param Request $request transaction_type (purchase, card, bill, null=전체), start_date, end_date, search
*/
public function dashboardDetail(Request $request)
{
$transactionType = $request->query('transaction_type');
$startDate = $request->query('start_date');
$endDate = $request->query('end_date');
$search = $request->query('search');
$data = $this->service->dashboardDetail($transactionType);
$data = $this->service->dashboardDetail($transactionType, $startDate, $endDate, $search);
return ApiResponse::success($data, __('message.fetched'));
}

View File

@@ -36,6 +36,37 @@ class Loan extends Model
self::STATUS_PARTIAL,
];
/**
* 카테고리 상수 (D1.7 기획서)
*/
public const CATEGORY_CARD = 'card'; // 카드
public const CATEGORY_CONGRATULATORY = 'congratulatory'; // 경조사
public const CATEGORY_GIFT_CERTIFICATE = 'gift_certificate'; // 상품권
public const CATEGORY_ENTERTAINMENT = 'entertainment'; // 접대비
/**
* 카테고리 목록
*/
public const CATEGORIES = [
self::CATEGORY_CARD,
self::CATEGORY_CONGRATULATORY,
self::CATEGORY_GIFT_CERTIFICATE,
self::CATEGORY_ENTERTAINMENT,
];
/**
* 카테고리 라벨 매핑
*/
public const CATEGORY_LABELS = [
self::CATEGORY_CARD => '카드',
self::CATEGORY_CONGRATULATORY => '경조사',
self::CATEGORY_GIFT_CERTIFICATE => '상품권',
self::CATEGORY_ENTERTAINMENT => '접대비',
];
/**
* 인정이자율 (연도별)
*/
@@ -71,6 +102,7 @@ class Loan extends Model
'settlement_date',
'settlement_amount',
'status',
'category',
'withdrawal_id',
'created_by',
'updated_by',
@@ -137,6 +169,14 @@ public function getStatusLabelAttribute(): string
};
}
/**
* 카테고리 라벨
*/
public function getCategoryLabelAttribute(): string
{
return self::CATEGORY_LABELS[$this->category] ?? $this->category ?? '카드';
}
/**
* 미정산 잔액
*/

View File

@@ -304,34 +304,41 @@ public function summary(array $params): array
* 대시보드 상세 조회 (CEO 대시보드 당월 예상 지출내역 모달용)
*
* @param string|null $transactionType 거래유형 필터 (purchase, card, bill, null=전체)
* @return array{
* summary: array{
* total_amount: float,
* previous_month_amount: float,
* change_rate: float,
* remaining_balance: float,
* item_count: int
* },
* monthly_trend: array,
* vendor_distribution: array,
* items: array,
* footer_summary: array
* }
* @param string|null $startDate 조회 시작일 (null이면 당월 1일)
* @param string|null $endDate 조회 종료일 (null이면 당월 말일)
* @param string|null $search 검색어 (거래처명, 적요)
*/
public function dashboardDetail(?string $transactionType = null): array
{
public function dashboardDetail(
?string $transactionType = null,
?string $startDate = null,
?string $endDate = null,
?string $search = null
): array {
$tenantId = $this->tenantId();
$currentMonthStart = now()->startOfMonth()->toDateString();
$currentMonthEnd = now()->endOfMonth()->toDateString();
$previousMonthStart = now()->subMonth()->startOfMonth()->toDateString();
$previousMonthEnd = now()->subMonth()->endOfMonth()->toDateString();
// 기본 쿼리 빌더 (transaction_type 필터 적용)
$baseQuery = function () use ($tenantId, $transactionType) {
// 날짜 범위: 파라미터 우선, 없으면 당월 기본값
$currentMonthStart = $startDate ?? now()->startOfMonth()->toDateString();
$currentMonthEnd = $endDate ?? now()->endOfMonth()->toDateString();
// 전월 대비: 조회 기간과 동일한 길이의 이전 기간 계산
$startCarbon = \Carbon\Carbon::parse($currentMonthStart);
$endCarbon = \Carbon\Carbon::parse($currentMonthEnd);
$daysDiff = $startCarbon->diffInDays($endCarbon) + 1;
$previousMonthStart = $startCarbon->copy()->subDays($daysDiff)->toDateString();
$previousMonthEnd = $startCarbon->copy()->subDay()->toDateString();
// 기본 쿼리 빌더 (transaction_type + search 필터 적용)
$baseQuery = function () use ($tenantId, $transactionType, $search) {
$query = ExpectedExpense::query()->where('tenant_id', $tenantId);
if ($transactionType) {
$query->where('transaction_type', $transactionType);
}
if ($search) {
$query->where(function ($q) use ($search) {
$q->where('client_name', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
});
}
return $query;
};
@@ -361,10 +368,10 @@ public function dashboardDetail(?string $transactionType = null): array
// 2. 월별 추이 (최근 7개월)
$monthlyTrend = $this->getMonthlyTrend($tenantId, $transactionType);
// 3. 거래처별 분포 (당월, 상위 5개)
// 3. 거래처별 분포 (조회 기간, 상위 5개)
$vendorDistribution = $this->getVendorDistribution($tenantId, $transactionType, $currentMonthStart, $currentMonthEnd);
// 4. 지출예상 목록 (당월, 지급일 순)
// 4. 지출예상 목록 (조회 기간, 지급일 순)
$itemsQuery = ExpectedExpense::query()
->select([
'expected_expenses.id',
@@ -385,6 +392,13 @@ public function dashboardDetail(?string $transactionType = null): array
$itemsQuery->where('expected_expenses.transaction_type', $transactionType);
}
if ($search) {
$itemsQuery->where(function ($q) use ($search) {
$q->where('expected_expenses.client_name', 'like', "%{$search}%")
->orWhere('expected_expenses.description', 'like', "%{$search}%");
});
}
$items = $itemsQuery
->orderBy('expected_expenses.expected_payment_date', 'asc')
->get()

View File

@@ -365,7 +365,8 @@ public function calculateInterest(int $year, ?int $userId = null): array
/**
* 가지급금 대시보드 데이터
*
* CEO 대시보드 카드/가지급금 관리 섹션(cm2) 모달용 데이터 제공
* CEO 대시보드 카드/가지급금 관리 섹션 데이터 제공
* D1.7: category_breakdown 추가 (카드/경조사/상품권/접대비 분류)
*
* @return array{
* summary: array{
@@ -373,6 +374,11 @@ public function calculateInterest(int $year, ?int $userId = null): array
* recognized_interest: float,
* outstanding_count: int
* },
* category_breakdown: array<string, array{
* outstanding_amount: float,
* total_count: int,
* unverified_count: int
* }>,
* loans: array
* }
*/
@@ -388,7 +394,10 @@ public function dashboard(): array
$interestData = $this->calculateInterest($currentYear);
$recognizedInterest = $interestData['summary']['total_recognized_interest'] ?? 0;
// 3. 가지급금 목록 (최근 10건, 미정산 우선)
// 3. 카테고리별 집계 (D1.7)
$categoryBreakdown = $this->getCategoryBreakdown($tenantId);
// 4. 가지급금 목록 (최근 10건, 미정산 우선)
$loans = Loan::query()
->where('tenant_id', $tenantId)
->with(['user:id,name,email', 'withdrawal'])
@@ -404,7 +413,7 @@ public function dashboard(): array
'id' => $loan->id,
'loan_date' => $loan->loan_date->format('Y-m-d'),
'user_name' => $loan->user?->name ?? '미지정',
'category' => $loan->withdrawal_id ? '카드' : '계좌',
'category' => $loan->category_label,
'amount' => (float) $loan->amount,
'status' => $loan->status,
'content' => $loan->purpose ?? '',
@@ -418,10 +427,51 @@ public function dashboard(): array
'recognized_interest' => (float) $recognizedInterest,
'outstanding_count' => (int) $summaryData['outstanding_count'],
],
'category_breakdown' => $categoryBreakdown,
'loans' => $loans,
];
}
/**
* 카테고리별 가지급금 집계
*
* @return array<string, array{outstanding_amount: float, total_count: int, unverified_count: int}>
*/
private function getCategoryBreakdown(int $tenantId): array
{
// 기본값: 4개 카테고리 모두 0으로 초기화
$breakdown = [];
foreach (Loan::CATEGORIES as $category) {
$breakdown[$category] = [
'outstanding_amount' => 0.0,
'total_count' => 0,
'unverified_count' => 0,
];
}
// 카테고리별 미정산 집계
$stats = Loan::query()
->where('tenant_id', $tenantId)
->whereIn('status', [Loan::STATUS_OUTSTANDING, Loan::STATUS_PARTIAL])
->selectRaw('category, COUNT(*) as total_count, SUM(amount - COALESCE(settlement_amount, 0)) as outstanding_amount')
->selectRaw('SUM(CASE WHEN purpose IS NULL OR purpose = \'\' THEN 1 ELSE 0 END) as unverified_count')
->groupBy('category')
->get();
foreach ($stats as $stat) {
$cat = $stat->category ?? Loan::CATEGORY_CARD;
if (isset($breakdown[$cat])) {
$breakdown[$cat] = [
'outstanding_amount' => (float) $stat->outstanding_amount,
'total_count' => (int) $stat->total_count,
'unverified_count' => (int) $stat->unverified_count,
];
}
}
return $breakdown;
}
/**
* 세금 시뮬레이션 데이터
*

View File

@@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* 가지급금 카테고리 컬럼 추가
* D1.7 기획서: 카드/경조사/상품권/접대비 4개 카테고리 분류
*/
public function up(): void
{
Schema::table('loans', function (Blueprint $table) {
$table->string('category', 30)
->default('card')
->after('status')
->comment('카테고리: card, congratulatory, gift_certificate, entertainment');
$table->index(['tenant_id', 'category'], 'idx_tenant_category');
});
}
public function down(): void
{
Schema::table('loans', function (Blueprint $table) {
$table->dropIndex('idx_tenant_category');
$table->dropColumn('category');
});
}
};