Files
sam-api/app/Services/AttendanceService.php
kent 0fef26f42a feat: HR 모델 userProfile 관계 추가 및 서비스 개선
## 모델 개선
- Leave: userProfile relation 추가
- Salary: userProfile relation 추가
- TenantUserProfile: department, position 관계 및 label accessor 추가

## 서비스 개선
- LeaveService: userProfile eager loading 추가
- SalaryService: 사원 정보 조회 개선
- CardService: 관계 정리 및 개선
- AttendanceService: 조회 기능 개선

## 시더
- DummySalarySeeder 추가
- DummyCardSeeder 멀티테넌트 지원 개선
- DummyDataSeeder에 급여 시더 등록

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-26 01:32:07 +09:00

419 lines
13 KiB
PHP

<?php
namespace App\Services;
use App\Models\Tenants\Attendance;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\DB;
class AttendanceService extends Service
{
/**
* 근태 목록 조회
*/
public function index(array $params): LengthAwarePaginator
{
$tenantId = $this->tenantId();
$query = Attendance::query()
->where('tenant_id', $tenantId)
->with([
'user:id,name,email',
'user.tenantProfiles' => function ($q) use ($tenantId) {
$q->where('tenant_id', $tenantId)
->with('department:id,name');
},
]);
// 사용자 필터
if (! empty($params['user_id'])) {
$query->where('user_id', $params['user_id']);
}
// 날짜 필터 (단일)
if (! empty($params['date'])) {
$query->whereDate('base_date', $params['date']);
}
// 날짜 범위 필터
if (! empty($params['date_from'])) {
$query->whereDate('base_date', '>=', $params['date_from']);
}
if (! empty($params['date_to'])) {
$query->whereDate('base_date', '<=', $params['date_to']);
}
// 상태 필터
if (! empty($params['status'])) {
$query->where('status', $params['status']);
}
// 부서 필터 (사용자의 부서)
if (! empty($params['department_id'])) {
$query->whereHas('user.tenantProfile', function ($q) use ($params) {
$q->where('department_id', $params['department_id']);
});
}
// 정렬
$sortBy = $params['sort_by'] ?? 'base_date';
$sortDir = $params['sort_dir'] ?? 'desc';
$query->orderBy($sortBy, $sortDir);
// 페이지네이션
$perPage = $params['per_page'] ?? 20;
return $query->paginate($perPage);
}
/**
* 근태 상세 조회
*/
public function show(int $id): Attendance
{
$tenantId = $this->tenantId();
$attendance = Attendance::query()
->where('tenant_id', $tenantId)
->with([
'user:id,name,email',
'user.tenantProfiles' => function ($q) use ($tenantId) {
$q->where('tenant_id', $tenantId)
->with('department:id,name');
},
])
->findOrFail($id);
return $attendance;
}
/**
* 근태 등록
*/
public function store(array $data): Attendance
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($data, $tenantId, $userId) {
// 기존 기록 확인 (같은 날 같은 사용자)
$existing = Attendance::query()
->where('tenant_id', $tenantId)
->where('user_id', $data['user_id'])
->whereDate('base_date', $data['base_date'])
->first();
if ($existing) {
throw new \Exception(__('error.attendance.already_exists'));
}
// json_details 구성
// json_details 객체가 직접 전달된 경우 그대로 사용, 아니면 개별 필드에서 구성
$jsonDetails = isset($data['json_details']) && is_array($data['json_details'])
? $data['json_details']
: $this->buildJsonDetails($data);
$attendance = Attendance::create([
'tenant_id' => $tenantId,
'user_id' => $data['user_id'],
'base_date' => $data['base_date'],
'status' => $data['status'] ?? 'onTime',
'json_details' => $jsonDetails,
'remarks' => $data['remarks'] ?? null,
'created_by' => $userId,
'updated_by' => $userId,
]);
return $attendance->fresh(['user:id,name,email']);
});
}
/**
* 근태 수정
*/
public function update(int $id, array $data): Attendance
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
return DB::transaction(function () use ($id, $data, $tenantId, $userId) {
$attendance = Attendance::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
// 기본 필드 업데이트
if (isset($data['status'])) {
$attendance->status = $data['status'];
}
if (array_key_exists('remarks', $data)) {
$attendance->remarks = $data['remarks'];
}
// json_details 업데이트
$jsonDetails = $attendance->json_details ?? [];
// json_details 객체가 직접 전달된 경우 그대로 병합, 아니면 개별 필드에서 구성
$detailsUpdate = isset($data['json_details']) && is_array($data['json_details'])
? $data['json_details']
: $this->buildJsonDetails($data);
$attendance->json_details = array_merge($jsonDetails, $detailsUpdate);
$attendance->updated_by = $userId;
$attendance->save();
return $attendance->fresh(['user:id,name,email']);
});
}
/**
* 근태 삭제
*/
public function destroy(int $id): bool
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$attendance = Attendance::query()
->where('tenant_id', $tenantId)
->findOrFail($id);
$attendance->deleted_by = $userId;
$attendance->save();
$attendance->delete();
return true;
}
/**
* 일괄 삭제
*/
public function bulkDelete(array $ids): array
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$deletedCount = 0;
DB::transaction(function () use ($ids, $tenantId, $userId, &$deletedCount) {
$attendances = Attendance::query()
->where('tenant_id', $tenantId)
->whereIn('id', $ids)
->get();
foreach ($attendances as $attendance) {
$attendance->deleted_by = $userId;
$attendance->save();
$attendance->delete();
$deletedCount++;
}
});
return ['deleted_count' => $deletedCount];
}
/**
* 출근 기록 (체크인)
*/
public function checkIn(array $data): Attendance
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$targetUserId = $data['user_id'] ?? $userId;
$today = now()->toDateString();
return DB::transaction(function () use ($data, $tenantId, $userId, $targetUserId, $today) {
// 오늘 기록 확인
$attendance = Attendance::query()
->where('tenant_id', $tenantId)
->where('user_id', $targetUserId)
->whereDate('base_date', $today)
->first();
$checkInTime = $data['check_in'] ?? now()->format('H:i:s');
$gpsData = $data['gps_data'] ?? null;
if ($attendance) {
// 기존 기록 업데이트
$jsonDetails = $attendance->json_details ?? [];
$jsonDetails['check_in'] = $checkInTime;
if ($gpsData) {
$jsonDetails['gps_data'] = array_merge(
$jsonDetails['gps_data'] ?? [],
['check_in' => $gpsData]
);
}
$attendance->json_details = $jsonDetails;
$attendance->updated_by = $userId;
$attendance->save();
} else {
// 새 기록 생성
$jsonDetails = ['check_in' => $checkInTime];
if ($gpsData) {
$jsonDetails['gps_data'] = ['check_in' => $gpsData];
}
$attendance = Attendance::create([
'tenant_id' => $tenantId,
'user_id' => $targetUserId,
'base_date' => $today,
'status' => $this->determineStatus($checkInTime, null),
'json_details' => $jsonDetails,
'created_by' => $userId,
'updated_by' => $userId,
]);
}
return $attendance->fresh(['user:id,name,email']);
});
}
/**
* 퇴근 기록 (체크아웃)
*/
public function checkOut(array $data): Attendance
{
$tenantId = $this->tenantId();
$userId = $this->apiUserId();
$targetUserId = $data['user_id'] ?? $userId;
$today = now()->toDateString();
return DB::transaction(function () use ($data, $tenantId, $userId, $targetUserId, $today) {
$attendance = Attendance::query()
->where('tenant_id', $tenantId)
->where('user_id', $targetUserId)
->whereDate('base_date', $today)
->first();
if (! $attendance) {
throw new \Exception(__('error.attendance.no_check_in'));
}
$checkOutTime = $data['check_out'] ?? now()->format('H:i:s');
$gpsData = $data['gps_data'] ?? null;
$jsonDetails = $attendance->json_details ?? [];
$jsonDetails['check_out'] = $checkOutTime;
// 근무 시간 계산
if (isset($jsonDetails['check_in'])) {
$checkIn = \Carbon\Carbon::createFromFormat('H:i:s', $jsonDetails['check_in']);
$checkOut = \Carbon\Carbon::createFromFormat('H:i:s', $checkOutTime);
$jsonDetails['work_minutes'] = $checkOut->diffInMinutes($checkIn);
}
if ($gpsData) {
$jsonDetails['gps_data'] = array_merge(
$jsonDetails['gps_data'] ?? [],
['check_out' => $gpsData]
);
}
$attendance->json_details = $jsonDetails;
$attendance->status = $this->determineStatus(
$jsonDetails['check_in'] ?? null,
$checkOutTime
);
$attendance->updated_by = $userId;
$attendance->save();
return $attendance->fresh(['user:id,name,email']);
});
}
/**
* 월간 통계 조회
*/
public function monthlyStats(array $params): array
{
$tenantId = $this->tenantId();
$year = $params['year'] ?? now()->year;
$month = $params['month'] ?? now()->month;
$userId = $params['user_id'] ?? null;
$startDate = \Carbon\Carbon::create($year, $month, 1)->startOfMonth();
$endDate = $startDate->copy()->endOfMonth();
$query = Attendance::query()
->where('tenant_id', $tenantId)
->whereBetween('base_date', [$startDate, $endDate]);
if ($userId) {
$query->where('user_id', $userId);
}
$attendances = $query->get();
$stats = [
'year' => $year,
'month' => $month,
'total_days' => $attendances->count(),
'by_status' => [
'onTime' => $attendances->where('status', 'onTime')->count(),
'late' => $attendances->where('status', 'late')->count(),
'absent' => $attendances->where('status', 'absent')->count(),
'vacation' => $attendances->where('status', 'vacation')->count(),
'businessTrip' => $attendances->where('status', 'businessTrip')->count(),
'fieldWork' => $attendances->where('status', 'fieldWork')->count(),
'overtime' => $attendances->where('status', 'overtime')->count(),
'remote' => $attendances->where('status', 'remote')->count(),
],
'total_work_minutes' => $attendances->sum(function ($a) {
return $a->json_details['work_minutes'] ?? 0;
}),
'total_overtime_minutes' => $attendances->sum(function ($a) {
return $a->json_details['overtime_minutes'] ?? 0;
}),
];
return $stats;
}
/**
* json_details 구성
*/
private function buildJsonDetails(array $data): array
{
$details = [];
$detailKeys = [
'check_in',
'check_out',
'gps_data',
'external_work',
'multiple_entries',
'work_minutes',
'overtime_minutes',
'late_minutes',
'early_leave_minutes',
'vacation_type',
];
foreach ($detailKeys as $key) {
if (isset($data[$key])) {
$details[$key] = $data[$key];
}
}
return $details;
}
/**
* 상태 자동 결정
* 실제 업무에서는 회사별 출근 시간 설정을 참조해야 함
*/
private function determineStatus(?string $checkIn, ?string $checkOut): string
{
if (! $checkIn) {
return 'absent';
}
// 기본 출근 시간: 09:00
$standardCheckIn = '09:00:00';
if ($checkIn > $standardCheckIn) {
return 'late';
}
return 'onTime';
}
}