feat(API): CEO 대시보드 지출내역 상세 조회 API 추가

- BillService: 발행어음 대시보드 상세 조회 (me3 모달용)
- WelfareService: 직원 수/급여 총액 조회 로직 개선 (salaries 테이블 기반)
- BillApi/ExpectedExpenseApi: Swagger 스키마 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-22 23:18:33 +09:00
parent a994f27696
commit 5ac83399b3
5 changed files with 336 additions and 32 deletions

View File

@@ -109,4 +109,14 @@ public function summary(Request $request)
return ApiResponse::success($summary, __('message.fetched'));
}
/**
* 대시보드 상세 조회 (CEO 대시보드 당월 예상 지출내역 me3 모달용)
*/
public function dashboardDetail()
{
$data = $this->service->dashboardDetail();
return ApiResponse::success($data, __('message.fetched'));
}
}

View File

@@ -328,6 +328,118 @@ public function summary(array $params): array
];
}
/**
* 발행어음 대시보드 상세 조회 (CEO 대시보드 당월 예상 지출내역 me3 모달용)
*
* @return array{
* summary: array{current_month_total: float, previous_month_total: float, change_rate: float},
* monthly_trend: array<array{month: string, amount: float}>,
* by_vendor: array<array{vendor_name: string, amount: float, ratio: float}>,
* items: array<array{id: int, vendor_name: string, issue_date: string, maturity_date: string, amount: float, status: string, status_label: string}>
* }
*/
public function dashboardDetail(): array
{
$tenantId = $this->tenantId();
// 현재 월 범위
$currentMonthStart = now()->startOfMonth()->toDateString();
$currentMonthEnd = now()->endOfMonth()->toDateString();
// 전월 범위
$previousMonthStart = now()->subMonth()->startOfMonth()->toDateString();
$previousMonthEnd = now()->subMonth()->endOfMonth()->toDateString();
// 1. 요약 정보 (발행어음 기준)
$currentMonthTotal = Bill::query()
->where('tenant_id', $tenantId)
->where('bill_type', 'issued')
->whereBetween('issue_date', [$currentMonthStart, $currentMonthEnd])
->sum('amount');
$previousMonthTotal = Bill::query()
->where('tenant_id', $tenantId)
->where('bill_type', 'issued')
->whereBetween('issue_date', [$previousMonthStart, $previousMonthEnd])
->sum('amount');
$changeRate = $previousMonthTotal > 0
? round((($currentMonthTotal - $previousMonthTotal) / $previousMonthTotal) * 100, 1)
: 0;
// 2. 월별 추이 (최근 7개월)
$monthlyTrend = [];
for ($i = 6; $i >= 0; $i--) {
$monthStart = now()->subMonths($i)->startOfMonth();
$monthEnd = now()->subMonths($i)->endOfMonth();
$amount = Bill::query()
->where('tenant_id', $tenantId)
->where('bill_type', 'issued')
->whereBetween('issue_date', [$monthStart->toDateString(), $monthEnd->toDateString()])
->sum('amount');
$monthlyTrend[] = [
'month' => $monthStart->format('Y-m'),
'amount' => (float) $amount,
];
}
// 3. 거래처별 분포 (현재 월)
$byVendorRaw = Bill::query()
->where('tenant_id', $tenantId)
->where('bill_type', 'issued')
->whereBetween('issue_date', [$currentMonthStart, $currentMonthEnd])
->select(
DB::raw('COALESCE(client_name, (SELECT name FROM clients WHERE clients.id = bills.client_id)) as vendor_name'),
DB::raw('SUM(amount) as amount')
)
->groupBy('client_id', 'client_name')
->orderByDesc('amount')
->get();
$totalAmount = $byVendorRaw->sum('amount');
$byVendor = $byVendorRaw->map(function ($item) use ($totalAmount) {
return [
'vendor_name' => $item->vendor_name ?? '미지정',
'amount' => (float) $item->amount,
'ratio' => $totalAmount > 0 ? round(($item->amount / $totalAmount) * 100, 1) : 0,
];
})->toArray();
// 4. 발행어음 목록 (현재 월)
$items = Bill::query()
->where('tenant_id', $tenantId)
->where('bill_type', 'issued')
->whereBetween('issue_date', [$currentMonthStart, $currentMonthEnd])
->with(['client:id,name'])
->orderBy('maturity_date', 'asc')
->get()
->map(function ($bill) {
return [
'id' => $bill->id,
'vendor_name' => $bill->client?->name ?? $bill->client_name ?? '-',
'issue_date' => $bill->issue_date->format('Y-m-d'),
'maturity_date' => $bill->maturity_date->format('Y-m-d'),
'amount' => (float) $bill->amount,
'status' => $bill->status,
'status_label' => Bill::ISSUED_STATUSES[$bill->status] ?? $bill->status,
];
})
->toArray();
return [
'summary' => [
'current_month_total' => (float) $currentMonthTotal,
'previous_month_total' => (float) $previousMonthTotal,
'change_rate' => $changeRate,
],
'monthly_trend' => $monthlyTrend,
'by_vendor' => $byVendor,
'items' => $items,
];
}
/**
* 어음번호 자동 생성
*/

