From 47578da42855fd9c3582395bc8cb0bc968faa52e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sat, 28 Feb 2026 20:05:58 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[payroll]=20=EA=B8=89=EC=97=AC=20?= =?UTF-8?q?=EC=9D=BC=EB=B0=98=EC=A0=84=ED=91=9C=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PayrollController에 generateJournalEntry() 메서드 추가 - 해당월 급여 합산 → 분개 행 자동 구성 (차변 801 급여, 대변 207/205) - 중복 체크 (source_type=payroll, source_key=payroll-YYYY-MM) - 0원 항목 행 제외, 차대 균형 검증 - 급여관리 페이지에 전표 생성 버튼 추가 --- .../Api/Admin/HR/PayrollController.php | 282 ++++++++++++++++++ resources/views/hr/payrolls/index.blade.php | 36 +++ routes/api.php | 1 + 3 files changed, 319 insertions(+) diff --git a/app/Http/Controllers/Api/Admin/HR/PayrollController.php b/app/Http/Controllers/Api/Admin/HR/PayrollController.php index 9f88c423..91d54e02 100644 --- a/app/Http/Controllers/Api/Admin/HR/PayrollController.php +++ b/app/Http/Controllers/Api/Admin/HR/PayrollController.php @@ -3,10 +3,17 @@ namespace App\Http\Controllers\Api\Admin\HR; use App\Http\Controllers\Controller; +use App\Models\Barobill\AccountCode; +use App\Models\Finance\JournalEntry; +use App\Models\Finance\JournalEntryLine; +use App\Models\Finance\TradingPartner; +use App\Models\HR\Payroll; use App\Services\HR\PayrollService; +use Carbon\Carbon; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Support\Facades\DB; use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Style\Alignment; use PhpOffice\PhpSpreadsheet\Style\Border; @@ -632,6 +639,281 @@ public function settingsUpdate(Request $request): JsonResponse } } + /** + * 급여 일반전표 자동 생성 + */ + public function generateJournalEntry(Request $request): JsonResponse + { + if ($denied = $this->checkPayrollAccess()) { + return $denied; + } + + $request->validate([ + 'year' => 'required|integer|min:2020|max:2100', + 'month' => 'required|integer|min:1|max:12', + ]); + + $year = $request->integer('year'); + $month = $request->integer('month'); + $tenantId = session('selected_tenant_id', 1); + $sourceKey = "payroll-{$year}-{$month}"; + + // 중복 체크 + $existing = JournalEntry::forTenant($tenantId) + ->where('source_type', 'payroll') + ->where('source_key', $sourceKey) + ->first(); + + if ($existing) { + return response()->json([ + 'success' => false, + 'message' => "이미 {$month}월분 급여 전표가 존재합니다 ({$existing->entry_no})", + ], 422); + } + + // 해당월 급여 합산 + $sums = Payroll::forTenant($tenantId) + ->forPeriod($year, $month) + ->selectRaw(' + SUM(gross_salary) as total_gross, + SUM(pension) as total_pension, + SUM(health_insurance) as total_health, + SUM(long_term_care) as total_ltc, + SUM(employment_insurance) as total_emp, + SUM(income_tax) as total_income_tax, + SUM(resident_tax) as total_resident_tax, + SUM(net_salary) as total_net + ') + ->first(); + + if (! $sums || (int) $sums->total_gross === 0) { + return response()->json([ + 'success' => false, + 'message' => '해당 월 급여 데이터가 없습니다.', + ], 422); + } + + // 거래처 조회 + $partnerNames = ['임직원', '건강보험연금', '건강보험건강', '건강보험고용', '강서세무서', '강서구청']; + $partners = TradingPartner::forTenant($tenantId) + ->whereIn('name', $partnerNames) + ->pluck('id', 'name'); + + $missingPartners = array_diff($partnerNames, $partners->keys()->toArray()); + if (! empty($missingPartners)) { + return response()->json([ + 'success' => false, + 'message' => '거래처가 등록되어 있지 않습니다: '.implode(', ', $missingPartners), + ], 422); + } + + // 계정과목 조회 + $accountCodes = AccountCode::whereIn('code', ['801', '207', '205']) + ->where('is_active', true) + ->pluck('name', 'code'); + + $missingCodes = array_diff(['801', '207', '205'], $accountCodes->keys()->toArray()); + if (! empty($missingCodes)) { + return response()->json([ + 'success' => false, + 'message' => '계정과목이 등록되어 있지 않습니다: '.implode(', ', $missingCodes), + ], 422); + } + + // 전표일자 (해당월 말일) + $entryDate = Carbon::create($year, $month)->endOfMonth()->toDateString(); + $monthLabel = "{$month}월분"; + + // 분개 행 구성 + $lines = []; + $lineNo = 1; + + // 1. 차변: 801 급여 / 임직원 + $grossAmount = (int) $sums->total_gross; + if ($grossAmount > 0) { + $lines[] = [ + 'dc_type' => 'debit', + 'account_code' => '801', + 'account_name' => $accountCodes['801'], + 'trading_partner_id' => $partners['임직원'], + 'trading_partner_name' => '임직원', + 'debit_amount' => $grossAmount, + 'credit_amount' => 0, + 'description' => "{$monthLabel} 급여", + 'line_no' => $lineNo++, + ]; + } + + // 2. 대변: 207 예수금 / 건강보험연금 — 국민연금 + $pension = (int) $sums->total_pension; + if ($pension > 0) { + $lines[] = [ + 'dc_type' => 'credit', + 'account_code' => '207', + 'account_name' => $accountCodes['207'], + 'trading_partner_id' => $partners['건강보험연금'], + 'trading_partner_name' => '건강보험연금', + 'debit_amount' => 0, + 'credit_amount' => $pension, + 'description' => '국민연금', + 'line_no' => $lineNo++, + ]; + } + + // 3. 대변: 207 예수금 / 건강보험건강 — 건강보험 + $health = (int) $sums->total_health; + if ($health > 0) { + $lines[] = [ + 'dc_type' => 'credit', + 'account_code' => '207', + 'account_name' => $accountCodes['207'], + 'trading_partner_id' => $partners['건강보험건강'], + 'trading_partner_name' => '건강보험건강', + 'debit_amount' => 0, + 'credit_amount' => $health, + 'description' => '건강보험', + 'line_no' => $lineNo++, + ]; + } + + // 4. 대변: 207 예수금 / 건강보험건강 — 장기요양보험 + $ltc = (int) $sums->total_ltc; + if ($ltc > 0) { + $lines[] = [ + 'dc_type' => 'credit', + 'account_code' => '207', + 'account_name' => $accountCodes['207'], + 'trading_partner_id' => $partners['건강보험건강'], + 'trading_partner_name' => '건강보험건강', + 'debit_amount' => 0, + 'credit_amount' => $ltc, + 'description' => '장기요양보험', + 'line_no' => $lineNo++, + ]; + } + + // 5. 대변: 207 예수금 / 건강보험고용 — 고용보험 + $emp = (int) $sums->total_emp; + if ($emp > 0) { + $lines[] = [ + 'dc_type' => 'credit', + 'account_code' => '207', + 'account_name' => $accountCodes['207'], + 'trading_partner_id' => $partners['건강보험고용'], + 'trading_partner_name' => '건강보험고용', + 'debit_amount' => 0, + 'credit_amount' => $emp, + 'description' => '고용보험', + 'line_no' => $lineNo++, + ]; + } + + // 6. 대변: 207 예수금 / 강서세무서 — 근로소득세 + $incomeTax = (int) $sums->total_income_tax; + if ($incomeTax > 0) { + $lines[] = [ + 'dc_type' => 'credit', + 'account_code' => '207', + 'account_name' => $accountCodes['207'], + 'trading_partner_id' => $partners['강서세무서'], + 'trading_partner_name' => '강서세무서', + 'debit_amount' => 0, + 'credit_amount' => $incomeTax, + 'description' => "{$monthLabel} 근로소득세", + 'line_no' => $lineNo++, + ]; + } + + // 7. 대변: 207 예수금 / 강서구청 — 지방소득세 + $residentTax = (int) $sums->total_resident_tax; + if ($residentTax > 0) { + $lines[] = [ + 'dc_type' => 'credit', + 'account_code' => '207', + 'account_name' => $accountCodes['207'], + 'trading_partner_id' => $partners['강서구청'], + 'trading_partner_name' => '강서구청', + 'debit_amount' => 0, + 'credit_amount' => $residentTax, + 'description' => "{$monthLabel} 지방소득세", + 'line_no' => $lineNo++, + ]; + } + + // 8. 대변: 205 미지급비용 / 임직원 — 급여 + $netSalary = (int) $sums->total_net; + if ($netSalary > 0) { + $lines[] = [ + 'dc_type' => 'credit', + 'account_code' => '205', + 'account_name' => $accountCodes['205'], + 'trading_partner_id' => $partners['임직원'], + 'trading_partner_name' => '임직원', + 'debit_amount' => 0, + 'credit_amount' => $netSalary, + 'description' => "{$monthLabel} 급여", + 'line_no' => $lineNo++, + ]; + } + + // 차대 균형 검증 + $totalDebit = collect($lines)->sum('debit_amount'); + $totalCredit = collect($lines)->sum('credit_amount'); + + if ($totalDebit !== $totalCredit || $totalDebit === 0) { + return response()->json([ + 'success' => false, + 'message' => "차변({$totalDebit})과 대변({$totalCredit})이 일치하지 않습니다.", + ], 422); + } + + try { + $entry = DB::transaction(function () use ($tenantId, $entryDate, $totalDebit, $totalCredit, $sourceKey, $monthLabel, $lines) { + $entryNo = JournalEntry::generateEntryNo($tenantId, $entryDate); + + $entry = JournalEntry::create([ + 'tenant_id' => $tenantId, + 'entry_no' => $entryNo, + 'entry_date' => $entryDate, + 'entry_type' => 'general', + 'description' => "{$monthLabel} 급여", + 'total_debit' => $totalDebit, + 'total_credit' => $totalCredit, + 'status' => 'draft', + 'source_type' => 'payroll', + 'source_key' => $sourceKey, + 'created_by_name' => auth()->user()?->name ?? '시스템', + ]); + + foreach ($lines as $line) { + JournalEntryLine::create(array_merge($line, [ + 'tenant_id' => $tenantId, + 'journal_entry_id' => $entry->id, + ])); + } + + return $entry; + }); + + return response()->json([ + 'success' => true, + 'message' => "급여 전표가 생성되었습니다 ({$entry->entry_no})", + 'data' => [ + 'entry_no' => $entry->entry_no, + 'entry_date' => $entry->entry_date->toDateString(), + ], + ]); + } catch (\Throwable $e) { + report($e); + + return response()->json([ + 'success' => false, + 'message' => '전표 생성 중 오류가 발생했습니다.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + /** * 급여 계산 미리보기 (AJAX) */ diff --git a/resources/views/hr/payrolls/index.blade.php b/resources/views/hr/payrolls/index.blade.php index 77b6add6..631d4d4e 100644 --- a/resources/views/hr/payrolls/index.blade.php +++ b/resources/views/hr/payrolls/index.blade.php @@ -57,6 +57,13 @@ class="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald- 엑셀 다운로드 + @@ -680,6 +687,35 @@ function getFilterValues() { return values; } + // ===== 전표 생성 ===== + function generateJournalEntry() { + const year = document.getElementById('payrollYear').value; + const month = document.getElementById('payrollMonth').value; + + if (!confirm(`${year}년 ${month}월 급여 데이터로 일반전표를 생성하시겠습니까?`)) return; + + fetch('{{ route("api.admin.hr.payrolls.generate-journal-entry") }}', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': '{{ csrf_token() }}', + }, + body: JSON.stringify({ year: parseInt(year), month: parseInt(month) }), + }) + .then(r => r.json()) + .then(result => { + if (result.success) { + showToast(result.message, 'success'); + } else { + showToast(result.message, 'error'); + } + }) + .catch(err => { + console.error(err); + showToast('전표 생성 중 오류가 발생했습니다.', 'error'); + }); + } + // ===== 엑셀 다운로드 ===== function exportPayrolls() { const params = new URLSearchParams(getFilterValues()); diff --git a/routes/api.php b/routes/api.php index 822f0de5..1d98994a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1165,6 +1165,7 @@ Route::post('/bulk-generate', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'bulkGenerate'])->name('bulk-generate'); Route::post('/copy-from-previous', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'copyFromPrevious'])->name('copy-from-previous'); Route::post('/calculate', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'calculate'])->name('calculate'); + Route::post('/generate-journal-entry', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'generateJournalEntry'])->name('generate-journal-entry'); Route::get('/', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'index'])->name('index'); Route::post('/', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'store'])->name('store'); Route::put('/{id}', [\App\Http\Controllers\Api\Admin\HR\PayrollController::class, 'update'])->name('update');