86 lines
19 KiB
PHP
86 lines
19 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;
|
|
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];});
|
|
}
|
|
}
|