From 3443fd7b0592a41237193a7f553a3909f7ba80e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Sat, 28 Feb 2026 18:24:33 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[payroll]=20=EA=B8=89=EC=97=AC=EB=AA=85?= =?UTF-8?q?=EC=84=B8=EC=84=9C=20=EC=97=91=EC=85=80=20=EB=82=B4=EB=B3=B4?= =?UTF-8?q?=EB=82=B4=EA=B8=B0=20CSV=20=E2=86=92=20XLSX=20=EB=B3=80?= =?UTF-8?q?=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 제목행 병합 + 14pt 굵게 가운데 정렬 - 남색(#1F3864) 헤더 + 흰색 글씨 + wrapText - 금액 열(D~O) #,##0 천단위 서식 + 오른쪽 정렬 - 합계행 SUM 수식 + 회색 배경 + 굵게 - 빈 행 포함 최소 10행까지 전체 테두리 - 파일명: 급여명세서_{year}년{month}월_{Ymd}.xlsx --- .../Api/Admin/HR/PayrollController.php | 160 ++++++++++++++---- 1 file changed, 128 insertions(+), 32 deletions(-) diff --git a/app/Http/Controllers/Api/Admin/HR/PayrollController.php b/app/Http/Controllers/Api/Admin/HR/PayrollController.php index 123eedfc..9f88c423 100644 --- a/app/Http/Controllers/Api/Admin/HR/PayrollController.php +++ b/app/Http/Controllers/Api/Admin/HR/PayrollController.php @@ -3,11 +3,15 @@ namespace App\Http\Controllers\Api\Admin\HR; use App\Http\Controllers\Controller; -use App\Models\HR\Payroll; 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 @@ -425,7 +429,7 @@ public function bulkGenerate(Request $request): JsonResponse } /** - * 엑셀(CSV) 내보내기 + * 엑셀(XLSX) 내보내기 — 급여명세서 서식 */ public function export(Request $request): StreamedResponse|JsonResponse { @@ -437,42 +441,134 @@ public function export(Request $request): StreamedResponse|JsonResponse $year = $request->input('year', now()->year); $month = $request->input('month', now()->month); - $filename = "급여관리_{$year}년{$month}월_".now()->format('Ymd').'.csv'; + $filename = "급여명세서_{$year}년{$month}월_".now()->format('Ymd').'.xlsx'; - return response()->streamDownload(function () use ($payrolls) { - $file = fopen('php://output', 'w'); - fwrite($file, "\xEF\xBB\xBF"); // UTF-8 BOM + $spreadsheet = new Spreadsheet; + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle('급여명세서'); - fputcsv($file, ['사원명', '부서', '기본급', '고정연장근로수당', '식대(비과세)', '총지급액', '국민연금', '건강보험', '장기요양보험', '고용보험', '근로소득세', '지방소득세', '총공제액', '실수령액', '상태']); + $lastCol = 'O'; + $headers = ['No', '사원명', '부서', '기본급', "고정연장\n근로수당", "식대\n(비과세)", '총지급액', '국민연금', '건강보험', "장기요양\n보험", '고용보험', '근로소득세', '지방소득세', '총공제액', '실수령액']; - foreach ($payrolls as $payroll) { - $profile = $payroll->user?->tenantProfiles?->first(); - $displayName = $profile?->display_name ?? $payroll->user?->name ?? '-'; - $department = $profile?->department?->name ?? '-'; - $statusLabel = Payroll::STATUS_MAP[$payroll->status] ?? $payroll->status; + // ── 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); - fputcsv($file, [ - $displayName, - $department, - $payroll->base_salary, - $payroll->overtime_pay, - $payroll->bonus, - $payroll->gross_salary, - $payroll->pension, - $payroll->health_insurance, - $payroll->long_term_care, - $payroll->employment_insurance, - $payroll->income_tax, - $payroll->resident_tax, - $payroll->total_deductions, - $payroll->net_salary, - $statusLabel, - ]); - } + // ── 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); - fclose($file); + // ── 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' => 'text/csv; charset=UTF-8', + 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Cache-Control' => 'max-age=0', ]); }