Files
sam-api/app/Services/AttendanceService.php
권혁성 94612e3b50 refactor: Attendance store() 메서드를 Upsert 패턴으로 변경
- 같은 날 같은 사용자의 기록이 있으면 업데이트, 없으면 생성
- 기존 Create Only 패턴에서 Upsert 패턴으로 변경
- Swagger 문서 업데이트 (409 응답 제거, 설명 변경)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-28 21:09:53 +09:00

643 lines
21 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;
}
/**
* 근태 등록 (Upsert)
* - 같은 날 같은 사용자의 기록이 있으면 업데이트
* - 없으면 새로 생성
*/
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();
// json_details 구성
$jsonDetails = isset($data['json_details']) && is_array($data['json_details'])
? $data['json_details']
: $this->buildJsonDetails($data);
if ($existing) {
// 기존 기록 업데이트 (Upsert)
$existing->status = $data['status'] ?? $existing->status;
$existing->json_details = array_merge($existing->json_details ?? [], $jsonDetails);
if (array_key_exists('remarks', $data)) {
$existing->remarks = $data['remarks'];
}
$existing->updated_by = $userId;
$existing->save();
return $existing->fresh(['user:id,name,email']);
}
// 새 기록 생성
$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];
}
/**
* 출근 기록 (체크인)
* - 모든 출근 기록을 check_ins 배열에 히스토리로 저장
* - check_in accessor는 가장 빠른 출근 시간 반환
*/
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;
// 출근 엔트리 생성
$entry = [
'time' => $checkInTime,
'recorded_at' => now()->toIso8601String(),
];
if ($gpsData) {
$entry['gps'] = $gpsData;
}
if ($attendance) {
// 기존 기록에 출근 추가
$jsonDetails = $attendance->json_details ?? [];
$checkIns = $jsonDetails['check_ins'] ?? [];
$checkIns[] = $entry;
$jsonDetails['check_ins'] = $checkIns;
$attendance->json_details = $jsonDetails;
$attendance->status = $this->determineStatusFromEntries($jsonDetails);
$attendance->updated_by = $userId;
$attendance->save();
} else {
// 새 기록 생성
$jsonDetails = [
'check_ins' => [$entry],
'check_outs' => [],
];
$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']);
});
}
/**
* 퇴근 기록 (체크아웃)
* - 모든 퇴근 기록을 check_outs 배열에 히스토리로 저장
* - check_out accessor는 가장 늦은 퇴근 시간 반환
*/
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;
// 퇴근 엔트리 생성
$entry = [
'time' => $checkOutTime,
'recorded_at' => now()->toIso8601String(),
];
if ($gpsData) {
$entry['gps'] = $gpsData;
}
$jsonDetails = $attendance->json_details ?? [];
$checkOuts = $jsonDetails['check_outs'] ?? [];
$checkOuts[] = $entry;
$jsonDetails['check_outs'] = $checkOuts;
// 근무 시간 계산 (가장 빠른 출근 ~ 가장 늦은 퇴근)
$checkIns = $jsonDetails['check_ins'] ?? [];
if (! empty($checkIns)) {
$earliestIn = $this->getEarliestTime($checkIns);
$latestOut = $this->getLatestTime($checkOuts);
if ($earliestIn && $latestOut) {
$checkInCarbon = \Carbon\Carbon::createFromFormat('H:i:s', $earliestIn);
$checkOutCarbon = \Carbon\Carbon::createFromFormat('H:i:s', $latestOut);
$totalMinutes = $checkOutCarbon->diffInMinutes($checkInCarbon);
// 휴게시간 계산 (근무 설정에서 조회)
$workSettingService = app(WorkSettingService::class);
$workSetting = $workSettingService->getWorkSetting();
$breakMinutes = 0;
// 설정된 휴게시간이 있으면 적용
if ($workSetting->break_start && $workSetting->break_end) {
$breakStart = \Carbon\Carbon::createFromFormat('H:i:s', $workSetting->break_start);
$breakEnd = \Carbon\Carbon::createFromFormat('H:i:s', $workSetting->break_end);
// 출근~퇴근 시간이 휴게시간을 포함하면 휴게시간 적용
if ($checkInCarbon->lte($breakStart) && $checkOutCarbon->gte($breakEnd)) {
$breakMinutes = $breakEnd->diffInMinutes($breakStart);
$jsonDetails['break_minutes'] = $breakMinutes;
}
}
// 실제 근무시간 = 총 시간 - 휴게시간
$jsonDetails['work_minutes'] = $totalMinutes - $breakMinutes;
}
}
$attendance->json_details = $jsonDetails;
$attendance->status = $this->determineStatusFromEntries($jsonDetails);
$attendance->updated_by = $userId;
$attendance->save();
return $attendance->fresh(['user:id,name,email']);
});
}
/**
* 엑셀 내보내기용 데이터 조회
*
* @return array{data: array<int, array<string, mixed>>, headings: array<int, string>}
*/
public function getExportData(array $params): array
{
$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);
$attendances = $query->get();
// 상태 레이블 매핑
$statusLabels = [
'onTime' => '정상출근',
'late' => '지각',
'absent' => '결근',
'vacation' => '휴가',
'businessTrip' => '출장',
'fieldWork' => '외근',
'overtime' => '야근',
'remote' => '재택',
];
// 엑셀 데이터 변환
$data = $attendances->map(function ($attendance) use ($statusLabels) {
$profile = $attendance->user?->tenantProfiles?->first();
$jsonDetails = $attendance->json_details ?? [];
return [
$attendance->base_date,
$attendance->user?->name ?? '-',
$profile?->department?->name ?? '-',
$statusLabels[$attendance->status] ?? $attendance->status,
$attendance->check_in ?? '-',
$attendance->check_out ?? '-',
isset($jsonDetails['work_minutes']) ? round($jsonDetails['work_minutes'] / 60, 1) : '-',
$attendance->remarks ?? '',
];
})->toArray();
$headings = [
'날짜',
'직원명',
'부서',
'상태',
'출근시간',
'퇴근시간',
'근무시간(h)',
'비고',
];
return [
'data' => $data,
'headings' => $headings,
];
}
/**
* 월간 통계 조회
*/
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';
}
// 근무 설정에서 출근 시간 조회
$workSettingService = app(WorkSettingService::class);
$workSetting = $workSettingService->getWorkSetting();
// 출근 시간 설정이 없으면 지각 판정 안함
if (! $workSetting->start_time) {
return 'onTime';
}
if ($checkIn > $workSetting->start_time) {
return 'late';
}
return 'onTime';
}
/**
* 엔트리 기반 상태 결정
* check_ins 배열에서 가장 빠른 시간을 기준으로 상태 판단
*/
private function determineStatusFromEntries(array $jsonDetails): string
{
$checkIns = $jsonDetails['check_ins'] ?? [];
$checkOuts = $jsonDetails['check_outs'] ?? [];
if (empty($checkIns)) {
return 'absent';
}
$earliestIn = $this->getEarliestTime($checkIns);
$latestOut = ! empty($checkOuts) ? $this->getLatestTime($checkOuts) : null;
return $this->determineStatus($earliestIn, $latestOut);
}
/**
* 엔트리 배열에서 가장 빠른 시간 추출
*/
private function getEarliestTime(array $entries): ?string
{
if (empty($entries)) {
return null;
}
$times = array_map(function ($entry) {
return $entry['time'] ?? null;
}, $entries);
$times = array_filter($times);
if (empty($times)) {
return null;
}
sort($times);
return $times[0];
}
/**
* 엔트리 배열에서 가장 늦은 시간 추출
*/
private function getLatestTime(array $entries): ?string
{
if (empty($entries)) {
return null;
}
$times = array_map(function ($entry) {
return $entry['time'] ?? null;
}, $entries);
$times = array_filter($times);
if (empty($times)) {
return null;
}
rsort($times);
return $times[0];
}
}