- 전 사원의 deductions JSON에서 고유 항목명 수집 - 개인별 추가공제 항목을 동적 열로 확장 출력 - 추가공제 열 헤더 보라색, 데이터 영역 연보라 배경 구분 - 추가공제 없는 사원은 해당 열 0 표시
1014 lines
38 KiB
PHP
1014 lines
38 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api\Admin\HR;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\Barobill\AccountCode;
|
|
use App\Models\Finance\JournalEntry;
|
|
use App\Models\Finance\JournalEntryLine;
|
|
use App\Models\Finance\TradingPartner;
|
|
use App\Models\HR\Payroll;
|
|
use App\Services\HR\PayrollService;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Http\JsonResponse;
|
|
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;
|
|
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
|
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
|
|
class PayrollController extends Controller
|
|
{
|
|
private const ALLOWED_PAYROLL_USERS = ['이경호', '전진선', '김보곤'];
|
|
|
|
public function __construct(
|
|
private PayrollService $payrollService
|
|
) {}
|
|
|
|
private function checkPayrollAccess(): ?JsonResponse
|
|
{
|
|
if (! in_array(auth()->user()->name, self::ALLOWED_PAYROLL_USERS)) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => '급여관리는 관계자만 볼 수 있습니다.',
|
|
], 403);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 급여 목록 조회 (HTMX → HTML / 일반 → JSON)
|
|
*/
|
|
public function index(Request $request): JsonResponse|Response
|
|
{
|
|
if ($denied = $this->checkPayrollAccess()) {
|
|
return $denied;
|
|
}
|
|
|
|
$payrolls = $this->payrollService->getPayrolls(
|
|
$request->all(),
|
|
$request->integer('per_page', 20)
|
|
);
|
|
|
|
if ($request->header('HX-Request')) {
|
|
return response(view('hr.payrolls.partials.table', compact('payrolls')));
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'data' => $payrolls->items(),
|
|
'meta' => [
|
|
'current_page' => $payrolls->currentPage(),
|
|
'last_page' => $payrolls->lastPage(),
|
|
'per_page' => $payrolls->perPage(),
|
|
'total' => $payrolls->total(),
|
|
],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 월간 통계 (HTMX → HTML / 일반 → JSON)
|
|
*/
|
|
public function stats(Request $request): JsonResponse|Response
|
|
{
|
|
if ($denied = $this->checkPayrollAccess()) {
|
|
return $denied;
|
|
}
|
|
|
|
$stats = $this->payrollService->getMonthlyStats(
|
|
$request->integer('year') ?: null,
|
|
$request->integer('month') ?: null
|
|
);
|
|
|
|
if ($request->header('HX-Request')) {
|
|
return response(view('hr.payrolls.partials.stats', compact('stats')));
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'data' => $stats,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 급여 등록
|
|
*/
|
|
public function store(Request $request): JsonResponse
|
|
{
|
|
if ($denied = $this->checkPayrollAccess()) {
|
|
return $denied;
|
|
}
|
|
|
|
$validated = $request->validate([
|
|
'user_id' => 'required|integer|exists:users,id',
|
|
'pay_year' => 'required|integer|min:2020|max:2100',
|
|
'pay_month' => 'required|integer|min:1|max:12',
|
|
'base_salary' => 'required|numeric|min:0',
|
|
'overtime_pay' => 'nullable|numeric|min:0',
|
|
'bonus' => 'nullable|numeric|min:0',
|
|
'allowances' => 'nullable|array',
|
|
'allowances.*.name' => 'required_with:allowances|string',
|
|
'allowances.*.amount' => 'required_with:allowances|numeric|min:0',
|
|
'deductions' => 'nullable|array',
|
|
'deductions.*.name' => 'required_with:deductions|string',
|
|
'deductions.*.amount' => 'required_with:deductions|numeric',
|
|
'deduction_overrides' => 'nullable|array',
|
|
'deduction_overrides.pension' => 'nullable|numeric',
|
|
'deduction_overrides.health_insurance' => 'nullable|numeric',
|
|
'deduction_overrides.long_term_care' => 'nullable|numeric',
|
|
'deduction_overrides.employment_insurance' => 'nullable|numeric',
|
|
'deduction_overrides.income_tax' => 'nullable|numeric',
|
|
'deduction_overrides.resident_tax' => 'nullable|numeric',
|
|
'note' => 'nullable|string|max:500',
|
|
]);
|
|
|
|
try {
|
|
$payroll = $this->payrollService->storePayroll($validated);
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => '급여가 등록되었습니다.',
|
|
'data' => $payroll,
|
|
], 201);
|
|
} catch (\InvalidArgumentException $e) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => $e->getMessage(),
|
|
], 422);
|
|
} catch (\Illuminate\Database\UniqueConstraintViolationException $e) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => '해당 직원의 동일 기간 급여가 이미 등록되어 있습니다.',
|
|
], 422);
|
|
} catch (\Throwable $e) {
|
|
report($e);
|
|
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => '급여 등록 중 오류가 발생했습니다.',
|
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 급여 수정
|
|
*/
|
|
public function update(Request $request, int $id): JsonResponse
|
|
{
|
|
if ($denied = $this->checkPayrollAccess()) {
|
|
return $denied;
|
|
}
|
|
|
|
$validated = $request->validate([
|
|
'base_salary' => 'sometimes|required|numeric|min:0',
|
|
'overtime_pay' => 'nullable|numeric|min:0',
|
|
'bonus' => 'nullable|numeric|min:0',
|
|
'allowances' => 'nullable|array',
|
|
'allowances.*.name' => 'required_with:allowances|string',
|
|
'allowances.*.amount' => 'required_with:allowances|numeric|min:0',
|
|
'deductions' => 'nullable|array',
|
|
'deductions.*.name' => 'required_with:deductions|string',
|
|
'deductions.*.amount' => 'required_with:deductions|numeric',
|
|
'deduction_overrides' => 'nullable|array',
|
|
'deduction_overrides.pension' => 'nullable|numeric',
|
|
'deduction_overrides.health_insurance' => 'nullable|numeric',
|
|
'deduction_overrides.long_term_care' => 'nullable|numeric',
|
|
'deduction_overrides.employment_insurance' => 'nullable|numeric',
|
|
'deduction_overrides.income_tax' => 'nullable|numeric',
|
|
'deduction_overrides.resident_tax' => 'nullable|numeric',
|
|
'note' => 'nullable|string|max:500',
|
|
]);
|
|
|
|
try {
|
|
$payroll = $this->payrollService->updatePayroll($id, $validated);
|
|
|
|
if (! $payroll) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => '급여 정보를 찾을 수 없거나 수정할 수 없는 상태입니다.',
|
|
], 404);
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => '급여가 수정되었습니다.',
|
|
'data' => $payroll,
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
report($e);
|
|
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => '급여 수정 중 오류가 발생했습니다.',
|
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 급여 삭제
|
|
*/
|
|
public function destroy(Request $request, int $id): JsonResponse|Response
|
|
{
|
|
if ($denied = $this->checkPayrollAccess()) {
|
|
return $denied;
|
|
}
|
|
|
|
try {
|
|
$result = $this->payrollService->deletePayroll($id);
|
|
|
|
if (! $result) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => '급여 정보를 찾을 수 없거나 삭제할 수 없는 상태입니다.',
|
|
], 404);
|
|
}
|
|
|
|
if ($request->header('HX-Request')) {
|
|
$payrolls = $this->payrollService->getPayrolls(
|
|
$request->all(),
|
|
$request->integer('per_page', 20)
|
|
);
|
|
|
|
return response(view('hr.payrolls.partials.table', compact('payrolls')));
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => '급여가 삭제되었습니다.',
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
report($e);
|
|
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => '급여 삭제 중 오류가 발생했습니다.',
|
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 급여 확정
|
|
*/
|
|
public function confirm(Request $request, int $id): JsonResponse
|
|
{
|
|
if ($denied = $this->checkPayrollAccess()) {
|
|
return $denied;
|
|
}
|
|
|
|
try {
|
|
$payroll = $this->payrollService->confirmPayroll($id);
|
|
|
|
if (! $payroll) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => '급여를 확정할 수 없습니다.',
|
|
], 404);
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => '급여가 확정되었습니다.',
|
|
'data' => $payroll,
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
report($e);
|
|
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => '급여 확정 중 오류가 발생했습니다.',
|
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 급여 확정 취소
|
|
*/
|
|
public function unconfirm(Request $request, int $id): JsonResponse
|
|
{
|
|
if ($denied = $this->checkPayrollAccess()) {
|
|
return $denied;
|
|
}
|
|
|
|
try {
|
|
$payroll = $this->payrollService->unconfirmPayroll($id);
|
|
|
|
if (! $payroll) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => '급여 확정을 취소할 수 없습니다.',
|
|
], 404);
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => '급여 확정이 취소되었습니다.',
|
|
'data' => $payroll,
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
report($e);
|
|
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => '급여 확정 취소 중 오류가 발생했습니다.',
|
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 급여 지급 처리
|
|
*/
|
|
public function pay(Request $request, int $id): JsonResponse
|
|
{
|
|
if ($denied = $this->checkPayrollAccess()) {
|
|
return $denied;
|
|
}
|
|
|
|
try {
|
|
$payroll = $this->payrollService->payPayroll($id);
|
|
|
|
if (! $payroll) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => '급여를 지급 처리할 수 없습니다.',
|
|
], 404);
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => '급여가 지급 처리되었습니다.',
|
|
'data' => $payroll,
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
report($e);
|
|
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => '급여 지급 처리 중 오류가 발생했습니다.',
|
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 전월 급여 복사 등록
|
|
*/
|
|
public function copyFromPrevious(Request $request): JsonResponse
|
|
{
|
|
if ($denied = $this->checkPayrollAccess()) {
|
|
return $denied;
|
|
}
|
|
|
|
$validated = $request->validate([
|
|
'pay_year' => 'required|integer|min:2020|max:2100',
|
|
'pay_month' => 'required|integer|min:1|max:12',
|
|
]);
|
|
|
|
try {
|
|
$result = $this->payrollService->copyFromPreviousMonth($validated['pay_year'], $validated['pay_month']);
|
|
|
|
if (! empty($result['no_previous'])) {
|
|
$prevYear = $validated['pay_month'] === 1 ? $validated['pay_year'] - 1 : $validated['pay_year'];
|
|
$prevMonth = $validated['pay_month'] === 1 ? 12 : $validated['pay_month'] - 1;
|
|
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => "{$prevYear}년 {$prevMonth}월 급여 데이터가 없습니다.",
|
|
], 422);
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => "전월 복사 완료: {$result['created']}건 생성, {$result['skipped']}건 건너뜀 (이미 존재).",
|
|
'data' => $result,
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
report($e);
|
|
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => '전월 복사 중 오류가 발생했습니다.',
|
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 일괄 생성
|
|
*/
|
|
public function bulkGenerate(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',
|
|
]);
|
|
|
|
try {
|
|
$result = $this->payrollService->bulkGenerate($validated['year'], $validated['month']);
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => "신규 {$result['created']}건 생성, {$result['skipped']}건 건너뜀 (이미 존재).",
|
|
'data' => $result,
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
report($e);
|
|
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => '일괄 생성 중 오류가 발생했습니다.',
|
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 엑셀(XLSX) 내보내기 — 급여명세서 서식 (추가공제 동적 열 포함)
|
|
*/
|
|
public function export(Request $request): StreamedResponse|JsonResponse
|
|
{
|
|
if ($denied = $this->checkPayrollAccess()) {
|
|
return $denied;
|
|
}
|
|
|
|
$payrolls = $this->payrollService->getExportData($request->all());
|
|
$year = $request->input('year', now()->year);
|
|
$month = $request->input('month', now()->month);
|
|
|
|
$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('급여명세서');
|
|
|
|
// ── Row 1: 제목 ──
|
|
$sheet->mergeCells("A1:{$lastColLetter}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) {
|
|
$colLetter = Coordinate::stringFromColumnIndex($colIdx + 1);
|
|
$sheet->setCellValue("{$colLetter}2", $header);
|
|
}
|
|
$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],
|
|
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['argb' => 'FF000000']]],
|
|
]);
|
|
$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;
|
|
|
|
foreach ($payrolls as $idx => $payroll) {
|
|
$profile = $payroll->user?->tenantProfiles?->first();
|
|
$displayName = $profile?->display_name ?? $payroll->user?->name ?? '-';
|
|
$department = $profile?->department?->name ?? '-';
|
|
|
|
// 개인별 추가공제를 이름 → 금액 맵으로 변환
|
|
$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++;
|
|
}
|
|
|
|
// 빈 행 채움 (최소 10행까지 테두리)
|
|
$minEndRow = $dataStartRow + 9;
|
|
while ($row <= $minEndRow) {
|
|
$row++;
|
|
}
|
|
$lastDataRow = $row - 1;
|
|
|
|
// ── 데이터 영역 스타일 ──
|
|
$sheet->getStyle("A{$dataStartRow}:{$lastColLetter}{$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 ($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;
|
|
$mergeEnd = Coordinate::stringFromColumnIndex(3); // C
|
|
$sheet->mergeCells("A{$sumRow}:{$mergeEnd}{$sumRow}");
|
|
$sheet->setCellValue("A{$sumRow}", '합계');
|
|
|
|
foreach ($moneyColLetters as $col) {
|
|
$sheet->setCellValue(
|
|
"{$col}{$sumRow}",
|
|
"=SUM({$col}{$dataStartRow}:{$col}{$lastDataRow})"
|
|
);
|
|
}
|
|
|
|
$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}:{$mergeEnd}{$sumRow}")
|
|
->getAlignment()->setHorizontal(Alignment::HORIZONTAL_CENTER);
|
|
|
|
foreach ($moneyColLetters as $col) {
|
|
$sheet->getStyle("{$col}{$sumRow}")->getNumberFormat()->setFormatCode('#,##0');
|
|
$sheet->getStyle("{$col}{$sumRow}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
|
|
}
|
|
|
|
// ── 열 너비 ──
|
|
$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);
|
|
}
|
|
|
|
// ── 응답 반환 ──
|
|
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',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 급여 설정 조회 (HTMX → HTML / 일반 → JSON)
|
|
*/
|
|
public function settingsIndex(Request $request): JsonResponse|Response
|
|
{
|
|
if ($denied = $this->checkPayrollAccess()) {
|
|
return $denied;
|
|
}
|
|
|
|
$settings = $this->payrollService->getSettings();
|
|
|
|
if ($request->header('HX-Request')) {
|
|
return response(view('hr.payrolls.partials.settings', compact('settings')));
|
|
}
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'data' => $settings,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 급여 설정 수정
|
|
*/
|
|
public function settingsUpdate(Request $request): JsonResponse
|
|
{
|
|
if ($denied = $this->checkPayrollAccess()) {
|
|
return $denied;
|
|
}
|
|
|
|
$validated = $request->validate([
|
|
'health_insurance_rate' => 'nullable|numeric|min:0|max:100',
|
|
'long_term_care_rate' => 'nullable|numeric|min:0|max:100',
|
|
'pension_rate' => 'nullable|numeric|min:0|max:100',
|
|
'employment_insurance_rate' => 'nullable|numeric|min:0|max:100',
|
|
'pension_max_salary' => 'nullable|numeric|min:0',
|
|
'pension_min_salary' => 'nullable|numeric|min:0',
|
|
'pay_day' => 'nullable|integer|min:1|max:31',
|
|
'auto_calculate' => 'nullable|boolean',
|
|
]);
|
|
|
|
try {
|
|
$settings = $this->payrollService->updateSettings($validated);
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => '급여 설정이 저장되었습니다.',
|
|
'data' => $settings,
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
report($e);
|
|
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => '설정 저장 중 오류가 발생했습니다.',
|
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 급여 일반전표 자동 생성
|
|
*/
|
|
public function generateJournalEntry(Request $request): JsonResponse
|
|
{
|
|
if ($denied = $this->checkPayrollAccess()) {
|
|
return $denied;
|
|
}
|
|
|
|
$request->validate([
|
|
'year' => 'required|integer|min:2020|max:2100',
|
|
'month' => 'required|integer|min:1|max:12',
|
|
]);
|
|
|
|
$year = $request->integer('year');
|
|
$month = $request->integer('month');
|
|
$tenantId = session('selected_tenant_id', 1);
|
|
$sourceKey = "payroll-{$year}-{$month}";
|
|
|
|
// 중복 체크
|
|
$existing = JournalEntry::forTenant($tenantId)
|
|
->where('source_type', 'payroll')
|
|
->where('source_key', $sourceKey)
|
|
->first();
|
|
|
|
if ($existing) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => "이미 {$month}월분 급여 전표가 존재합니다 ({$existing->entry_no})",
|
|
], 422);
|
|
}
|
|
|
|
// 해당월 급여 합산
|
|
$sums = Payroll::forTenant($tenantId)
|
|
->forPeriod($year, $month)
|
|
->selectRaw('
|
|
SUM(gross_salary) as total_gross,
|
|
SUM(pension) as total_pension,
|
|
SUM(health_insurance) as total_health,
|
|
SUM(long_term_care) as total_ltc,
|
|
SUM(employment_insurance) as total_emp,
|
|
SUM(income_tax) as total_income_tax,
|
|
SUM(resident_tax) as total_resident_tax,
|
|
SUM(net_salary) as total_net
|
|
')
|
|
->first();
|
|
|
|
if (! $sums || (int) $sums->total_gross === 0) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => '해당 월 급여 데이터가 없습니다.',
|
|
], 422);
|
|
}
|
|
|
|
// 거래처 조회
|
|
$partnerNames = ['임직원', '건강보험연금', '건강보험건강', '건강보험고용', '강서세무서', '강서구청'];
|
|
$partners = TradingPartner::forTenant($tenantId)
|
|
->whereIn('name', $partnerNames)
|
|
->pluck('id', 'name');
|
|
|
|
$missingPartners = array_diff($partnerNames, $partners->keys()->toArray());
|
|
if (! empty($missingPartners)) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => '거래처가 등록되어 있지 않습니다: '.implode(', ', $missingPartners),
|
|
], 422);
|
|
}
|
|
|
|
// 계정과목 조회
|
|
$accountCodes = AccountCode::whereIn('code', ['801', '207', '205'])
|
|
->where('is_active', true)
|
|
->pluck('name', 'code');
|
|
|
|
$missingCodes = array_diff(['801', '207', '205'], $accountCodes->keys()->toArray());
|
|
if (! empty($missingCodes)) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => '계정과목이 등록되어 있지 않습니다: '.implode(', ', $missingCodes),
|
|
], 422);
|
|
}
|
|
|
|
// 전표일자 (해당월 말일)
|
|
$entryDate = Carbon::create($year, $month)->endOfMonth()->toDateString();
|
|
$monthLabel = "{$month}월분";
|
|
|
|
// 분개 행 구성
|
|
$lines = [];
|
|
$lineNo = 1;
|
|
|
|
// 1. 차변: 801 급여 / 임직원
|
|
$grossAmount = (int) $sums->total_gross;
|
|
if ($grossAmount > 0) {
|
|
$lines[] = [
|
|
'dc_type' => 'debit',
|
|
'account_code' => '801',
|
|
'account_name' => $accountCodes['801'],
|
|
'trading_partner_id' => $partners['임직원'],
|
|
'trading_partner_name' => '임직원',
|
|
'debit_amount' => $grossAmount,
|
|
'credit_amount' => 0,
|
|
'description' => "{$monthLabel} 급여",
|
|
'line_no' => $lineNo++,
|
|
];
|
|
}
|
|
|
|
// 2. 대변: 207 예수금 / 건강보험연금 — 국민연금
|
|
$pension = (int) $sums->total_pension;
|
|
if ($pension > 0) {
|
|
$lines[] = [
|
|
'dc_type' => 'credit',
|
|
'account_code' => '207',
|
|
'account_name' => $accountCodes['207'],
|
|
'trading_partner_id' => $partners['건강보험연금'],
|
|
'trading_partner_name' => '건강보험연금',
|
|
'debit_amount' => 0,
|
|
'credit_amount' => $pension,
|
|
'description' => '국민연금',
|
|
'line_no' => $lineNo++,
|
|
];
|
|
}
|
|
|
|
// 3. 대변: 207 예수금 / 건강보험건강 — 건강보험
|
|
$health = (int) $sums->total_health;
|
|
if ($health > 0) {
|
|
$lines[] = [
|
|
'dc_type' => 'credit',
|
|
'account_code' => '207',
|
|
'account_name' => $accountCodes['207'],
|
|
'trading_partner_id' => $partners['건강보험건강'],
|
|
'trading_partner_name' => '건강보험건강',
|
|
'debit_amount' => 0,
|
|
'credit_amount' => $health,
|
|
'description' => '건강보험',
|
|
'line_no' => $lineNo++,
|
|
];
|
|
}
|
|
|
|
// 4. 대변: 207 예수금 / 건강보험건강 — 장기요양보험
|
|
$ltc = (int) $sums->total_ltc;
|
|
if ($ltc > 0) {
|
|
$lines[] = [
|
|
'dc_type' => 'credit',
|
|
'account_code' => '207',
|
|
'account_name' => $accountCodes['207'],
|
|
'trading_partner_id' => $partners['건강보험건강'],
|
|
'trading_partner_name' => '건강보험건강',
|
|
'debit_amount' => 0,
|
|
'credit_amount' => $ltc,
|
|
'description' => '장기요양보험',
|
|
'line_no' => $lineNo++,
|
|
];
|
|
}
|
|
|
|
// 5. 대변: 207 예수금 / 건강보험고용 — 고용보험
|
|
$emp = (int) $sums->total_emp;
|
|
if ($emp > 0) {
|
|
$lines[] = [
|
|
'dc_type' => 'credit',
|
|
'account_code' => '207',
|
|
'account_name' => $accountCodes['207'],
|
|
'trading_partner_id' => $partners['건강보험고용'],
|
|
'trading_partner_name' => '건강보험고용',
|
|
'debit_amount' => 0,
|
|
'credit_amount' => $emp,
|
|
'description' => '고용보험',
|
|
'line_no' => $lineNo++,
|
|
];
|
|
}
|
|
|
|
// 6. 대변: 207 예수금 / 강서세무서 — 근로소득세
|
|
$incomeTax = (int) $sums->total_income_tax;
|
|
if ($incomeTax > 0) {
|
|
$lines[] = [
|
|
'dc_type' => 'credit',
|
|
'account_code' => '207',
|
|
'account_name' => $accountCodes['207'],
|
|
'trading_partner_id' => $partners['강서세무서'],
|
|
'trading_partner_name' => '강서세무서',
|
|
'debit_amount' => 0,
|
|
'credit_amount' => $incomeTax,
|
|
'description' => "{$monthLabel} 근로소득세",
|
|
'line_no' => $lineNo++,
|
|
];
|
|
}
|
|
|
|
// 7. 대변: 207 예수금 / 강서구청 — 지방소득세
|
|
$residentTax = (int) $sums->total_resident_tax;
|
|
if ($residentTax > 0) {
|
|
$lines[] = [
|
|
'dc_type' => 'credit',
|
|
'account_code' => '207',
|
|
'account_name' => $accountCodes['207'],
|
|
'trading_partner_id' => $partners['강서구청'],
|
|
'trading_partner_name' => '강서구청',
|
|
'debit_amount' => 0,
|
|
'credit_amount' => $residentTax,
|
|
'description' => "{$monthLabel} 지방소득세",
|
|
'line_no' => $lineNo++,
|
|
];
|
|
}
|
|
|
|
// 8. 대변: 205 미지급비용 / 임직원 — 급여
|
|
$netSalary = (int) $sums->total_net;
|
|
if ($netSalary > 0) {
|
|
$lines[] = [
|
|
'dc_type' => 'credit',
|
|
'account_code' => '205',
|
|
'account_name' => $accountCodes['205'],
|
|
'trading_partner_id' => $partners['임직원'],
|
|
'trading_partner_name' => '임직원',
|
|
'debit_amount' => 0,
|
|
'credit_amount' => $netSalary,
|
|
'description' => "{$monthLabel} 급여",
|
|
'line_no' => $lineNo++,
|
|
];
|
|
}
|
|
|
|
// 차대 균형 검증
|
|
$totalDebit = collect($lines)->sum('debit_amount');
|
|
$totalCredit = collect($lines)->sum('credit_amount');
|
|
|
|
if ($totalDebit !== $totalCredit || $totalDebit === 0) {
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => "차변({$totalDebit})과 대변({$totalCredit})이 일치하지 않습니다.",
|
|
], 422);
|
|
}
|
|
|
|
try {
|
|
$entry = DB::transaction(function () use ($tenantId, $entryDate, $totalDebit, $totalCredit, $sourceKey, $monthLabel, $lines) {
|
|
$entryNo = JournalEntry::generateEntryNo($tenantId, $entryDate);
|
|
|
|
$entry = JournalEntry::create([
|
|
'tenant_id' => $tenantId,
|
|
'entry_no' => $entryNo,
|
|
'entry_date' => $entryDate,
|
|
'entry_type' => 'general',
|
|
'description' => "{$monthLabel} 급여",
|
|
'total_debit' => $totalDebit,
|
|
'total_credit' => $totalCredit,
|
|
'status' => 'draft',
|
|
'source_type' => 'payroll',
|
|
'source_key' => $sourceKey,
|
|
'created_by_name' => auth()->user()?->name ?? '시스템',
|
|
]);
|
|
|
|
foreach ($lines as $line) {
|
|
JournalEntryLine::create(array_merge($line, [
|
|
'tenant_id' => $tenantId,
|
|
'journal_entry_id' => $entry->id,
|
|
]));
|
|
}
|
|
|
|
return $entry;
|
|
});
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'message' => "급여 전표가 생성되었습니다 ({$entry->entry_no})",
|
|
'data' => [
|
|
'entry_no' => $entry->entry_no,
|
|
'entry_date' => $entry->entry_date->toDateString(),
|
|
],
|
|
]);
|
|
} catch (\Throwable $e) {
|
|
report($e);
|
|
|
|
return response()->json([
|
|
'success' => false,
|
|
'message' => '전표 생성 중 오류가 발생했습니다.',
|
|
'error' => config('app.debug') ? $e->getMessage() : null,
|
|
], 500);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 급여 계산 미리보기 (AJAX)
|
|
*/
|
|
public function calculate(Request $request): JsonResponse
|
|
{
|
|
if ($denied = $this->checkPayrollAccess()) {
|
|
return $denied;
|
|
}
|
|
|
|
$validated = $request->validate([
|
|
'base_salary' => 'required|numeric|min:0',
|
|
'overtime_pay' => 'nullable|numeric|min:0',
|
|
'bonus' => 'nullable|numeric|min:0',
|
|
'allowances' => 'nullable|array',
|
|
'deductions' => 'nullable|array',
|
|
'user_id' => 'nullable|integer',
|
|
]);
|
|
|
|
$familyCount = 1;
|
|
if (! empty($validated['user_id'])) {
|
|
$familyCount = $this->payrollService->resolveFamilyCount($validated['user_id']);
|
|
}
|
|
|
|
$result = $this->payrollService->calculateAmounts($validated, null, $familyCount);
|
|
|
|
return response()->json([
|
|
'success' => true,
|
|
'data' => array_merge($result, ['family_count' => $familyCount]),
|
|
]);
|
|
}
|
|
}
|