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];}); } }