32 lines
3.9 KiB
PHP
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];
|
|
}
|
|
}
|