feat: [payroll] 엑셀 export에 추가공제 항목 동적 열 포함

- 전 사원의 deductions JSON에서 고유 항목명 수집
- 개인별 추가공제 항목을 동적 열로 확장 출력
- 추가공제 열 헤더 보라색, 데이터 영역 연보라 배경 구분
- 추가공제 없는 사원은 해당 열 0 표시
This commit is contained in:
김보곤
2026-03-05 16:28:58 +09:00
parent a112ace148
commit 579a6caf39

View File

@@ -14,6 +14,7 @@
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
@@ -436,7 +437,7 @@ public function bulkGenerate(Request $request): JsonResponse
}
/**
* 엑셀(XLSX) 내보내기 — 급여명세서 서식
* 엑셀(XLSX) 내보내기 — 급여명세서 서식 (추가공제 동적 열 포함)
*/
public function export(Request $request): StreamedResponse|JsonResponse
{
@@ -450,15 +451,45 @@ public function export(Request $request): StreamedResponse|JsonResponse
$filename = "급여명세서_{$year}{$month}월_".now()->format('Ymd').'.xlsx';
// ── 추가공제 항목명 수집 (전 사원 대상, 등장 순서 유지) ──
$extraDeductionNames = [];
foreach ($payrolls as $payroll) {
foreach ($payroll->deductions ?? [] as $ded) {
$name = $ded['name'] ?? '';
if ($name !== '' && ! in_array($name, $extraDeductionNames, true)) {
$extraDeductionNames[] = $name;
}
}
}
// ── 헤더 구성 (고정 + 동적 추가공제 + 합계/실수령) ──
$headers = ['No', '사원명', '부서', '기본급', "고정연장\n근로수당", "식대\n(비과세)", '총지급액', '국민연금', '건강보험', "장기요양\n보험", '고용보험', '근로소득세', '지방소득세'];
// 추가공제 열 시작 인덱스 (0-based)
$extraDeductionStartIdx = count($headers);
foreach ($extraDeductionNames as $name) {
$headers[] = "추가공제\n({$name})";
}
// 마지막 고정 열
$headers[] = '총공제액';
$headers[] = '실수령액';
$totalColCount = count($headers);
$lastColLetter = Coordinate::stringFromColumnIndex($totalColCount);
// ── 금액 열 인덱스 수집 (D부터 ~ 마지막까지, B/C 제외) ──
$moneyColLetters = [];
for ($i = 3; $i < $totalColCount; $i++) { // 0=No, 1=사원명, 2=부서 → 3부터 금액
$moneyColLetters[] = Coordinate::stringFromColumnIndex($i + 1);
}
$spreadsheet = new Spreadsheet;
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('급여명세서');
$lastCol = 'O';
$headers = ['No', '사원명', '부서', '기본급', "고정연장\n근로수당", "식대\n(비과세)", '총지급액', '국민연금', '건강보험', "장기요양\n보험", '고용보험', '근로소득세', '지방소득세', '총공제액', '실수령액'];
// ── Row 1: 제목 ──
$sheet->mergeCells("A1:{$lastCol}1");
$sheet->mergeCells("A1:{$lastColLetter}1");
$sheet->setCellValue('A1', "< {$year}년도 {$month}월 급여명세서 >");
$sheet->getStyle('A1')->applyFromArray([
'font' => ['bold' => true, 'size' => 14],
@@ -468,10 +499,10 @@ public function export(Request $request): StreamedResponse|JsonResponse
// ── Row 2: 헤더 ──
foreach ($headers as $colIdx => $header) {
$cell = chr(65 + $colIdx).'2';
$sheet->setCellValue($cell, $header);
$colLetter = Coordinate::stringFromColumnIndex($colIdx + 1);
$sheet->setCellValue("{$colLetter}2", $header);
}
$sheet->getStyle("A2:{$lastCol}2")->applyFromArray([
$sheet->getStyle("A2:{$lastColLetter}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],
@@ -479,31 +510,57 @@ public function export(Request $request): StreamedResponse|JsonResponse
]);
$sheet->getRowDimension(2)->setRowHeight(36);
// 추가공제 헤더 배경색 구분 (보라색 계열)
if (! empty($extraDeductionNames)) {
$startLetter = Coordinate::stringFromColumnIndex($extraDeductionStartIdx + 1);
$endLetter = Coordinate::stringFromColumnIndex($extraDeductionStartIdx + count($extraDeductionNames));
$sheet->getStyle("{$startLetter}2:{$endLetter}2")->applyFromArray([
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['argb' => 'FF6B21A8']],
]);
}
// ── 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);
// 개인별 추가공제를 이름 → 금액 맵으로 변환
$personalDeductions = [];
foreach ($payroll->deductions ?? [] as $ded) {
$name = $ded['name'] ?? '';
if ($name !== '') {
$personalDeductions[$name] = (int) ($ded['amount'] ?? 0);
}
}
$col = 1; // 1-based index for Coordinate
$sheet->setCellValue(Coordinate::stringFromColumnIndex($col++).$row, $idx + 1);
$sheet->setCellValue(Coordinate::stringFromColumnIndex($col++).$row, $displayName);
$sheet->setCellValue(Coordinate::stringFromColumnIndex($col++).$row, $department);
$sheet->setCellValue(Coordinate::stringFromColumnIndex($col++).$row, (int) $payroll->base_salary);
$sheet->setCellValue(Coordinate::stringFromColumnIndex($col++).$row, (int) $payroll->overtime_pay);
$sheet->setCellValue(Coordinate::stringFromColumnIndex($col++).$row, (int) $payroll->bonus);
$sheet->setCellValue(Coordinate::stringFromColumnIndex($col++).$row, (int) $payroll->gross_salary);
$sheet->setCellValue(Coordinate::stringFromColumnIndex($col++).$row, (int) $payroll->pension);
$sheet->setCellValue(Coordinate::stringFromColumnIndex($col++).$row, (int) $payroll->health_insurance);
$sheet->setCellValue(Coordinate::stringFromColumnIndex($col++).$row, (int) $payroll->long_term_care);
$sheet->setCellValue(Coordinate::stringFromColumnIndex($col++).$row, (int) $payroll->employment_insurance);
$sheet->setCellValue(Coordinate::stringFromColumnIndex($col++).$row, (int) $payroll->income_tax);
$sheet->setCellValue(Coordinate::stringFromColumnIndex($col++).$row, (int) $payroll->resident_tax);
// 추가공제 동적 열
foreach ($extraDeductionNames as $name) {
$amount = $personalDeductions[$name] ?? 0;
$sheet->setCellValue(Coordinate::stringFromColumnIndex($col++).$row, $amount);
}
// 총공제액, 실수령액
$sheet->setCellValue(Coordinate::stringFromColumnIndex($col++).$row, (int) $payroll->total_deductions);
$sheet->setCellValue(Coordinate::stringFromColumnIndex($col++).$row, (int) $payroll->net_salary);
$row++;
}
@@ -516,7 +573,7 @@ public function export(Request $request): StreamedResponse|JsonResponse
$lastDataRow = $row - 1;
// ── 데이터 영역 스타일 ──
$sheet->getStyle("A{$dataStartRow}:{$lastCol}{$lastDataRow}")->applyFromArray([
$sheet->getStyle("A{$dataStartRow}:{$lastColLetter}{$lastDataRow}")->applyFromArray([
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['argb' => 'FF000000']]],
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
'font' => ['size' => 10],
@@ -527,45 +584,54 @@ public function export(Request $request): StreamedResponse|JsonResponse
->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
// 금액 서식: #,##0 + 오른쪽 정렬
foreach ($moneyColumns as $col) {
foreach ($moneyColLetters as $col) {
$range = "{$col}{$dataStartRow}:{$col}{$lastDataRow}";
$sheet->getStyle($range)->getNumberFormat()->setFormatCode('#,##0');
$sheet->getStyle($range)->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
}
// 추가공제 열 배경 (연보라)
if (! empty($extraDeductionNames)) {
$startLetter = Coordinate::stringFromColumnIndex($extraDeductionStartIdx + 1);
$endLetter = Coordinate::stringFromColumnIndex($extraDeductionStartIdx + count($extraDeductionNames));
$sheet->getStyle("{$startLetter}{$dataStartRow}:{$endLetter}{$lastDataRow}")->applyFromArray([
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['argb' => 'FFF3E8FF']],
]);
}
// ── 합계 행 ──
$sumRow = $lastDataRow + 1;
$sheet->mergeCells("A{$sumRow}:C{$sumRow}");
$mergeEnd = Coordinate::stringFromColumnIndex(3); // C
$sheet->mergeCells("A{$sumRow}:{$mergeEnd}{$sumRow}");
$sheet->setCellValue("A{$sumRow}", '합계');
foreach ($moneyColumns as $col) {
foreach ($moneyColLetters as $col) {
$sheet->setCellValue(
"{$col}{$sumRow}",
"=SUM({$col}{$dataStartRow}:{$col}{$lastDataRow})"
);
}
$sheet->getStyle("A{$sumRow}:{$lastCol}{$sumRow}")->applyFromArray([
$sheet->getStyle("A{$sumRow}:{$lastColLetter}{$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}")
$sheet->getStyle("A{$sumRow}:{$mergeEnd}{$sumRow}")
->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
foreach ($moneyColumns as $col) {
foreach ($moneyColLetters 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);
$sheet->getColumnDimension('A')->setWidth(6);
$sheet->getColumnDimension('B')->setWidth(12);
$sheet->getColumnDimension('C')->setWidth(12);
for ($i = 4; $i <= $totalColCount; $i++) {
$sheet->getColumnDimension(Coordinate::stringFromColumnIndex($i))->setWidth(13);
}
// ── 응답 반환 ──