feat: [payroll] 엑셀 내보내기 및 전표 생성 API 추가
- GET /payrolls/export: 급여 현황 엑셀 다운로드 (필터 지원) - POST /payrolls/journal-entries: 연월 기준 급여 전표 일괄 생성 - JournalEntry SOURCE_PAYROLL 상수 추가 - StorePayrollJournalRequest 유효성 검증 추가
This commit is contained in:
@@ -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'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 급여 설정 조회
|
||||
*/
|
||||
|
||||
31
app/Http/Requests/V1/Payroll/StorePayrollJournalRequest.php
Normal file
31
app/Http/Requests/V1/Payroll/StorePayrollJournalRequest.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,6 +289,7 @@
|
||||
'invalid_withdrawal' => '유효하지 않은 출금 내역입니다.',
|
||||
'user_not_found' => '직원 정보를 찾을 수 없습니다.',
|
||||
'no_base_salary' => '기본급이 설정되지 않았습니다.',
|
||||
'no_confirmed_payrolls' => '해당 연월에 확정된 급여가 없습니다.',
|
||||
],
|
||||
|
||||
// 세금계산서 관련
|
||||
|
||||
@@ -328,6 +328,8 @@
|
||||
'copied' => '전월 급여가 복사되었습니다.',
|
||||
'calculated' => '급여가 일괄 계산되었습니다.',
|
||||
'payslip_fetched' => '급여명세서를 조회했습니다.',
|
||||
'exported' => '급여 현황이 내보내기되었습니다.',
|
||||
'journal_created' => '급여 전표가 생성되었습니다.',
|
||||
],
|
||||
|
||||
// 급여 설정 관리
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user