feat: [leaves] 휴가신청 → 전자결재 자동 연동

- LeaveService: 휴가 신청 시 결재 자동 생성+상신
- LeaveService: approveByApproval/rejectByApproval 메서드 추가
- LeaveService: deletePendingLeave 시 연결된 결재 자동 취소
- ApprovalService: 승인/반려/회수/전결 시 휴가 상태 자동 동기화
- Leave 모델: approval_id, approval() 관계 추가
- UI: pending 휴가에 결재 상세 링크 추가, 승인/반려 버튼 제거
This commit is contained in:
김보곤
2026-02-28 15:54:41 +09:00
parent 9a69af98f0
commit 50c0c9ce50
5 changed files with 331 additions and 26 deletions

View File

@@ -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
// =========================================================================

View File

@@ -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 스냅샷 보강
*/

View File

@@ -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) 자동 생성
*/

View File

@@ -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()"

View File

@@ -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