View File

@@ -124,10 +124,25 @@ public function getSummary(
}
/**
* 직원 수 조회
* 직원 수 조회 (급여 대상 직원 기준)
*
* salaries 테이블에서 유니크한 employee_id 수를 카운트합니다.
* 급여 데이터가 없으면 user_tenants 테이블에서 조회합니다.
*/
private function getEmployeeCount(int $tenantId): int
{
// 1차: salaries 테이블에서 급여 대상 직원 수 조회
$count = DB::table('salaries')
->where('tenant_id', $tenantId)
->whereNull('deleted_at')
->distinct('employee_id')
->count('employee_id');
if ($count > 0) {
return $count;
}
// 2차: salaries 데이터가 없으면 user_tenants에서 조회
$count = DB::table('users')
->join('user_tenants', 'users.id', '=', 'user_tenants.user_id')
->where('user_tenants.tenant_id', $tenantId)
@@ -135,33 +150,70 @@ private function getEmployeeCount(int $tenantId): int
->whereNull('users.deleted_at')
->count();
return $count ?: 50; // 임시 기본값
return $count ?: 0;
}
/**
* 연간 급여 총액 조회
*
* salaries 테이블에서 해당 연도의 base_salary 합계를 조회합니다.
* 연간 데이터가 부족한 경우, 최근 월 데이터를 12배하여 추정합니다.
*/
private function getTotalSalary(int $tenantId, int $year): float
{
// TODO: 실제 급여 테이블에서 조회
// payroll 또는 salary_histories에서 연간 급여 합계
return 2000000000; // 임시 기본값 (20억)
// 해당 연도의 급여 합계 조회
$yearlyTotal = DB::table('salaries')
->where('tenant_id', $tenantId)
->where('year', $year)
->whereNull('deleted_at')
->sum('base_salary');
if ($yearlyTotal > 0) {
// 데이터가 있는 월 수 확인
$monthCount = DB::table('salaries')
->where('tenant_id', $tenantId)
->where('year', $year)
->whereNull('deleted_at')
->distinct('month')
->count('month');
// 연간 추정 (데이터가 일부 월만 있을 경우 12개월로 환산)
if ($monthCount > 0 && $monthCount < 12) {
return ($yearlyTotal / $monthCount) * 12;
}
return $yearlyTotal;
}
// 해당 연도 데이터가 없으면 최근 월 데이터로 추정
$latestMonth = DB::table('salaries')
->where('tenant_id', $tenantId)
->whereNull('deleted_at')
->sum('base_salary');
$employeeCount = $this->getEmployeeCount($tenantId);
if ($latestMonth > 0 && $employeeCount > 0) {
// 최근 월 급여를 12배하여 연간 추정
return ($latestMonth / $employeeCount) * $employeeCount * 12;
}
return 0;
}
/**
* 복리후생비 사용액 조회
*
* expense_accounts 테이블에서 welfare 타입 지출액을 조회합니다.
* 데이터가 없으면 0을 반환합니다.
*/
private function getUsedAmount(int $tenantId, string $startDate, string $endDate): float
{
// TODO: 실제 복리후생비 계정과목에서 조회
$amount = DB::table('expense_accounts')
return DB::table('expense_accounts')
->where('tenant_id', $tenantId)
->where('account_type', 'welfare')
->whereBetween('expense_date', [$startDate, $endDate])
->whereNull('deleted_at')
->sum('amount');
return $amount ?: 5123000; // 임시 기본값
->sum('amount') ?: 0;
}
/**
@@ -291,12 +343,18 @@ public function getDetail(
/**
* 복리후생비 계정 잔액 조회
*
* 해당 연도의 복리후생비 계정 지출액을 조회합니다.
* 데이터가 없으면 0을 반환합니다.
*/
private function getAccountBalance(int $tenantId, int $year): float
{
// TODO: 실제 계정 잔액 조회 로직 구현
// 예: accounting_accounts에서 복리후생비 계정 잔액 조회
return 3123000; // 임시 기본값
return DB::table('expense_accounts')
->where('tenant_id', $tenantId)
->where('account_type', 'welfare')
->whereYear('expense_date', $year)
->whereNull('deleted_at')
->sum('amount') ?: 0;
}
/**
@@ -361,16 +419,7 @@ private function getCategoryDistribution(int $tenantId, string $startDate, strin
];
}
// 데이터가 없는 경우 기본값 반환
if (empty($result)) {
$result = [
['category' => 'meal', 'label' => '식비', 'amount' => 55000000, 'ratio' => 55],
['category' => 'health_check', 'label' => '건강검진', 'amount' => 25000000, 'ratio' => 25],
['category' => 'congratulation', 'label' => '경조사비', 'amount' => 10000000, 'ratio' => 10],
['category' => 'other', 'label' => '기타', 'amount' => 10000000, 'ratio' => 10],
];
}
// 데이터가 없는 경우 빈 배열 반환 (mock 데이터 제거)
return $result;
}
@@ -420,15 +469,7 @@ private function getTransactions(int $tenantId, string $startDate, string $endDa
];
}
// 데이터가 없는 경우 기본값 반환
if (empty($result)) {
$result = [
['id' => 1, 'card_name' => '카드명', 'user_name' => '홍길동', 'expense_date' => '2025-12-12 12:12', 'vendor_name' => '가맹점명', 'amount' => 1000000, 'sub_type' => 'meal', 'sub_type_label' => '식비'],
['id' => 2, 'card_name' => '카드명', 'user_name' => '홍길동', 'expense_date' => '2025-12-12 12:12', 'vendor_name' => '가맹점명', 'amount' => 1200000, 'sub_type' => 'health_check', 'sub_type_label' => '건강검진'],
['id' => 3, 'card_name' => '카드명', 'user_name' => '홍길동', 'expense_date' => '2025-12-12 12:12', 'vendor_name' => '가맹점명', 'amount' => 1500000, 'sub_type' => 'congratulation', 'sub_type_label' => '경조사비'],
];
}
// 데이터가 없는 경우 빈 배열 반환 (mock 데이터 제거)
return $result;
}

View File

@@ -164,6 +164,62 @@
* ),
* @OA\Property(property="maturity_alert_amount", type="number", example=10000000, description="만기 임박 금액 (7일 이내)")
* )
*
* @OA\Schema(
* schema="BillDashboardDetail",
* description="발행어음 대시보드 상세 (CEO 대시보드 당월 예상 지출내역 me3 모달용)",
* type="object",
*
* @OA\Property(
* property="summary",
* type="object",
* description="요약 정보",
* @OA\Property(property="current_month_total", type="number", example=50000000, description="당월 발행어음 합계"),
* @OA\Property(property="previous_month_total", type="number", example=45000000, description="전월 발행어음 합계"),
* @OA\Property(property="change_rate", type="number", example=11.1, description="전월 대비 증감율 (%)")
* ),
* @OA\Property(
* property="monthly_trend",
* type="array",
* description="월별 추이 (최근 7개월)",
*
* @OA\Items(
* type="object",
*
* @OA\Property(property="month", type="string", example="2025-07", description="년-월"),
* @OA\Property(property="amount", type="number", example=42000000, description="해당 월 발행어음 합계")
* )
* ),
* @OA\Property(
* property="by_vendor",
* type="array",
* description="거래처별 분포 (당월)",
*
* @OA\Items(
* type="object",
*
* @OA\Property(property="vendor_name", type="string", example="(주)삼성전자", description="거래처명"),
* @OA\Property(property="amount", type="number", example=25000000, description="금액"),
* @OA\Property(property="percentage", type="number", example=50.0, description="비율 (%)")
* )
* ),
* @OA\Property(
* property="items",
* type="array",
* description="발행어음 목록 (당월)",
*
* @OA\Items(
* type="object",
*
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="vendor", type="string", example="(주)삼성전자", description="거래처명"),
* @OA\Property(property="issue_date", type="string", format="date", example="2025-01-05", description="발행일"),
* @OA\Property(property="due_date", type="string", format="date", example="2025-04-05", description="만기일"),
* @OA\Property(property="amount", type="number", example=10000000, description="금액"),
* @OA\Property(property="status", type="string", example="stored", description="상태")
* )
* )
* )
*/
class BillApi
{
@@ -396,4 +452,28 @@ public function updateStatus() {}
* )
*/
public function summary() {}
/**
* @OA\Get(
* path="/api/v1/bills/dashboard-detail",
* operationId="getBillDashboardDetail",
* tags={"Bills"},
* summary="발행어음 대시보드 상세 조회",
* description="CEO 대시보드 당월 예상 지출내역 me3 모달용 상세 데이터를 조회합니다. 요약 정보, 월별 추이, 거래처별 분포, 발행어음 목록을 제공합니다.",
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string", example="데이터를 조회했습니다."),
* @OA\Property(property="data", ref="#/components/schemas/BillDashboardDetail")
* )
* )
* )
*/
public function dashboardDetail() {}
}

