commit melhorias sgf

This commit is contained in:
2026-11-11 09:00:17 +02:00
parent 0b05a1192c
commit 1d11a5e57b
736 changed files with 40746 additions and 210 deletions
@@ -0,0 +1,12 @@
<?php
namespace App\Modules\HumanResources\Http;
use App\Modules\HumanResources\Personnel;use Illuminate\Http\Request;
final class PersonnelController
{
private function response(\Closure $fn){try{return response()->json($fn())->header('Cache-Control','no-store, private');}catch(\Illuminate\Database\QueryException$e){if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw$e;return response()->json(['message'=>'Conferência de pessoal ocupada. Recupere a mesma confirmação.'],503)->header('Retry-After','5')->header('Cache-Control','no-store');}}
public function index(Request $r,Personnel $f){return$this->response(fn()=>$f->listing($r->user()->id,$r->query()));}
public function preview(Request $r,Personnel $f){abort_if(strlen(json_encode($r->all()))>16384,422);return$this->response(fn()=>$f->preview($r->user()->id,$r->all(),strtolower((string)$r->header('Idempotency-Key'))));}
public function confirm(Request $r,Personnel $f){$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);return$this->response(fn()=>$f->confirm($r->user()->id,$d['preview']));}
public function receipt(Request $r,Personnel $f,string $key){abort_if($r->query()!==[],422);return$this->response(fn()=>$f->receipt($r->user()->id,strtolower($key)));}
public function export(Request $r,Personnel $f,int $id){abort_if($r->query()!==[],422);$out=$f->export($r->user()->id,$id);return response($out['bytes'])->header('Content-Type','text/csv; charset=utf-8')->header('Content-Disposition','attachment; filename="sgf-rh-pessoal-interno-QA.csv"')->header('X-Content-SHA256',$out['sha256'])->header('Cache-Control','no-store, private');}
}
@@ -0,0 +1,12 @@
<?php
namespace App\Modules\HumanResources\Http;
use App\Modules\HumanResources\Remunerations;use Illuminate\Http\Request;
final class RemunerationsController
{
private function response(\Closure $fn){try{return response()->json($fn())->header('Cache-Control','no-store, private');}catch(\Illuminate\Database\QueryException$e){if(!in_array($e->errorInfo[0]??null,['55P03','57014'],true))throw$e;return response()->json(['message'=>'Processamento de remunerações ocupado. Recupere a mesma confirmação.'],503)->header('Retry-After','5')->header('Cache-Control','no-store');}}
public function index(Request $r,Remunerations $f){return$this->response(fn()=>$f->listing($r->user()->id,$r->query()));}
public function preview(Request $r,Remunerations $f){abort_if(strlen(json_encode($r->all()))>16384,422);return$this->response(fn()=>$f->preview($r->user()->id,$r->all(),strtolower((string)$r->header('Idempotency-Key'))));}
public function confirm(Request $r,Remunerations $f){$d=$r->validate(['preview'=>'required|string|max:131072']);abort_if(array_diff(array_keys($r->all()),['preview']),422);return$this->response(fn()=>$f->confirm($r->user()->id,$d['preview']));}
public function receipt(Request $r,Remunerations $f,string $key){abort_if($r->query()!==[],422);return$this->response(fn()=>$f->receipt($r->user()->id,strtolower($key)));}
public function export(Request $r,Remunerations $f,int $id){abort_if($r->query()!==[],422);$out=$f->export($r->user()->id,$id);return response($out['bytes'])->header('Content-Type','text/csv; charset=utf-8')->header('Content-Disposition','attachment; filename="sgf-remuneracao-interna-QA.csv"')->header('X-Content-SHA256',$out['sha256'])->header('Cache-Control','no-store, private');}
}
+112
View File
@@ -0,0 +1,112 @@
<?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'];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];
}
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";}
public function export(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['bytes'=>$d['artifact_csv'],'sha256'=>$d['artifact_sha256']];}
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');$items=[];foreach($page->items()as$r){$a=$this->row($r);$a['effective']=$this->row($effective->get($r->record_key));$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.remunerations.'.$d['action'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'version'=>$out['version'],'reason'=>$d['reason']]);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];}
}
@@ -0,0 +1,31 @@
<?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];
}
}
@@ -0,0 +1,85 @@
<?php
namespace App\Modules\HumanResources;
use App\Modules\Identity\Services\{Audit,Permissions};
use App\Shared\Context\{CompanyContext,ModuleRecords};
use App\Shared\Installation\CompositionRuntime;
use Illuminate\Support\Facades\{Crypt,DB,Validator};
use Tempo\Finance\Payables\Payables;
/** Explicit artificial payroll only: no legal tables, debt, payment or ledger writer. */
final class Remunerations
{
public const POLICY='SGF-REMUNERATIONS-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();}
public function scope(int $actor,string $action='view'):array{
abort_unless(config('remunerations.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']);
foreach(array_unique(['view',$action])as$r)$this->permissions->require($actor,$s['tenant_id'],$s['company_id'],'human_resources.remunerations.'.$r,$s['unit_id']);
\App\Shared\Installation\HumanResourcesProfiles::assertActor($actor,$s,$action);
$m=DB::table('rm_meta')->where('policy',self::POLICY)->first();abort_unless($m&&$m->composition===CompositionRuntime::physical()['composition']&&hash_equals($m->source_hash,(string)config('remunerations.schema_sha256')),503,'Schema de remunerações 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('rm_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 input(array $v):array{
if(is_string($v['reason']??null))$v['reason']=trim($v['reason']);$d=$this->exact($v,['action'=>'required|in:prepare,review','kind'=>'required|in:worker,rules,payroll','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;}
$common=['operation'=>'required|in:establish,amend,cancel'];
$rules=match($d['kind']){
'worker'=>['code'=>'required|string|regex:/^QA-[A-Z0-9-]{2,60}$/D','status'=>'required|in:active,suspended'],
'rules'=>['label'=>'required|string|min:5|max:100','valid_from'=>'required|date_format:Y-m-d','valid_to'=>'required|date_format:Y-m-d|after_or_equal:valid_from','worker_rate'=>'present|nullable|numeric|min:0|max:100|regex:/^[0-9]{1,3}(?:\.[0-9]{1,4})?$/D','employer_rate'=>'present|nullable|numeric|min:0|max:100|regex:/^[0-9]{1,3}(?:\.[0-9]{1,4})?$/D','retention_rate'=>'present|nullable|numeric|min:0|max:100|regex:/^[0-9]{1,3}(?:\.[0-9]{1,4})?$/D','source'=>'required|string|min:10|max:500'],
'payroll'=>['contract_key'=>'sometimes|uuid','worker_key'=>'required|uuid','rules_key'=>'required|uuid','period'=>'required|date_format:Y-m','gross'=>'required|string|regex:/^[0-9]{1,9}\.[0-9]{2}$/D']};
$d['data']=$this->exact($d['data'],$common+$rules);if($d['kind']!=='payroll')abort_if($d['data']['operation']==='cancel',422,'Use o estado do cadastro ou uma versão de regras, sem apagar a origem.');
if($d['kind']==='payroll'){foreach(['worker_key','rules_key','contract_key']as$k)if(isset($d['data'][$k]))$d['data'][$k]=strtolower($d['data'][$k]);abort_unless((int)str_replace('.','',$d['data']['gross'])>0,422);}
return$d;
}
private function dependency(array $s,string $key,string $kind):array{$last=$this->latest($s,$key);$a=$this->latest($s,$key,true);abort_unless($last&&$a&&$last['kind']===$kind&&$a['kind']===$kind,409,'Origem escolhida sem revisão actual disponível.');return['latest_id'=>$last['id'],'approved_id'=>$a['id'],'version'=>$a['version'],'record_key'=>$key,'data'=>$a['data']];}
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);$data=$d['action']==='prepare'?$d['data']:($last['data']['input']??[]);$deps=[];
if($d['kind']==='payroll'){abort_unless(isset($data['worker_key'],$data['rules_key']),409);$deps=['worker'=>$this->dependency($s,$data['worker_key'],'worker'),'rules'=>$this->dependency($s,$data['rules_key'],'rules')];if(isset($data['contract_key']))$deps['contract']=app(Personnel::class)->payrollContract($actor,$data['contract_key'],$data['worker_key'],$data['period'],$data['gross']);}
return['last'=>$last,'effective'=>$effective,'dependencies'=>$deps,'actor_identity'=>$this->identity($actor,$s)];
}
public static function calculate(string $gross,array $rates):array{
$base=\Brick\Math\BigInteger::of(str_replace('.','',$gross));$lines=[];$sum=\Brick\Math\BigInteger::zero();$complete=true;
foreach(['worker_rate','employer_rate','retention_rate']as$key){$rate=$rates[$key]??null;if($rate===null){$complete=false;$lines[$key]=['rate'=>null,'cents'=>null,'exact'=>null,'difference'=>null];continue;}$r=\Brick\Math\BigRational::of($rate)->dividedBy(100)->multipliedBy($base);$c=$r->getNumerator()->quotient($r->getDenominator());$lines[$key]=['rate'=>(string)$rate,'cents'=>(string)$c,'exact'=>(string)$r->simplified(),'difference'=>(string)$r->minus($c)->simplified()];if($key!=='employer_rate')$sum=$sum->plus($c);}
$net=$complete?$base->minus($sum):null;abort_if($net&&$net->isNegative(),422,'Descontos excedem o bruto.');return['gross_cents'=>(string)$base,'rubrics'=>$lines,'complete'=>$complete,'deductions_cents'=>$complete?(string)$sum:null,'net_cents'=>$net===null?null:(string)$net,'employer_cost_cents'=>$lines['employer_rate']['cents']===null?null:(string)$base->plus($lines['employer_rate']['cents']),'policy'=>'truncate_each_rubric_to_cents_preserve_exact_difference','synthetic_only'=>true,'legal_calculation_validated'=>false];
}
private function plan(int $actor,array $s,array $d,array $b):array{
$last=$b['last'];$effective=$b['effective'];abort_unless(($last['version']??0)===$d['version']&&(!$last||$last['kind']===$d['kind']),409,'Versão/tipo alterados. Confira novamente.');$old=$effective['data']??null;
if($d['action']==='prepare'){$input=$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'];abort_unless(hash_equals($identity,$this->identity($prepared,$s)),409,'Identidade do preparador alterada.');$this->permissions->require($prepared,$s['tenant_id'],$s['company_id'],'human_resources.remunerations.prepare',$s['unit_id']);$input=$last['data']['input'];abort_unless($this->hash($last['data']['dependencies'])===$this->hash($b['dependencies']),409,'Cadastro ou regras mudaram. Prepare novamente.');}
$op=$input['operation'];if($op==='establish')abort_if($effective,409,'Registo já aprovado. Use correcção.');else abort_unless($old&&$old['status']!=='cancelled',409,'Operação exige registo activo já aprovado.');
$result=null;$status='active';$kind=$d['kind'];
if($kind==='worker'){$status=$input['status'];if($old)abort_unless($input['code']===$old['input']['code'],409,'Código imutável.');$duplicate=$this->rows($s)->where('kind','worker')->where('record_key','<>',$d['record_key'])->whereRaw("data->'input'->>'code'=?",[$input['code']])->exists();abort_if($duplicate,409,'Código já usado; reutilize o cadastro.');}
if($kind==='payroll'){
$w=$b['dependencies']['worker'];$r=$b['dependencies']['rules'];$begin=$input['period'].'-01';$end=(new \DateTimeImmutable($begin))->modify('last day of this month')->format('Y-m-d');
if($op!=='cancel'){abort_unless($w['data']['status']==='active',409,'Cadastro suspenso.');abort_unless($r['data']['input']['valid_from']<=$begin&&$r['data']['input']['valid_to']>=$end,409,'Regras não cobrem o período inteiro.');}
if($old){foreach(['worker_key','period']as$k)abort_unless($input[$k]===$old['input'][$k],409,'Pessoa/período imutáveis; corrija neste registo.');}
abort_if($this->rows($s)->where('kind','payroll')->where('record_key','<>',$d['record_key'])->whereRaw("data->'input'->>'worker_key'=? AND data->'input'->>'period'=?",[$input['worker_key'],$input['period']])->exists(),409,'Pessoa/período já registados. Reutilize a origem.');
if($op==='cancel'){foreach(['rules_key','gross']as$k)abort_unless($input[$k]===$old['input'][$k],409,'Cancelamento conserva os valores aprovados.');$result=$old['calculation'];$status='cancelled';}else$result=self::calculate($input['gross'],$r['data']['input']);
if($d['action']==='review'&&$d['data']['decision']==='approved'&&$op!=='cancel')abort_unless($result['complete'],409,'Parâmetros ausentes: líquido indeterminado; aprovação completa bloqueada.');
}
$data=['input'=>$input,'status'=>$status,'dependencies'=>$b['dependencies'],'calculation'=>$result,'synthetic_only'=>true];$state=$d['action']==='prepare'?'prepared':$d['data']['decision'];$version=$d['version']+1;
if($kind==='payroll'&&$state==='approved'){$csv=$this->csv($data,$d['record_key'],$version);$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 csv(array $d,string $key,int $version):string{
$c=$d['calculation'];$rows=[['SGF QA - REMUNERACOES INTERNAS - NAO E RECIBO SALARIAL LEGAL'],['Registo',$key,'Versao',$version],['Estado interno',$d['status']],['Codigo ficticio',"'".$d['dependencies']['worker']['data']['input']['code']],['Periodo',$d['input']['period']],['Origem regras',"'".$d['dependencies']['rules']['data']['input']['source'],'Versao',$d['dependencies']['rules']['version']],['Rubrica','Base em centavos','Percentagem artificial','Valor em centavos','Exacto em centavos','Diferenca em centavos'],['Bruto',$c['gross_cents']]];
foreach($c['rubrics']as$k=>$v)$rows[]=[$k,$c['gross_cents'],$v['rate']??'Indeterminado',$v['cents']??'Indeterminado',$v['exact']??'Indeterminado',$v['difference']??'Indeterminado'];$rows[]=['Contrato explicitamente escolhido',isset($d['dependencies']['contract'])?($d['dependencies']['contract']['reference'].' v'.$d['dependencies']['contract']['version']):'Sem ligacao contratual historica comprovada'];$rows[]=['Descontos',$c['deductions_cents']??'Indeterminado'];$rows[]=['Liquido interno',$c['net_cents']??'Indeterminado'];$rows[]=['Custo interno',$c['employer_cost_cents']??'Indeterminado'];return implode("\r\n",array_map(fn($r)=>implode(';',array_map(fn($v)=>'"'.str_replace('"','""',(string)$v).'"',$r)),$rows))."\r\n";
}
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['action']);return DB::transaction(function()use($actor,$s,$d,$key){$this->lock($actor,$s);$this->scope($actor,$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['action']);return DB::transaction(function()use($actor,$s,$d,$c){$this->lock($actor,$s);$this->scope($actor,$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('rm_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('rm_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('rm_commands')->insert($q+['request_hash'=>$this->hash($d),'input'=>$this->json($d),'result'=>$this->json($result),'created_at'=>now()->utc()]);Audit::record('human_resources.remunerations.'.$d['action'],$actor,$s['tenant_id'],$s['company_id'],$s['unit_id'],details:['record_id'=>$id,'version'=>$out['version'],'reason'=>$d['reason']]);return$result+['replayed'=>false];});
}
public function receipt(int $actor,string $key):array{$this->exact(['key'=>$key],['key'=>'required|uuid']);$s=$this->scope($actor);$old=DB::table('rm_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];}
public function export(int $actor,int $id):array{$s=$this->scope($actor);$r=$this->row($this->rows($s)->where(['id'=>$id,'kind'=>'payroll','state'=>'approved'])->first());abort_unless($r,403);$d=$r['data'];abort_unless(isset($d['artifact_csv'],$d['artifact_sha256'])&&hash_equals($d['artifact_sha256'],hash('sha256',$d['artifact_csv'])),409,'Exportação conservada não confere.');return['bytes'=>$d['artifact_csv'],'sha256'=>$d['artifact_sha256']];}
public function listing(int $actor,array $input):array{
$s=$this->scope($actor);$d=$this->exact($input,['kind'=>'required|in:worker,rules,payroll','record_key'=>'sometimes|uuid','period'=>'sometimes|date_format:Y-m','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']);
return DB::transaction(function()use($actor,$s,$d){if(DB::transactionLevel()===1)DB::statement('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');$q=$this->rows($s)->where('kind',$d['kind']);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('rm_versions as next');foreach(['tenant_id','company_id','unit_id','record_key']as$k)$n->whereColumn('next.'.$k,'rm_versions.'.$k);$n->whereColumn('next.version','>','rm_versions.version');});
if(isset($d['period']))$q->whereRaw("data->'input'->>'period'=?",[$d['period']]);if(isset($d['state']))$q->where('state',$d['state']);if(isset($d['search'])){$needle='%'.str_replace(['!','%','_'],['!!','!%','!_'],$d['search']).'%';$q->whereRaw("coalesce(data->'input'->>'code',data->'input'->>'label',data->'dependencies'->'worker'->'data'->'input'->>'code','') ILIKE ? ESCAPE '!'",[$needle]);}
$page=$q->orderByDesc('id')->paginate($d['per_page']??25,['*'],'page',$d['page']??1);$keys=$page->pluck('record_key')->unique()->all();$a=$this->rows($s)->whereIn('record_key',$keys)->where('state','approved')->whereNotExists(function($n){$n->selectRaw('1')->from('rm_versions as e');foreach(['tenant_id','company_id','unit_id','record_key']as$k)$n->whereColumn('e.'.$k,'rm_versions.'.$k);$n->where('e.state','approved')->whereColumn('e.version','>','rm_versions.version');})->get()->keyBy('record_key');
$items=[];$totals=['gross_cents'=>'0','net_cents'=>'0','employer_cost_cents'=>'0','approved_active_on_page'=>0,'incomplete_on_page'=>0];foreach($page->items()as$r){$row=$this->row($r);$eff=$this->row($a->get($r->record_key));$row['effective']=$eff;$items[]=$row;if($d['kind']==='payroll'&&!isset($d['record_key'])&&$eff&&$eff['data']['status']==='active'){$c=$eff['data']['calculation'];if(!$c['complete'])$totals['incomplete_on_page']++;else{$totals['approved_active_on_page']++;foreach(['gross_cents','net_cents','employer_cost_cents']as$k)$totals[$k]=(string)\Brick\Math\BigInteger::of($totals[$k])->plus($c[$k]);}}}$page->setCollection(collect($items));return['policy'=>self::POLICY,'kind'=>$d['kind'],'records'=>$page->toArray(),'page_totals'=>$totals,'totals_basis'=>'effective_approved_active_payroll_on_this_page_only','capabilities'=>['prepare'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'human_resources.remunerations.prepare',$s['unit_id']),'review'=>$this->permissions->has($actor,$s['tenant_id'],$s['company_id'],'human_resources.remunerations.review',$s['unit_id'])],'synthetic_only'=>true,'legal_payroll_validated'=>false];});
}
}