432 lines
13 KiB
PHP
432 lines
13 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
namespace App\Services\HR;
|
||
|
|
|
||
|
|
use App\Models\HR\Attendance;
|
||
|
|
use App\Models\HR\Leave;
|
||
|
|
use App\Models\HR\LeaveBalance;
|
||
|
|
use App\Models\Tenants\Department;
|
||
|
|
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']);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 전체 사원 잔여연차 요약
|
||
|
|
*/
|
||
|
|
public function getBalanceSummary(?int $year = null): Collection
|
||
|
|
{
|
||
|
|
$tenantId = session('selected_tenant_id');
|
||
|
|
$year = $year ?? now()->year;
|
||
|
|
|
||
|
|
return LeaveBalance::query()
|
||
|
|
->with([
|
||
|
|
'user',
|
||
|
|
'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId),
|
||
|
|
'user.tenantProfiles.department',
|
||
|
|
])
|
||
|
|
->forTenant($tenantId)
|
||
|
|
->forYear($year)
|
||
|
|
->orderBy('user_id')
|
||
|
|
->get();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 개별 사원 잔여연차
|
||
|
|
*/
|
||
|
|
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();
|
||
|
|
}
|
||
|
|
}
|