- formatMoneyInput: 음수 부호(-) 유지하도록 수정 - doRecalculate/submitPayroll: amount > 0 → amount !== 0 조건 변경 - Controller validation: deductions.*.amount에서 min:0 제약 제거 - 연말정산 환급 등 음수 공제 항목 지원
425 lines
15 KiB
PHP
425 lines
15 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api\Admin\HR;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\HR\Payroll;
|
|
use App\Services\HR\PayrollService;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Http\Response;
|
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
|
|
class PayrollController extends Controller
|
|
{
|
|
public function __construct(
|
|
private PayrollService $payrollService
|
|
) {}
|
|
|
|
/**
|
|
* 급여 목록 조회 (HTMX → HTML / 일반 → JSON)
|
|
*/
|
|
public function index(Request $request): JsonResponse|Response
|
|
{
|
|
$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
|
|
{
|
|
$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
|
|
{
|
|
$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|min:0',
|
|
'deduction_overrides.health_insurance' => 'nullable|numeric|min:0',
|
|
'deduction_overrides.long_term_care' => 'nullable|numeric|min:0',
|
|
'deduction_overrides.employment_insurance' => 'nullable|numeric|min:0',
|
|
'deduction_overrides.income_tax' => 'nullable|numeric|min:0',
|
|
'deduction_overrides.resident_tax' => 'nullable|numeric|min:0',
|
|
'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 (\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
|
|
{
|
|
$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|min:0',
|
|
'deduction_overrides.health_insurance' => 'nullable|numeric|min:0',
|
|
'deduction_overrides.long_term_care' => 'nullable|numeric|min:0',
|
|
'deduction_overrides.employment_insurance' => 'nullable|numeric|min:0',
|
|
'deduction_overrides.income_tax' => 'nullable|numeric|min:0',
|
|
'deduction_overrides.resident_tax' => 'nullable|numeric|min:0',
|
|
'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
|
|
{
|
|
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
|
|
{
|
|
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 pay(Request $request, int $id): JsonResponse
|
|
{
|
|
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 bulkGenerate(Request $request): JsonResponse
|
|
{
|
|
$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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 엑셀(CSV) 내보내기
|
|
*/
|
|
public function export(Request $request): StreamedResponse
|
|
{
|
|
$payrolls = $this->payrollService->getExportData($request->all());
|
|
$year = $request->input('year', now()->year);
|
|
$month = $request->input('month', now()->month);
|
|
|
|
$filename = "급여관리_{$year}년{$month}월_".now()->format('Ymd').'.csv';
|
|
|
|
return response()->streamDownload(function () use ($payrolls) {
|
|
$file = fopen('php://output', 'w');
|
|
fwrite($file, "\xEF\xBB\xBF"); // UTF-8 BOM
|
|
|
|
fputcsv($file, ['사원명', '부서', '기본급', '고정연장근로수당', '식대(비과세)', '총지급액', '국민연금', '건강보험', '장기요양보험', '고용보험', '근로소득세', '지방소득세', '총공제액', '실수령액', '상태']);
|
|
|
|
foreach ($payrolls as $payroll) {
|
|
$profile = $payroll->user?->tenantProfiles?->first();
|
|
$displayName = $profile?->display_name ?? $payroll->user?->name ?? '-';
|
|
$department = $profile?->department?->name ?? '-';
|
|
$statusLabel = Payroll::STATUS_MAP[$payroll->status] ?? $payroll->status;
|
|
|
|
fputcsv($file, [
|
|
$displayName,
|
|
$department,
|
|
$payroll->base_salary,
|
|
$payroll->overtime_pay,
|
|
$payroll->bonus,
|
|
$payroll->gross_salary,
|
|
$payroll->pension,
|
|
$payroll->health_insurance,
|
|
$payroll->long_term_care,
|
|
$payroll->employment_insurance,
|
|
$payroll->income_tax,
|
|
$payroll->resident_tax,
|
|
$payroll->total_deductions,
|
|
$payroll->net_salary,
|
|
$statusLabel,
|
|
]);
|
|
}
|
|
|
|
fclose($file);
|
|
}, $filename, [
|
|
'Content-Type' => 'text/csv; charset=UTF-8',
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 급여 설정 조회 (HTMX → HTML / 일반 → JSON)
|
|
*/
|
|
public function settingsIndex(Request $request): JsonResponse|Response
|
|
{
|
|
$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
|
|
{
|
|
$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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 급여 계산 미리보기 (AJAX)
|
|
*/
|
|
public function calculate(Request $request): JsonResponse
|
|
{
|
|
$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]),
|
|
]);
|
|
}
|
|
}
|