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:
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 어음번호 자동 생성
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user