Files
sam-manage/app/Services/HR/LeaveService.php

581 lines
19 KiB
PHP
Raw Normal View History

<?php
namespace App\Services\HR;
use App\Models\HR\Attendance;
use App\Models\HR\Employee;
use App\Models\HR\Leave;
use App\Models\HR\LeaveBalance;
use App\Models\HR\LeavePolicy;
use App\Models\Tenants\Department;
use Carbon\Carbon;
use Carbon\CarbonPeriod;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class LeaveService
{
/**
* 휴가 목록 조회 (필터 + 페이지네이션)
*/
public function getLeaves(array $filters = [], int $perPage = 20): LengthAwarePaginator
{
$tenantId = session('selected_tenant_id');
$query = Leave::query()
->with([
'user',
'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId),
'user.tenantProfiles.department',
'approver',
])
->forTenant($tenantId);
if (! empty($filters['q'])) {
$search = $filters['q'];
$query->whereHas('user', fn ($q) => $q->where('name', 'like', "%{$search}%"));
}
if (! empty($filters['user_id'])) {
$query->where('user_id', $filters['user_id']);
}
if (! empty($filters['department_id'])) {
$deptId = $filters['department_id'];
$query->whereHas('user.tenantProfiles', function ($q) use ($tenantId, $deptId) {
$q->where('tenant_id', $tenantId)->where('department_id', $deptId);
});
}
if (! empty($filters['leave_type'])) {
$query->where('leave_type', $filters['leave_type']);
}
if (! empty($filters['status'])) {
$query->where('status', $filters['status']);
}
if (! empty($filters['date_from']) && ! empty($filters['date_to'])) {
$query->betweenDates($filters['date_from'], $filters['date_to']);
} elseif (! empty($filters['date_from'])) {
$query->where('start_date', '>=', $filters['date_from']);
} elseif (! empty($filters['date_to'])) {
$query->where('end_date', '<=', $filters['date_to']);
}
return $query
->orderByRaw("FIELD(status, 'pending', 'approved', 'rejected', 'cancelled')")
->orderBy('created_at', 'desc')
->paginate($perPage);
}
/**
* 휴가 신청 등록
*/
public function storeLeave(array $data): Leave
{
$tenantId = session('selected_tenant_id');
$days = $this->calculateDays($data['leave_type'], $data['start_date'], $data['end_date']);
// 연차 차감 대상이면 잔여일수 검증
if (in_array($data['leave_type'], Leave::DEDUCTIBLE_TYPES)) {
$balance = LeaveBalance::query()
->forTenant($tenantId)
->forUser($data['user_id'])
->forYear(now()->year)
->first();
if (! $balance || ! $balance->canUse($days)) {
throw new \RuntimeException('잔여 연차가 부족합니다.');
}
}
return Leave::create([
'tenant_id' => $tenantId,
'user_id' => $data['user_id'],
'leave_type' => $data['leave_type'],
'start_date' => $data['start_date'],
'end_date' => $data['end_date'],
'days' => $days,
'reason' => $data['reason'] ?? null,
'status' => 'pending',
'created_by' => auth()->id(),
'updated_by' => auth()->id(),
]);
}
/**
* 승인 LeaveBalance 차감 Attendance 자동 생성
*/
public function approve(int $id): ?Leave
{
$tenantId = session('selected_tenant_id');
$leave = Leave::query()
->forTenant($tenantId)
->withStatus('pending')
->find($id);
if (! $leave) {
return null;
}
return DB::transaction(function () use ($leave, $tenantId) {
$leave->update([
'status' => 'approved',
'approved_by' => auth()->id(),
'approved_at' => now(),
'updated_by' => auth()->id(),
]);
// 연차 차감 대상이면 LeaveBalance 차감
if ($leave->is_deductible) {
$balance = LeaveBalance::query()
->forTenant($tenantId)
->forUser($leave->user_id)
->forYear($leave->start_date->year)
->first();
if ($balance) {
$balance->useLeave($leave->days);
}
}
// 기간 내 영업일마다 Attendance(vacation) 자동 생성
$this->createAttendanceRecords($leave, $tenantId);
return $leave->fresh(['user', 'approver']);
});
}
/**
* 반려
*/
public function reject(int $id, ?string $reason = null): ?Leave
{
$tenantId = session('selected_tenant_id');
$leave = Leave::query()
->forTenant($tenantId)
->withStatus('pending')
->find($id);
if (! $leave) {
return null;
}
$leave->update([
'status' => 'rejected',
'approved_by' => auth()->id(),
'approved_at' => now(),
'reject_reason' => $reason,
'updated_by' => auth()->id(),
]);
return $leave->fresh(['user', 'approver']);
}
/**
* 취소 LeaveBalance 복원 Attendance 삭제
*/
public function cancel(int $id): ?Leave
{
$tenantId = session('selected_tenant_id');
$leave = Leave::query()
->forTenant($tenantId)
->withStatus('approved')
->find($id);
if (! $leave) {
return null;
}
return DB::transaction(function () use ($leave, $tenantId) {
$leave->update([
'status' => 'cancelled',
'updated_by' => auth()->id(),
]);
// 연차 차감 대상이면 LeaveBalance 복원
if ($leave->is_deductible) {
$balance = LeaveBalance::query()
->forTenant($tenantId)
->forUser($leave->user_id)
->forYear($leave->start_date->year)
->first();
if ($balance) {
$balance->restoreLeave($leave->days);
}
}
// 해당 기간 vacation Attendance soft delete
$this->deleteAttendanceRecords($leave, $tenantId);
return $leave->fresh(['user']);
});
}
/**
* 전체 사원 잔여연차 요약
*
* 사원관리의 모든 재직/휴직 직원을 표시하며,
* balance 레코드가 없는 직원은 자동 생성한다.
*/
public function getBalanceSummary(?int $year = null, ?string $sort = null, ?string $direction = null): Collection
{
$tenantId = session('selected_tenant_id');
$year = $year ?? now()->year;
// (1) 테넌트 연차 정책 조회
$policy = LeavePolicy::forTenant($tenantId)->first();
// (2) 재직/휴직/퇴사 직원 전체 조회
$employees = Employee::query()
->with(['user:id,name', 'department:id,name'])
->forTenant($tenantId)
->whereIn('employee_status', ['active', 'leave', 'resigned'])
->get();
// (3) 기존 balance 일괄 조회
$existingBalances = LeaveBalance::query()
->forTenant($tenantId)
->forYear($year)
->get()
->keyBy('user_id');
// (4) balance 없는 직원 → insertOrIgnore로 자동 생성
$newRecords = [];
foreach ($employees as $employee) {
if (! $existingBalances->has($employee->user_id)) {
$newRecords[] = [
'tenant_id' => $tenantId,
'user_id' => $employee->user_id,
'year' => $year,
'total_days' => $this->calculateAnnualLeaveDays($employee, $year, $policy),
'used_days' => 0,
'created_at' => now(),
'updated_at' => now(),
];
}
}
if (! empty($newRecords)) {
LeaveBalance::insertOrIgnore($newRecords);
// 새로 생성된 레코드 포함하여 다시 조회
$existingBalances = LeaveBalance::query()
->forTenant($tenantId)
->forYear($year)
->get()
->keyBy('user_id');
}
// (5) Employee 정보를 balance에 연결
$employeesByUserId = $employees->keyBy('user_id');
// (6) 현재연도 + 1년 미만 직원은 매번 재계산 (월별 발생 방식)
// 퇴사자는 퇴사일 기준으로 확정되므로 재계산 대상에서 제외
if ($year === (int) now()->year) {
foreach ($existingBalances as $balance) {
$employee = $employeesByUserId->get($balance->user_id);
if (! $employee || ! $employee->hire_date) {
continue;
}
if ($employee->employee_status === 'resigned') {
continue;
}
$hire = Carbon::parse($employee->hire_date);
if ($hire->diffInMonths(today()) < 12) {
$recalculated = $this->calculateAnnualLeaveDays($employee, $year, $policy);
if (abs($balance->total_days - $recalculated) > 0.001) {
$balance->update(['total_days' => $recalculated]);
}
}
}
}
$result = $existingBalances
->filter(fn ($balance) => $employeesByUserId->has($balance->user_id))
->map(function ($balance) use ($employeesByUserId) {
$employee = $employeesByUserId->get($balance->user_id);
$balance->employee = $employee;
return $balance;
});
// 정렬 (기본: 입사일 오름차순)
$sortField = $sort ?? 'hire_date';
$isDesc = ($direction ?? 'asc') === 'desc';
$sortCallback = match ($sortField) {
'name' => fn ($b) => $b->employee?->display_name ?? '',
'department' => fn ($b) => $b->employee?->department?->name ?? '',
'hire_date' => fn ($b) => $b->employee?->hire_date ?? '9999-12-31',
'status' => fn ($b) => match ($b->employee?->employee_status) {
'active' => 1, 'leave' => 2, 'resigned' => 3, default => 9,
},
'resign_date' => fn ($b) => $b->employee?->resign_date ?? '9999-12-31',
'total_days' => fn ($b) => $b->total_days,
'used_days' => fn ($b) => $b->used_days,
'remaining' => fn ($b) => $b->total_days - $b->used_days,
'rate' => fn ($b) => $b->total_days > 0 ? $b->used_days / $b->total_days : 0,
default => fn ($b) => $b->employee?->hire_date ?? '9999-12-31',
};
return ($isDesc ? $result->sortByDesc($sortCallback) : $result->sortBy($sortCallback))->values();
}
/**
* 입사일 기반 연차일수 자동 산출 (근로기준법 제60조)
*
* - 입사일 없음 default_annual_leave (기본 15)
* - 1 미만 입사일~기준일 완료 월수 (최대 11, 월별 발생)
* - 1 이상 15 + 2년마다 +1 (최대 max_annual_leave)
*/
private function calculateAnnualLeaveDays(Employee $employee, int $year, ?LeavePolicy $policy): float
{
$defaultDays = $policy->default_annual_leave ?? 15;
$maxDays = $policy->max_annual_leave ?? 25;
$hireDate = $employee->hire_date;
if (! $hireDate) {
return (float) $defaultDays;
}
$hire = Carbon::parse($hireDate);
// 기준일: 현재연도면 오늘, 과거연도면 연말
$referenceDate = $year === (int) now()->year
? today()
: Carbon::create($year, 12, 31);
// 퇴사자: 기준일을 퇴사일로 제한
$resignDate = $employee->resign_date;
if ($resignDate) {
$resign = Carbon::parse($resignDate);
if ($resign->lessThan($referenceDate)) {
$referenceDate = $resign;
}
}
// 아직 입사 전이면 0일
if ($hire->greaterThan($referenceDate)) {
return 0;
}
// 입사일~기준일 완료 월수
$totalMonthsWorked = (int) $hire->diffInMonths($referenceDate);
// 1년 미만: 완료된 월수 × 1일 (최대 11일)
if ($totalMonthsWorked < 12) {
return (float) min($totalMonthsWorked, 11);
}
// 1년 이상: 15일 + 매 2년마다 +1일
$yearsWorked = (int) $hire->diffInYears($referenceDate);
$additionalDays = (int) floor(($yearsWorked - 1) / 2);
$totalDays = $defaultDays + $additionalDays;
return (float) min($totalDays, $maxDays);
}
/**
* 개별 사원 잔여연차
*/
public function getUserBalance(int $userId, ?int $year = null): ?LeaveBalance
{
$tenantId = session('selected_tenant_id');
$year = $year ?? now()->year;
return LeaveBalance::query()
->forTenant($tenantId)
->forUser($userId)
->forYear($year)
->first();
}
/**
* 유형별/사원별 사용 통계
*/
public function getUsageStats(?int $year = null): array
{
$tenantId = session('selected_tenant_id');
$year = $year ?? now()->year;
// 유형별 집계
$byType = Leave::query()
->forTenant($tenantId)
->forYear($year)
->withStatus('approved')
->select('leave_type', DB::raw('COUNT(*) as count'), DB::raw('SUM(days) as total_days'))
->groupBy('leave_type')
->get()
->keyBy('leave_type');
// 사원별 유형 크로스 테이블
$byUser = Leave::query()
->forTenant($tenantId)
->forYear($year)
->withStatus('approved')
->with([
'user',
'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId),
'user.tenantProfiles.department',
])
->select('user_id', 'leave_type', DB::raw('SUM(days) as total_days'))
->groupBy('user_id', 'leave_type')
->get()
->groupBy('user_id');
return [
'by_type' => $byType,
'by_user' => $byUser,
'year' => $year,
];
}
/**
* 일수 자동 계산 (반차 = 0.5, 연차/기타 = 영업일수)
*/
public function calculateDays(string $type, string $start, string $end): float
{
if (in_array($type, ['half_am', 'half_pm'])) {
return 0.5;
}
$period = CarbonPeriod::create($start, $end);
$businessDays = 0;
foreach ($period as $date) {
if (! $date->isWeekend()) {
$businessDays++;
}
}
return (float) $businessDays;
}
/**
* CSV 내보내기용 데이터
*/
public function getExportData(array $filters = []): Collection
{
$tenantId = session('selected_tenant_id');
$query = Leave::query()
->with([
'user',
'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId),
'user.tenantProfiles.department',
'approver',
])
->forTenant($tenantId);
if (! empty($filters['status'])) {
$query->withStatus($filters['status']);
}
if (! empty($filters['leave_type'])) {
$query->where('leave_type', $filters['leave_type']);
}
if (! empty($filters['date_from']) && ! empty($filters['date_to'])) {
$query->betweenDates($filters['date_from'], $filters['date_to']);
}
return $query->orderBy('start_date', 'desc')->get();
}
/**
* 부서 목록
*/
public function getDepartments(): \Illuminate\Database\Eloquent\Collection
{
$tenantId = session('selected_tenant_id');
return Department::query()
->where('is_active', true)
->when($tenantId, fn ($q) => $q->where('tenant_id', $tenantId))
->orderBy('sort_order')
->orderBy('name')
->get(['id', 'name', 'code']);
}
/**
* 활성 사원 목록 (드롭다운용)
*/
public function getActiveEmployees(): \Illuminate\Database\Eloquent\Collection
{
$tenantId = session('selected_tenant_id');
return \App\Models\HR\Employee::query()
->with('user:id,name')
->forTenant($tenantId)
->activeEmployees()
->orderBy('display_name')
->get(['id', 'user_id', 'display_name', 'department_id']);
}
// =========================================================================
// Private
// =========================================================================
/**
* 승인 기간 영업일마다 Attendance(vacation) 자동 생성
*/
private function createAttendanceRecords(Leave $leave, int $tenantId): void
{
$period = CarbonPeriod::create($leave->start_date, $leave->end_date);
foreach ($period as $date) {
if ($date->isWeekend()) {
continue;
}
Attendance::updateOrCreate(
[
'tenant_id' => $tenantId,
'user_id' => $leave->user_id,
'base_date' => $date->toDateString(),
],
[
'status' => 'vacation',
'remarks' => $leave->reason ? mb_substr($leave->reason, 0, 100) : null,
'updated_by' => auth()->id(),
]
);
}
}
/**
* 취소 해당 기간 vacation Attendance soft delete
*/
private function deleteAttendanceRecords(Leave $leave, int $tenantId): void
{
Attendance::query()
->where('tenant_id', $tenantId)
->where('user_id', $leave->user_id)
->where('status', 'vacation')
->whereBetween('base_date', [
$leave->start_date->toDateString(),
$leave->end_date->toDateString(),
])
->update(['deleted_by' => auth()->id()]);
Attendance::query()
->where('tenant_id', $tenantId)
->where('user_id', $leave->user_id)
->where('status', 'vacation')
->whereBetween('base_date', [
$leave->start_date->toDateString(),
$leave->end_date->toDateString(),
])
->delete();
}
}