feat: 근태관리/직원관리 API 구현
- AttendanceController, AttendanceService 추가 - EmployeeController, EmployeeService 추가 - Attendance 모델 및 마이그레이션 추가 - TenantUserProfile에 employee_status 컬럼 추가 - DepartmentService 트리 조회 기능 개선 - Swagger 문서 추가 (AttendanceApi, EmployeeApi) - API 라우트 등록
This commit is contained in:
406
app/Services/AttendanceService.php
Normal file
406
app/Services/AttendanceService.php
Normal file
@@ -0,0 +1,406 @@
|
||||
<?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']);
|
||||
|
||||
// 사용자 필터
|
||||
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'])
|
||||
->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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user