Files
sam-manage/app/Services/HR/AttendanceService.php
김보곤 e8d38953d0 feat: [hr] 근태현황 MNG 프론트엔드 구현
- Attendance 모델 (attendances 테이블, 상태/색상 매핑, check_in/check_out accessor)
- AttendanceService (목록/월간통계/CRUD, 부서/사원 드롭다운)
- API 컨트롤러 (HTMX+JSON 이중 응답, stats/index/store/update/destroy)
- 페이지 컨트롤러 (index 페이지 렌더링)
- 웹/API 라우트 등록 (hr/attendances, api/admin/hr/attendances)
- index.blade.php (통계카드+필터+등록/수정 모달)
- partials/table.blade.php (HTMX 부분 로드 테이블)
2026-02-26 19:34:07 +09:00

227 lines
7.0 KiB
PHP

<?php
namespace App\Services\HR;
use App\Models\HR\Attendance;
use App\Models\HR\Employee;
use App\Models\Tenants\Department;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
class AttendanceService
{
/**
* 근태 목록 조회 (페이지네이션)
*/
public function getAttendances(array $filters = [], int $perPage = 20): LengthAwarePaginator
{
$tenantId = session('selected_tenant_id');
$query = Attendance::query()
->with(['user', 'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId)])
->forTenant($tenantId);
// 이름 검색
if (! empty($filters['q'])) {
$search = $filters['q'];
$query->whereHas('user', function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%");
});
}
// 부서 필터
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['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->whereDate('base_date', '>=', $filters['date_from']);
} elseif (! empty($filters['date_to'])) {
$query->whereDate('base_date', '<=', $filters['date_to']);
}
$query->orderBy('base_date', 'desc')
->orderBy('created_at', 'desc');
return $query->paginate($perPage);
}
/**
* 월간 통계 (상태별 카운트)
*/
public function getMonthlyStats(?int $year = null, ?int $month = null): array
{
$tenantId = session('selected_tenant_id');
$year = $year ?? now()->year;
$month = $month ?? now()->month;
$startDate = sprintf('%04d-%02d-01', $year, $month);
$endDate = now()->year == $year && now()->month == $month
? now()->toDateString()
: sprintf('%04d-%02d-%02d', $year, $month, cal_days_in_month(CAL_GREGORIAN, $month, $year));
$counts = Attendance::query()
->forTenant($tenantId)
->betweenDates($startDate, $endDate)
->select('status', DB::raw('COUNT(*) as cnt'))
->groupBy('status')
->pluck('cnt', 'status')
->toArray();
return [
'onTime' => $counts['onTime'] ?? 0,
'late' => $counts['late'] ?? 0,
'absent' => $counts['absent'] ?? 0,
'vacation' => $counts['vacation'] ?? 0,
'etc' => ($counts['businessTrip'] ?? 0) + ($counts['fieldWork'] ?? 0) + ($counts['overtime'] ?? 0) + ($counts['remote'] ?? 0),
'year' => $year,
'month' => $month,
];
}
/**
* 근태 등록 (Upsert: tenant_id + user_id + base_date)
*/
public function storeAttendance(array $data): Attendance
{
$tenantId = session('selected_tenant_id');
return DB::transaction(function () use ($data, $tenantId) {
$jsonDetails = [];
if (! empty($data['check_in'])) {
$jsonDetails['check_in'] = $data['check_in'];
}
if (! empty($data['check_out'])) {
$jsonDetails['check_out'] = $data['check_out'];
}
$attendance = Attendance::updateOrCreate(
[
'tenant_id' => $tenantId,
'user_id' => $data['user_id'],
'base_date' => $data['base_date'],
],
[
'status' => $data['status'] ?? 'onTime',
'json_details' => ! empty($jsonDetails) ? $jsonDetails : null,
'remarks' => $data['remarks'] ?? null,
'created_by' => auth()->id(),
'updated_by' => auth()->id(),
]
);
return $attendance->load('user');
});
}
/**
* 근태 수정
*/
public function updateAttendance(int $id, array $data): ?Attendance
{
$tenantId = session('selected_tenant_id');
$attendance = Attendance::query()
->forTenant($tenantId)
->find($id);
if (! $attendance) {
return null;
}
$updateData = [];
if (array_key_exists('status', $data)) {
$updateData['status'] = $data['status'];
}
if (array_key_exists('remarks', $data)) {
$updateData['remarks'] = $data['remarks'];
}
// json_details 업데이트
$jsonDetails = $attendance->json_details ?? [];
if (array_key_exists('check_in', $data)) {
if ($data['check_in']) {
$jsonDetails['check_in'] = $data['check_in'];
} else {
unset($jsonDetails['check_in']);
}
}
if (array_key_exists('check_out', $data)) {
if ($data['check_out']) {
$jsonDetails['check_out'] = $data['check_out'];
} else {
unset($jsonDetails['check_out']);
}
}
$updateData['json_details'] = ! empty($jsonDetails) ? $jsonDetails : null;
$updateData['updated_by'] = auth()->id();
$attendance->update($updateData);
return $attendance->fresh('user');
}
/**
* 근태 삭제
*/
public function deleteAttendance(int $id): bool
{
$tenantId = session('selected_tenant_id');
$attendance = Attendance::query()
->forTenant($tenantId)
->find($id);
if (! $attendance) {
return false;
}
$attendance->update(['deleted_by' => auth()->id()]);
$attendance->delete();
return true;
}
/**
* 부서 목록 (드롭다운용)
*/
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 Employee::query()
->with('user:id,name')
->forTenant($tenantId)
->activeEmployees()
->orderBy('display_name')
->get(['id', 'user_id', 'display_name', 'department_id']);
}
}