View File

@@ -107,6 +107,44 @@
* @OA\Property(property="by_transaction_type", type="object"),
* @OA\Property(property="by_month", type="object")
* )
*
* @OA\Schema(
* schema="ExpectedExpenseDashboardDetail",
* type="object",
* description="CEO 대시보드 당월 예상 지출내역 me4 모달용 데이터",
*
* @OA\Property(
* property="summary",
* type="object",
* @OA\Property(property="current_month_total", type="number", format="float", example=5000000, description="당월 지출예상 합계"),
* @OA\Property(property="previous_month_total", type="number", format="float", example=4500000, description="전월 지출예상 합계"),
* @OA\Property(property="change_rate", type="number", format="float", example=11.1, description="전월대비 증감률(%)"),
* @OA\Property(property="pending_balance", type="number", format="float", example=3000000, description="미지급 잔액")
* ),
* @OA\Property(
* property="items",
* type="array",
* description="당월 지출예상 목록",
*
* @OA\Items(
* type="object",
*
* @OA\Property(property="id", type="integer", example=1),
* @OA\Property(property="payment_date", type="string", format="date", example="2026-01-15"),
* @OA\Property(property="item", type="string", example="월 임대료"),
* @OA\Property(property="amount", type="number", format="float", example=1500000),
* @OA\Property(property="vendor", type="string", example="(주)빌딩관리"),
* @OA\Property(property="account", type="string", example="임차료"),
* @OA\Property(property="status", type="string", example="pending")
* )
* ),
* @OA\Property(
* property="footer_summary",
* type="object",
* @OA\Property(property="total_amount", type="number", format="float", example=5000000),
* @OA\Property(property="total_count", type="integer", example=8)
* )
* )
*/
class ExpectedExpenseApi
{
@@ -341,4 +379,27 @@ public function updateExpectedPaymentDate() {}
* )
*/
public function summary() {}
/**
* @OA\Get(
* path="/api/v1/expected-expenses/dashboard-detail",
* summary="대시보드 상세 조회 (CEO 대시보드 당월 예상 지출내역 me4 모달용)",
* description="당월 지출예상 요약, 상세 목록을 제공합니다. me4(지출예상) 모달에서 사용됩니다.",
* tags={"ExpectedExpense"},
* security={{"ApiKeyAuth": {}}, {"BearerAuth": {}}},
*
* @OA\Response(
* response=200,
* description="성공",
*
* @OA\JsonContent(
*
* @OA\Property(property="success", type="boolean", example=true),
* @OA\Property(property="message", type="string"),
* @OA\Property(property="data", ref="#/components/schemas/ExpectedExpenseDashboardDetail")
* )
* )
* )
*/
public function dashboardDetail() {}
}