feat: [leaves] 휴가신청 → 전자결재 자동 연동
- LeaveService: 휴가 신청 시 결재 자동 생성+상신 - LeaveService: approveByApproval/rejectByApproval 메서드 추가 - LeaveService: deletePendingLeave 시 연결된 결재 자동 취소 - ApprovalService: 승인/반려/회수/전결 시 휴가 상태 자동 동기화 - Leave 모델: approval_id, approval() 관계 추가 - UI: pending 휴가에 결재 상세 링크 추가, 승인/반려 버튼 제거
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models\HR;
|
||||
|
||||
use App\Models\Approvals\Approval;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
@@ -24,6 +25,7 @@ class Leave extends Model
|
||||
'status',
|
||||
'approved_by',
|
||||
'approved_at',
|
||||
'approval_id',
|
||||
'reject_reason',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
@@ -33,6 +35,7 @@ class Leave extends Model
|
||||
protected $casts = [
|
||||
'tenant_id' => 'int',
|
||||
'user_id' => 'int',
|
||||
'approval_id' => 'int',
|
||||
'approved_by' => 'int',
|
||||
'created_by' => 'int',
|
||||
'updated_by' => 'int',
|
||||
@@ -92,6 +95,11 @@ public function creator(): BelongsTo
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function approval(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Approval::class, 'approval_id');
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Accessor
|
||||
// =========================================================================
|
||||
|
||||
@@ -276,6 +276,9 @@ public function approve(int $id, ?string $comment = null): Approval
|
||||
'completed_at' => now(),
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// 연동 후처리 (휴가 등)
|
||||
$this->handleApprovalCompleted($approval);
|
||||
}
|
||||
|
||||
return $approval->fresh(['form', 'drafter', 'steps.approver']);
|
||||
@@ -315,6 +318,9 @@ public function reject(int $id, string $comment): Approval
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// 연동 후처리 (휴가 등)
|
||||
$this->handleApprovalRejected($approval, $comment);
|
||||
|
||||
return $approval->fresh(['form', 'drafter', 'steps.approver']);
|
||||
});
|
||||
}
|
||||
@@ -358,6 +364,9 @@ public function cancel(int $id, ?string $recallReason = null): Approval
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// 연동 후처리 (휴가 회수)
|
||||
$this->handleApprovalCancelled($approval);
|
||||
|
||||
return $approval->fresh(['form', 'drafter', 'steps.approver']);
|
||||
});
|
||||
}
|
||||
@@ -472,6 +481,9 @@ public function preDecide(int $id, ?string $comment = null): Approval
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// 연동 후처리 (휴가 등)
|
||||
$this->handleApprovalCompleted($approval);
|
||||
|
||||
return $approval->fresh(['form', 'drafter', 'steps.approver']);
|
||||
});
|
||||
}
|
||||
@@ -692,6 +704,58 @@ public function getBadgeCounts(int $userId): array
|
||||
// Private 헬퍼
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 결재 최종 승인 시 연동 처리 (휴가 등)
|
||||
*/
|
||||
private function handleApprovalCompleted(Approval $approval): void
|
||||
{
|
||||
if (! $approval->form || $approval->form->code !== 'leave') {
|
||||
return;
|
||||
}
|
||||
|
||||
$leave = \App\Models\HR\Leave::where('approval_id', $approval->id)->first();
|
||||
if ($leave && $leave->status === 'pending') {
|
||||
app(\App\Services\HR\LeaveService::class)->approveByApproval($leave, $approval);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 반려 시 연동 처리 (휴가 등)
|
||||
*/
|
||||
private function handleApprovalRejected(Approval $approval, string $comment): void
|
||||
{
|
||||
if (! $approval->form || $approval->form->code !== 'leave') {
|
||||
return;
|
||||
}
|
||||
|
||||
$leave = \App\Models\HR\Leave::where('approval_id', $approval->id)->first();
|
||||
if ($leave && $leave->status === 'pending') {
|
||||
app(\App\Services\HR\LeaveService::class)->rejectByApproval(
|
||||
$leave,
|
||||
$comment,
|
||||
auth()->id()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 회수 시 연동 처리 (휴가 등)
|
||||
*/
|
||||
private function handleApprovalCancelled(Approval $approval): void
|
||||
{
|
||||
if (! $approval->form || $approval->form->code !== 'leave') {
|
||||
return;
|
||||
}
|
||||
|
||||
$leave = \App\Models\HR\Leave::where('approval_id', $approval->id)->first();
|
||||
if ($leave && $leave->status === 'pending') {
|
||||
$leave->update([
|
||||
'status' => 'cancelled',
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재선 steps에 user_name, department, position 스냅샷 보강
|
||||
*/
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
|
||||
namespace App\Services\HR;
|
||||
|
||||
use App\Models\Approvals\Approval;
|
||||
use App\Models\Approvals\ApprovalForm;
|
||||
use App\Models\Approvals\ApprovalLine;
|
||||
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 App\Services\ApprovalService;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\CarbonPeriod;
|
||||
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
|
||||
@@ -71,7 +75,7 @@ public function getLeaves(array $filters = [], int $perPage = 20): LengthAwarePa
|
||||
}
|
||||
|
||||
/**
|
||||
* 휴가 신청 등록
|
||||
* 휴가 신청 등록 → 결재 자동 생성 + 상신
|
||||
*/
|
||||
public function storeLeave(array $data): Leave
|
||||
{
|
||||
@@ -91,18 +95,26 @@ public function storeLeave(array $data): Leave
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
]);
|
||||
return DB::transaction(function () use ($data, $tenantId, $days) {
|
||||
$leave = 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(),
|
||||
]);
|
||||
|
||||
// 결재 자동 생성 + 상신
|
||||
$approval = $this->createLeaveApproval($leave, $tenantId);
|
||||
$leave->update(['approval_id' => $approval->id]);
|
||||
|
||||
return $leave;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -177,7 +189,7 @@ public function reject(int $id, ?string $reason = null): ?Leave
|
||||
}
|
||||
|
||||
/**
|
||||
* 취소 → LeaveBalance 복원 → Attendance 삭제
|
||||
* 취소 → LeaveBalance 복원 → Attendance 삭제 → 결재 취소
|
||||
*/
|
||||
public function cancel(int $id): ?Leave
|
||||
{
|
||||
@@ -218,6 +230,98 @@ public function cancel(int $id): ?Leave
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* pending 상태 휴가 삭제 → 연결된 결재 취소
|
||||
*/
|
||||
public function deletePendingLeave(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) {
|
||||
// 연결된 결재가 있고 아직 진행 중이면 취소
|
||||
if ($leave->approval_id) {
|
||||
$approval = Approval::find($leave->approval_id);
|
||||
if ($approval && in_array($approval->status, ['draft', 'pending'])) {
|
||||
try {
|
||||
app(ApprovalService::class)->cancel($approval->id);
|
||||
} catch (\Throwable $e) {
|
||||
// 결재 취소 실패해도 휴가 삭제는 진행
|
||||
report($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$leave->update(['deleted_by' => auth()->id()]);
|
||||
$leave->delete();
|
||||
|
||||
return $leave;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 승인에 의한 휴가 자동 승인
|
||||
*/
|
||||
public function approveByApproval(Leave $leave, Approval $approval): Leave
|
||||
{
|
||||
$tenantId = $leave->tenant_id;
|
||||
|
||||
// 최종 결재자 ID 찾기
|
||||
$lastApprover = $approval->steps
|
||||
->where('status', 'approved')
|
||||
->sortByDesc('step_order')
|
||||
->first();
|
||||
|
||||
$leave->update([
|
||||
'status' => 'approved',
|
||||
'approved_by' => $lastApprover?->approver_id ?? auth()->id(),
|
||||
'approved_at' => now(),
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
// 연차 차감
|
||||
if ($leave->is_deductible) {
|
||||
$balance = LeaveBalance::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('user_id', $leave->user_id)
|
||||
->where('year', $leave->start_date->year)
|
||||
->first();
|
||||
|
||||
if ($balance) {
|
||||
$balance->useLeave($leave->days);
|
||||
}
|
||||
}
|
||||
|
||||
// Attendance 생성
|
||||
$this->createAttendanceRecords($leave, $tenantId);
|
||||
|
||||
return $leave->fresh(['user', 'approver']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 반려에 의한 휴가 자동 반려
|
||||
*/
|
||||
public function rejectByApproval(Leave $leave, string $comment, int $rejecterId): Leave
|
||||
{
|
||||
$leave->update([
|
||||
'status' => 'rejected',
|
||||
'reject_reason' => $comment,
|
||||
'approved_by' => $rejecterId,
|
||||
'approved_at' => now(),
|
||||
'updated_by' => auth()->id(),
|
||||
]);
|
||||
|
||||
return $leave->fresh(['user', 'approver']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 사원 잔여연차 요약
|
||||
*
|
||||
@@ -525,6 +629,127 @@ public function getActiveEmployees(): \Illuminate\Database\Eloquent\Collection
|
||||
// Private
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* 휴가신청 결재 자동 생성 + 상신
|
||||
*/
|
||||
private function createLeaveApproval(Leave $leave, int $tenantId): Approval
|
||||
{
|
||||
$approvalService = app(ApprovalService::class);
|
||||
|
||||
// 1. 휴가신청 양식 조회
|
||||
$form = ApprovalForm::where('code', 'leave')
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('is_active', true)
|
||||
->first();
|
||||
|
||||
if (! $form) {
|
||||
throw new \RuntimeException('휴가신청 결재 양식이 등록되지 않았습니다.');
|
||||
}
|
||||
|
||||
// 2. 기본결재선 조회
|
||||
$defaultLine = ApprovalLine::where('tenant_id', $tenantId)
|
||||
->where('is_default', true)
|
||||
->first();
|
||||
|
||||
if (! $defaultLine) {
|
||||
throw new \RuntimeException('기본결재선을 먼저 설정해주세요.');
|
||||
}
|
||||
|
||||
// 3. 결재 본문 생성
|
||||
$body = $this->buildLeaveApprovalBody($leave, $tenantId);
|
||||
|
||||
// 4. steps 변환
|
||||
$steps = collect($defaultLine->steps)->map(fn ($s) => [
|
||||
'user_id' => $s['user_id'],
|
||||
'step_type' => $s['step_type'] ?? $s['type'] ?? 'approval',
|
||||
])->toArray();
|
||||
|
||||
// 5. 결재 생성
|
||||
$typeName = Leave::TYPE_MAP[$leave->leave_type] ?? $leave->leave_type;
|
||||
$userName = $leave->user->name ?? '';
|
||||
$period = $leave->start_date->format('n/j').'~'.$leave->end_date->format('n/j');
|
||||
|
||||
$approval = $approvalService->createApproval([
|
||||
'form_id' => $form->id,
|
||||
'line_id' => $defaultLine->id,
|
||||
'title' => "휴가신청 - {$userName} ({$typeName} {$period})",
|
||||
'body' => $body,
|
||||
'content' => [
|
||||
'leave_id' => $leave->id,
|
||||
'user_name' => $userName,
|
||||
'leave_type' => $typeName,
|
||||
'start_date' => $leave->start_date->toDateString(),
|
||||
'end_date' => $leave->end_date->toDateString(),
|
||||
'days' => $leave->days,
|
||||
'reason' => $leave->reason,
|
||||
],
|
||||
'is_urgent' => false,
|
||||
'steps' => $steps,
|
||||
]);
|
||||
|
||||
// 6. 자동 상신
|
||||
$approvalService->submit($approval->id);
|
||||
|
||||
return $approval->fresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* 결재 본문 HTML 생성
|
||||
*/
|
||||
private function buildLeaveApprovalBody(Leave $leave, int $tenantId): string
|
||||
{
|
||||
$user = $leave->user;
|
||||
$typeName = Leave::TYPE_MAP[$leave->leave_type] ?? $leave->leave_type;
|
||||
$period = $leave->start_date->format('Y-m-d').' ~ '.$leave->end_date->format('Y-m-d');
|
||||
$daysStr = ($leave->days == (int) $leave->days) ? (int) $leave->days.'일' : $leave->days.'일';
|
||||
|
||||
// 잔여연차 정보
|
||||
$balanceInfo = '';
|
||||
if (in_array($leave->leave_type, Leave::DEDUCTIBLE_TYPES)) {
|
||||
$balance = LeaveBalance::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('user_id', $leave->user_id)
|
||||
->where('year', now()->year)
|
||||
->first();
|
||||
|
||||
if ($balance) {
|
||||
$balanceInfo = $balance->remaining.'일'
|
||||
.' (부여: '.$balance->total_days.' / 사용: '.$balance->used_days.')';
|
||||
}
|
||||
}
|
||||
|
||||
// 부서 정보
|
||||
$profile = $user?->tenantProfiles?->where('tenant_id', $tenantId)->first();
|
||||
$deptName = $profile?->department?->name ?? '';
|
||||
|
||||
// HTML 테이블 본문
|
||||
$rows = [
|
||||
['신청자', e($user->name ?? '')],
|
||||
];
|
||||
if ($deptName) {
|
||||
$rows[] = ['부서', e($deptName)];
|
||||
}
|
||||
$rows[] = ['휴가유형', $typeName];
|
||||
$rows[] = ['기간', $period.' ('.$daysStr.')'];
|
||||
if ($leave->reason) {
|
||||
$rows[] = ['사유', e($leave->reason)];
|
||||
}
|
||||
if ($balanceInfo) {
|
||||
$rows[] = ['잔여연차', $balanceInfo];
|
||||
}
|
||||
|
||||
$html = '<p>아래와 같이 휴가를 신청합니다.</p>';
|
||||
$html .= '<table style="border-collapse:collapse; width:100%; margin-top:12px; font-size:14px;">';
|
||||
$thStyle = 'style="padding:8px 12px; background:#f8f9fa; border:1px solid #dee2e6; text-align:left; width:120px; font-weight:600;"';
|
||||
$tdStyle = 'style="padding:8px 12px; border:1px solid #dee2e6;"';
|
||||
foreach ($rows as [$label, $value]) {
|
||||
$html .= "<tr><th {$thStyle}>{$label}</th><td {$tdStyle}>{$value}</td></tr>";
|
||||
}
|
||||
$html .= '</table>';
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/**
|
||||
* 승인 시 기간 내 영업일마다 Attendance(vacation) 자동 생성
|
||||
*/
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-4 mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">휴가관리</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">휴가 신청, 승인/반려 및 연차 현황을 관리합니다.</p>
|
||||
<p class="text-sm text-gray-500 mt-1">휴가 신청 시 결재가 자동 생성되며, 결재 승인/반려에 따라 휴가가 처리됩니다.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<button type="button" onclick="openLeaveModal()"
|
||||
|
||||
@@ -91,8 +91,21 @@
|
||||
|
||||
{{-- 액션 --}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-center">
|
||||
@if($leave->status === 'pending')
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
@if($leave->approval_id)
|
||||
<a href="{{ route('approvals.show', $leave->approval_id) }}"
|
||||
class="px-2.5 py-1 text-xs font-medium text-blue-700 bg-blue-50 hover:bg-blue-100 rounded transition-colors">
|
||||
결재 상세 →
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@if($leave->status === 'approved')
|
||||
<button type="button" onclick="cancelLeave({{ $leave->id }})"
|
||||
class="px-2.5 py-1 text-xs font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded transition-colors">
|
||||
취소
|
||||
</button>
|
||||
@elseif($leave->status === 'pending' && !$leave->approval_id)
|
||||
{{-- 결재 연동 없는 기존 pending 건만 직접 승인/반려 허용 --}}
|
||||
<button type="button" onclick="approveLeave({{ $leave->id }})"
|
||||
class="px-2.5 py-1 text-xs font-medium text-emerald-700 bg-emerald-50 hover:bg-emerald-100 rounded transition-colors">
|
||||
승인
|
||||
@@ -101,15 +114,10 @@ class="px-2.5 py-1 text-xs font-medium text-emerald-700 bg-emerald-50 hover:bg-e
|
||||
class="px-2.5 py-1 text-xs font-medium text-red-700 bg-red-50 hover:bg-red-100 rounded transition-colors">
|
||||
반려
|
||||
</button>
|
||||
</div>
|
||||
@elseif($leave->status === 'approved')
|
||||
<button type="button" onclick="cancelLeave({{ $leave->id }})"
|
||||
class="px-2.5 py-1 text-xs font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded transition-colors">
|
||||
취소
|
||||
</button>
|
||||
@else
|
||||
<span class="text-xs text-gray-400">-</span>
|
||||
@endif
|
||||
@elseif(!$leave->approval_id && $leave->status !== 'pending' && $leave->status !== 'approved')
|
||||
<span class="text-xs text-gray-400">-</span>
|
||||
@endif
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
|
||||
Reference in New Issue
Block a user