feat: [payroll] 엑셀 내보내기 및 전표 생성 API 추가

- GET /payrolls/export: 급여 현황 엑셀 다운로드 (필터 지원)
- POST /payrolls/journal-entries: 연월 기준 급여 전표 일괄 생성
- JournalEntry SOURCE_PAYROLL 상수 추가
- StorePayrollJournalRequest 유효성 검증 추가
This commit is contained in:
김보곤
2026-03-12 10:55:17 +09:00
parent 069d0206a0
commit f401e17447
7 changed files with 339 additions and 1 deletions

View File

@@ -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'));
}
/**
* 급여 설정 조회
*/

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests\V1\Payroll;
use Illuminate\Foundation\Http\FormRequest;
class StorePayrollJournalRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'year' => ['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'),
];
}
}

View File

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

View File

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

View File

@@ -289,6 +289,7 @@
'invalid_withdrawal' => '유효하지 않은 출금 내역입니다.',
'user_not_found' => '직원 정보를 찾을 수 없습니다.',
'no_base_salary' => '기본급이 설정되지 않았습니다.',
'no_confirmed_payrolls' => '해당 연월에 확정된 급여가 없습니다.',
],
// 세금계산서 관련

View File

@@ -328,6 +328,8 @@
'copied' => '전월 급여가 복사되었습니다.',
'calculated' => '급여가 일괄 계산되었습니다.',
'payslip_fetched' => '급여명세서를 조회했습니다.',
'exported' => '급여 현황이 내보내기되었습니다.',
'journal_created' => '급여 전표가 생성되었습니다.',
],
// 급여 설정 관리

View File

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