Files
sam-manage/app/Http/Controllers/Api/Admin/HR/PayrollController.php
김보곤 3443fd7b05 feat: [payroll] 급여명세서 엑셀 내보내기 CSV → XLSX 변환
- 제목행 병합 + 14pt 굵게 가운데 정렬
- 남색(#1F3864) 헤더 + 흰색 글씨 + wrapText
- 금액 열(D~O) #,##0 천단위 서식 + 오른쪽 정렬
- 합계행 SUM 수식 + 회색 배경 + 굵게
- 빈 행 포함 최소 10행까지 전체 테두리
- 파일명: 급여명세서_{year}년{month}월_{Ymd}.xlsx
2026-02-28 18:24:33 +09:00

666 lines
24 KiB
PHP

<?php
namespace App\Http\Controllers\Api\Admin\HR;
use App\Http\Controllers\Controller;
use App\Services\HR\PayrollService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\StreamedResponse;
class PayrollController extends Controller
{
private const ALLOWED_PAYROLL_USERS = ['이경호', '전진선', '김보곤'];
public function __construct(
private PayrollService $payrollService
) {}
private function checkPayrollAccess(): ?JsonResponse
{
if (! in_array(auth()->user()->name, self::ALLOWED_PAYROLL_USERS)) {
return response()->json([
'success' => false,
'message' => '급여관리는 관계자만 볼 수 있습니다.',
], 403);
}
return null;
}
/**
* 급여 목록 조회 (HTMX → HTML / 일반 → JSON)
*/
public function index(Request $request): JsonResponse|Response
{
if ($denied = $this->checkPayrollAccess()) {
return $denied;
}
$payrolls = $this->payrollService->getPayrolls(
$request->all(),
$request->integer('per_page', 20)
);
if ($request->header('HX-Request')) {
return response(view('hr.payrolls.partials.table', compact('payrolls')));
}
return response()->json([
'success' => true,
'data' => $payrolls->items(),
'meta' => [
'current_page' => $payrolls->currentPage(),
'last_page' => $payrolls->lastPage(),
'per_page' => $payrolls->perPage(),
'total' => $payrolls->total(),
],
]);
}
/**
* 월간 통계 (HTMX → HTML / 일반 → JSON)
*/
public function stats(Request $request): JsonResponse|Response
{
if ($denied = $this->checkPayrollAccess()) {
return $denied;
}
$stats = $this->payrollService->getMonthlyStats(
$request->integer('year') ?: null,
$request->integer('month') ?: null
);
if ($request->header('HX-Request')) {
return response(view('hr.payrolls.partials.stats', compact('stats')));
}
return response()->json([
'success' => true,
'data' => $stats,
]);
}
/**
* 급여 등록
*/
public function store(Request $request): JsonResponse
{
if ($denied = $this->checkPayrollAccess()) {
return $denied;
}
$validated = $request->validate([
'user_id' => 'required|integer|exists:users,id',
'pay_year' => 'required|integer|min:2020|max:2100',
'pay_month' => 'required|integer|min:1|max:12',
'base_salary' => 'required|numeric|min:0',
'overtime_pay' => 'nullable|numeric|min:0',
'bonus' => 'nullable|numeric|min:0',
'allowances' => 'nullable|array',
'allowances.*.name' => 'required_with:allowances|string',
'allowances.*.amount' => 'required_with:allowances|numeric|min:0',
'deductions' => 'nullable|array',
'deductions.*.name' => 'required_with:deductions|string',
'deductions.*.amount' => 'required_with:deductions|numeric',
'deduction_overrides' => 'nullable|array',
'deduction_overrides.pension' => 'nullable|numeric',
'deduction_overrides.health_insurance' => 'nullable|numeric',
'deduction_overrides.long_term_care' => 'nullable|numeric',
'deduction_overrides.employment_insurance' => 'nullable|numeric',
'deduction_overrides.income_tax' => 'nullable|numeric',
'deduction_overrides.resident_tax' => 'nullable|numeric',
'note' => 'nullable|string|max:500',
]);
try {
$payroll = $this->payrollService->storePayroll($validated);
return response()->json([
'success' => true,
'message' => '급여가 등록되었습니다.',
'data' => $payroll,
], 201);
} catch (\InvalidArgumentException $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 422);
} catch (\Illuminate\Database\UniqueConstraintViolationException $e) {
return response()->json([
'success' => false,
'message' => '해당 직원의 동일 기간 급여가 이미 등록되어 있습니다.',
], 422);
} catch (\Throwable $e) {
report($e);
return response()->json([
'success' => false,
'message' => '급여 등록 중 오류가 발생했습니다.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* 급여 수정
*/
public function update(Request $request, int $id): JsonResponse
{
if ($denied = $this->checkPayrollAccess()) {
return $denied;
}
$validated = $request->validate([
'base_salary' => 'sometimes|required|numeric|min:0',
'overtime_pay' => 'nullable|numeric|min:0',
'bonus' => 'nullable|numeric|min:0',
'allowances' => 'nullable|array',
'allowances.*.name' => 'required_with:allowances|string',
'allowances.*.amount' => 'required_with:allowances|numeric|min:0',
'deductions' => 'nullable|array',
'deductions.*.name' => 'required_with:deductions|string',
'deductions.*.amount' => 'required_with:deductions|numeric',
'deduction_overrides' => 'nullable|array',
'deduction_overrides.pension' => 'nullable|numeric',
'deduction_overrides.health_insurance' => 'nullable|numeric',
'deduction_overrides.long_term_care' => 'nullable|numeric',
'deduction_overrides.employment_insurance' => 'nullable|numeric',
'deduction_overrides.income_tax' => 'nullable|numeric',
'deduction_overrides.resident_tax' => 'nullable|numeric',
'note' => 'nullable|string|max:500',
]);
try {
$payroll = $this->payrollService->updatePayroll($id, $validated);
if (! $payroll) {
return response()->json([
'success' => false,
'message' => '급여 정보를 찾을 수 없거나 수정할 수 없는 상태입니다.',
], 404);
}
return response()->json([
'success' => true,
'message' => '급여가 수정되었습니다.',
'data' => $payroll,
]);
} catch (\Throwable $e) {
report($e);
return response()->json([
'success' => false,
'message' => '급여 수정 중 오류가 발생했습니다.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* 급여 삭제
*/
public function destroy(Request $request, int $id): JsonResponse|Response
{
if ($denied = $this->checkPayrollAccess()) {
return $denied;
}
try {
$result = $this->payrollService->deletePayroll($id);
if (! $result) {
return response()->json([
'success' => false,
'message' => '급여 정보를 찾을 수 없거나 삭제할 수 없는 상태입니다.',
], 404);
}
if ($request->header('HX-Request')) {
$payrolls = $this->payrollService->getPayrolls(
$request->all(),
$request->integer('per_page', 20)
);
return response(view('hr.payrolls.partials.table', compact('payrolls')));
}
return response()->json([
'success' => true,
'message' => '급여가 삭제되었습니다.',
]);
} catch (\Throwable $e) {
report($e);
return response()->json([
'success' => false,
'message' => '급여 삭제 중 오류가 발생했습니다.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* 급여 확정
*/
public function confirm(Request $request, int $id): JsonResponse
{
if ($denied = $this->checkPayrollAccess()) {
return $denied;
}
try {
$payroll = $this->payrollService->confirmPayroll($id);
if (! $payroll) {
return response()->json([
'success' => false,
'message' => '급여를 확정할 수 없습니다.',
], 404);
}
return response()->json([
'success' => true,
'message' => '급여가 확정되었습니다.',
'data' => $payroll,
]);
} catch (\Throwable $e) {
report($e);
return response()->json([
'success' => false,
'message' => '급여 확정 중 오류가 발생했습니다.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* 급여 확정 취소
*/
public function unconfirm(Request $request, int $id): JsonResponse
{
if ($denied = $this->checkPayrollAccess()) {
return $denied;
}
try {
$payroll = $this->payrollService->unconfirmPayroll($id);
if (! $payroll) {
return response()->json([
'success' => false,
'message' => '급여 확정을 취소할 수 없습니다.',
], 404);
}
return response()->json([
'success' => true,
'message' => '급여 확정이 취소되었습니다.',
'data' => $payroll,
]);
} catch (\Throwable $e) {
report($e);
return response()->json([
'success' => false,
'message' => '급여 확정 취소 중 오류가 발생했습니다.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* 급여 지급 처리
*/
public function pay(Request $request, int $id): JsonResponse
{
if ($denied = $this->checkPayrollAccess()) {
return $denied;
}
try {
$payroll = $this->payrollService->payPayroll($id);
if (! $payroll) {
return response()->json([
'success' => false,
'message' => '급여를 지급 처리할 수 없습니다.',
], 404);
}
return response()->json([
'success' => true,
'message' => '급여가 지급 처리되었습니다.',
'data' => $payroll,
]);
} catch (\Throwable $e) {
report($e);
return response()->json([
'success' => false,
'message' => '급여 지급 처리 중 오류가 발생했습니다.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* 전월 급여 복사 등록
*/
public function copyFromPrevious(Request $request): JsonResponse
{
if ($denied = $this->checkPayrollAccess()) {
return $denied;
}
$validated = $request->validate([
'pay_year' => 'required|integer|min:2020|max:2100',
'pay_month' => 'required|integer|min:1|max:12',
]);
try {
$result = $this->payrollService->copyFromPreviousMonth($validated['pay_year'], $validated['pay_month']);
if (! empty($result['no_previous'])) {
$prevYear = $validated['pay_month'] === 1 ? $validated['pay_year'] - 1 : $validated['pay_year'];
$prevMonth = $validated['pay_month'] === 1 ? 12 : $validated['pay_month'] - 1;
return response()->json([
'success' => false,
'message' => "{$prevYear}{$prevMonth}월 급여 데이터가 없습니다.",
], 422);
}
return response()->json([
'success' => true,
'message' => "전월 복사 완료: {$result['created']}건 생성, {$result['skipped']}건 건너뜀 (이미 존재).",
'data' => $result,
]);
} catch (\Throwable $e) {
report($e);
return response()->json([
'success' => false,
'message' => '전월 복사 중 오류가 발생했습니다.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* 일괄 생성
*/
public function bulkGenerate(Request $request): JsonResponse
{
if ($denied = $this->checkPayrollAccess()) {
return $denied;
}
$validated = $request->validate([
'year' => 'required|integer|min:2020|max:2100',
'month' => 'required|integer|min:1|max:12',
]);
try {
$result = $this->payrollService->bulkGenerate($validated['year'], $validated['month']);
return response()->json([
'success' => true,
'message' => "신규 {$result['created']}건 생성, {$result['skipped']}건 건너뜀 (이미 존재).",
'data' => $result,
]);
} catch (\Throwable $e) {
report($e);
return response()->json([
'success' => false,
'message' => '일괄 생성 중 오류가 발생했습니다.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* 엑셀(XLSX) 내보내기 — 급여명세서 서식
*/
public function export(Request $request): StreamedResponse|JsonResponse
{
if ($denied = $this->checkPayrollAccess()) {
return $denied;
}
$payrolls = $this->payrollService->getExportData($request->all());
$year = $request->input('year', now()->year);
$month = $request->input('month', now()->month);
$filename = "급여명세서_{$year}{$month}월_".now()->format('Ymd').'.xlsx';
$spreadsheet = new Spreadsheet;
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('급여명세서');
$lastCol = 'O';
$headers = ['No', '사원명', '부서', '기본급', "고정연장\n근로수당", "식대\n(비과세)", '총지급액', '국민연금', '건강보험', "장기요양\n보험", '고용보험', '근로소득세', '지방소득세', '총공제액', '실수령액'];
// ── Row 1: 제목 ──
$sheet->mergeCells("A1:{$lastCol}1");
$sheet->setCellValue('A1', "< {$year}년도 {$month}월 급여명세서 >");
$sheet->getStyle('A1')->applyFromArray([
'font' => ['bold' => true, 'size' => 14],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
]);
$sheet->getRowDimension(1)->setRowHeight(30);
// ── Row 2: 헤더 ──
foreach ($headers as $colIdx => $header) {
$cell = chr(65 + $colIdx).'2';
$sheet->setCellValue($cell, $header);
}
$sheet->getStyle("A2:{$lastCol}2")->applyFromArray([
'font' => ['bold' => true, 'size' => 10, 'color' => ['argb' => 'FFFFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['argb' => 'FF1F3864']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER, 'wrapText' => true],
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['argb' => 'FF000000']]],
]);
$sheet->getRowDimension(2)->setRowHeight(36);
// ── Row 3~: 데이터 ──
$dataStartRow = 3;
$row = $dataStartRow;
$moneyColumns = ['D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O'];
foreach ($payrolls as $idx => $payroll) {
$profile = $payroll->user?->tenantProfiles?->first();
$displayName = $profile?->display_name ?? $payroll->user?->name ?? '-';
$department = $profile?->department?->name ?? '-';
$sheet->setCellValue("A{$row}", $idx + 1);
$sheet->setCellValue("B{$row}", $displayName);
$sheet->setCellValue("C{$row}", $department);
$sheet->setCellValue("D{$row}", (int) $payroll->base_salary);
$sheet->setCellValue("E{$row}", (int) $payroll->overtime_pay);
$sheet->setCellValue("F{$row}", (int) $payroll->bonus);
$sheet->setCellValue("G{$row}", (int) $payroll->gross_salary);
$sheet->setCellValue("H{$row}", (int) $payroll->pension);
$sheet->setCellValue("I{$row}", (int) $payroll->health_insurance);
$sheet->setCellValue("J{$row}", (int) $payroll->long_term_care);
$sheet->setCellValue("K{$row}", (int) $payroll->employment_insurance);
$sheet->setCellValue("L{$row}", (int) $payroll->income_tax);
$sheet->setCellValue("M{$row}", (int) $payroll->resident_tax);
$sheet->setCellValue("N{$row}", (int) $payroll->total_deductions);
$sheet->setCellValue("O{$row}", (int) $payroll->net_salary);
$row++;
}
// 빈 행 채움 (최소 10행까지 테두리)
$minEndRow = $dataStartRow + 9;
while ($row <= $minEndRow) {
$row++;
}
$lastDataRow = $row - 1;
// ── 데이터 영역 스타일 ──
$sheet->getStyle("A{$dataStartRow}:{$lastCol}{$lastDataRow}")->applyFromArray([
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['argb' => 'FF000000']]],
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
'font' => ['size' => 10],
]);
// No 가운데 정렬
$sheet->getStyle("A{$dataStartRow}:A{$lastDataRow}")
->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
// 금액 서식: #,##0 + 오른쪽 정렬
foreach ($moneyColumns as $col) {
$range = "{$col}{$dataStartRow}:{$col}{$lastDataRow}";
$sheet->getStyle($range)->getNumberFormat()->setFormatCode('#,##0');
$sheet->getStyle($range)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
}
// ── 합계 행 ──
$sumRow = $lastDataRow + 1;
$sheet->mergeCells("A{$sumRow}:C{$sumRow}");
$sheet->setCellValue("A{$sumRow}", '합계');
foreach ($moneyColumns as $col) {
$sheet->setCellValue(
"{$col}{$sumRow}",
"=SUM({$col}{$dataStartRow}:{$col}{$lastDataRow})"
);
}
$sheet->getStyle("A{$sumRow}:{$lastCol}{$sumRow}")->applyFromArray([
'font' => ['bold' => true, 'size' => 10],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['argb' => 'FFF2F2F2']],
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['argb' => 'FF000000']]],
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
]);
$sheet->getStyle("A{$sumRow}:C{$sumRow}")
->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
foreach ($moneyColumns as $col) {
$sheet->getStyle("{$col}{$sumRow}")->getNumberFormat()->setFormatCode('#,##0');
$sheet->getStyle("{$col}{$sumRow}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
}
// ── 열 너비 ──
$widths = ['A' => 6, 'B' => 12, 'C' => 12];
foreach (range('D', 'O') as $col) {
$widths[$col] = 13;
}
foreach ($widths as $col => $width) {
$sheet->getColumnDimension($col)->setWidth($width);
}
// ── 응답 반환 ──
return response()->streamDownload(function () use ($spreadsheet) {
$writer = new Xlsx($spreadsheet);
$writer->save('php://output');
$spreadsheet->disconnectWorksheets();
}, $filename, [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'max-age=0',
]);
}
/**
* 급여 설정 조회 (HTMX → HTML / 일반 → JSON)
*/
public function settingsIndex(Request $request): JsonResponse|Response
{
if ($denied = $this->checkPayrollAccess()) {
return $denied;
}
$settings = $this->payrollService->getSettings();
if ($request->header('HX-Request')) {
return response(view('hr.payrolls.partials.settings', compact('settings')));
}
return response()->json([
'success' => true,
'data' => $settings,
]);
}
/**
* 급여 설정 수정
*/
public function settingsUpdate(Request $request): JsonResponse
{
if ($denied = $this->checkPayrollAccess()) {
return $denied;
}
$validated = $request->validate([
'health_insurance_rate' => 'nullable|numeric|min:0|max:100',
'long_term_care_rate' => 'nullable|numeric|min:0|max:100',
'pension_rate' => 'nullable|numeric|min:0|max:100',
'employment_insurance_rate' => 'nullable|numeric|min:0|max:100',
'pension_max_salary' => 'nullable|numeric|min:0',
'pension_min_salary' => 'nullable|numeric|min:0',
'pay_day' => 'nullable|integer|min:1|max:31',
'auto_calculate' => 'nullable|boolean',
]);
try {
$settings = $this->payrollService->updateSettings($validated);
return response()->json([
'success' => true,
'message' => '급여 설정이 저장되었습니다.',
'data' => $settings,
]);
} catch (\Throwable $e) {
report($e);
return response()->json([
'success' => false,
'message' => '설정 저장 중 오류가 발생했습니다.',
'error' => config('app.debug') ? $e->getMessage() : null,
], 500);
}
}
/**
* 급여 계산 미리보기 (AJAX)
*/
public function calculate(Request $request): JsonResponse
{
if ($denied = $this->checkPayrollAccess()) {
return $denied;
}
$validated = $request->validate([
'base_salary' => 'required|numeric|min:0',
'overtime_pay' => 'nullable|numeric|min:0',
'bonus' => 'nullable|numeric|min:0',
'allowances' => 'nullable|array',
'deductions' => 'nullable|array',
'user_id' => 'nullable|integer',
]);
$familyCount = 1;
if (! empty($validated['user_id'])) {
$familyCount = $this->payrollService->resolveFamilyCount($validated['user_id']);
}
$result = $this->payrollService->calculateAmounts($validated, null, $familyCount);
return response()->json([
'success' => true,
'data' => array_merge($result, ['family_count' => $familyCount]),
]);
}
}