Files
2026-11-11 15:04:01 +02:00

120 lines
34 KiB
PHP

<?php
namespace App\Modules\HumanResources;
use App\Modules\Identity\Services\{Audit,Permissions};
use App\Shared\Context\{CompanyContext,ModuleRecords};
use App\Shared\Installation\{CompositionRuntime,HumanResourcesProfiles};
use Illuminate\Support\Facades\{Crypt,DB,Validator};
use Tempo\Finance\Payables\Payables;
/** Versioned administrative facts only. No payroll/payment/ledger effects. */
final class Personnel
{
public const POLICY='SGF-HR-PERSONNEL-QA-v1';
public function __construct(private CompanyContext $context,private Permissions $permissions){}
private function json(array $a):string{return Payables::canonical($a);}
private function hash(array $a):string{return hash('sha256',$this->json($a));}
private function exact(array $a,array $rules):array{abort_if(array_diff(array_keys($a),array_keys($rules)),422);return Validator::make($a,$rules)->validate();}
private function area(string $kind):string{return match($kind){'contract'=>'contracts','entitlement','leave'=>'leave','day','report'=>'attendance',default=>abort(422)};}
public function scope(int $actor,string $kind,string $action='view'):array{
abort_unless(config('hr_personnel.enabled')===true,404);CompositionRuntime::assert();$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];abort_unless($actor===$this->context->userId()&&$s['unit_id']&&ModuleRecords::active()==='human_resources',403);$this->context->selectFor($actor,$s['tenant_id'],$s['company_id'],$s['unit_id']);HumanResourcesProfiles::assertActor($actor,$s,$action);
foreach(array_unique(['view',$action])as$r)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'human_resources.'.$this->area($kind).'.'.$r,$s['unit_id']);$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'human_resources.remunerations.view',$s['unit_id']);
if($kind==='report')foreach(['contracts','leave']as$a)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'human_resources.'.$a.'.view',$s['unit_id']);
$p=json_decode(file_get_contents(base_path('docs/integration/sgf-human-resources-personnel-access-proposal.json')),true);abort_unless(($p['approved']??false)===true&&($p['authorization']['decision']??null)==='Autorizar o bloco e as30 concessões QA apresentadas'&&hash_equals((string)config('hr_personnel.access_sha256'),$this->hash(['scope'=>$p['scope'],'grants'=>$p['grants_per_book']])),503,'Acesso de pessoal não conferido.');$m=DB::table('hp_meta')->where('policy',self::POLICY)->first();abort_unless($m&&$m->composition===CompositionRuntime::physical()['composition']&&hash_equals($m->source_hash,(string)config('hr_personnel.schema_sha256')),503,'Schema de pessoal não conferido.');return$s;
}
private function identity(int $actor,array $s):string{$u=DB::table('users')->where('id',$actor)->first(['id','name','email']);abort_unless($u&&DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->exists(),403);return$this->hash((array)$u);}
private function lock(int $actor,array $s):void{DB::statement("SET LOCAL lock_timeout='5s'");DB::statement("SET LOCAL statement_timeout='20s'");DB::table('companies')->where(['tenant_id'=>$s['tenant_id'],'id'=>$s['company_id']])->lockForUpdate()->firstOrFail();DB::table('users')->where('id',$actor)->lockForUpdate()->firstOrFail();DB::table('company_memberships')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id'],'user_id'=>$actor])->lockForUpdate()->first();DB::table('permission_grants')->where(['tenant_id'=>$s['tenant_id'],'company_id'=>$s['company_id']])->orderBy('id')->lockForUpdate()->get();}
private function rows(array $s){return DB::table('hp_versions')->where($s);}
private function row(?object $r):?array{if(!$r)return null;$a=(array)$r;foreach(['data','impact']as$k)$a[$k]=json_decode($a[$k],true,64,JSON_THROW_ON_ERROR);return$a;}
private function latest(array $s,string $key,bool $approved=false):?array{$q=$this->rows($s)->where('record_key',$key);if($approved)$q->where('state','approved');return$this->row($q->orderByDesc('version')->first());}
private function effective(array $s,string $kind){return$this->rows($s)->where('kind',$kind)->where('state','approved')->whereNotExists(function($n){$n->selectRaw('1')->from('hp_versions as newer');foreach(['tenant_id','company_id','unit_id','record_key']as$k)$n->whereColumn('newer.'.$k,'hp_versions.'.$k);$n->where('newer.state','approved')->whereColumn('newer.version','>','hp_versions.version');});}
private function personQuery($q,string $worker){return$q->whereRaw("data->'input'->>'worker_key'=?",[$worker]);}
private function worker(array $s,string $key):array{$q=DB::table('rm_versions')->where($s)->where(['record_key'=>$key,'kind'=>'worker']);$last=(clone$q)->orderByDesc('version')->first();$a=(clone$q)->where('state','approved')->orderByDesc('version')->first();abort_unless($a&&$last,409,'Escolha um cadastro RH revisto deste âmbito.');$data=json_decode($a->data,true);return['record_key'=>$key,'latest_id'=>$last->id,'approved_id'=>$a->id,'version'=>$a->version,'code'=>$data['input']['code'],'status'=>$data['status']];}
private function input(array $v):array{
if(is_string($v['reason']??null))$v['reason']=trim($v['reason']);$d=$this->exact($v,['kind'=>'required|in:contract,day,entitlement,leave,report','action'=>'required|in:prepare,review','record_key'=>'required|uuid','version'=>'required|integer|min:0','reason'=>'required|string|min:10|max:2000','data'=>'present|array']);$d['version']=(int)$d['version'];$d['record_key']=strtolower($d['record_key']);
if($d['action']==='review'){$d['data']=$this->exact($d['data'],['decision'=>'required|in:approved,refused']);return$d;}
$base=['operation'=>'required|in:establish,amend,terminate,cancel,take','worker_key'=>'required|uuid','evidence'=>'required|string|min:10|max:1000'];$date='required|date_format:Y-m-d|after_or_equal:2000-01-01|before_or_equal:2100-12-31';
$rules=match($d['kind']){
'contract'=>['reference'=>'required|string|regex:/^QA-[A-Z0-9-]{2,60}$/D','mode'=>'required|in:fixed,open,unknown_end','valid_from'=>$date,'valid_to'=>'present|nullable|date_format:Y-m-d|after_or_equal:valid_from','gross'=>'required|string|regex:/^[0-9]{1,9}\.[0-9]{2}$/D'],
'day'=>['contract_key'=>'required|uuid','date'=>$date,'reference_on'=>$date,'planned'=>'present|nullable|array|max:8','actual'=>'present|nullable|array|max:8','leave_conferred'=>'required|boolean'],
'entitlement'=>['contract_key'=>'required|uuid','valid_from'=>$date,'valid_to'=>$date.'|after_or_equal:valid_from','minutes'=>'present|nullable|integer|min:0|max:527040'],
'leave'=>['contract_key'=>'required|uuid','entitlement_key'=>'required|uuid','day_key'=>'required|uuid','date'=>$date,'reference_on'=>$date,'intervals'=>'required|array|min:1|max:8'],
'report'=>['period'=>'required|date_format:Y-m']};
$d['data']=$this->exact($d['data'],$base+$rules);foreach(['worker_key','contract_key','entitlement_key','day_key']as$k)if(isset($d['data'][$k]))$d['data'][$k]=strtolower($d['data'][$k]);$op=$d['data']['operation'];abort_if($op==='take'&&$d['kind']!=='leave'||$op==='terminate'&&$d['kind']!=='contract',422);
if($d['kind']==='contract'){abort_unless((int)str_replace('.','',$d['data']['gross'])>0,422);abort_unless(($d['data']['mode']==='fixed')===($d['data']['valid_to']!==null),422,'Prazo exige fim explícito; sem termo/fim desconhecido conserva fim nulo.');}
if($d['kind']==='day'){foreach(['planned','actual']as$k)if($d['data'][$k]!==null)$d['data'][$k]=PersonnelTime::intervals($d['data'][$k]);$d['data']['leave_conferred']=(bool)$d['data']['leave_conferred'];}
if($d['kind']==='leave')$d['data']['intervals']=PersonnelTime::intervals($d['data']['intervals']);if($d['kind']==='entitlement'&&$d['data']['minutes']!==null)$d['data']['minutes']=(int)$d['data']['minutes'];return$d;
}
private function basis(int $actor,array $s,array $d):array{
$last=$this->latest($s,$d['record_key']);$effective=$this->latest($s,$d['record_key'],true);$in=$d['action']==='prepare'?$d['data']:($last['data']['input']??[]);abort_unless(isset($in['worker_key']),409);$worker=$this->worker($s,$in['worker_key']);$facts=$this->personQuery($this->rows($s),$in['worker_key'])->where('record_key','<>',$d['record_key'])->where('kind','<>','report')->where('state','approved')->selectRaw('count(*) count,coalesce(max(id),0) last_id')->first();$pay=DB::table('rm_versions')->where($s)->where('kind','payroll')->whereRaw("data->'input'->>'worker_key'=?",[$in['worker_key']])->selectRaw('count(*) count,coalesce(max(id),0) last_id')->first();$refs=[];foreach(['contract_key','day_key','entitlement_key']as$k)if(isset($in[$k])){$x=$this->latest($s,$in[$k]);$refs[$k]=['key'=>$in[$k],'latest_id'=>$x['id']??null];}$deps=['worker'=>$worker,'personnel_marker'=>(array)$facts,'referenced_versions'=>$refs,'payroll_marker'=>(array)$pay];return['last'=>$last,'effective'=>$effective,'dependencies'=>$deps,'actor_identity'=>$this->identity($actor,$s)];
}
private function source(array $s,string $key,string $kind,string $worker):array{$r=$this->latest($s,$key,true);abort_unless($r&&$r['kind']===$kind&&$r['data']['input']['worker_key']===$worker&&$r['data']['status']!=='cancelled',409,'Fonte revista da pessoa/contexto deixou de estar disponível.');return$r;}
private function covers(array $contract,string $from,string $to):bool{$d=$contract['data']['input'];return$contract['data']['status']!=='cancelled'&&$d['mode']!=='unknown_end'&&$d['valid_from']<=$from&&($d['valid_to']===null||$d['valid_to']>=$to);}
private function leaveForDay(array $s,string $worker,string $date,?string $except=null):array{$q=$this->personQuery($this->effective($s,'leave'),$worker)->whereRaw("data->'input'->>'date'=? AND data->>'status'<>'cancelled'",[$date]);if($except)$q->where('record_key','<>',$except);$ranges=[];foreach($q->cursor()as$r)foreach(json_decode($r->data,true)['input']['intervals']as$range)$ranges[]=$range;abort_if(count($ranges)>64,409,'Mais de64 intervalos: requer consolidação individual dos pedidos.');return$this->union($ranges);}
private function union(array $ranges):array{usort($ranges,fn($a,$b)=>$a['from']<=>$b['from']);$out=[];foreach($ranges as$r){$i=count($out)-1;if($i>=0){abort_if($r['from']<$out[$i]['to'],409,'Pedidos de férias sobrepostos.');if($r['from']===$out[$i]['to']){$out[$i]['to']=$r['to'];continue;}}$out[]=$r;}abort_if(count($out)>8,409,'Mais de oito intervalos efectivos num dia.');return$out;}
private function balance(array $s,string $entitlement,?string $except=null,?array $candidate=null):array{$e=$this->latest($s,$entitlement,true);abort_unless($e&&$e['kind']==='entitlement'&&$e['data']['status']!=='cancelled',409,'Condição de férias revista obrigatória.');$q=$this->effective($s,'leave')->whereRaw("data->'input'->>'entitlement_key'=?",[$entitlement]);if($except)$q->where('record_key','<>',$except);$tot=$q->selectRaw("coalesce(sum((data->'result'->>'minutes')::bigint) FILTER(WHERE data->>'status'='reserved'),0) reserved,coalesce(sum((data->'result'->>'minutes')::bigint) FILTER(WHERE data->>'status'='taken'),0) taken")->first();$reserved=(int)$tot->reserved;$taken=(int)$tot->taken;if($candidate){if($candidate['status']==='reserved')$reserved+=$candidate['minutes'];if($candidate['status']==='taken')$taken+=$candidate['minutes'];}$initial=$e['data']['input']['minutes'];$available=$initial===null?null:$initial-$reserved-$taken;abort_if($available!==null&&$available<0,409,'Saldo de férias insuficiente.');return['entitlement_key'=>$entitlement,'entitlement_version'=>$e['version'],'entitlement_minutes'=>$initial,'reserved_minutes'=>$reserved,'taken_minutes'=>$taken,'available_minutes'=>$available,'legal_entitlement_validated'=>false,'salary_effect'=>false];}
private function plan(int $actor,array $s,array $d,array $b):array{
$last=$b['last'];$eff=$b['effective'];abort_unless(($last['version']??0)===$d['version']&&(!$last||$last['kind']===$d['kind']),409,'Versão/tipo alterados. Confira novamente.');$old=$eff['data']??null;
if($d['action']==='prepare'){$in=$d['data'];$prepared=$actor;$identity=$this->identity($actor,$s);}else{abort_unless($last&&$last['state']==='prepared'&&$last['prepared_by']!==$actor,403,'Preparação alheia actual obrigatória.');$prepared=$last['prepared_by'];$identity=$last['preparer_identity'];HumanResourcesProfiles::assertActor($prepared,$s,'prepare');abort_unless(hash_equals($identity,$this->identity($prepared,$s)),409,'Identidade do preparador mudou.');foreach(['view','prepare']as$a)$this->permissions->require($prepared,$s['tenant_id'],$s['company_id'],'human_resources.'.$this->area($d['kind']).'.'.$a,$s['unit_id']);$in=$last['data']['input'];if($d['data']['decision']==='refused')return$this->refusal($s,$d,$b,$prepared,$identity);abort_unless($this->hash($last['data']['dependencies'])===$this->hash($b['dependencies']),409,'Fontes mudaram desde a preparação. Prepare novamente.');}
$op=$in['operation'];$kind=$d['kind'];$worker=$in['worker_key'];if($op==='establish')abort_if($eff,409,'Já revisto. Use correcção neste registo.');else abort_unless($old&&$old['status']!=='cancelled',409,'Operação exige origem efectiva não cancelada.');if($last)abort_unless($worker===$last['data']['input']['worker_key'],409,'Pessoa imutável neste registo.');if($op!=='cancel')abort_unless($b['dependencies']['worker']['status']==='active',409,'Cadastro suspenso.');
if(in_array($op,['cancel','take'],true)){foreach($old['input']as$k=>$v)if(!in_array($k,['operation','evidence','reference_on'],true))abort_unless($this->hash(['v'=>$in[$k]??null])===$this->hash(['v'=>$v]),409,'Esta decisão conserva a origem e os valores revistos.');}
$status=$op==='cancel'?'cancelled':'active';$result=[];
if($kind==='contract'){
if($old)foreach(['reference','valid_from']as$k)abort_unless($in[$k]===$old['input'][$k],409,'Referência/início estáveis; não mudar identidade contratual.');
abort_if($this->rows($s)->where('kind','contract')->where('record_key','<>',$d['record_key'])->whereRaw("data->'input'->>'reference'=?",[$in['reference']])->exists(),409,'Referência já utilizada.');
if($op==='terminate'){abort_unless($in['mode']==='fixed'&&$in['valid_to']!==null&&$in['gross']===$old['input']['gross']&&($old['input']['valid_to']===null||$in['valid_to']<=$old['input']['valid_to']),409,'Terminação exige fim explícito sem alterar bruto.');$status='terminated';}
if($status!=='cancelled')abort_if($this->personQuery($this->effective($s,'contract'),$worker)->where('record_key','<>',$d['record_key'])->whereRaw("data->>'status'<>'cancelled' AND data->'input'->>'valid_from'<=? AND coalesce(data->'input'->>'valid_to','2100-12-31')>=?",[$in['valid_to']??'2100-12-31',$in['valid_from']])->exists(),409,'Contrato efectivo sobreposto ou fim desconhecido.');
$dependents=0;foreach(['day','entitlement','leave']as$k){$q=$this->effective($s,$k)->whereRaw("data->'input'->>'contract_key'=? AND data->>'status'<>'cancelled'",[$d['record_key']]);$dependents+=(clone$q)->count();if($status==='cancelled')abort_if((clone$q)->exists(),409,'Há dependentes efectivos; conferir/cancelar individualmente antes do contrato.');else{$lo=$k==='entitlement'?'valid_from':'date';$hi=$k==='entitlement'?'valid_to':'date';abort_if($q->where(function($x)use($in,$lo,$hi){$x->whereRaw("data->'input'->>? < ?",[$lo,$in['valid_from']]);if($in['valid_to']!==null)$x->orWhereRaw("data->'input'->>? > ?",[$hi,$in['valid_to']]);if($in['mode']==='unknown_end')$x->orWhereRaw('true');})->exists(),409,'Alteração exclui datas de dependentes revistos.');}}
$bound=DB::table('rm_versions')->where($s)->where('kind','payroll')->where('state','approved')->whereRaw("data->'input'->>'contract_key'=?",[$d['record_key']])->count();if($status==='cancelled')abort_if($bound>0,409,'Processamento contratual revisto exige conferência própria antes de cancelar.');$result=['dependent_records'=>$dependents,'historical_bound_payroll_versions'=>$bound,'mode'=>$in['mode'],'legal_contract_validated'=>false];
}else{
if($kind!=='report'){$contract=$this->source($s,$in['contract_key'],'contract',$worker);$from=$in['valid_from']??$in['date'];$to=$in['valid_to']??$in['date'];if($op!=='cancel')abort_unless($this->covers($contract,$from,$to),409,'Contrato não cobre a vigência/dia explicitamente.');if($old)abort_unless($in['contract_key']===$old['input']['contract_key'],409,'Contrato de origem imutável.');}
if($kind==='day'){
if($last)abort_unless($in['date']===$last['data']['input']['date'],409,'Dia imutável.');abort_if($this->personQuery($this->rows($s)->where('kind','day'),$worker)->where('record_key','<>',$d['record_key'])->whereRaw("data->'input'->>'date'=?",[$in['date']])->exists(),409,'Dia já registado; reutilize a origem.');
abort_unless($in['reference_on']<=(new \DateTimeImmutable('today',new \DateTimeZone(PersonnelTime::ZONE)))->format('Y-m-d'),422,'Referência futura não comprova declaração.');if($in['actual']!==null)abort_unless($in['date']<=$in['reference_on'],422,'Não declarar trabalho futuro.');$leaves=$this->leaveForDay($s,$worker,$in['date']);if($status==='cancelled')abort_if($leaves!==[],409,'Há férias efectivas neste dia.');$result=PersonnelTime::day($in['date'],$in['planned'],$in['actual'],$in['leave_conferred']?$leaves:null);if($leaves!==[])PersonnelTime::day($in['date'],$in['planned'],$in['actual'],$leaves);
}
if($kind==='entitlement'){
if($op!=='cancel')abort_if($this->personQuery($this->effective($s,'entitlement'),$worker)->where('record_key','<>',$d['record_key'])->whereRaw("data->>'status'<>'cancelled' AND data->'input'->>'valid_from'<=? AND data->'input'->>'valid_to'>=?",[$in['valid_to'],$in['valid_from']])->exists(),409,'Condições de férias sobrepostas.');
$q=$this->effective($s,'leave')->whereRaw("data->'input'->>'entitlement_key'=? AND data->>'status'<>'cancelled'",[$d['record_key']]);if($op==='cancel')abort_if((clone$q)->exists(),409,'Reservas/gozos efectivos exigem conferência individual primeiro.');else abort_if((clone$q)->where(fn($x)=>$x->whereRaw("data->'input'->>'date'<?",[$in['valid_from']])->orWhereRaw("data->'input'->>'date'>?",[$in['valid_to']]))->exists(),409,'Nova vigência exclui pedido efectivo.');
$tot=$q->selectRaw("coalesce(sum((data->'result'->>'minutes')::bigint) FILTER(WHERE data->>'status'='reserved'),0) reserved,coalesce(sum((data->'result'->>'minutes')::bigint) FILTER(WHERE data->>'status'='taken'),0) taken")->first();$avail=$in['minutes']===null?null:$in['minutes']-(int)$tot->reserved-(int)$tot->taken;abort_if($avail!==null&&$avail<0,409,'Condição insuficiente para pedidos efectivos.');$result=['entitlement_minutes'=>$in['minutes'],'reserved_minutes'=>(int)$tot->reserved,'taken_minutes'=>(int)$tot->taken,'available_minutes'=>$avail,'legal_entitlement_validated'=>false];
}
if($kind==='leave'){
$ent=$this->source($s,$in['entitlement_key'],'entitlement',$worker);$day=$this->source($s,$in['day_key'],'day',$worker);abort_unless($ent['data']['input']['contract_key']===$in['contract_key']&&$day['data']['input']['contract_key']===$in['contract_key']&&$day['data']['input']['date']===$in['date'],409,'Pedido tem de usar plano/condição do mesmo contrato e dia.');abort_unless($in['date']>=$ent['data']['input']['valid_from']&&$in['date']<=$ent['data']['input']['valid_to'],409,'Pedido fora da condição de férias.');
if($last)foreach(['date','entitlement_key','day_key']as$k)abort_unless($in[$k]===$last['data']['input'][$k],409,'Origem e dia imutáveis; corrija os intervalos.');$status=$op==='cancel'?'cancelled':($op==='take'?'taken':($old['status']??'reserved'));if($op==='take')abort_unless($old['status']==='reserved'&&$in['date']<=$in['reference_on'],409,'Só declarar gozo de uma reserva já revista na data aplicável.');abort_unless($in['reference_on']<=(new \DateTimeImmutable('today',new \DateTimeZone(PersonnelTime::ZONE)))->format('Y-m-d'),422);
$ranges=$op==='cancel'?[]:$in['intervals'];$others=$this->leaveForDay($s,$worker,$in['date'],$d['record_key']);$union=$this->union([...$others,...$ranges]);abort_unless($day['data']['input']['planned']!==null,409,'Plano explícito obrigatório.');PersonnelTime::day($in['date'],$day['data']['input']['planned'],$day['data']['input']['actual'],$union);$minutes=array_sum(array_map(fn($r)=>$r['to']-$r['from'],$in['intervals']));$balance=$this->balance($s,$in['entitlement_key'],$d['record_key'],['status'=>$status,'minutes'=>$minutes]);abort_unless($balance['available_minutes']!==null||$status==='cancelled',409,'Saldo inicial desconhecido: decisão de férias bloqueada.');$result=['minutes'=>$minutes,'balance_after'=>$balance,'plan_version'=>$day['version'],'contract_version'=>$contract['version'],'legal_leave_validated'=>false];
}
if($kind==='report'){if($last)abort_unless($in['period']===$last['data']['input']['period'],409,'Período imutável.');abort_if($this->personQuery($this->rows($s)->where('kind','report'),$worker)->where('record_key','<>',$d['record_key'])->whereRaw("data->'input'->>'period'=?",[$in['period']])->exists(),409,'Conferência deste período já tem origem.');$result=$this->period($actor,$s,$worker,$in['period']);}
}
$data=['input'=>$in,'status'=>$status,'dependencies'=>$b['dependencies'],'result'=>$result,'synthetic_only'=>true];$state=$d['action']==='prepare'?'prepared':$d['data']['decision'];$version=$d['version']+1;if($state==='approved'){$csv=$this->csv($kind,$d['record_key'],$version,$data);$data['artifact_csv']=$csv;$data['artifact_sha256']=hash('sha256',$csv);}
$impact=['before'=>['version'=>$last['version']??0,'state'=>$last['state']??null,'effective'=>$old],'after'=>['version'=>$version,'state'=>$state,'proposal'=>$data,'effective'=>$state==='approved'?$data:$old],'payments'=>0,'debt_created'=>false,'journals'=>0,'legal_payroll'=>false];return['kind'=>$kind,'record_key'=>$d['record_key'],'version'=>$version,'previous_id'=>$last['id']??null,'action'=>$d['action'],'state'=>$state,'prepared_by'=>$prepared,'preparer_identity'=>$identity,'data'=>$data,'impact'=>$impact];
}
private function refusal(array $s,array $d,array $b,int $prepared,string $identity):array{
$last=$b['last'];$old=$b['effective']['data']??null;$data=$last['data'];unset($data['artifact_csv'],$data['artifact_sha256']);$currentBalance=null;if($d['kind']==='leave'){try{$currentBalance=$this->balance($s,$data['input']['entitlement_key']);}catch(\Symfony\Component\HttpKernel\Exception\HttpExceptionInterface$e){if($e->getStatusCode()!==409)throw$e;}}$data['refusal_conference']=['current_balance'=>$currentBalance,'observed_dependencies'=>$b['dependencies'],'original_source_changed'=>$this->hash($last['data']['dependencies'])!==$this->hash($b['dependencies']),'proposal_applied'=>false];$version=$d['version']+1;$impact=['before'=>['version'=>$last['version'],'state'=>$last['state'],'effective'=>$old],'after'=>['version'=>$version,'state'=>'refused','proposal'=>$data,'effective'=>$old],'payments'=>0,'debt_created'=>false,'journals'=>0,'legal_payroll'=>false];return['kind'=>$d['kind'],'record_key'=>$d['record_key'],'version'=>$version,'previous_id'=>$last['id'],'action'=>'review','state'=>'refused','prepared_by'=>$prepared,'preparer_identity'=>$identity,'data'=>$data,'impact'=>$impact];
}
public function period(int $actor,array $s,string $worker,string $period):array{
$this->scope($actor,'report');$from=$period.'-01';$to=(new \DateTimeImmutable($from))->modify('last day of this month')->format('Y-m-d');$q=$this->personQuery($this->effective($s,'day'),$worker)->whereRaw("data->>'status'<>'cancelled' AND data->'input'->>'date'>=? AND data->'input'->>'date'<=?",[$from,$to])->orderByRaw("data->'input'->>'date'");$days=[];$known=0;$unknown=0;$unaccounted=0;$contracts=[];$entitlements=[];
foreach($q->cursor()as$r){abort_if(count($days)>=31,409,'Mais de um registo efectivo por dia exige conferência.');$row=$this->row($r);$i=$row['data']['input'];$leaves=$this->leaveForDay($s,$worker,$i['date']);$calc=PersonnelTime::day($i['date'],$i['planned'],$i['actual'],$i['leave_conferred']?$leaves:null);$days[]=['record_key'=>$r->record_key,'version'=>$r->version,'contract_key'=>$i['contract_key'],'calculation'=>$calc];if($calc['unaccounted_minutes']===null)$unknown++;else{$known++;$unaccounted+=$calc['unaccounted_minutes'];}$contracts[$i['contract_key']]=true;}
$contractSources=[];foreach(array_keys($contracts)as$key){$c=$this->source($s,$key,'contract',$worker);$contractSources[]=['record_key'=>$key,'version'=>$c['version'],'reference'=>$c['data']['input']['reference'],'status'=>$c['data']['status']];}
$entQ=$this->personQuery($this->effective($s,'entitlement'),$worker)->whereRaw("data->>'status'<>'cancelled' AND data->'input'->>'valid_from'<=? AND data->'input'->>'valid_to'>=?",[$to,$from]);foreach($entQ->cursor()as$e){abort_if(count($entitlements)>=31,409,'Condições efectivas incompatíveis.');$entitlements[]=$this->balance($s,$e->record_key);}
$pay=DB::table('rm_versions')->where($s)->where(['kind'=>'payroll','state'=>'approved'])->whereRaw("data->'input'->>'worker_key'=? AND data->'input'->>'period'=?",[$worker,$period])->whereNotExists(function($n){$n->selectRaw('1')->from('rm_versions as next');foreach(['tenant_id','company_id','unit_id','record_key']as$k)$n->whereColumn('next.'.$k,'rm_versions.'.$k);$n->where('next.state','approved')->whereColumn('next.version','>','rm_versions.version');});$payroll=[];foreach($pay->cursor()as$r){abort_if(count($payroll)>=1,409,'Mais de um processamento efectivo.');$v=json_decode($r->data,true);$payroll[]=['record_key'=>$r->record_key,'version'=>$r->version,'status'=>$v['status'],'net_cents'=>$v['calculation']['net_cents'],'contract_link_proven'=>isset($v['input']['contract_key']),'contract_key'=>$v['input']['contract_key']??null];}
return['period'=>$period,'worker'=>$this->worker($s,$worker),'days'=>$days,'contracts'=>$contractSources,'leave_balances'=>$entitlements,'payroll'=>$payroll,'known_days'=>count($days),'known_conferred_days'=>$known,'days_with_unknown_conference'=>$unknown,'other_calendar_days_unknown'=>(int)substr($to,-2)-count($days),'unaccounted_known_minutes'=>$unaccounted,'complete_month_proven'=>false,'basis'=>'explicit_days_only_not_legal_calendar','salary_effect'=>false];
}
private function csv(string $kind,string $key,int $version,array $data):string{$rows=[['SGF QA - RH INTERNO - NAO E DOCUMENTO LEGAL'],['Tipo',$kind,'Registo',$key,'Versao',$version],['Pessoa ficticia',$data['dependencies']['worker']['code']],['Estado',$data['status']],['Sem salario/pagamento/lancamento automaticos'],['Input',$this->json($data['input'])],['Resultado',$this->json($data['result'])],['Fontes',$this->json($data['dependencies'])]];return implode("\r\n",array_map(fn($r)=>implode(';',array_map(fn($v)=>'"'.str_replace('"','""',"'".(string)$v).'"',$r)),$rows))."\r\n";}
private function approvedExportRecord(int $actor,int $id):array{$s=$this->context->identifiers()+['unit_id'=>$this->context->unitId()];$r=$this->row($this->rows($s)->where(['id'=>$id,'state'=>'approved'])->first());abort_unless($r,403);$this->scope($actor,$r['kind']);$d=$r['data'];abort_unless(hash_equals($d['artifact_sha256'],hash('sha256',$d['artifact_csv'])),409,'Exportação conservada não confere.');return$r;}
public function export(int $actor,int $id):array{$r=$this->approvedExportRecord($actor,$id);return['bytes'=>$r['data']['artifact_csv'],'sha256'=>$r['data']['artifact_sha256']];}
public function readable(int $actor,int $id):array{$r=$this->approvedExportRecord($actor,$id);$bytes=PersonnelReadableCsv::render($r);return['bytes'=>$bytes,'sha256'=>hash('sha256',$bytes),'source_sha256'=>$r['data']['artifact_sha256'],'format'=>PersonnelReadableCsv::VERSION];}
public function payrollContract(int $actor,string $key,string $worker,string $period,string $gross):array{$s=$this->scope($actor,'contract');$r=$this->source($s,$key,'contract',$worker);$from=$period.'-01';$to=(new \DateTimeImmutable($from))->modify('last day of this month')->format('Y-m-d');abort_unless($this->covers($r,$from,$to)&&$r['data']['input']['gross']===$gross,409,'Contrato revisto não cobre o mês/bruto escolhidos.');return['record_key'=>$key,'version'=>$r['version'],'id'=>$r['id'],'latest_id'=>$this->latest($s,$key)['id'],'reference'=>$r['data']['input']['reference'],'gross'=>$gross,'valid_from'=>$r['data']['input']['valid_from'],'valid_to'=>$r['data']['input']['valid_to']];}
public function listing(int $actor,array $input):array{
$d=$this->exact($input,['kind'=>'required|in:contract,day,entitlement,leave,report','worker_key'=>'sometimes|uuid','record_key'=>'sometimes|uuid','period'=>'sometimes|date_format:Y-m','date'=>'sometimes|date_format:Y-m-d','state'=>'sometimes|in:prepared,approved,refused','search'=>'sometimes|string|max:100','page'=>'sometimes|integer|min:1|max:100000','per_page'=>'sometimes|integer|in:25,100']);$s=$this->scope($actor,$d['kind']);
return DB::transaction(function()use($actor,$s,$d){if(DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$worker=isset($d['worker_key'])?$this->worker($s,strtolower($d['worker_key'])):null;$q=$this->rows($s)->where('kind',$d['kind']);if($worker)$q=$this->personQuery($q,$worker['record_key']);if(isset($d['record_key'])){$q->where('record_key',$d['record_key']);abort_unless((clone$q)->exists(),403);}else$q->whereNotExists(function($n){$n->selectRaw('1')->from('hp_versions as next');foreach(['tenant_id','company_id','unit_id','record_key']as$k)$n->whereColumn('next.'.$k,'hp_versions.'.$k);$n->whereColumn('next.version','>','hp_versions.version');});
if(isset($d['period']))$q->whereRaw("coalesce(data->'input'->>'period',substring(data->'input'->>'date' from 1 for 7))=?",[$d['period']]);if(isset($d['date']))$q->whereRaw("data->'input'->>'date'=?",[$d['date']]);if(isset($d['state']))$q->where('state',$d['state']);if(isset($d['search'])){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$q->whereRaw("(coalesce(data->'input'->>'reference','')||' '||coalesce(data->'dependencies'->'worker'->>'code','')) ILIKE ? ESCAPE '!'",[$needle]);}
$page=$q->orderByDesc('id')->paginate($d['per_page']??25,['*'],'page',$d['page']??1);$keys=$page->pluck('record_key')->all();$effective=$this->effective($s,$d['kind'])->whereIn('record_key',$keys)->get()->keyBy('record_key');$balances=[];if($d['kind']==='entitlement'&&$keys!==[]){$totals=$this->effective($s,'leave')->whereIn(DB::raw("data->'input'->>'entitlement_key'"),$keys)->selectRaw("data->'input'->>'entitlement_key' balance_key,coalesce(sum((data->'result'->>'minutes')::bigint) FILTER(WHERE data->>'status'='reserved'),0) reserved,coalesce(sum((data->'result'->>'minutes')::bigint) FILTER(WHERE data->>'status'='taken'),0) taken")->groupByRaw("data->'input'->>'entitlement_key'")->get()->keyBy('balance_key');foreach($effective as$key=>$row){$e=$this->row($row);if($e['data']['status']==='cancelled')continue;$sum=$totals->get($key);$initial=$e['data']['input']['minutes'];$reserved=(int)($sum?->reserved??0);$taken=(int)($sum?->taken??0);$balances[$key]=['effective_entitlement_version'=>$e['version'],'entitlement_minutes'=>$initial,'reserved_minutes'=>$reserved,'taken_minutes'=>$taken,'available_minutes'=>$initial===null?null:$initial-$reserved-$taken,'basis'=>'current_effective_requests_at_read'];}}
$items=[];foreach($page->items()as$r){$a=$this->row($r);$a['effective']=$this->row($effective->get($r->record_key));$a['current_balance']=$balances[$r->record_key]??null;$items[]=$a;}$page->setCollection(collect($items));$area=$this->area($d['kind']);return['policy'=>self::POLICY,'kind'=>$d['kind'],'worker'=>$worker,'records'=>$page->toArray(),'capabilities'=>['prepare'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'human_resources.'.$area.'.prepare',$s['unit_id']),'review'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'human_resources.'.$area.'.review',$s['unit_id'])],'synthetic_only'=>true,'salary_effect'=>false,'legal_framework_validated'=>false];});
}
public function preview(int $actor,array $input,string $key):array{$d=$this->input($input);$this->exact(['key'=>$key],['key'=>'required|uuid']);$s=$this->scope($actor,$d['kind'],$d['action']);return DB::transaction(function()use($actor,$s,$d,$key){$this->lock($actor,$s);$this->scope($actor,$d['kind'],$d['action']);$b=$this->basis($actor,$s,$d);$out=$this->plan($actor,$s,$d,$b);$c=['actor'=>$actor,'scope'=>$s,'generation'=>request()->session()->get('company_context.token'),'input'=>$d,'key'=>strtolower($key),'basis'=>$this->hash($b),'impact'=>$out['impact'],'observed'=>time()];return['preview'=>Crypt::encryptString($this->json($c)),'impact'=>$out['impact']];});}
public function confirm(int $actor,string $token):array{
try{$c=json_decode(Crypt::decryptString($token),true,64,JSON_THROW_ON_ERROR);}catch(\Throwable){abort(409,'Prévia inválida.');}abort_unless(is_array($c)&&is_array($c['input']??null),409);$d=$this->input($c['input']);$s=$this->scope($actor,$d['kind'],$d['action']);return DB::transaction(function()use($actor,$s,$d,$c){$this->lock($actor,$s);$this->scope($actor,$d['kind'],$d['action']);abort_unless($c['actor']===$actor&&$this->hash($c['scope'])===$this->hash($s)&&$c['generation']===request()->session()->get('company_context.token')&&$c['observed']>=time()-600&&$c['observed']<=time(),409,'Pessoa/contexto/prévia alterados.');$q=$s+['actor_id'=>$actor,'command_key'=>$c['key']];$old=DB::table('hp_commands')->where($q)->first();if($old){abort_unless(hash_equals($old->request_hash,$this->hash($d)),409,'Chave usada com outros dados.');return json_decode($old->result,true)+['replayed'=>true];}$b=$this->basis($actor,$s,$d);abort_unless(hash_equals($c['basis'],$this->hash($b)),409,'Origem/versão alterada. Confira novamente.');$out=$this->plan($actor,$s,$d,$b);abort_unless($this->hash($out['impact'])===$this->hash($c['impact']),409,'Impacto mudou.');$row=$out;unset($row['impact'],$row['data']);$id=DB::table('hp_versions')->insertGetId($s+$row+['data'=>$this->json($out['data']),'impact'=>$this->json($out['impact']),'source_basis'=>$this->hash($b['dependencies']),'actor_id'=>$actor,'reason'=>$d['reason'],'created_at'=>now()->utc()]);$result=['id'=>$id,'record_key'=>$d['record_key'],'version'=>$out['version'],'state'=>$out['state'],'impact'=>$out['impact']];DB::table('hp_commands')->insert($q+['request_hash'=>$this->hash($d),'input'=>$this->json($d),'result'=>$this->json($result),'created_at'=>now()->utc()]);Audit::record('human_resources.'.$this->area($d['kind']).'.'.$d['action'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'version'=>$out['version'],'reason'=>$d['reason'],'area'=>$this->area($d['kind'])]);return$result+['replayed'=>false];});
}
public function receipt(int $actor,string $key):array{$this->exact(['key'=>$key],['key'=>'required|uuid']);$s=$this->scope($actor,'report');$old=DB::table('hp_commands')->where($s+['actor_id'=>$actor,'command_key'=>strtolower($key)])->first();abort_unless($old,404);return['result'=>json_decode($old->result,true),'created_at'=>$old->created_at,'reason'=>json_decode($old->input,true)['reason'],'historical_receipt_only'=>true,'operation_executed'=>false];}
}