feat: [hr] 연차촉진 관리 페이지 추가

- LeavePromotionController: 대상자 목록 조회 + 일괄 통지 발송
- LeaveService: getPromotionCandidates(), sendPromotionNotices() 메서드 추가
- 통지 현황 추적 (미발송/1차 발송/완료)
- 일괄 선택 + 결재 문서 자동 생성 + 상신
This commit is contained in:
김보곤
2026-03-07 00:46:10 +09:00
parent b708f473d1
commit d9be4e2400
4 changed files with 643 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers\HR;
use App\Http\Controllers\Controller;
use App\Services\HR\LeaveService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
class LeavePromotionController extends Controller
{
public function __construct(
private LeaveService $leaveService
) {}
public function index(Request $request): \Illuminate\Contracts\View\View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('hr.leave-promotions.index'));
}
$year = (int) ($request->get('year', now()->year));
$candidates = $this->leaveService->getPromotionCandidates($year);
$stats = [
'total' => $candidates->count(),
'not_sent' => $candidates->where('promotion_status', 'not_sent')->count(),
'first_sent' => $candidates->where('promotion_status', 'first_sent')->count(),
'completed' => $candidates->where('promotion_status', 'completed')->count(),
];
return view('hr.leave-promotions.index', [
'candidates' => $candidates,
'stats' => $stats,
'year' => $year,
]);
}
public function store(Request $request)
{
$request->validate([
'employee_ids' => 'required|array|min:1',
'employee_ids.*' => 'integer',
'notice_type' => 'required|in:1st,2nd',
'deadline' => 'required_if:notice_type,1st|date',
'designated_dates' => 'nullable|array',
'designated_dates.*' => 'date',
]);
$year = (int) ($request->get('year', now()->year));
$noticeType = $request->get('notice_type');
$employeeIds = $request->get('employee_ids');
$result = $this->leaveService->sendPromotionNotices(
employeeIds: $employeeIds,
noticeType: $noticeType,
year: $year,
deadline: $request->get('deadline'),
designatedDates: $request->get('designated_dates', []),
);
return response()->json([
'success' => true,
'message' => count($result['created']).'건의 연차촉진 통지서가 생성되었습니다.',
'created' => $result['created'],
'skipped' => $result['skipped'],
]);
}
}

View File

