From f401e17447719a3ff90665d92729ee95d3284f69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 12 Mar 2026 10:55:17 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[payroll]=20=EC=97=91=EC=85=80=20?= =?UTF-8?q?=EB=82=B4=EB=B3=B4=EB=82=B4=EA=B8=B0=20=EB=B0=8F=20=EC=A0=84?= =?UTF-8?q?=ED=91=9C=20=EC=83=9D=EC=84=B1=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /payrolls/export: 급여 현황 엑셀 다운로드 (필터 지원) - POST /payrolls/journal-entries: 연월 기준 급여 전표 일괄 생성 - JournalEntry SOURCE_PAYROLL 상수 추가 - StorePayrollJournalRequest 유효성 검증 추가 --- .../Controllers/Api/V1/PayrollController.php | 47 +++- .../V1/Payroll/StorePayrollJournalRequest.php | 31 +++ app/Models/Tenants/JournalEntry.php | 2 + app/Services/PayrollService.php | 255 ++++++++++++++++++ lang/ko/error.php | 1 + lang/ko/message.php | 2 + routes/api/v1/finance.php | 2 + 7 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 app/Http/Requests/V1/Payroll/StorePayrollJournalRequest.php diff --git a/app/Http/Controllers/Api/V1/PayrollController.php b/app/Http/Controllers/Api/V1/PayrollController.php index e1197df..8a0b4dc 100644 --- a/app/Http/Controllers/Api/V1/PayrollController.php +++ b/app/Http/Controllers/Api/V1/PayrollController.php @@ -8,16 +8,20 @@ use App\Http\Requests\V1\Payroll\CalculatePayrollRequest; use App\Http\Requests\V1\Payroll\CopyFromPreviousPayrollRequest; use App\Http\Requests\V1\Payroll\PayPayrollRequest; +use App\Http\Requests\V1\Payroll\StorePayrollJournalRequest; use App\Http\Requests\V1\Payroll\StorePayrollRequest; use App\Http\Requests\V1\Payroll\UpdatePayrollRequest; use App\Http\Requests\V1\Payroll\UpdatePayrollSettingRequest; +use App\Services\ExportService; use App\Services\PayrollService; use Illuminate\Http\Request; +use Symfony\Component\HttpFoundation\BinaryFileResponse; class PayrollController extends Controller { public function __construct( - private readonly PayrollService $service + private readonly PayrollService $service, + private readonly ExportService $exportService ) {} /** @@ -218,6 +222,47 @@ public function payslip(int $id) return ApiResponse::success($payslip, __('message.fetched')); } + /** + * 급여 엑셀 내보내기 + */ + public function export(Request $request): BinaryFileResponse + { + $params = $request->only([ + 'year', + 'month', + 'status', + 'user_id', + 'department_id', + 'search', + 'sort_by', + 'sort_dir', + ]); + + $exportData = $this->service->getExportData($params); + $filename = '급여현황_'.date('Ymd_His'); + + return $this->exportService->download( + $exportData['data'], + $exportData['headings'], + $filename, + '급여현황' + ); + } + + /** + * 급여 전표 생성 + */ + public function journalEntries(StorePayrollJournalRequest $request) + { + $year = (int) $request->input('year'); + $month = (int) $request->input('month'); + $entryDate = $request->input('entry_date'); + + $entry = $this->service->createJournalEntries($year, $month, $entryDate); + + return ApiResponse::success($entry, __('message.payroll.journal_created')); + } + /** * 급여 설정 조회 */ diff --git a/app/Http/Requests/V1/Payroll/StorePayrollJournalRequest.php b/app/Http/Requests/V1/Payroll/StorePayrollJournalRequest.php new file mode 100644 index 0000000..b8643cc --- /dev/null +++ b/app/Http/Requests/V1/Payroll/StorePayrollJournalRequest.php @@ -0,0 +1,31 @@ + ['required', 'integer', 'min:2000', 'max:2100'], + 'month' => ['required', 'integer', 'min:1', 'max:12'], + 'entry_date' => ['nullable', 'date_format:Y-m-d'], + ]; + } + + public function attributes(): array + { + return [ + 'year' => __('validation.attributes.pay_year'), + 'month' => __('validation.attributes.pay_month'), + 'entry_date' => __('validation.attributes.entry_date'), + ]; + } +} diff --git a/app/Models/Tenants/JournalEntry.php b/app/Models/Tenants/JournalEntry.php index 6b2b415..ab8c4a9 100644 --- a/app/Models/Tenants/JournalEntry.php +++ b/app/Models/Tenants/JournalEntry.php @@ -52,6 +52,8 @@ class JournalEntry extends Model public const SOURCE_HOMETAX_INVOICE = 'hometax_invoice'; + public const SOURCE_PAYROLL = 'payroll'; + // Entry type public const TYPE_GENERAL = 'general'; diff --git a/app/Services/PayrollService.php b/app/Services/PayrollService.php index a4fdb0b..798d363 100644 --- a/app/Services/PayrollService.php +++ b/app/Services/PayrollService.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Models\Tenants\IncomeTaxBracket; +use App\Models\Tenants\JournalEntry; use App\Models\Tenants\Payroll; use App\Models\Tenants\PayrollSetting; use App\Models\Tenants\TenantUserProfile; @@ -916,4 +917,258 @@ public function resolveFamilyCount(int $userId): int return max(1, min(11, 1 + $dependentCount)); } + + // ========================================================================= + // 엑셀 내보내기 + // ========================================================================= + + /** + * 급여 엑셀 내보내기용 데이터 + */ + public function getExportData(array $params): array + { + $tenantId = $this->tenantId(); + + $query = Payroll::query() + ->where('tenant_id', $tenantId) + ->with(['user:id,name,email', 'user.tenantProfiles' => function ($q) use ($tenantId) { + $q->where('tenant_id', $tenantId)->with('department:id,name'); + }]); + + if (! empty($params['year'])) { + $query->where('pay_year', $params['year']); + } + if (! empty($params['month'])) { + $query->where('pay_month', $params['month']); + } + if (! empty($params['status'])) { + $query->where('status', $params['status']); + } + if (! empty($params['user_id'])) { + $query->where('user_id', $params['user_id']); + } + if (! empty($params['department_id'])) { + $deptId = $params['department_id']; + $query->whereHas('user.tenantProfiles', function ($q) use ($tenantId, $deptId) { + $q->where('tenant_id', $tenantId)->where('department_id', $deptId); + }); + } + if (! empty($params['search'])) { + $query->whereHas('user', function ($q) use ($params) { + $q->where('name', 'like', "%{$params['search']}%"); + }); + } + + $sortBy = $params['sort_by'] ?? 'pay_year'; + $sortDir = $params['sort_dir'] ?? 'desc'; + + if ($sortBy === 'period') { + $query->orderBy('pay_year', $sortDir)->orderBy('pay_month', $sortDir); + } else { + $query->orderBy($sortBy, $sortDir); + } + + $payrolls = $query->get(); + + $statusLabels = [ + Payroll::STATUS_DRAFT => '작성중', + Payroll::STATUS_CONFIRMED => '확정', + Payroll::STATUS_PAID => '지급완료', + ]; + + $data = $payrolls->map(function ($payroll) use ($statusLabels) { + $profile = $payroll->user?->tenantProfiles?->first(); + $department = $profile?->department?->name ?? '-'; + + return [ + $payroll->pay_year.'년 '.$payroll->pay_month.'월', + $payroll->user?->name ?? '-', + $department, + number_format($payroll->base_salary), + number_format($payroll->overtime_pay), + number_format($payroll->bonus), + number_format($payroll->gross_salary), + number_format($payroll->income_tax), + number_format($payroll->resident_tax), + number_format($payroll->health_insurance), + number_format($payroll->long_term_care), + number_format($payroll->pension), + number_format($payroll->employment_insurance), + number_format($payroll->total_deductions), + number_format($payroll->net_salary), + $statusLabels[$payroll->status] ?? $payroll->status, + ]; + })->toArray(); + + $headings = [ + '급여월', + '직원명', + '부서', + '기본급', + '야근수당', + '상여금', + '총지급액', + '소득세', + '주민세', + '건강보험', + '장기요양', + '국민연금', + '고용보험', + '공제합계', + '실지급액', + '상태', + ]; + + return [ + 'data' => $data, + 'headings' => $headings, + ]; + } + + // ========================================================================= + // 전표 생성 + // ========================================================================= + + /** + * 급여 전표 일괄 생성 + * + * 해당 연월의 확정/지급완료 급여를 합산하여 전표를 생성한다. + * - 차변: 급여 (총지급액) + * - 대변: 각 공제항목 + 미지급금(실지급액) + */ + public function createJournalEntries(int $year, int $month, ?string $entryDate = null): JournalEntry + { + $tenantId = $this->tenantId(); + + $payrolls = Payroll::query() + ->where('tenant_id', $tenantId) + ->forPeriod($year, $month) + ->whereIn('status', [Payroll::STATUS_CONFIRMED, Payroll::STATUS_PAID]) + ->get(); + + if ($payrolls->isEmpty()) { + throw new BadRequestHttpException(__('error.payroll.no_confirmed_payrolls')); + } + + // 합산 + $totalGross = $payrolls->sum('gross_salary'); + $totalIncomeTax = $payrolls->sum('income_tax'); + $totalResidentTax = $payrolls->sum('resident_tax'); + $totalHealthInsurance = $payrolls->sum('health_insurance'); + $totalLongTermCare = $payrolls->sum('long_term_care'); + $totalPension = $payrolls->sum('pension'); + $totalEmploymentInsurance = $payrolls->sum('employment_insurance'); + $totalNet = $payrolls->sum('net_salary'); + + // 전표일자: 지정값 또는 해당월 급여지급일 + if (! $entryDate) { + $settings = PayrollSetting::getOrCreate($tenantId); + $payDay = min($settings->pay_day, 28); + $entryDate = sprintf('%04d-%02d-%02d', $year, $month, $payDay); + } + + $sourceKey = "payroll_{$year}_{$month}"; + $description = "{$year}년 {$month}월 급여"; + + // 분개 행 구성 + $rows = []; + + // 차변: 급여 (총지급액) + $rows[] = [ + 'side' => 'debit', + 'account_code' => '51100', + 'account_name' => '급여', + 'debit_amount' => (int) $totalGross, + 'credit_amount' => 0, + 'memo' => $description, + ]; + + // 대변: 소득세예수금 + if ($totalIncomeTax > 0) { + $rows[] = [ + 'side' => 'credit', + 'account_code' => '25500', + 'account_name' => '예수금-소득세', + 'debit_amount' => 0, + 'credit_amount' => (int) $totalIncomeTax, + ]; + } + + // 대변: 주민세예수금 + if ($totalResidentTax > 0) { + $rows[] = [ + 'side' => 'credit', + 'account_code' => '25501', + 'account_name' => '예수금-주민세', + 'debit_amount' => 0, + 'credit_amount' => (int) $totalResidentTax, + ]; + } + + // 대변: 건강보험예수금 + if ($totalHealthInsurance > 0) { + $rows[] = [ + 'side' => 'credit', + 'account_code' => '25502', + 'account_name' => '예수금-건강보험', + 'debit_amount' => 0, + 'credit_amount' => (int) $totalHealthInsurance, + ]; + } + + // 대변: 장기요양보험예수금 + if ($totalLongTermCare > 0) { + $rows[] = [ + 'side' => 'credit', + 'account_code' => '25503', + 'account_name' => '예수금-장기요양', + 'debit_amount' => 0, + 'credit_amount' => (int) $totalLongTermCare, + ]; + } + + // 대변: 국민연금예수금 + if ($totalPension > 0) { + $rows[] = [ + 'side' => 'credit', + 'account_code' => '25504', + 'account_name' => '예수금-국민연금', + 'debit_amount' => 0, + 'credit_amount' => (int) $totalPension, + ]; + } + + // 대변: 고용보험예수금 + if ($totalEmploymentInsurance > 0) { + $rows[] = [ + 'side' => 'credit', + 'account_code' => '25505', + 'account_name' => '예수금-고용보험', + 'debit_amount' => 0, + 'credit_amount' => (int) $totalEmploymentInsurance, + ]; + } + + // 대변: 미지급금 (실지급액) + if ($totalNet > 0) { + $rows[] = [ + 'side' => 'credit', + 'account_code' => '25300', + 'account_name' => '미지급금', + 'debit_amount' => 0, + 'credit_amount' => (int) $totalNet, + 'memo' => "급여 실지급액 ({$payrolls->count()}명)", + ]; + } + + $syncService = app(JournalSyncService::class); + + return $syncService->saveForSource( + JournalEntry::SOURCE_PAYROLL, + $sourceKey, + $entryDate, + $description, + $rows + ); + } } diff --git a/lang/ko/error.php b/lang/ko/error.php index 4b426e0..a3165cb 100644 --- a/lang/ko/error.php +++ b/lang/ko/error.php @@ -289,6 +289,7 @@ 'invalid_withdrawal' => '유효하지 않은 출금 내역입니다.', 'user_not_found' => '직원 정보를 찾을 수 없습니다.', 'no_base_salary' => '기본급이 설정되지 않았습니다.', + 'no_confirmed_payrolls' => '해당 연월에 확정된 급여가 없습니다.', ], // 세금계산서 관련 diff --git a/lang/ko/message.php b/lang/ko/message.php index 46159a0..bd1e14a 100644 --- a/lang/ko/message.php +++ b/lang/ko/message.php @@ -328,6 +328,8 @@ 'copied' => '전월 급여가 복사되었습니다.', 'calculated' => '급여가 일괄 계산되었습니다.', 'payslip_fetched' => '급여명세서를 조회했습니다.', + 'exported' => '급여 현황이 내보내기되었습니다.', + 'journal_created' => '급여 전표가 생성되었습니다.', ], // 급여 설정 관리 diff --git a/routes/api/v1/finance.php b/routes/api/v1/finance.php index d4a580a..0a18dc4 100644 --- a/routes/api/v1/finance.php +++ b/routes/api/v1/finance.php @@ -103,6 +103,8 @@ Route::post('/bulk-confirm', [PayrollController::class, 'bulkConfirm'])->name('v1.payrolls.bulk-confirm'); Route::post('/bulk-generate', [PayrollController::class, 'bulkGenerate'])->name('v1.payrolls.bulk-generate'); Route::post('/copy-from-previous', [PayrollController::class, 'copyFromPrevious'])->name('v1.payrolls.copy-from-previous'); + Route::get('/export', [PayrollController::class, 'export'])->name('v1.payrolls.export'); + Route::post('/journal-entries', [PayrollController::class, 'journalEntries'])->name('v1.payrolls.journal-entries'); Route::get('/{id}', [PayrollController::class, 'show'])->whereNumber('id')->name('v1.payrolls.show'); Route::put('/{id}', [PayrollController::class, 'update'])->whereNumber('id')->name('v1.payrolls.update'); Route::delete('/{id}', [PayrollController::class, 'destroy'])->whereNumber('id')->name('v1.payrolls.destroy');