From 579a6caf39c627800ba30d40d898e2282d7ca35f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 5 Mar 2026 16:28:58 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[payroll]=20=EC=97=91=EC=85=80=20export?= =?UTF-8?q?=EC=97=90=20=EC=B6=94=EA=B0=80=EA=B3=B5=EC=A0=9C=20=ED=95=AD?= =?UTF-8?q?=EB=AA=A9=20=EB=8F=99=EC=A0=81=20=EC=97=B4=20=ED=8F=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 전 사원의 deductions JSON에서 고유 항목명 수집 - 개인별 추가공제 항목을 동적 열로 확장 출력 - 추가공제 열 헤더 보라색, 데이터 영역 연보라 배경 구분 - 추가공제 없는 사원은 해당 열 0 표시 --- .../Api/Admin/HR/PayrollController.php | 140 +++++++++++++----- 1 file changed, 103 insertions(+), 37 deletions(-) diff --git a/app/Http/Controllers/Api/Admin/HR/PayrollController.php b/app/Http/Controllers/Api/Admin/HR/PayrollController.php index 91d54e02..a05b52c4 100644 --- a/app/Http/Controllers/Api/Admin/HR/PayrollController.php +++ b/app/Http/Controllers/Api/Admin/HR/PayrollController.php @@ -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); } // ── 응답 반환 ──