- 같은 날 같은 사용자의 기록이 있으면 업데이트, 없으면 생성 - 기존 Create Only 패턴에서 Upsert 패턴으로 변경 - Swagger 문서 업데이트 (409 응답 제거, 설명 변경) Co-Authored-By: Claude <noreply@anthropic.com>
643 lines
21 KiB
PHP
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];
|
|
}
|
|
}
|