210 lines
6.6 KiB
PHP
210 lines
6.6 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Hr;
|
|
|
|
use App\Models\Attendance;
|
|
use App\Models\Employee;
|
|
use Carbon\CarbonInterface;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class AttendanceService
|
|
{
|
|
public function listForCalendar(
|
|
CarbonInterface $start,
|
|
CarbonInterface $end,
|
|
?int $scopedEmployeeId = null,
|
|
bool $hasScopedAccess = true,
|
|
): Collection {
|
|
return Attendance::query()
|
|
->with(['employee.user.profile'])
|
|
->when(! $hasScopedAccess, function (Builder $query): void {
|
|
$query->whereRaw('1 = 0');
|
|
})
|
|
->when($hasScopedAccess && $scopedEmployeeId !== null, function (Builder $query) use ($scopedEmployeeId): void {
|
|
$query->where('employee_id', $scopedEmployeeId);
|
|
})
|
|
->whereDate('attendance_date', '>=', $start->toDateString())
|
|
->whereDate('attendance_date', '<', $end->toDateString())
|
|
->orderBy('attendance_date')
|
|
->orderBy('check_in_at')
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
public function todayAttendanceForEmployee(Employee $employee): ?array
|
|
{
|
|
$attendance = Attendance::query()
|
|
->where('employee_id', $employee->id)
|
|
->whereDate('attendance_date', today())
|
|
->first();
|
|
|
|
return $attendance?->toArray();
|
|
}
|
|
|
|
/**
|
|
* @param array{photo: string, latitude: float, longitude: float, location_tag: string} $validated
|
|
*/
|
|
public function checkIn(array $validated): void
|
|
{
|
|
$employee = $this->resolveAuthEmployee();
|
|
|
|
$existing = Attendance::query()
|
|
->where('employee_id', $employee->id)
|
|
->whereDate('attendance_date', today())
|
|
->exists();
|
|
|
|
if ($existing) {
|
|
throw ValidationException::withMessages([
|
|
'attendance' => 'Anda sudah melakukan presensi masuk hari ini.',
|
|
]);
|
|
}
|
|
|
|
$locationTag = $this->resolveLocationTag(
|
|
$validated['location_tag'],
|
|
(float) $validated['latitude'],
|
|
(float) $validated['longitude'],
|
|
);
|
|
|
|
Attendance::create([
|
|
'employee_id' => $employee->id,
|
|
'attendance_date' => today(),
|
|
'check_in_at' => now(),
|
|
'check_in_photo_path' => $this->storePhoto($validated['photo'], $employee->id, 'check-in'),
|
|
'check_in_latitude' => $validated['latitude'],
|
|
'check_in_longitude' => $validated['longitude'],
|
|
'check_in_location_tag' => $locationTag,
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @param array{photo: string, latitude: float, longitude: float, location_tag: string} $validated
|
|
*/
|
|
public function checkOut(array $validated): void
|
|
{
|
|
$employee = $this->resolveAuthEmployee();
|
|
|
|
$attendance = Attendance::query()
|
|
->where('employee_id', $employee->id)
|
|
->whereDate('attendance_date', today())
|
|
->first();
|
|
|
|
if ($attendance === null) {
|
|
throw ValidationException::withMessages([
|
|
'attendance' => 'Anda belum melakukan presensi masuk hari ini.',
|
|
]);
|
|
}
|
|
|
|
if ($attendance->check_out_at !== null) {
|
|
throw ValidationException::withMessages([
|
|
'attendance' => 'Anda sudah melakukan presensi pulang hari ini.',
|
|
]);
|
|
}
|
|
|
|
$checkOutAt = now();
|
|
$workDurationMinutes = (int) $attendance->check_in_at->diffInMinutes($checkOutAt);
|
|
|
|
$locationTag = $this->resolveLocationTag(
|
|
$validated['location_tag'],
|
|
(float) $validated['latitude'],
|
|
(float) $validated['longitude'],
|
|
);
|
|
|
|
$attendance->check_out_at = $checkOutAt;
|
|
$attendance->check_out_photo_path = $this->storePhoto($validated['photo'], $employee->id, 'check-out');
|
|
$attendance->check_out_latitude = $validated['latitude'];
|
|
$attendance->check_out_longitude = $validated['longitude'];
|
|
$attendance->check_out_location_tag = $locationTag;
|
|
$attendance->work_duration_minutes = $workDurationMinutes;
|
|
$attendance->save();
|
|
}
|
|
|
|
public function delete(Attendance $attendance): void
|
|
{
|
|
if ($attendance->check_in_photo_path) {
|
|
Storage::disk('public')->delete($attendance->check_in_photo_path);
|
|
}
|
|
|
|
if ($attendance->check_out_photo_path) {
|
|
Storage::disk('public')->delete($attendance->check_out_photo_path);
|
|
}
|
|
|
|
$attendance->delete();
|
|
}
|
|
|
|
private function resolveAuthEmployee(): Employee
|
|
{
|
|
$employee = auth()->user()?->employee;
|
|
|
|
if ($employee === null) {
|
|
throw ValidationException::withMessages([
|
|
'employee' => 'Akun Anda tidak terhubung ke data pegawai.',
|
|
]);
|
|
}
|
|
|
|
return $employee;
|
|
}
|
|
|
|
private function storePhoto(string $base64Photo, int $employeeId, string $type): string
|
|
{
|
|
$image = base64_decode(
|
|
(string) preg_replace('#^data:image/\w+;base64,#i', '', $base64Photo),
|
|
true,
|
|
);
|
|
|
|
if ($image === false) {
|
|
throw ValidationException::withMessages([
|
|
'photo' => 'Foto presensi tidak valid.',
|
|
]);
|
|
}
|
|
|
|
$filename = sprintf('%d_%s_%s.jpg', $employeeId, now()->format('Y-m-d_His'), $type);
|
|
$path = "attendances/{$filename}";
|
|
|
|
Storage::disk('public')->put($path, $image);
|
|
|
|
return $path;
|
|
}
|
|
|
|
private function resolveLocationTag(string $clientTag, float $latitude, float $longitude): string
|
|
{
|
|
if ($clientTag !== '') {
|
|
return mb_substr($clientTag, 0, 255);
|
|
}
|
|
|
|
$geocoded = $this->reverseGeocode($latitude, $longitude);
|
|
|
|
if ($geocoded !== null) {
|
|
return mb_substr($geocoded, 0, 255);
|
|
}
|
|
|
|
return mb_substr(sprintf('%s, %s', $latitude, $longitude), 0, 255);
|
|
}
|
|
|
|
private function reverseGeocode(float $latitude, float $longitude): ?string
|
|
{
|
|
try {
|
|
$response = Http::timeout(5)
|
|
->withHeaders(['User-Agent' => config('app.name', 'DST Collection')])
|
|
->get('https://nominatim.openstreetmap.org/reverse', [
|
|
'lat' => $latitude,
|
|
'lon' => $longitude,
|
|
'format' => 'json',
|
|
]);
|
|
|
|
if (! $response->successful()) {
|
|
return null;
|
|
}
|
|
|
|
return $response->json('display_name');
|
|
} catch (\Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|