@@ -803,6 +803,188 @@ public function getActiveEmployees(): \Illuminate\Database\Eloquent\Collection
->get(['id', 'user_id', 'display_name', 'department_id']);
}
// =========================================================================
// 연차촉진 관리
// =========================================================================
/**
* 연차촉진 대상자 조회 — 잔여 연차가 있는 직원 + 통지 발송 현황
*/
public function getPromotionCandidates(int $year): Collection
{
$tenantId = session('selected_tenant_id');
// 1. 연차 현황 조회 (잔여 연차 > 0인 직원)
$balances = $this->getBalanceSummary($year, 'name', 'asc', 'active');
// 2. 기존 발송된 통지서 조회 (approvals 테이블)
$promotionApprovals = Approval::query()
->where('tenant_id', $tenantId)
->whereHas('form', function ($q) {
$q->whereIn('code', ['leave_promotion_1st', 'leave_promotion_2nd']);
})
->with('form:id,code')
->get(['id', 'form_id', 'content', 'status', 'created_at']);
// 3. employee_id 기준으로 발송 현황 매핑
$sentMap = [];
foreach ($promotionApprovals as $approval) {
$employeeId = $approval->content['employee_id'] ?? null;
$noticeYear = $approval->content['year'] ?? null;
if (! $employeeId || $noticeYear != $year) {
continue;
}
$code = $approval->form?->code;
if (! isset($sentMap[$employeeId])) {
$sentMap[$employeeId] = ['1st' => null, '2nd' => null];
}
if ($code === 'leave_promotion_1st') {
$sentMap[$employeeId]['1st'] = $approval;
} elseif ($code === 'leave_promotion_2nd') {
$sentMap[$employeeId]['2nd'] = $approval;
}
}
// 4. 잔여일수 > 0 필터링 + 통지 상태 결합
return $balances
->filter(fn ($b) => ($b->total_days - $b->used_days) > 0)
->map(function ($balance) use ($sentMap) {
$userId = $balance->user_id;
$sent = $sentMap[$userId] ?? ['1st' => null, '2nd' => null];
$balance->first_notice = $sent['1st'];
$balance->second_notice = $sent['2nd'];
$balance->promotion_status = match (true) {
$sent['2nd'] !== null => 'completed',
$sent['1st'] !== null => 'first_sent',
default => 'not_sent',
};
return $balance;
})
->values();
}
/**
* 연차촉진 통지서 일괄 생성 (결재 문서로 생성 + 자동 상신)
*/
public function sendPromotionNotices(array $employeeIds, string $noticeType, int $year, ?string $deadline = null, array $designatedDates = []): array
{
$tenantId = session('selected_tenant_id');
$formCode = $noticeType === '1st' ? 'leave_promotion_1st' : 'leave_promotion_2nd';
$form = ApprovalForm::where('code', $formCode)
->where('tenant_id', $tenantId)
->where('is_active', true)
->first();
if (! $form) {
throw new \RuntimeException('연차촉진 통지서 결재 양식이 등록되지 않았습니다.');
}
// 기본 결재선
$line = ApprovalLine::where('tenant_id', $tenantId)
->where('is_default', true)
->first();
if (! $line) {
throw new \RuntimeException('기본 결재선을 설정해주세요.');
}
// 대상 직원 + 잔여연차 조회
$balances = LeaveBalance::query()
->forTenant($tenantId)
->forYear($year)
->whereIn('user_id', $employeeIds)
->get()
->keyBy('user_id');
$employees = Employee::query()
->with(['user:id,name', 'department:id,name'])
->forTenant($tenantId)
->whereIn('user_id', $employeeIds)
->get()
->keyBy('user_id');
// 이미 발송된 직원 확인
$existingApprovals = Approval::query()
->where('tenant_id', $tenantId)
->where('form_id', $form->id)
->get(['id', 'content'])
->filter(function ($a) use ($year) {
return ($a->content['year'] ?? null) == $year;
})
->pluck('content.employee_id')
->filter()
->toArray();
$approvalService = app(\App\Services\ApprovalService::class);
$steps = collect($line->steps)->map(fn ($s) => [
'user_id' => $s['user_id'],
'step_type' => $s['step_type'] ?? $s['type'] ?? 'approval',
])->toArray();
$created = [];
$skipped = [];
foreach ($employeeIds as $userId) {
// 이미 발송된 직원 스킵
if (in_array($userId, $existingApprovals)) {
$skipped[] = $userId;
continue;
}
$employee = $employees->get($userId);
$balance = $balances->get($userId);
if (! $employee || ! $balance) {
$skipped[] = $userId;
continue;
}
$remaining = $balance->total_days - $balance->used_days;
$empName = $employee->display_name ?? $employee->user?->name ?? '';
$deptName = $employee->department?->name ?? '';
// 통지서 content 구성
$content = [
'employee_id' => $userId,
'employee_name' => $empName,
'department' => $deptName,
'position' => $employee->position_key ?? '',
'year' => $year,
'total_days' => $balance->total_days,
'used_days' => $balance->used_days,
'remaining_days' => $remaining,
];
if ($noticeType === '1st') {
$content['deadline'] = $deadline;
$title = "연차촉진 1차 통지 - {$empName} ({$year}년 잔여 {$remaining}일)";
} else {
$content['designated_dates'] = $designatedDates;
$title = "연차촉진 2차 통지 - {$empName} ({$year}년 잔여 {$remaining}일)";
}
$approval = $approvalService->createApproval([
'form_id' => $form->id,
'line_id' => $line->id,
'title' => $title,
'content' => $content,
'body' => '',
'is_urgent' => false,
'steps' => $steps,
]);
$approvalService->submit($approval->id);
$created[] = $userId;
}
return ['created' => $created, 'skipped' => $skipped];
}
// =========================================================================
// Private
// =========================================================================