From 7b5d98abdc59e1f18fd35c844d41e707ac821a1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Fri, 20 Mar 2026 09:30:11 +0900 Subject: [PATCH] =?UTF-8?q?refactor:[=EA=B7=BC=ED=83=9C]=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EA=B2=B0=EA=B7=BC=20=EC=B2=98=EB=A6=AC=EB=A5=BC=20?= =?UTF-8?q?=EC=88=98=EB=8F=99=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Console/Commands/MarkAbsentEmployees.php | 15 ++- .../Api/Admin/HR/AttendanceController.php | 43 +++++++ app/Services/HR/AttendanceService.php | 85 +++++++------ .../views/hr/attendances/index.blade.php | 114 ++++++++++++++++++ routes/api.php | 1 + routes/console.php | 4 +- 6 files changed, 218 insertions(+), 44 deletions(-) diff --git a/app/Console/Commands/MarkAbsentEmployees.php b/app/Console/Commands/MarkAbsentEmployees.php index 236078de..952fbe09 100644 --- a/app/Console/Commands/MarkAbsentEmployees.php +++ b/app/Console/Commands/MarkAbsentEmployees.php @@ -15,14 +15,19 @@ public function handle(AttendanceService $service): int { $date = $this->option('date') ?: now()->toDateString(); - $this->info("자동 결근 처리 시작: {$date}"); + $this->info("결근 처리 시작: {$date}"); - $count = $service->markAbsentees($date); + $result = $service->markAbsentees($date); - if ($count > 0) { - $this->info("{$count}명 결근 처리 완료"); + if ($result['skipped_weekend']) { + $this->info('주말이므로 건너뜁니다.'); + } elseif ($result['count'] > 0) { + $this->info("{$result['count']}명 결근 처리 완료"); + foreach ($result['names'] as $name) { + $this->line(" - {$name}"); + } } else { - $this->info('결근 처리 대상이 없습니다 (주말이거나 모든 사원에 기록이 있음)'); + $this->info('결근 처리 대상이 없습니다 (모든 사원에 기록이 있음)'); } return self::SUCCESS; diff --git a/app/Http/Controllers/Api/Admin/HR/AttendanceController.php b/app/Http/Controllers/Api/Admin/HR/AttendanceController.php index 09dd9f31..57f604a6 100644 --- a/app/Http/Controllers/Api/Admin/HR/AttendanceController.php +++ b/app/Http/Controllers/Api/Admin/HR/AttendanceController.php @@ -186,6 +186,49 @@ public function export(Request $request): StreamedResponse ]); } + /** + * 수동 결근 처리 (출근 기록 없는 사원) + */ + public function markAbsent(Request $request): JsonResponse + { + $validated = $request->validate([ + 'date' => 'required|date|before_or_equal:today', + ]); + + try { + $result = $this->attendanceService->markAbsentees($validated['date']); + + if ($result['skipped_weekend']) { + return response()->json([ + 'success' => false, + 'message' => '주말은 결근 처리 대상이 아닙니다.', + ]); + } + + if ($result['count'] === 0) { + return response()->json([ + 'success' => true, + 'message' => '결근 처리 대상이 없습니다. 모든 사원에 근태 기록이 있습니다.', + 'data' => $result, + ]); + } + + return response()->json([ + 'success' => true, + 'message' => "{$result['count']}명 결근 처리 완료", + 'data' => $result, + ]); + } catch (\Throwable $e) { + report($e); + + return response()->json([ + 'success' => false, + 'message' => '결근 처리 중 오류가 발생했습니다.', + 'error' => config('app.debug') ? $e->getMessage() : null, + ], 500); + } + } + /** * 일괄 삭제 */ diff --git a/app/Services/HR/AttendanceService.php b/app/Services/HR/AttendanceService.php index 2b72b8b2..f153942f 100644 --- a/app/Services/HR/AttendanceService.php +++ b/app/Services/HR/AttendanceService.php @@ -597,57 +597,68 @@ public function getOvertimeAlerts(): array } /** - * 자동 결근 처리 (영업일에 출근 기록 없는 사원) + * 결근 처리 (출근 기록 없는 사원 대상, 수동 트리거) + * + * @param string $date 대상 날짜 + * @param int|null $tenantId 테넌트 ID (null이면 세션) + * @return array{count: int, skipped_weekend: bool, names: string[]} */ - public function markAbsentees(?string $date = null): int + public function markAbsentees(?string $date = null, ?int $tenantId = null): array { $date = $date ?? now()->toDateString(); $carbonDate = Carbon::parse($date); + $tenantId = $tenantId ?? session('selected_tenant_id'); // 주말이면 스킵 if ($carbonDate->isWeekend()) { - return 0; + return ['count' => 0, 'skipped_weekend' => true, 'names' => []]; } + // 활성 사원 조회 + $activeUserIds = Employee::query() + ->forTenant($tenantId) + ->activeEmployees() + ->pluck('user_id') + ->toArray(); + + if (empty($activeUserIds)) { + return ['count' => 0, 'skipped_weekend' => false, 'names' => []]; + } + + // 제외 대상 (영업팀 + 강제 제외) 제거 + $excludedUserIds = $this->getExcludedUserIds($tenantId); + $activeUserIds = array_diff($activeUserIds, $excludedUserIds); + + // 이미 기록이 있는 사원 제외 + $existingUserIds = Attendance::query() + ->where('tenant_id', $tenantId) + ->whereDate('base_date', $date) + ->pluck('user_id') + ->toArray(); + + $absentUserIds = array_diff($activeUserIds, $existingUserIds); + $count = 0; + $names = []; + $userId = auth()->id(); - // 모든 테넌트의 활성 사원 조회 - $tenantIds = DB::table('tenants')->pluck('id'); + foreach ($absentUserIds as $absentUserId) { + $attendance = Attendance::create([ + 'tenant_id' => $tenantId, + 'user_id' => $absentUserId, + 'base_date' => $date, + 'status' => 'absent', + 'remarks' => '결근 처리 (수동)', + 'created_by' => $userId, + ]); - foreach ($tenantIds as $tenantId) { - $activeUserIds = Employee::query() - ->where('tenant_id', $tenantId) - ->activeEmployees() - ->pluck('user_id') - ->toArray(); - - if (empty($activeUserIds)) { - continue; - } - - // 이미 기록이 있는 사원 제외 - $existingUserIds = Attendance::query() - ->where('tenant_id', $tenantId) - ->whereDate('base_date', $date) - ->pluck('user_id') - ->toArray(); - - $absentUserIds = array_diff($activeUserIds, $existingUserIds); - - foreach ($absentUserIds as $userId) { - Attendance::create([ - 'tenant_id' => $tenantId, - 'user_id' => $userId, - 'base_date' => $date, - 'status' => 'absent', - 'remarks' => '자동 결근 처리', - 'created_by' => null, - ]); - $count++; - } + // 사원명 수집 (결과 표시용) + $profile = $attendance->user?->tenantProfiles?->first(); + $names[] = $profile?->display_name ?? $attendance->user?->name ?? "ID:{$absentUserId}"; + $count++; } - return $count; + return ['count' => $count, 'skipped_weekend' => false, 'names' => $names]; } /** diff --git a/resources/views/hr/attendances/index.blade.php b/resources/views/hr/attendances/index.blade.php index a76b4114..7abcf889 100644 --- a/resources/views/hr/attendances/index.blade.php +++ b/resources/views/hr/attendances/index.blade.php @@ -29,6 +29,13 @@ class="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald- 엑셀 다운로드 + @@ -187,6 +194,43 @@ class="px-4 py-2 text-sm text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg +{{-- 결근 처리 모달 --}} + @endsection @push('scripts') @@ -316,5 +360,75 @@ function openGpsModal(gpsData) { function closeGpsModal() { document.getElementById('gpsModal').classList.add('hidden'); } + + // ===== 결근 처리 모달 ===== + function openMarkAbsentModal() { + document.getElementById('markAbsentDate').value = new Date().toISOString().split('T')[0]; + document.getElementById('markAbsentResult').classList.add('hidden'); + document.getElementById('markAbsentBtn').disabled = false; + document.getElementById('markAbsentModal').classList.remove('hidden'); + } + + function closeMarkAbsentModal() { + document.getElementById('markAbsentModal').classList.add('hidden'); + } + + function executeMarkAbsent() { + const date = document.getElementById('markAbsentDate').value; + if (!date) { + alert('날짜를 선택하세요.'); + return; + } + + if (!confirm(date + ' 날짜의 미기록 사원을 결근 처리하시겠습니까?')) { + return; + } + + const btn = document.getElementById('markAbsentBtn'); + btn.disabled = true; + btn.textContent = '처리 중...'; + + fetch('{{ route("api.admin.hr.attendances.mark-absent") }}', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': '{{ csrf_token() }}', + }, + body: JSON.stringify({ date: date }), + }) + .then(r => r.json()) + .then(data => { + const resultDiv = document.getElementById('markAbsentResult'); + const content = document.getElementById('markAbsentResultContent'); + resultDiv.classList.remove('hidden'); + + if (data.success && data.data?.count > 0) { + content.className = 'text-sm p-3 rounded-lg bg-green-50 text-green-800'; + let html = '

' + data.message + '

'; + if (data.data.names?.length) { + html += ''; + } + content.innerHTML = html; + refreshTable(); + refreshStats(); + } else if (data.success) { + content.className = 'text-sm p-3 rounded-lg bg-blue-50 text-blue-800'; + content.textContent = data.message; + } else { + content.className = 'text-sm p-3 rounded-lg bg-red-50 text-red-800'; + content.textContent = data.message; + } + + btn.disabled = false; + btn.textContent = '결근 처리 실행'; + }) + .catch(() => { + alert('결근 처리 중 오류가 발생했습니다.'); + btn.disabled = false; + btn.textContent = '결근 처리 실행'; + }); + } @endpush diff --git a/routes/api.php b/routes/api.php index f91a8bce..6925b753 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1217,6 +1217,7 @@ Route::get('/overtime-alerts', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'overtimeAlerts'])->name('overtime-alerts'); Route::get('/export', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'export'])->name('export'); Route::get('/leave-balance/{userId}', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'leaveBalance'])->name('leave-balance'); + Route::post('/mark-absent', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'markAbsent'])->name('mark-absent'); Route::post('/bulk-store', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'bulkStore'])->name('bulk-store'); Route::post('/bulk-delete', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'bulkDestroy'])->name('bulk-delete'); Route::get('/', [\App\Http\Controllers\Api\Admin\HR\AttendanceController::class, 'index'])->name('index'); diff --git a/routes/console.php b/routes/console.php index 24ee21b6..237670a2 100644 --- a/routes/console.php +++ b/routes/console.php @@ -8,8 +8,8 @@ $this->comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); -// 매일 23:50 자동 결근 처리 -Schedule::command('attendance:mark-absent')->dailyAt('23:50'); +// 자동 결근 처리 — 스케줄러 비활성화, 근태현황 페이지에서 수동 실행 +// Schedule::command('attendance:mark-absent')->dailyAt('23:50'); // 2시간마다 바로빌 카드 사용내역 자동 동기화 (영업시간 08~22시) Schedule::command('barobill:sync-cards --days=7')