Files
SGF/app/Modules/HumanResources/PersonnelTime.php
T
2026-11-11 09:00:17 +02:00

32 lines
3.9 KiB
PHP

<?php
namespace App\Modules\HumanResources;
/** Pure interval arithmetic. No calendar, legal entitlement, payroll or persistence assumptions. */
final class PersonnelTime
{
public const ZONE='Africa/Johannesburg';
public static function intervals(array $input):array
{
abort_if(count($input)>8,422,'Máximo de oito intervalos por dia.');$result=[];
foreach($input as$r){abort_unless(is_array($r)&&count($r)===2&&!array_diff(array_keys($r),['from','to']),422,'Intervalo deve indicar apenas início/fim em minutos locais.');abort_unless(is_int($r['from'])&&is_int($r['to'])&&$r['from']>=0&&$r['from']<1440&&$r['to']>0&&$r['to']<=1440&&$r['from']<$r['to'],422,'Intervalo fora do dia ou sem duração positiva.');$result[]=$r;}
usort($result,fn($a,$b)=>$a['from']<=>$b['from']);$out=[];foreach($result as$r){$i=count($out)-1;if($i>=0){abort_if($r['from']<$out[$i]['to'],409,'Intervalos sobrepostos exigem correcção explícita.');if($r['from']===$out[$i]['to']){$out[$i]['to']=$r['to'];continue;}}$out[]=$r;}return$out;
}
private static function minutes(array $ranges):int{return array_sum(array_map(fn($r)=>$r['to']-$r['from'],$ranges));}
private static function intersection(array $a,array $b):int{$sum=0;foreach($a as$x)foreach($b as$y)$sum+=max(0,min($x['to'],$y['to'])-max($x['from'],$y['from']));return$sum;}
public static function day(string $date,?array $planned,?array $actual,?array $approvedLeave):array
{
$zone=new \DateTimeZone(self::ZONE);$day=\DateTimeImmutable::createFromFormat('!Y-m-d',$date,$zone);abort_unless($day&&$day->format('Y-m-d')===$date,422,'Data local inválida.');
$p=$planned===null?null:self::intervals($planned);$a=$actual===null?null:self::intervals($actual);$l=$approvedLeave===null?null:self::intervals($approvedLeave);
if($a!==null&&$l!==null)abort_if(self::intersection($a,$l)>0,409,'Trabalho e férias sobrepostos exigem nova conferência.');
if($p!==null&&$l!==null)abort_unless(self::intersection($p,$l)===self::minutes($l),409,'Férias têm de corresponder a intervalos previstos explicitamente.');
$unaccounted=($p===null||$a===null||$l===null)?null:self::minutes($p)-self::intersection($p,$a)-self::intersection($p,$l);
$utc=$p===null?null:array_map(fn($r)=>['from'=>$day->modify('+'.$r['from'].' minutes')->setTimezone(new \DateTimeZone('UTC'))->format(DATE_ATOM),'to'=>$day->modify('+'.$r['to'].' minutes')->setTimezone(new \DateTimeZone('UTC'))->format(DATE_ATOM)],$p);
return['date'=>$date,'zone'=>self::ZONE,'planned'=>$p,'actual'=>$a,'approved_leave'=>$l,'planned_utc'=>$utc,'planned_minutes'=>$p===null?null:self::minutes($p),'declared_minutes'=>$a===null?null:self::minutes($a),'leave_minutes'=>$l===null?null:self::minutes($l),'outside_plan_minutes'=>($a===null||$p===null)?null:self::minutes($a)-self::intersection($p,$a),'unaccounted_minutes'=>$unaccounted,'salary_effect'=>false,'overtime_authorized'=>false,'legal_classification'=>null];
}
public static function leaveBalance(?int $entitlement,array $requests):array
{
abort_if($entitlement!==null&&$entitlement<0,422);$seen=[];$reserved=0;$taken=0;
foreach($requests as$r){abort_unless(is_array($r)&&isset($r['key'],$r['minutes'],$r['state'])&&is_string($r['key'])&&$r['key']!==''&&is_int($r['minutes'])&&$r['minutes']>0&&in_array($r['state'],['reserved','taken','cancelled'],true),422);abort_if(isset($seen[$r['key']]),409,'Cada pedido contribui uma única vez pelo estado efectivo.');$seen[$r['key']]=true;if($r['state']==='reserved')$reserved+=$r['minutes'];if($r['state']==='taken')$taken+=$r['minutes'];}
$available=$entitlement===null?null:$entitlement-$reserved-$taken;abort_if($available!==null&&$available<0,409,'Saldo de férias insuficiente.');return['entitlement_minutes'=>$entitlement,'reserved_minutes'=>$reserved,'taken_minutes'=>$taken,'available_minutes'=>$available,'unit'=>'minutes','legal_entitlement_validated'=>false,'salary_effect'=>false];
}
}