feat: [payroll] 급여 일반전표 자동 생성 기능

- PayrollController에 generateJournalEntry() 메서드 추가
- 해당월 급여 합산 → 분개 행 자동 구성 (차변 801 급여, 대변 207/205)
- 중복 체크 (source_type=payroll, source_key=payroll-YYYY-MM)
- 0원 항목 행 제외, 차대 균형 검증
- 급여관리 페이지에 전표 생성 버튼 추가
This commit is contained in:
김보곤
2026-02-28 20:05:58 +09:00
parent f74bd8960b
commit 47578da428
3 changed files with 319 additions and 0 deletions

View File

@@ -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)
*/

View File

@@ -57,6 +57,13 @@ class="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-
</svg>
엑셀 다운로드
</button>
<button type="button" onclick="generateJournalEntry()"
class="inline-flex items-center gap-2 px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-lg transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
전표 생성
</button>
</div>
</div>
@@ -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());

View File

@@ -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');