Files
sam-manage/app/Http/Controllers/Api/Admin/HR/BusinessIncomePaymentController.php
김보곤 650f0ee3a7 fix: [hr] 사업소득자 임금대장 행 삭제 후 일괄저장 실패 수정
- 모든 행 삭제 시 "저장할 데이터가 없습니다" 오류 → 확인 후 서버 전송으로 변경
- 백엔드 validation: required|array → present|array (빈 배열 허용)
- 서버의 orphan draft 자동 삭제 로직이 정상 동작하도록 수정
2026-03-03 19:30:24 +09:00

230 lines
9.1 KiB
PHP

<?php
namespace App\Http\Controllers\Api\Admin\HR;
use App\Http\Controllers\Controller;
use App\Services\HR\BusinessIncomePaymentService;
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\Color;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\StreamedResponse;
class BusinessIncomePaymentController extends Controller
{
private const ALLOWED_PAYROLL_USERS = ['이경호', '전진선', '김보곤'];
public function __construct(
private BusinessIncomePaymentService $service
) {}
private function checkPayrollAccess(): ?JsonResponse
{
if (! in_array(auth()->user()->name, self::ALLOWED_PAYROLL_USERS)) {
return response()->json([
'success' => false,
'message' => '급여관리는 관계자만 볼 수 있습니다.',
], 403);
}
return null;
}
/**
* 사업소득 지급 목록 (HTMX → 스프레드시트 파셜)
*/
public function index(Request $request): JsonResponse|Response
{
if ($denied = $this->checkPayrollAccess()) {
return $denied;
}
$year = $request->integer('year') ?: now()->year;
$month = $request->integer('month') ?: now()->month;
$earners = $this->service->getActiveEarners();
$payments = $this->service->getPayments($year, $month);
$stats = $this->service->getMonthlyStats($year, $month);
$earnersForJs = $earners->map(fn ($e) => [
'user_id' => $e->user_id,
'business_name' => $e->business_name ?? ($e->user?->name ?? ''),
'user_name' => $e->user?->name ?? '',
'business_reg_number' => $e->business_registration_number ?? '',
])->values();
if ($request->header('HX-Request')) {
return response(
view('hr.business-income-payments.partials.stats', compact('stats')).
'<!-- SPLIT -->'.
view('hr.business-income-payments.partials.spreadsheet', compact('payments', 'earnersForJs', 'year', 'month'))
);
}
return response()->json([
'success' => true,
'data' => $payments,
'stats' => $stats,
]);
}
/**
* 일괄 저장
*/
public function bulkSave(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',
'items' => 'present|array',
'items.*.payment_id' => 'nullable|integer',
'items.*.user_id' => 'nullable|integer',
'items.*.display_name' => 'required|string|max:100',
'items.*.business_reg_number' => 'nullable|string|max:20',
'items.*.gross_amount' => 'required|numeric|min:0',
'items.*.service_content' => 'nullable|string|max:200',
'items.*.payment_date' => 'nullable|date',
'items.*.note' => 'nullable|string|max:500',
]);
$result = $this->service->bulkSave(
$validated['year'],
$validated['month'],
$validated['items']
);
return response()->json([
'success' => true,
'message' => "저장 {$result['saved']}건, 삭제 {$result['deleted']}건, 건너뜀 {$result['skipped']}",
'data' => $result,
]);
}
/**
* XLSX 내보내기 (스타일링 포함)
*/
public function export(Request $request): StreamedResponse|JsonResponse
{
if ($denied = $this->checkPayrollAccess()) {
return $denied;
}
$year = $request->integer('year') ?: now()->year;
$month = $request->integer('month') ?: now()->month;
$payments = $this->service->getExportData($year, $month);
$filename = "사업소득자임금대장_{$year}{$month}월_".now()->format('Ymd').'.xlsx';
$spreadsheet = new Spreadsheet;
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('사업소득자 임금대장');
$lastCol = 'K';
$headers = ['구분', '상호/성명', "사업자등록번호\n/주민등록번호", '용역내용', '지급총액', "소득세\n(3%)", "지방소득세\n(0.3%)", '공제합계', '실지급액', '지급일자', '비고'];
// ── 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 = ['E', 'F', 'G', 'H', 'I'];
foreach ($payments as $idx => $payment) {
$name = $payment->display_name ?: ($payment->user?->name ?? '-');
$regNumber = $payment->business_reg_number ?? '';
$sheet->setCellValue("A{$row}", $idx + 1);
$sheet->setCellValue("B{$row}", $name);
$sheet->setCellValueExplicit("C{$row}", $regNumber, \PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING);
$sheet->setCellValue("D{$row}", $payment->service_content ?? '');
$sheet->setCellValue("E{$row}", (int) $payment->gross_amount);
$sheet->setCellValue("F{$row}", (int) $payment->income_tax);
$sheet->setCellValue("G{$row}", (int) $payment->local_income_tax);
$sheet->setCellValue("H{$row}", (int) $payment->total_deductions);
$sheet->setCellValue("I{$row}", (int) $payment->net_amount);
$sheet->setCellValue("J{$row}", $payment->payment_date?->format('Y-m-d') ?? '');
$sheet->setCellValue("K{$row}", $payment->note ?? '');
// 지급일자 빨간색
if ($payment->payment_date) {
$sheet->getStyle("J{$row}")->getFont()->setColor(new Color('FF0000'));
}
$row++;
}
// 빈 행 채움 (최소 10행)
$minEndRow = $dataStartRow + 9;
while ($row <= $minEndRow) {
$row++;
}
$lastDataRow = $row - 1;
// ── 데이터 영역 스타일 ──
$dataRange = "A{$dataStartRow}:{$lastCol}{$lastDataRow}";
$sheet->getStyle($dataRange)->applyFromArray([
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['argb' => 'FF000000']]],
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
'font' => ['size' => 10],
]);
// 가운데 정렬: 구분, 용역내용, 지급일자, 비고
foreach (['A', 'D', 'J', 'K'] as $col) {
$sheet->getStyle("{$col}{$dataStartRow}:{$col}{$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);
}
// ── 열 너비 ──
$widths = ['A' => 8, 'B' => 14, 'C' => 22, 'D' => 14, 'E' => 14, 'F' => 14, 'G' => 14, 'H' => 14, 'I' => 14, 'J' => 14, 'K' => 14];
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',
]);
}
}