- 모든 행 삭제 시 "저장할 데이터가 없습니다" 오류 → 확인 후 서버 전송으로 변경 - 백엔드 validation: required|array → present|array (빈 배열 허용) - 서버의 orphan draft 자동 삭제 로직이 정상 동작하도록 수정
230 lines
9.1 KiB
PHP
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',
|
|
]);
|
|
}
|
|
}
|