<?php namespace App\Services;
use App\Models\{Employee,PayrollBatch,PayrollItem,SalaryRevision,SalaryAdjustment,EmployeeAdvance,LeaveRequest,AttendanceRecord}; use Illuminate\Support\Facades\DB; use RuntimeException;
class PayrollService {public function calculate(string $period):PayrollBatch{return DB::transaction(function()use($period){$batch=PayrollBatch::firstOrCreate(['period'=>$period],['status'=>'draft']);if($batch->locked_at)throw new RuntimeException('Locked payroll cannot be recalculated.');$batch->items()->delete();$start=$period.'-01';$end=date('Y-m-t',strtotime($start));$days=(int)date('t',strtotime($start));foreach(Employee::whereIn('status',['active','probation'])->get() as $employee){$salary=SalaryRevision::where('employee_id',$employee->id)->whereDate('effective_from','<=',$start)->latest('effective_from')->first();if(!$salary)continue;$base=(float)$salary->base_salary;$allow=collect($salary->allowances??[])->sum(fn($v)=>(float)$v);$fixedDed=collect($salary->deductions??[])->sum(fn($v)=>(float)$v);$adjustments=SalaryAdjustment::where('employee_id',$employee->id)->where('period',$period)->where('status','approved')->get();$bonus=(float)$adjustments->where('type','bonus')->sum('amount');$tax=(float)$adjustments->where('type','tax')->sum('amount');$otherAdd=(float)$adjustments->whereIn('type',['allowance','reimbursement'])->sum('amount');$otherDed=(float)$adjustments->whereIn('type',['deduction','penalty'])->sum('amount');$unpaidDays=(float)LeaveRequest::where('user_id',$employee->user_id)->where('status','approved')->whereHas('type',fn($q)=>$q->where('is_paid',false))->whereBetween('start_date',[$start,$end])->sum('days');$unpaid=round(($base/$days)*$unpaidDays,2);$otMinutes=(int)AttendanceRecord::where('user_id',$employee->user_id)->whereBetween('work_date',[$start,$end])->sum('overtime_minutes');$hourly=$base/max(1,$days*8);$ot=round(($otMinutes/60)*$hourly*1.5,2);$advance=EmployeeAdvance::where('employee_id',$employee->id)->where('status','active')->where('remaining_amount','>',0)->first();$advDed=$advance?min((float)$advance->monthly_deduction,(float)$advance->remaining_amount):0;if($advance&&$advDed>0){$advance->decrement('remaining_amount',$advDed);if((float)$advance->fresh()->remaining_amount<=0)$advance->update(['status'=>'settled']);}$ded=$fixedDed+$otherDed+$unpaid+$tax+$advDed;$net=$base+$allow+$bonus+$otherAdd+$ot-$ded;PayrollItem::create(['payroll_batch_id'=>$batch->id,'employee_id'=>$employee->id,'base_salary'=>$base,'allowances_total'=>$allow+$otherAdd,'deductions_total'=>$fixedDed+$otherDed,'bonus_total'=>$bonus,'net_salary'=>max(0,$net),'payable_days'=>max(0,$days-$unpaidDays),'unpaid_leave_deduction'=>$unpaid,'overtime_amount'=>$ot,'tax_withholding'=>$tax,'advance_deduction'=>$advDed,'other_adjustments'=>$otherAdd-$otherDed,'payment_status'=>'pending','snapshot'=>['salary_revision_id'=>$salary->id,'period'=>$period,'unpaid_days'=>$unpaidDays,'overtime_minutes'=>$otMinutes,'adjustment_ids'=>$adjustments->pluck('id')->all()]]);} $batch->update(['status'=>'calculated','calculated_at'=>now()]);return $batch->fresh('items');});}
public function approve(PayrollBatch $batch,int $uid):void{if($batch->status!=='calculated')throw new RuntimeException('Only calculated payroll can be approved.');$batch->update(['status'=>'approved','approved_at'=>now(),'approved_by'=>$uid,'locked_at'=>now()]);}public function markPaid(PayrollBatch $batch):void{DB::transaction(function()use($batch){if($batch->status!=='approved')throw new RuntimeException('Only approved payroll can be paid.');$batch->items()->update(['payment_status'=>'paid','paid_at'=>now()]);$batch->update(['status'=>'paid','paid_at'=>now()]);});}}