From 77914da7b7b7e9b6a9c027052e0241f024cff064 Mon Sep 17 00:00:00 2001 From: hskwon Date: Wed, 17 Dec 2025 22:51:17 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=B3=B4=EA=B3=A0=EC=84=9C(Reports)=20?= =?UTF-8?q?API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 일일 일보 조회/엑셀 다운로드 API 추가 - 지출 예상 내역서 조회/엑셀 다운로드 API 추가 - ReportService: 전일/당일 잔액 계산, 월별 지출 예상 집계 - Laravel Excel을 이용한 엑셀 내보내기 구현 - Swagger 문서 작성 완료 --- app/Exports/DailyReportExport.php | 95 ++++++++ app/Exports/ExpenseEstimateExport.php | 121 ++++++++++ .../Controllers/Api/V1/ReportController.php | 61 +++++ .../Requests/V1/Report/DailyReportRequest.php | 36 +++ .../V1/Report/ExpenseEstimateRequest.php | 46 ++++ app/Services/ReportService.php | 222 ++++++++++++++++++ app/Swagger/v1/ReportApi.php | 212 +++++++++++++++++ lang/ko/message.php | 11 + routes/api.php | 13 +- 9 files changed, 815 insertions(+), 2 deletions(-) create mode 100644 app/Exports/DailyReportExport.php create mode 100644 app/Exports/ExpenseEstimateExport.php create mode 100644 app/Http/Controllers/Api/V1/ReportController.php create mode 100644 app/Http/Requests/V1/Report/DailyReportRequest.php create mode 100644 app/Http/Requests/V1/Report/ExpenseEstimateRequest.php create mode 100644 app/Services/ReportService.php create mode 100644 app/Swagger/v1/ReportApi.php diff --git a/app/Exports/DailyReportExport.php b/app/Exports/DailyReportExport.php new file mode 100644 index 0000000..f8c4eee --- /dev/null +++ b/app/Exports/DailyReportExport.php @@ -0,0 +1,95 @@ +report['date']], + [], + ['전일 잔액', number_format($this->report['previous_balance']).'원'], + ['당일 입금액', number_format($this->report['daily_deposit']).'원'], + ['당일 출금액', number_format($this->report['daily_withdrawal']).'원'], + ['당일 잔액', number_format($this->report['current_balance']).'원'], + [], + ['구분', '거래처명', '계정과목', '입금액', '출금액', '적요'], + ]; + } + + /** + * 데이터 배열 + */ + public function array(): array + { + $rows = []; + + foreach ($this->report['details'] as $detail) { + $rows[] = [ + $detail['type_label'], + $detail['client_name'], + $detail['account_code'], + $detail['deposit_amount'] > 0 ? number_format($detail['deposit_amount']) : '', + $detail['withdrawal_amount'] > 0 ? number_format($detail['withdrawal_amount']) : '', + $detail['description'], + ]; + } + + // 합계 행 추가 + $rows[] = []; + $rows[] = [ + '합계', + '', + '', + number_format($this->report['daily_deposit']), + number_format($this->report['daily_withdrawal']), + '', + ]; + + return $rows; + } + + /** + * 스타일 정의 + */ + public function styles(Worksheet $sheet): array + { + return [ + 1 => ['font' => ['bold' => true, 'size' => 14]], + 3 => ['font' => ['bold' => true]], + 4 => ['font' => ['bold' => true]], + 5 => ['font' => ['bold' => true]], + 6 => ['font' => ['bold' => true]], + 8 => [ + 'font' => ['bold' => true], + 'fill' => [ + 'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID, + 'startColor' => ['rgb' => 'E0E0E0'], + ], + ], + ]; + } +} diff --git a/app/Exports/ExpenseEstimateExport.php b/app/Exports/ExpenseEstimateExport.php new file mode 100644 index 0000000..a8a8ce0 --- /dev/null +++ b/app/Exports/ExpenseEstimateExport.php @@ -0,0 +1,121 @@ +report['year_month']], + [], + ['예상 지출 합계', number_format($this->report['total_estimate']).'원'], + ['계좌 잔액', number_format($this->report['account_balance']).'원'], + ['예상 잔액', number_format($this->report['expected_balance']).'원'], + [], + ['예상 지급일', '품목', '지출금액', '거래처', '계좌'], + ]; + } + + /** + * 데이터 배열 + */ + public function array(): array + { + $rows = []; + + foreach ($this->report['items'] as $item) { + $rows[] = [ + $item['expected_date'], + $item['item_name'], + number_format($item['amount']), + $item['client_name'], + $item['account_name'], + ]; + } + + // 빈 줄 추가 + $rows[] = []; + + // 월별 합계 + $rows[] = ['[월별 합계]', '', '', '', '']; + + foreach ($this->report['monthly_summary']['by_month'] as $month) { + $rows[] = [ + $month['month'].' 계', + '', + number_format($month['total']), + '', + '', + ]; + } + + // 최종 합계 + $rows[] = []; + $rows[] = [ + '지출 합계', + '', + number_format($this->report['monthly_summary']['total_expense']), + '', + '', + ]; + $rows[] = [ + '계좌 잔액', + '', + number_format($this->report['monthly_summary']['account_balance']), + '', + '', + ]; + $rows[] = [ + '최종 차액', + '', + number_format($this->report['monthly_summary']['final_difference']), + '', + '', + ]; + + return $rows; + } + + /** + * 스타일 정의 + */ + public function styles(Worksheet $sheet): array + { + return [ + 1 => ['font' => ['bold' => true, 'size' => 14]], + 3 => ['font' => ['bold' => true]], + 4 => ['font' => ['bold' => true]], + 5 => ['font' => ['bold' => true]], + 7 => [ + 'font' => ['bold' => true], + 'fill' => [ + 'fillType' => \PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID, + 'startColor' => ['rgb' => 'E0E0E0'], + ], + ], + ]; + } +} diff --git a/app/Http/Controllers/Api/V1/ReportController.php b/app/Http/Controllers/Api/V1/ReportController.php new file mode 100644 index 0000000..a872da4 --- /dev/null +++ b/app/Http/Controllers/Api/V1/ReportController.php @@ -0,0 +1,61 @@ +service->dailyReport($request->validated()); + + return ApiResponse::handle(__('message.fetched'), $report); + } + + /** + * 일일 일보 엑셀 다운로드 + */ + public function dailyExport(DailyReportRequest $request) + { + $report = $this->service->dailyReport($request->validated()); + $filename = 'daily_report_'.$report['date'].'.xlsx'; + + return Excel::download(new DailyReportExport($report), $filename); + } + + /** + * 지출 예상 내역서 조회 + */ + public function expenseEstimate(ExpenseEstimateRequest $request) + { + $report = $this->service->expenseEstimate($request->validated()); + + return ApiResponse::handle(__('message.fetched'), $report); + } + + /** + * 지출 예상 내역서 엑셀 다운로드 + */ + public function expenseEstimateExport(ExpenseEstimateRequest $request) + { + $report = $this->service->expenseEstimate($request->validated()); + $filename = 'expense_estimate_'.$report['year_month'].'.xlsx'; + + return Excel::download(new ExpenseEstimateExport($report), $filename); + } +} diff --git a/app/Http/Requests/V1/Report/DailyReportRequest.php b/app/Http/Requests/V1/Report/DailyReportRequest.php new file mode 100644 index 0000000..ae7dd84 --- /dev/null +++ b/app/Http/Requests/V1/Report/DailyReportRequest.php @@ -0,0 +1,36 @@ + ['nullable', 'date', 'date_format:Y-m-d'], + ]; + } + + /** + * Custom attribute names + */ + public function attributes(): array + { + return [ + 'date' => __('validation.attributes.date'), + ]; + } +} diff --git a/app/Http/Requests/V1/Report/ExpenseEstimateRequest.php b/app/Http/Requests/V1/Report/ExpenseEstimateRequest.php new file mode 100644 index 0000000..7ea7ff4 --- /dev/null +++ b/app/Http/Requests/V1/Report/ExpenseEstimateRequest.php @@ -0,0 +1,46 @@ + ['nullable', 'regex:/^\d{4}-\d{2}$/'], + ]; + } + + /** + * Custom attribute names + */ + public function attributes(): array + { + return [ + 'year_month' => __('validation.attributes.year_month'), + ]; + } + + /** + * Custom validation messages + */ + public function messages(): array + { + return [ + 'year_month.regex' => __('validation.date_format', ['format' => 'YYYY-MM']), + ]; + } +} diff --git a/app/Services/ReportService.php b/app/Services/ReportService.php new file mode 100644 index 0000000..c77e674 --- /dev/null +++ b/app/Services/ReportService.php @@ -0,0 +1,222 @@ +tenantId(); + $date = Carbon::parse($params['date'] ?? now()->toDateString()); + $previousDate = $date->copy()->subDay(); + + // 전일 잔액 계산 (기준일 전일까지의 모든 입출금 합계) + $previousBalance = $this->calculateBalanceUntilDate($tenantId, $previousDate); + + // 당일 입금 합계 + $dailyDeposit = Deposit::query() + ->where('tenant_id', $tenantId) + ->whereDate('deposit_date', $date) + ->sum('amount'); + + // 당일 출금 합계 + $dailyWithdrawal = Withdrawal::query() + ->where('tenant_id', $tenantId) + ->whereDate('withdrawal_date', $date) + ->sum('amount'); + + // 당일 잔액 + $currentBalance = $previousBalance + $dailyDeposit - $dailyWithdrawal; + + // 상세 내역 (입출금 통합) + $details = $this->getDailyDetails($tenantId, $date); + + return [ + 'date' => $date->toDateString(), + 'previous_balance' => (float) $previousBalance, + 'daily_deposit' => (float) $dailyDeposit, + 'daily_withdrawal' => (float) $dailyWithdrawal, + 'current_balance' => (float) $currentBalance, + 'details' => $details, + ]; + } + + /** + * 지출 예상 내역서 조회 + * + * @param array $params [year_month: YYYY-MM 형식] + */ + public function expenseEstimate(array $params): array + { + $tenantId = $this->tenantId(); + $yearMonth = $params['year_month'] ?? now()->format('Y-m'); + [$year, $month] = explode('-', $yearMonth); + + $startDate = Carbon::createFromDate($year, $month, 1)->startOfMonth(); + $endDate = $startDate->copy()->endOfMonth(); + + // 미결제 매입 내역 조회 (지출 예상) + $items = Purchase::query() + ->where('tenant_id', $tenantId) + ->whereIn('status', ['draft', 'confirmed']) + ->whereNull('withdrawal_id') + ->whereBetween('purchase_date', [$startDate, $endDate]) + ->with(['client:id,name']) + ->orderBy('purchase_date') + ->get() + ->map(function ($purchase) { + return [ + 'id' => $purchase->id, + 'expected_date' => $purchase->purchase_date->toDateString(), + 'item_name' => $purchase->description ?? __('message.report.purchase'), + 'amount' => (float) $purchase->total_amount, + 'client_name' => $purchase->client?->name ?? '', + 'account_name' => '', + ]; + }); + + // 예상 지출 합계 + $totalEstimate = $items->sum('amount'); + + // 계좌 잔액 (대표 계좌 기준, 없으면 모든 계좌 합산) + $accountBalance = $this->getAccountBalance($tenantId); + + // 예상 잔액 + $expectedBalance = $accountBalance - $totalEstimate; + + // 월별 합계 + $monthlySummary = $this->getMonthlySummary($tenantId, $startDate); + + return [ + 'year_month' => $yearMonth, + 'total_estimate' => (float) $totalEstimate, + 'account_balance' => (float) $accountBalance, + 'expected_balance' => (float) $expectedBalance, + 'items' => $items->values()->toArray(), + 'monthly_summary' => $monthlySummary, + ]; + } + + /** + * 특정 날짜까지의 잔액 계산 + */ + private function calculateBalanceUntilDate(int $tenantId, Carbon $date): float + { + $totalDeposits = Deposit::query() + ->where('tenant_id', $tenantId) + ->whereDate('deposit_date', '<=', $date) + ->sum('amount'); + + $totalWithdrawals = Withdrawal::query() + ->where('tenant_id', $tenantId) + ->whereDate('withdrawal_date', '<=', $date) + ->sum('amount'); + + return (float) ($totalDeposits - $totalWithdrawals); + } + + /** + * 일일 상세 내역 조회 (입출금 통합) + */ + private function getDailyDetails(int $tenantId, Carbon $date): array + { + // 입금 내역 + $deposits = Deposit::query() + ->where('tenant_id', $tenantId) + ->whereDate('deposit_date', $date) + ->with(['client:id,name']) + ->get() + ->map(function ($deposit) { + return [ + 'type' => 'deposit', + 'type_label' => __('message.report.deposit'), + 'client_name' => $deposit->client?->name ?? $deposit->client_name ?? '', + 'account_code' => $deposit->account_code ?? '', + 'deposit_amount' => (float) $deposit->amount, + 'withdrawal_amount' => 0, + 'description' => $deposit->description ?? '', + 'payment_method' => $deposit->payment_method, + ]; + }); + + // 출금 내역 + $withdrawals = Withdrawal::query() + ->where('tenant_id', $tenantId) + ->whereDate('withdrawal_date', $date) + ->with(['client:id,name']) + ->get() + ->map(function ($withdrawal) { + return [ + 'type' => 'withdrawal', + 'type_label' => __('message.report.withdrawal'), + 'client_name' => $withdrawal->client?->name ?? $withdrawal->client_name ?? '', + 'account_code' => $withdrawal->account_code ?? '', + 'deposit_amount' => 0, + 'withdrawal_amount' => (float) $withdrawal->amount, + 'description' => $withdrawal->description ?? '', + 'payment_method' => $withdrawal->payment_method, + ]; + }); + + return $deposits->concat($withdrawals)->values()->toArray(); + } + + /** + * 계좌 잔액 조회 + * 입출금 내역 기반으로 계산 + */ + private function getAccountBalance(int $tenantId): float + { + return $this->calculateBalanceUntilDate($tenantId, Carbon::now()); + } + + /** + * 월별 지출 예상 합계 + */ + private function getMonthlySummary(int $tenantId, Carbon $baseDate): array + { + $summary = []; + + // 기준월부터 3개월간 집계 + for ($i = 0; $i < 3; $i++) { + $targetDate = $baseDate->copy()->addMonths($i); + $startOfMonth = $targetDate->copy()->startOfMonth(); + $endOfMonth = $targetDate->copy()->endOfMonth(); + + $total = Purchase::query() + ->where('tenant_id', $tenantId) + ->whereIn('status', ['draft', 'confirmed']) + ->whereNull('withdrawal_id') + ->whereBetween('purchase_date', [$startOfMonth, $endOfMonth]) + ->sum('total_amount'); + + $summary[] = [ + 'month' => $targetDate->format('Y/m'), + 'total' => (float) $total, + ]; + } + + // 전체 합계 + $grandTotal = collect($summary)->sum('total'); + + // 계좌 잔액 + $accountBalance = $this->getAccountBalance($tenantId); + + return [ + 'by_month' => $summary, + 'total_expense' => $grandTotal, + 'account_balance' => $accountBalance, + 'final_difference' => $accountBalance - $grandTotal, + ]; + } +} diff --git a/app/Swagger/v1/ReportApi.php b/app/Swagger/v1/ReportApi.php new file mode 100644 index 0000000..c7f762f --- /dev/null +++ b/app/Swagger/v1/ReportApi.php @@ -0,0 +1,212 @@ + '현장이 삭제되었습니다.', 'active_fetched' => '활성화된 현장 목록을 조회했습니다.', ], + + // 보고서 관리 + 'report' => [ + 'fetched' => '보고서를 조회했습니다.', + 'daily_fetched' => '일일 일보를 조회했습니다.', + 'expense_estimate_fetched' => '지출 예상 내역서를 조회했습니다.', + 'exported' => '보고서가 다운로드되었습니다.', + 'deposit' => '입금', + 'withdrawal' => '출금', + 'purchase' => '매입', + ], ]; diff --git a/routes/api.php b/routes/api.php index 33f6321..b0460ee 100644 --- a/routes/api.php +++ b/routes/api.php @@ -51,20 +51,21 @@ use App\Http\Controllers\Api\V1\QuoteController; use App\Http\Controllers\Api\V1\RefreshController; use App\Http\Controllers\Api\V1\RegisterController; +use App\Http\Controllers\Api\V1\ReportController; use App\Http\Controllers\Api\V1\RoleController; use App\Http\Controllers\Api\V1\RolePermissionController; use App\Http\Controllers\Api\V1\SaleController; use App\Http\Controllers\Api\V1\SiteController; use App\Http\Controllers\Api\V1\TenantController; use App\Http\Controllers\Api\V1\TenantFieldSettingController; -use App\Http\Controllers\Api\V1\TenantOptionGroupController; // 설계 전용 (디자인 네임스페이스) +use App\Http\Controllers\Api\V1\TenantOptionGroupController; use App\Http\Controllers\Api\V1\TenantOptionValueController; use App\Http\Controllers\Api\V1\TenantStatFieldController; use App\Http\Controllers\Api\V1\TenantUserProfileController; use App\Http\Controllers\Api\V1\UserController; -use App\Http\Controllers\Api\V1\UserRoleController; // 모델셋 관리 (견적 시스템) +use App\Http\Controllers\Api\V1\UserRoleController; use App\Http\Controllers\Api\V1\WithdrawalController; use App\Http\Controllers\Api\V1\WorkSettingController; use Illuminate\Support\Facades\Route; @@ -340,6 +341,14 @@ Route::post('/{id}/confirm', [PurchaseController::class, 'confirm'])->whereNumber('id')->name('v1.purchases.confirm'); }); + // Report API (보고서) + Route::prefix('reports')->group(function () { + Route::get('/daily', [ReportController::class, 'daily'])->name('v1.reports.daily'); + Route::get('/daily/export', [ReportController::class, 'dailyExport'])->name('v1.reports.daily.export'); + Route::get('/expense-estimate', [ReportController::class, 'expenseEstimate'])->name('v1.reports.expense-estimate'); + Route::get('/expense-estimate/export', [ReportController::class, 'expenseEstimateExport'])->name('v1.reports.expense-estimate.export'); + }); + // Permission API Route::prefix('permissions')->group(function () { Route::get('departments/{dept_id}/menu-matrix', [PermissionController::class, 'deptMenuMatrix'])->name('v1.permissions.deptMenuMatrix'); // 부서별 권한 메트릭스