Files
sam-manage/app/Services/HR/AttendanceService.php

561 lines
18 KiB
PHP
Raw Normal View History

<?php
namespace App\Services\HR;
use App\Models\HR\Attendance;
use App\Models\HR\Employee;
use App\Models\HR\LeaveBalance;
use App\Models\Tenants\Department;
use Carbon\Carbon;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
class AttendanceService
{
/**
* 필터 적용 쿼리 생성 (목록/엑셀 공통)
*/
private function buildFilteredQuery(array $filters = [])
{
$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']);
}
return $query->orderBy('base_date', 'desc')->orderBy('created_at', 'desc');
}
/**
* 근태 목록 조회 (페이지네이션)
*/
public function getAttendances(array $filters = [], int $perPage = 20): LengthAwarePaginator
{
return $this->buildFilteredQuery($filters)->paginate($perPage);
}
/**
* 엑셀 내보내기용 데이터 (전체)
*/
public function getExportData(array $filters = []): Collection
{
return $this->buildFilteredQuery($filters)->get();
}
/**
* 월간 통계 (상태별 카운트)
*/
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'];
}
// 근무 시간 자동 계산
if (! empty($data['check_in']) && ! empty($data['check_out'])) {
$in = Carbon::createFromFormat('H:i', $data['check_in']);
$out = Carbon::createFromFormat('H:i', $data['check_out']);
if ($out->gt($in)) {
$jsonDetails['work_minutes'] = $out->diffInMinutes($in);
}
}
$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,
'updated_by' => auth()->id(),
]
);
if ($attendance->wasRecentlyCreated) {
$attendance->update(['created_by' => auth()->id()]);
}
// 휴가 상태이면 연차 차감
if (($data['status'] ?? '') === 'vacation') {
$this->deductLeaveBalance($tenantId, $data['user_id']);
}
return $attendance->load('user');
});
}
/**
* 일괄 등록
*/
public function bulkStore(array $data): array
{
$tenantId = session('selected_tenant_id');
$created = 0;
$updated = 0;
DB::transaction(function () use ($data, $tenantId, &$created, &$updated) {
$jsonDetails = [];
if (! empty($data['check_in'])) {
$jsonDetails['check_in'] = $data['check_in'];
}
if (! empty($data['check_out'])) {
$jsonDetails['check_out'] = $data['check_out'];
}
if (! empty($data['check_in']) && ! empty($data['check_out'])) {
$in = Carbon::createFromFormat('H:i', $data['check_in']);
$out = Carbon::createFromFormat('H:i', $data['check_out']);
if ($out->gt($in)) {
$jsonDetails['work_minutes'] = $out->diffInMinutes($in);
}
}
foreach ($data['user_ids'] as $userId) {
$attendance = Attendance::updateOrCreate(
[
'tenant_id' => $tenantId,
'user_id' => $userId,
'base_date' => $data['base_date'],
],
[
'status' => $data['status'] ?? 'onTime',
'json_details' => ! empty($jsonDetails) ? $jsonDetails : null,
'remarks' => $data['remarks'] ?? null,
'updated_by' => auth()->id(),
]
);
if ($attendance->wasRecentlyCreated) {
$attendance->update(['created_by' => auth()->id()]);
$created++;
} else {
$updated++;
}
if (($data['status'] ?? '') === 'vacation') {
$this->deductLeaveBalance($tenantId, $userId);
}
}
});
return ['created' => $created, 'updated' => $updated];
}
/**
* 근태 수정
*/
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']);
}
}
// 근무 시간 재계산
$checkIn = $jsonDetails['check_in'] ?? null;
$checkOut = $jsonDetails['check_out'] ?? null;
if ($checkIn && $checkOut) {
$in = Carbon::createFromFormat('H:i', $checkIn);
$out = Carbon::createFromFormat('H:i', $checkOut);
if ($out->gt($in)) {
$jsonDetails['work_minutes'] = $out->diffInMinutes($in);
}
} else {
unset($jsonDetails['work_minutes']);
}
$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 bulkDelete(array $ids): int
{
$tenantId = session('selected_tenant_id');
$attendances = Attendance::query()
->forTenant($tenantId)
->whereIn('id', $ids)
->get();
$count = 0;
foreach ($attendances as $attendance) {
$attendance->update(['deleted_by' => auth()->id()]);
$attendance->delete();
$count++;
}
return $count;
}
/**
* 월간 캘린더 데이터 (base_date 기준 그룹화)
*/
public function getMonthlyCalendarData(int $year, int $month, ?int $userId = null): Collection
{
$tenantId = session('selected_tenant_id');
$startDate = sprintf('%04d-%02d-01', $year, $month);
$endDate = sprintf('%04d-%02d-%02d', $year, $month, cal_days_in_month(CAL_GREGORIAN, $month, $year));
$query = Attendance::query()
->with(['user', 'user.tenantProfiles' => fn ($q) => $q->where('tenant_id', $tenantId)])
->forTenant($tenantId)
->betweenDates($startDate, $endDate)
->orderBy('base_date')
->orderBy('user_id');
if ($userId) {
$query->where('user_id', $userId);
}
return $query->get();
}
/**
* 사원별 월간 요약
*/
public function getEmployeeMonthlySummary(int $year, int $month): array
{
$tenantId = session('selected_tenant_id');
$startDate = sprintf('%04d-%02d-01', $year, $month);
$endDate = sprintf('%04d-%02d-%02d', $year, $month, cal_days_in_month(CAL_GREGORIAN, $month, $year));
$raw = Attendance::query()
->forTenant($tenantId)
->betweenDates($startDate, $endDate)
->select(
'user_id',
'status',
DB::raw('COUNT(*) as cnt'),
DB::raw("SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(json_details, '$.work_minutes')) AS UNSIGNED)) as total_minutes")
)
->groupBy('user_id', 'status')
->get();
$summary = [];
foreach ($raw as $row) {
if (! isset($summary[$row->user_id])) {
$summary[$row->user_id] = [
'user_id' => $row->user_id,
'total_days' => 0,
'total_minutes' => 0,
'statuses' => [],
];
}
$summary[$row->user_id]['total_days'] += $row->cnt;
$summary[$row->user_id]['total_minutes'] += (int) $row->total_minutes;
$summary[$row->user_id]['statuses'][$row->status] = $row->cnt;
}
// 사원 정보 가져오기
$employees = Employee::query()
->with(['user:id,name', 'department:id,name'])
->forTenant($tenantId)
->activeEmployees()
->get()
->keyBy('user_id');
foreach ($summary as &$item) {
$emp = $employees[$item['user_id']] ?? null;
$item['name'] = $emp?->display_name ?? $emp?->user?->name ?? '-';
$item['department'] = $emp?->department?->name ?? '-';
}
return array_values($summary);
}
/**
* 초과근무 알림 (이번 기준)
*/
public function getOvertimeAlerts(): array
{
$tenantId = session('selected_tenant_id');
$weekStart = now()->startOfWeek(Carbon::MONDAY)->toDateString();
$weekEnd = now()->endOfWeek(Carbon::SUNDAY)->toDateString();
$results = Attendance::query()
->forTenant($tenantId)
->betweenDates($weekStart, $weekEnd)
->select(
'user_id',
DB::raw("SUM(CAST(JSON_UNQUOTE(JSON_EXTRACT(json_details, '$.work_minutes')) AS UNSIGNED)) as week_minutes")
)
->groupBy('user_id')
->having('week_minutes', '>=', 2880) // 48시간 = 2880분
->get();
$alerts = [];
if ($results->isNotEmpty()) {
$employees = Employee::query()
->with(['user:id,name'])
->forTenant($tenantId)
->activeEmployees()
->get()
->keyBy('user_id');
foreach ($results as $row) {
$emp = $employees[$row->user_id] ?? null;
$hours = round($row->week_minutes / 60, 1);
$alerts[] = [
'user_id' => $row->user_id,
'name' => $emp?->display_name ?? $emp?->user?->name ?? '-',
'hours' => $hours,
'level' => $row->week_minutes >= 3120 ? 'danger' : 'warning', // 52h = 3120분
];
}
}
return $alerts;
}
/**
* 자동 결근 처리 (영업일에 출근 기록 없는 사원)
*/
public function markAbsentees(?string $date = null): int
{
$date = $date ?? now()->toDateString();
$carbonDate = Carbon::parse($date);
// 주말이면 스킵
if ($carbonDate->isWeekend()) {
return 0;
}
$count = 0;
// 모든 테넌트의 활성 사원 조회
$tenantIds = DB::table('tenants')->pluck('id');
foreach ($tenantIds as $tenantId) {
$activeUserIds = Employee::query()
->where('tenant_id', $tenantId)
->activeEmployees()
->pluck('user_id')
->toArray();
if (empty($activeUserIds)) {
continue;
}
// 이미 기록이 있는 사원 제외
$existingUserIds = Attendance::query()
->where('tenant_id', $tenantId)
->whereDate('base_date', $date)
->pluck('user_id')
->toArray();
$absentUserIds = array_diff($activeUserIds, $existingUserIds);
foreach ($absentUserIds as $userId) {
Attendance::create([
'tenant_id' => $tenantId,
'user_id' => $userId,
'base_date' => $date,
'status' => 'absent',
'remarks' => '자동 결근 처리',
'created_by' => null,
]);
$count++;
}
}
return $count;
}
/**
* 연차 잔여 조회
*/
public function getLeaveBalance(int $userId): ?LeaveBalance
{
$tenantId = session('selected_tenant_id');
$year = now()->year;
return LeaveBalance::query()
->where('tenant_id', $tenantId)
->where('user_id', $userId)
->where('year', $year)
->first();
}
/**
* 연차 차감 (remaining_days는 stored generated이므로 used_days만 업데이트)
*/
private function deductLeaveBalance(int $tenantId, int $userId): void
{
$year = now()->year;
$balance = LeaveBalance::query()
->where('tenant_id', $tenantId)
->where('user_id', $userId)
->where('year', $year)
->first();
if ($balance && $balance->remaining_days > 0) {
$balance->update([
'used_days' => $balance->used_days + 1,
]);
}
}
/**
* 부서 목록 (드롭다운용)
*/
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']);
}
}