<?php namespace App\Services;
use App\Models\{AttendanceAttempt,AttendanceRecord,OfficeIpRule,User}; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use RuntimeException;
class AttendanceService {public function __construct(private IpMatcher $matcher,private AuditService $audit,private AttendancePolicyService $policy){}
public function punch(User $user,string $action,Request $request):AttendanceRecord{$worker=$user->employee?:$user->intern;if(!$worker||!in_array($worker->status,['active','probation','active internship'],true))throw new RuntimeException('Your workforce profile is not active.');$now=now();if($this->policy->periodLocked($now))throw new RuntimeException('Attendance for this month is locked.');if($this->policy->holiday($now))throw new RuntimeException('Today is configured as a company holiday.');if($this->policy->weeklyOff($worker,$now))throw new RuntimeException('Today is a weekly off day for your shift.');if(!$worker->office_id)throw new RuntimeException('No office is assigned to your profile.');$ip=(string)$request->ip();$remote=$this->policy->remoteApproved($user->id,$now);$rules=OfficeIpRule::where('office_id',$worker->office_id)->where('is_active',true)->pluck('cidr');$enforce=filter_var(env('ATTENDANCE_ENFORCE_IP',true),FILTER_VALIDATE_BOOL);$allowed=!$enforce||$remote||$rules->contains(fn($rule)=>$this->matcher->matches($ip,$rule));AttendanceAttempt::create(['user_id'=>$user->id,'office_id'=>$worker->office_id,'action'=>$action,'ip_address'=>$ip,'user_agent'=>$request->userAgent(),'allowed'=>$allowed,'reason'=>$allowed?($remote?'Approved remote-work exception':'IP allowed'):'IP is outside the approved office network']);if(!$allowed){$this->audit->log($user->id,'attendance.denied','user',$user->id,[],[],['action'=>$action,'ip'=>$ip],$request);throw new RuntimeException('Attendance is only allowed from an approved office IP address unless remote work is approved.');}
return DB::transaction(function()use($user,$action,$request,$ip,$worker,$now,$remote){$date=$now->toDateString();$record=AttendanceRecord::lockForUpdate()->firstOrCreate(['user_id'=>$user->id,'work_date'=>$date],['status'=>'present','work_mode'=>$remote?'remote':'office']);if($record->is_locked)throw new RuntimeException('This attendance record is locked.');if($action==='check_in'){if($record->check_in_at)throw new RuntimeException('You are already checked in today.');$record->check_in_at=$now;$record->check_in_ip=$ip;$late=$this->lateMinutes($worker,$now);$record->late_minutes=$late;$record->status=$late>0?'late':'present';}elseif($action==='check_out'){if(!$record->check_in_at)throw new RuntimeException('You must check in before checking out.');if($record->check_out_at)throw new RuntimeException('You are already checked out today.');$record->check_out_at=$now;$record->check_out_ip=$ip;$record->worked_minutes=$record->check_in_at->diffInMinutes($now);$record->early_leave_minutes=$this->earlyMinutes($worker,$now);$threshold=$worker->shift?->overtime_after_minutes??540;$record->overtime_minutes=max(0,$record->worked_minutes-$threshold);}else throw new RuntimeException('Invalid attendance action.');$record->save();$this->audit->log($user->id,'attendance.'.$action,AttendanceRecord::class,$record->id,[],['work_date'=>$date,'work_mode'=>$record->work_mode],[],$request);return $record->fresh();});}
private function lateMinutes($worker,$now):int{if(!$worker->shift)return 0;$start=$now->copy()->setTimeFromTimeString($worker->shift->start_time)->addMinutes($worker->shift->late_grace_minutes);return $now->greaterThan($start)?$start->diffInMinutes($now):0;}private function earlyMinutes($worker,$now):int{if(!$worker->shift)return 0;$end=$now->copy()->setTimeFromTimeString($worker->shift->end_time)->subMinutes($worker->shift->early_checkout_grace_minutes);return $now->lessThan($end)?$now->diffInMinutes($end):0;}}