feat: [payroll] 급여 일반전표 자동 생성 기능
- PayrollController에 generateJournalEntry() 메서드 추가 - 해당월 급여 합산 → 분개 행 자동 구성 (차변 801 급여, 대변 207/205) - 중복 체크 (source_type=payroll, source_key=payroll-YYYY-MM) - 0원 항목 행 제외, 차대 균형 검증 - 급여관리 페이지에 전표 생성 버튼 추가
This commit is contained in:
@@ -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)
|
||||
*/
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user